feat: Implement inventory management features with status tracking and checkout functionality

- Updated layout metadata to reflect "Inventory Management" title and description.
- Enhanced inventory item model to include status (active, checked-out, used-up).
- Added status filter to inventory items display.
- Introduced CheckoutBasket component for managing selected items during checkout.
- Implemented checkout process to update inventory based on item status and location.
- Modified database schema to include status column with migration for existing items.
- Updated API endpoints to support fetching and updating inventory items by status.
This commit is contained in:
2025-08-04 15:53:07 +02:00
parent 738f42f3b4
commit ec1dbaa2a7
9 changed files with 841 additions and 115 deletions

View File

@@ -34,7 +34,7 @@ export async function PUT(
) {
try {
const { id } = await params;
const { name, quantity, value, location } = await request.json();
const { name, quantity, value, location, status } = await request.json();
if (!name || quantity === undefined || value === undefined) {
return NextResponse.json(
@@ -58,6 +58,7 @@ export async function PUT(
quantity,
value,
location || '',
status || 'active',
id
);
@@ -81,13 +82,17 @@ export async function PUT(
if (currentItem.location !== (location || '')) {
auditQueries.create.run(id, 'UPDATE', 'location', currentItem.location || '', location || '');
}
if (currentItem.status !== (status || 'active')) {
auditQueries.create.run(id, 'UPDATE', 'status', currentItem.status || 'active', status || 'active');
}
return NextResponse.json({
id: id,
name,
quantity,
value,
location: location || ''
location: location || '',
status: status || 'active'
});
} catch (error) {
console.error('Error updating inventory item:', error);

View File

@@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server';
import { inventoryQueries, auditQueries } from '@/lib/database';
// GET /api/inventory - Get all inventory items
export async function GET() {
export async function GET(request: NextRequest) {
try {
const items = inventoryQueries.getAll.all();
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
let items;
if (status) {
items = inventoryQueries.getByStatus.all(status);
} else {
items = inventoryQueries.getActive.all(); // Default to active items only
}
return NextResponse.json(items);
} catch (error) {
console.error('Error fetching inventory:', error);
@@ -18,7 +27,7 @@ export async function GET() {
// POST /api/inventory - Create new inventory item
export async function POST(request: NextRequest) {
try {
const { name, quantity, value, location } = await request.json();
const { name, quantity, value, location, status } = await request.json();
if (!name || quantity === undefined || value === undefined) {
return NextResponse.json(
@@ -27,7 +36,7 @@ export async function POST(request: NextRequest) {
);
}
const result = inventoryQueries.create.run(name, quantity, value, location || '');
const result = inventoryQueries.create.run(name, quantity, value, location || '', status || 'active');
const itemId = result.lastInsertRowid;
// Log the creation
@@ -39,7 +48,8 @@ export async function POST(request: NextRequest) {
name,
quantity,
value,
location: location || ''
location: location || '',
status: status || 'active'
},
{ status: 201 }
);

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { userQueries } from '@/lib/database';
export async function GET() {
try {
const users = userQueries.getAll.all();
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const { name, email } = await request.json();
if (!name || !email) {
return NextResponse.json(
{ error: 'Name and email are required' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = userQueries.getByEmail.get(email);
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
const result = userQueries.create.run(name, email);
const newUser = userQueries.getById.get(result.lastInsertRowid);
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,56 @@
@custom-variant dark (&:is(.dark *));
/* Mobile-friendly touch targets and scrolling */
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none;
}
/* Better touch targets */
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Smooth scrolling for mobile */
* {
-webkit-overflow-scrolling: touch;
}
/* Prevent zoom on inputs on iOS */
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
select,
textarea {
font-size: 16px;
}
@media (max-width: 640px) {
/* Larger touch targets on mobile */
button,
input[type="button"],
input[type="submit"],
input[type="checkbox"],
select {
min-height: 44px;
}
/* Better spacing for mobile */
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View File

@@ -9,6 +9,7 @@ interface InventoryItem {
quantity: number;
value: number;
location: string;
status: string;
created_at: string;
updated_at: string;
}
@@ -34,7 +35,8 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
name: '',
quantity: 0,
value: 0,
location: ''
location: '',
status: 'active'
});
// Initialize item ID from params
@@ -59,7 +61,8 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
name: data.name,
quantity: data.quantity,
value: data.value,
location: data.location
location: data.location,
status: data.status || 'active'
});
} else {
console.error('Item not found');
@@ -129,7 +132,8 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
name: item.name,
quantity: item.quantity,
value: item.value,
location: item.location
location: item.location,
status: item.status || 'active'
});
}
setIsEditing(false);
@@ -183,33 +187,33 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
}
return (
<div className="container mx-auto p-4 max-w-4xl">
<div className="container mx-auto p-3 sm:p-4 max-w-4xl min-h-screen">
{/* Navigation */}
<div className="mb-6">
<div className="mb-4 sm:mb-6">
<button
onClick={() => router.push('/')}
className="text-blue-500 hover:text-blue-600 font-medium"
className="text-blue-500 hover:text-blue-600 font-medium text-sm touch-manipulation"
>
Back to Inventory
</button>
</div>
{/* Item Details */}
<div className="bg-white shadow-md rounded-lg p-6 mb-6">
<div className="flex justify-between items-start mb-4">
<h1 className="text-2xl font-bold">{item.name}</h1>
<div className="flex gap-2">
<div className="bg-white shadow-md rounded-lg p-4 sm:p-6 mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row justify-between items-start mb-4 gap-3">
<h1 className="text-xl sm:text-2xl font-bold">{item.name}</h1>
<div className="flex flex-wrap gap-2 w-full sm:w-auto">
{!isEditing ? (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded font-medium"
className="flex-1 sm:flex-initial bg-blue-500 hover:bg-blue-600 text-white px-3 sm:px-4 py-2 rounded font-medium text-sm touch-manipulation"
>
Edit
</button>
<button
onClick={deleteItem}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded font-medium"
className="flex-1 sm:flex-initial bg-red-500 hover:bg-red-600 text-white px-3 sm:px-4 py-2 rounded font-medium text-sm touch-manipulation"
>
Delete
</button>
@@ -218,13 +222,13 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
<>
<button
onClick={saveItem}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded font-medium"
className="flex-1 sm:flex-initial bg-green-500 hover:bg-green-600 text-white px-3 sm:px-4 py-2 rounded font-medium text-sm touch-manipulation"
>
Save
</button>
<button
onClick={cancelEdit}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded font-medium"
className="flex-1 sm:flex-initial bg-gray-300 hover:bg-gray-400 text-gray-700 px-3 sm:px-4 py-2 rounded font-medium text-sm touch-manipulation"
>
Cancel
</button>
@@ -234,7 +238,7 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
</div>
{isEditing ? (
<form onSubmit={saveItem} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<form onSubmit={saveItem} className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
@@ -242,7 +246,7 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
<input
id="name"
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
@@ -255,7 +259,7 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
<input
id="quantity"
type="number"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 0 })}
required
@@ -269,7 +273,7 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
id="value"
type="number"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: parseFloat(e.target.value) || 0 })}
required
@@ -282,32 +286,102 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
<input
id="location"
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
/>
</div>
<div className="sm:col-span-2">
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
id="status"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
>
<option value="active">Active</option>
<option value="checked-out">Checked Out</option>
<option value="used-up">Used Up</option>
</select>
</div>
</form>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Quantity</label>
<p className="text-lg font-semibold">{item.quantity}</p>
<div className="space-y-4">
{/* Mobile Layout */}
<div className="sm:hidden space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700">Quantity</label>
<p className="text-lg font-semibold">{item.quantity}</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Value each</label>
<p className="text-lg font-semibold">${item.value.toFixed(2)}</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Value per Item</label>
<p className="text-lg font-semibold">${item.value.toFixed(2)}</p>
<label className="block text-xs font-medium text-gray-700">Location</label>
<p className="text-sm font-semibold">{item.location || 'Not specified'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700">Status</label>
<p className={`text-sm font-semibold ${
item.status === 'active' ? 'text-green-600' :
item.status === 'checked-out' ? 'text-blue-600' :
item.status === 'used-up' ? 'text-red-600' :
'text-gray-600'
}`}>
{item.status === 'checked-out' ? 'Checked Out' :
item.status === 'used-up' ? 'Used Up' :
item.status === 'active' ? 'Active' :
item.status}
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Total Value</label>
<p className="text-lg font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</p>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Location</label>
<p className="text-lg font-semibold">{item.location || 'Not specified'}</p>
{/* Desktop Layout */}
<div className="hidden sm:grid sm:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Quantity</label>
<p className="text-lg font-semibold">{item.quantity}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Value per Item</label>
<p className="text-lg font-semibold">${item.value.toFixed(2)}</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Total Value</label>
<p className="text-lg font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Location</label>
<p className="text-lg font-semibold">{item.location || 'Not specified'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<p className={`text-lg font-semibold ${
item.status === 'active' ? 'text-green-600' :
item.status === 'checked-out' ? 'text-blue-600' :
item.status === 'used-up' ? 'text-red-600' :
'text-gray-600'
}`}>
{item.status === 'checked-out' ? 'Checked Out' :
item.status === 'used-up' ? 'Used Up' :
item.status === 'active' ? 'Active' :
item.status}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Total Value</label>
<p className="text-lg font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</p>
</div>
</div>
</div>
</div>
@@ -322,28 +396,28 @@ export default function ItemPage({ params }: { params: Promise<{ id: string }> }
</div>
{/* Change History */}
<div className="bg-white shadow-md rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Change History</h2>
<div className="bg-white shadow-md rounded-lg p-4 sm:p-6">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Change History</h2>
{auditLogs.length === 0 ? (
<p className="text-gray-500">No changes recorded for this item.</p>
<p className="text-gray-500 text-center py-6 sm:py-8 text-sm">No changes recorded for this item.</p>
) : (
<div className="space-y-4">
<div className="space-y-2 sm:space-y-4">
{auditLogs.map((log) => (
<div
key={log.id}
className="border-l-4 border-blue-400 bg-blue-50 p-4 rounded-r"
className="border-l-4 border-blue-400 bg-blue-50 p-3 sm:p-4 rounded-r"
>
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-gray-800 mb-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1 sm:gap-2">
<div className="flex-1">
<div className="font-medium text-gray-800 mb-1 text-sm">
{formatAuditMessage(log)}
</div>
<div className="text-sm text-gray-600">
<div className="text-xs sm:text-sm text-gray-600">
{formatTimestamp(log.timestamp)}
</div>
</div>
<div className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
<div className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded self-start">
{log.action}
</div>
</div>

View File

@@ -13,8 +13,14 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Inventory Management",
description: "Simple inventory management system with item tracking",
};
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import CheckoutBasket from '@/components/CheckoutBasket';
interface InventoryItem {
id: number;
@@ -9,6 +10,7 @@ interface InventoryItem {
quantity: number;
value: number;
location: string;
status: string;
created_at: string;
updated_at: string;
}
@@ -18,17 +20,24 @@ export default function Home() {
const [items, setItems] = useState<InventoryItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [showBasket, setShowBasket] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>('active');
const [formData, setFormData] = useState({
name: '',
quantity: 0,
value: 0,
location: ''
location: '',
status: 'active'
});
// Fetch all inventory items
// Fetch items by status
const fetchItems = async () => {
try {
const response = await fetch('/api/inventory');
const url = statusFilter === 'all'
? '/api/inventory?status=all'
: `/api/inventory?status=${statusFilter}`;
const response = await fetch(url);
const data = await response.json();
setItems(data);
} catch (error) {
@@ -85,14 +94,15 @@ export default function Home() {
name: item.name,
quantity: item.quantity,
value: item.value,
location: item.location
location: item.location,
status: item.status || 'active'
});
};
// Reset form
const resetForm = () => {
setEditingItem(null);
setFormData({ name: '', quantity: 0, value: 0, location: '' });
setFormData({ name: '', quantity: 0, value: 0, location: '', status: 'active' });
};
// Navigate to item page
@@ -100,9 +110,30 @@ export default function Home() {
router.push(`/inventory/${itemId}`);
};
// Handle item selection for checkout
const toggleItemSelection = (itemId: number) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
} else {
newSelected.add(itemId);
}
setSelectedItems(newSelected);
};
// Clear all selections
const clearSelections = () => {
setSelectedItems(new Set());
};
// Get selected items data
const getSelectedItemsData = () => {
return items.filter(item => selectedItems.has(item.id));
};
useEffect(() => {
fetchItems();
}, []);
}, [statusFilter]);
if (isLoading) {
return (
@@ -113,23 +144,66 @@ export default function Home() {
}
return (
<div className="container mx-auto p-4 max-w-6xl">
<h1 className="text-3xl font-bold mb-6">Inventory Management</h1>
<div className="container mx-auto p-3 sm:p-4 max-w-6xl min-h-screen">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 sm:mb-6 gap-3 sm:gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Inventory Management</h1>
{/* Status Filter */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
<div className="flex items-center gap-2 w-full sm:w-auto">
<label htmlFor="statusFilter" className="text-sm font-medium text-gray-700 whitespace-nowrap">
Show:
</label>
<select
id="statusFilter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex-1 sm:flex-initial px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<option value="active">Active Items</option>
<option value="checked-out">Checked Out</option>
<option value="used-up">Used Up</option>
<option value="all">All Items</option>
</select>
</div>
{/* Checkout Actions */}
{selectedItems.size > 0 && statusFilter === 'active' && (
<div className="flex gap-2 items-center w-full sm:w-auto">
<span className="text-xs sm:text-sm text-gray-600 whitespace-nowrap">
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setShowBasket(true)}
className="flex-1 sm:flex-initial bg-green-500 hover:bg-green-600 text-white px-3 sm:px-4 py-2 rounded font-medium text-sm whitespace-nowrap"
>
Basket ({selectedItems.size})
</button>
<button
onClick={clearSelections}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-3 py-2 rounded font-medium text-sm whitespace-nowrap"
>
Clear
</button>
</div>
)}
</div>
</div>
{/* Add/Edit Form */}
<div className="bg-white shadow-md rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">
<div className="bg-white shadow-md rounded-lg p-4 sm:p-6 mb-4 sm:mb-6">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">
{editingItem ? 'Edit Item' : 'Add New Item'}
</h2>
<form onSubmit={saveItem} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<form onSubmit={saveItem} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="sm:col-span-2 lg:col-span-1">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
id="name"
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
@@ -142,7 +216,7 @@ export default function Home() {
<input
id="quantity"
type="number"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 0 })}
required
@@ -156,35 +230,35 @@ export default function Home() {
id="value"
type="number"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div>
<div className="sm:col-span-2 lg:col-span-1">
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
id="location"
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
/>
</div>
<div className="md:col-span-4 flex gap-2">
<div className="sm:col-span-2 lg:col-span-4 flex flex-col sm:flex-row gap-2 sm:gap-2 pt-2">
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md font-medium"
className="flex-1 sm:flex-initial bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md font-medium text-sm"
>
{editingItem ? 'Update' : 'Add'} Item
</button>
{editingItem && (
<button
type="button"
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-md font-medium"
className="flex-1 sm:flex-initial bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-md font-medium text-sm"
onClick={resetForm}
>
Cancel
@@ -195,50 +269,160 @@ export default function Home() {
</div>
{/* Items List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => (
<div key={item.id} className="bg-white shadow-md rounded-lg p-6">
<div
className="cursor-pointer hover:bg-gray-50 -m-6 p-6 rounded-lg transition-colors"
onClick={() => viewItem(item.id)}
>
<h3 className="text-lg font-semibold mb-3">{item.name}</h3>
<div className="space-y-2">
<p><strong>Quantity:</strong> {item.quantity}</p>
<p><strong>Value:</strong> ${item.value.toFixed(2)}</p>
<p><strong>Location:</strong> {item.location || 'Not specified'}</p>
<p><strong>Total Value:</strong> ${(item.quantity * item.value).toFixed(2)}</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<button
className="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-sm font-medium"
onClick={() => editItem(item)}
>
Edit
</button>
<button
className="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded text-sm font-medium"
onClick={() => viewItem(item.id)}
>
View Details
</button>
<button
className="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded text-sm font-medium"
onClick={() => deleteItem(item.id)}
>
Delete
</button>
</div>
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<div className="px-4 sm:px-6 py-3 sm:py-4 bg-gray-50 border-b">
<h2 className="text-base sm:text-lg font-semibold">Inventory Items</h2>
</div>
{items.length === 0 ? (
<div className="text-center text-gray-500 py-8 px-4">
No inventory items found. Add your first item above!
</div>
))}
) : (
<div className="divide-y divide-gray-200">
{items.map((item) => (
<div
key={item.id}
className="px-3 sm:px-6 py-3 sm:py-4 hover:bg-gray-50 transition-colors"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-start gap-3 flex-1">
{/* Only show checkbox for active items */}
{statusFilter === 'active' && (
<input
type="checkbox"
checked={selectedItems.has(item.id)}
onChange={() => toggleItemSelection(item.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 mt-1 sm:mt-0"
/>
)}
<div
className="flex-1 cursor-pointer"
onClick={() => viewItem(item.id)}
>
{/* Mobile Layout */}
<div className="sm:hidden">
<div className="flex justify-between items-start mb-2">
<h3 className="font-medium text-gray-900 text-sm">{item.name}</h3>
<div className={`text-xs font-medium px-2 py-1 rounded ${
item.status === 'active' ? 'bg-green-100 text-green-600' :
item.status === 'checked-out' ? 'bg-blue-100 text-blue-600' :
item.status === 'used-up' ? 'bg-red-100 text-red-600' :
'bg-gray-100 text-gray-600'
}`}>
{item.status === 'checked-out' ? 'Checked Out' :
item.status === 'used-up' ? 'Used Up' :
item.status === 'active' ? 'Active' :
item.status}
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>Qty: <span className="font-medium text-gray-900">{item.quantity}</span></div>
<div>Value: <span className="font-medium text-gray-900">${item.value.toFixed(2)}</span></div>
<div className="col-span-2">Location: <span className="font-medium text-gray-900">{item.location || 'Not specified'}</span></div>
<div className="col-span-2">Total: <span className="font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</span></div>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden sm:grid sm:grid-cols-5 gap-4">
<div>
<div className="text-sm font-medium text-gray-900">{item.name}</div>
<div className="text-sm text-gray-500">ID: {item.id}</div>
</div>
<div>
<div className="text-sm text-gray-500">Quantity</div>
<div className="text-sm font-medium text-gray-900">{item.quantity}</div>
</div>
<div>
<div className="text-sm text-gray-500">Value</div>
<div className="text-sm font-medium text-gray-900">${item.value.toFixed(2)}</div>
</div>
<div>
<div className="text-sm text-gray-500">Location</div>
<div className="text-sm font-medium text-gray-900">{item.location || 'Not specified'}</div>
</div>
<div>
<div className="text-sm text-gray-500">Status</div>
<div className={`text-sm font-medium ${
item.status === 'active' ? 'text-green-600' :
item.status === 'checked-out' ? 'text-blue-600' :
item.status === 'used-up' ? 'text-red-600' :
'text-gray-600'
}`}>
{item.status === 'checked-out' ? 'Checked Out' :
item.status === 'used-up' ? 'Used Up' :
item.status === 'active' ? 'Active' :
item.status}
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-2 sm:ml-4">
{/* Mobile: Show total value prominently */}
<div className="sm:hidden">
<div className="text-xs text-gray-500">Total</div>
<div className="text-sm font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</div>
</div>
{/* Desktop: Show total value */}
<div className="hidden sm:block text-right mr-4">
<div className="text-sm text-gray-500">Total Value</div>
<div className="text-sm font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</div>
</div>
{/* Action Buttons */}
<div className="flex gap-1">
<button
className="bg-blue-500 hover:bg-blue-600 text-white px-2 py-1 rounded text-xs font-medium touch-manipulation"
onClick={(e) => {
e.stopPropagation();
editItem(item);
}}
>
Edit
</button>
<button
className="bg-green-500 hover:bg-green-600 text-white px-2 py-1 rounded text-xs font-medium touch-manipulation"
onClick={(e) => {
e.stopPropagation();
viewItem(item.id);
}}
>
View
</button>
<button
className="bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium touch-manipulation"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Del
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{items.length === 0 && (
<div className="text-center text-gray-500 mt-8">
No inventory items found. Add your first item above!
</div>
)}
{/* Checkout Basket Modal */}
<CheckoutBasket
isOpen={showBasket}
onClose={() => setShowBasket(false)}
selectedItems={getSelectedItemsData()}
onCheckout={() => {
fetchItems(); // Refresh the list after checkout
setSelectedItems(new Set()); // Clear selections
setShowBasket(false); // Close basket
}}
/>
</div>
);
}

View File

@@ -0,0 +1,322 @@
'use client';
import { useState, useEffect } from 'react';
interface InventoryItem {
id: number;
name: string;
quantity: number;
value: number;
location: string;
created_at: string;
updated_at: string;
}
interface CheckoutItem {
item: InventoryItem;
requestedQuantity: number;
}
interface CheckoutBasketProps {
isOpen: boolean;
onClose: () => void;
selectedItems: InventoryItem[];
onCheckout: () => void;
}
export default function CheckoutBasket({ isOpen, onClose, selectedItems, onCheckout }: CheckoutBasketProps) {
const [checkoutItems, setCheckoutItems] = useState<CheckoutItem[]>([]);
const [newLocation, setNewLocation] = useState('');
const [markAsUsedUp, setMarkAsUsedUp] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Initialize checkout items when basket opens
useEffect(() => {
if (isOpen && selectedItems.length > 0) {
setCheckoutItems(
selectedItems.map(item => ({
item,
requestedQuantity: 1 // Default to 1
}))
);
}
}, [isOpen, selectedItems]);
// Update requested quantity for an item
const updateQuantity = (itemId: number, quantity: number) => {
setCheckoutItems(prev =>
prev.map(checkoutItem =>
checkoutItem.item.id === itemId
? { ...checkoutItem, requestedQuantity: Math.max(0, Math.min(quantity, checkoutItem.item.quantity)) }
: checkoutItem
)
);
};
// Remove item from checkout
const removeItem = (itemId: number) => {
setCheckoutItems(prev => prev.filter(checkoutItem => checkoutItem.item.id !== itemId));
};
// Process checkout
const processCheckout = async () => {
if (!newLocation.trim()) {
alert('Please specify a new location');
return;
}
if (checkoutItems.length === 0) {
alert('No items to checkout');
return;
}
setIsProcessing(true);
try {
// Process each item
for (const checkoutItem of checkoutItems) {
const { item, requestedQuantity } = checkoutItem;
if (requestedQuantity > 0) {
const remainingQuantity = item.quantity - requestedQuantity;
const newStatus = markAsUsedUp ? 'used-up' : 'active';
if (remainingQuantity > 0) {
// Partial checkout: Update original item with remaining quantity
await fetch(`/api/inventory/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: item.name,
quantity: remainingQuantity,
value: item.value,
location: item.location, // Keep original location
status: 'active' // Remains active
})
});
// Create new item for checked-out portion
await fetch('/api/inventory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: markAsUsedUp ? `${item.name} (Used Up)` : `${item.name} (Moved)`,
quantity: requestedQuantity,
value: item.value,
location: newLocation,
status: newStatus
})
});
} else {
// Full checkout: Update the entire item
await fetch(`/api/inventory/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: item.name,
quantity: requestedQuantity,
value: item.value,
location: newLocation,
status: newStatus
})
});
}
}
}
// Success
alert('Checkout completed successfully! Items have been properly tracked.');
onCheckout();
setCheckoutItems([]);
setNewLocation('');
setMarkAsUsedUp(false);
} catch (error) {
console.error('Checkout failed:', error);
alert('Checkout failed. Please try again.');
} finally {
setIsProcessing(false);
}
};
// Calculate total value
const totalValue = checkoutItems.reduce(
(sum, checkoutItem) => sum + (checkoutItem.requestedQuantity * checkoutItem.item.value),
0
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-3 sm:p-4 z-50">
<div className="bg-white rounded-lg p-4 sm:p-6 w-full max-w-4xl max-h-[90vh] sm:max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h2 className="text-xl sm:text-2xl font-semibold">Checkout Basket</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-2xl p-1 touch-manipulation"
>
×
</button>
</div>
{checkoutItems.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No items in basket
</div>
) : (
<>
{/* New Location Input */}
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-blue-50 rounded-lg">
<label htmlFor="newLocation" className="block text-sm font-medium text-gray-700 mb-2">
New Location (where items will be moved to):
</label>
<input
id="newLocation"
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={newLocation}
onChange={(e) => setNewLocation(e.target.value)}
placeholder="e.g., Office Desk, Storage Room A, etc."
required
/>
</div>
{/* Items List */}
<div className="space-y-3 sm:space-y-4 mb-4 sm:mb-6">
{checkoutItems.map((checkoutItem) => (
<div key={checkoutItem.item.id} className="border rounded-lg p-3 sm:p-4">
{/* Mobile Layout */}
<div className="sm:hidden">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="font-medium text-gray-900 text-sm">{checkoutItem.item.name}</h3>
<div className="text-xs text-gray-500 mt-1">
Available: {checkoutItem.item.quantity} |
${checkoutItem.item.value.toFixed(2)} each
</div>
<div className="text-xs text-gray-500">
From: {checkoutItem.item.location || 'Not specified'}
</div>
</div>
<button
onClick={() => removeItem(checkoutItem.item.id)}
className="text-red-500 hover:text-red-700 px-2 py-1 text-xs touch-manipulation"
>
Remove
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700">Qty:</label>
<input
type="number"
min="0"
max={checkoutItem.item.quantity}
value={checkoutItem.requestedQuantity}
onChange={(e) => updateQuantity(checkoutItem.item.id, parseInt(e.target.value) || 0)}
className="w-16 px-2 py-1 border border-gray-300 rounded text-center text-sm"
/>
</div>
<div className="text-sm font-medium text-green-600">
${(checkoutItem.requestedQuantity * checkoutItem.item.value).toFixed(2)}
</div>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden sm:flex sm:items-center sm:justify-between">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{checkoutItem.item.name}</h3>
<div className="text-sm text-gray-500 mt-1">
Available: {checkoutItem.item.quantity} |
Value: ${checkoutItem.item.value.toFixed(2)} each |
Current Location: {checkoutItem.item.location || 'Not specified'}
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm font-medium text-gray-700">Quantity:</label>
<input
type="number"
min="0"
max={checkoutItem.item.quantity}
value={checkoutItem.requestedQuantity}
onChange={(e) => updateQuantity(checkoutItem.item.id, parseInt(e.target.value) || 0)}
className="w-20 px-2 py-1 border border-gray-300 rounded text-center"
/>
</div>
<div className="text-sm font-medium text-green-600">
${(checkoutItem.requestedQuantity * checkoutItem.item.value).toFixed(2)}
</div>
<button
onClick={() => removeItem(checkoutItem.item.id)}
className="text-red-500 hover:text-red-700 px-2 py-1"
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
{/* Summary */}
<div className="border-t pt-3 sm:pt-4 mb-4 sm:mb-6">
<div className="flex justify-between items-center text-base sm:text-lg font-semibold">
<span>Total Value:</span>
<span className="text-green-600">${totalValue.toFixed(2)}</span>
</div>
<div className="text-xs sm:text-sm text-gray-600 mt-1">
{checkoutItems.reduce((sum, item) => sum + item.requestedQuantity, 0)} item(s) selected
</div>
</div>
{/* Status Options */}
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-yellow-50 rounded-lg">
<div className="flex items-start sm:items-center space-x-2">
<input
id="markAsUsedUp"
type="checkbox"
checked={markAsUsedUp}
onChange={(e) => setMarkAsUsedUp(e.target.checked)}
className="h-4 w-4 text-red-600 rounded border-gray-300 focus:ring-red-500 mt-0.5 sm:mt-0"
/>
<div className="flex-1">
<label htmlFor="markAsUsedUp" className="text-sm font-medium text-gray-700 block">
Mark items as "Used Up" (consumed/disposed)
</label>
<div className="text-xs text-gray-500 mt-1">
{markAsUsedUp
? "Items will be marked as 'Used Up' and won't be available for future checkout"
: "Items will remain 'Active' and available for future checkout from their new location"
}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={processCheckout}
disabled={isProcessing || !newLocation.trim() || checkoutItems.length === 0}
className="flex-1 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 text-white px-4 py-3 sm:py-2 rounded font-medium text-sm touch-manipulation"
>
{isProcessing ? 'Processing...' : 'Complete Checkout'}
</button>
<button
onClick={onClose}
className="flex-1 sm:flex-initial px-4 py-3 sm:py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded font-medium text-sm touch-manipulation"
>
Cancel
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ export function initializeDatabase() {
quantity INTEGER NOT NULL DEFAULT 0,
value REAL NOT NULL DEFAULT 0,
location TEXT,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@@ -40,6 +41,23 @@ export function initializeDatabase() {
db.exec(createInventoryTable);
db.exec(createAuditLogTable);
// Migration: Add status column if it doesn't exist
try {
const checkColumn = db.prepare("PRAGMA table_info(inventory)");
const columns = checkColumn.all() as any[];
const hasStatusColumn = columns.some(col => col.name === 'status');
if (!hasStatusColumn) {
console.log('Adding status column to inventory table...');
db.exec("ALTER TABLE inventory ADD COLUMN status TEXT DEFAULT 'active'");
// Update all existing items to have active status
db.exec("UPDATE inventory SET status = 'active' WHERE status IS NULL");
console.log('Status column added successfully');
}
} catch (error) {
console.error('Error during migration:', error);
}
console.log('Database initialized successfully');
}
@@ -51,6 +69,12 @@ export const inventoryQueries = {
// Get all inventory items
getAll: db.prepare('SELECT * FROM inventory ORDER BY name ASC'),
// Get active inventory items only
getActive: db.prepare("SELECT * FROM inventory WHERE status = 'active' ORDER BY name ASC"),
// Get items by status
getByStatus: db.prepare('SELECT * FROM inventory WHERE status = ? ORDER BY name ASC'),
// Get inventory item by ID
getById: db.prepare('SELECT * FROM inventory WHERE id = ?'),
@@ -58,16 +82,19 @@ export const inventoryQueries = {
searchByName: db.prepare('SELECT * FROM inventory WHERE name LIKE ? ORDER BY name ASC'),
// Create new inventory item
create: db.prepare('INSERT INTO inventory (name, quantity, value, location) VALUES (?, ?, ?, ?)'),
create: db.prepare('INSERT INTO inventory (name, quantity, value, location, status) VALUES (?, ?, ?, ?, ?)'),
// Update inventory item
update: db.prepare('UPDATE inventory SET name = ?, quantity = ?, value = ?, location = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
update: db.prepare('UPDATE inventory SET name = ?, quantity = ?, value = ?, location = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
// Delete inventory item
delete: db.prepare('DELETE FROM inventory WHERE id = ?'),
// Update quantity only
updateQuantity: db.prepare('UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
// Update location and status
updateLocationAndStatus: db.prepare('UPDATE inventory SET location = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
};
// Audit log operations