Initial commit: Complete inventory management system with audit logging

- Added Next.js 15 inventory management application
- SQLite database with audit trail functionality
- Item CRUD operations (Create, Read, Update, Delete)
- Dedicated item detail pages with edit functionality
- Complete change history tracking for all modifications
- Responsive UI with Tailwind CSS (removed Shadcn UI for simplicity)
- API routes for inventory management and audit logs
- Clean navigation between list and detail views
This commit is contained in:
2025-08-02 13:53:13 +02:00
parent 97d64dfda5
commit 738f42f3b4
11 changed files with 2837 additions and 133 deletions

6
.gitignore vendored
View File

@@ -39,3 +39,9 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Database files (SQLite)
database.db
*.sqlite
*.sqlite3
*.db

1835
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,24 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.2.0",
"next": "15.4.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.4.5" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.5", "eslint-config-next": "15.4.5",
"@eslint/eslintrc": "^3" "tailwindcss": "^4",
"tw-animate-css": "^1.3.6",
"typescript": "^5"
} }
} }

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditQueries } from '@/lib/database';
// GET /api/inventory/[id]/audit - Get audit log for specific item
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const logs = auditQueries.getByItemId.all(id);
return NextResponse.json(logs);
} catch (error) {
console.error('Error fetching audit logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch audit logs' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server';
import { inventoryQueries, auditQueries } from '@/lib/database';
// GET /api/inventory/[id] - Get inventory item by ID
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const item = inventoryQueries.getById.get(id);
if (!item) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
);
}
return NextResponse.json(item);
} catch (error) {
console.error('Error fetching inventory item:', error);
return NextResponse.json(
{ error: 'Failed to fetch inventory item' },
{ status: 500 }
);
}
}
// PUT /api/inventory/[id] - Update inventory item
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { name, quantity, value, location } = await request.json();
if (!name || quantity === undefined || value === undefined) {
return NextResponse.json(
{ error: 'Name, quantity, and value are required' },
{ status: 400 }
);
}
// Get current item to compare changes
const currentItem = inventoryQueries.getById.get(id) as any;
if (!currentItem) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
);
}
const result = inventoryQueries.update.run(
name,
quantity,
value,
location || '',
id
);
if (result.changes === 0) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
);
}
// Log changes
if (currentItem.name !== name) {
auditQueries.create.run(id, 'UPDATE', 'name', currentItem.name, name);
}
if (currentItem.quantity !== quantity) {
auditQueries.create.run(id, 'UPDATE', 'quantity', currentItem.quantity.toString(), quantity.toString());
}
if (currentItem.value !== value) {
auditQueries.create.run(id, 'UPDATE', 'value', currentItem.value.toString(), value.toString());
}
if (currentItem.location !== (location || '')) {
auditQueries.create.run(id, 'UPDATE', 'location', currentItem.location || '', location || '');
}
return NextResponse.json({
id: id,
name,
quantity,
value,
location: location || ''
});
} catch (error) {
console.error('Error updating inventory item:', error);
return NextResponse.json(
{ error: 'Failed to update inventory item' },
{ status: 500 }
);
}
}
// DELETE /api/inventory/[id] - Delete inventory item
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get current item for logging
const currentItem = inventoryQueries.getById.get(id) as any;
if (!currentItem) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
);
}
// Log deletion before deleting
auditQueries.create.run(id, 'DELETE', null, null, `Item deleted: ${currentItem.name}`);
const result = inventoryQueries.delete.run(id);
if (result.changes === 0) {
return NextResponse.json(
{ error: 'Inventory item not found' },
{ status: 404 }
);
}
return NextResponse.json({ message: 'Inventory item deleted successfully' });
} catch (error) {
console.error('Error deleting inventory item:', error);
return NextResponse.json(
{ error: 'Failed to delete inventory item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { inventoryQueries, auditQueries } from '@/lib/database';
// GET /api/inventory - Get all inventory items
export async function GET() {
try {
const items = inventoryQueries.getAll.all();
return NextResponse.json(items);
} catch (error) {
console.error('Error fetching inventory:', error);
return NextResponse.json(
{ error: 'Failed to fetch inventory' },
{ status: 500 }
);
}
}
// POST /api/inventory - Create new inventory item
export async function POST(request: NextRequest) {
try {
const { name, quantity, value, location } = await request.json();
if (!name || quantity === undefined || value === undefined) {
return NextResponse.json(
{ error: 'Name, quantity, and value are required' },
{ status: 400 }
);
}
const result = inventoryQueries.create.run(name, quantity, value, location || '');
const itemId = result.lastInsertRowid;
// Log the creation
auditQueries.create.run(itemId, 'CREATE', null, null, `Item created: ${name}`);
return NextResponse.json(
{
id: itemId,
name,
quantity,
value,
location: location || ''
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating inventory item:', error);
return NextResponse.json(
{ error: 'Failed to create inventory item' },
{ status: 500 }
);
}
}

View File

@@ -1,26 +1,122 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
} }
@media (prefers-color-scheme: dark) { :root {
:root { --radius: 0.625rem;
--background: #0a0a0a; --background: oklch(1 0 0);
--foreground: #ededed; --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
} }
} }
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,357 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
interface InventoryItem {
id: number;
name: string;
quantity: number;
value: number;
location: string;
created_at: string;
updated_at: string;
}
interface AuditLog {
id: number;
item_id: number;
action: string;
field_changed: string | null;
old_value: string | null;
new_value: string | null;
timestamp: string;
}
export default function ItemPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const [item, setItem] = useState<InventoryItem | null>(null);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [itemId, setItemId] = useState<string>('');
const [formData, setFormData] = useState({
name: '',
quantity: 0,
value: 0,
location: ''
});
// Initialize item ID from params
useEffect(() => {
const getParams = async () => {
const resolvedParams = await params;
setItemId(resolvedParams.id);
};
getParams();
}, [params]);
// Fetch item details
const fetchItem = async () => {
if (!itemId) return;
try {
const response = await fetch(`/api/inventory/${itemId}`);
if (response.ok) {
const data = await response.json();
setItem(data);
setFormData({
name: data.name,
quantity: data.quantity,
value: data.value,
location: data.location
});
} else {
console.error('Item not found');
router.push('/');
}
} catch (error) {
console.error('Error fetching item:', error);
router.push('/');
}
};
// Fetch audit logs
const fetchAuditLogs = async () => {
if (!itemId) return;
try {
const response = await fetch(`/api/inventory/${itemId}/audit`);
const logs = await response.json();
setAuditLogs(logs);
} catch (error) {
console.error('Error fetching audit logs:', error);
}
};
// Update item
const saveItem = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch(`/api/inventory/${itemId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
await fetchItem();
await fetchAuditLogs();
setIsEditing(false);
}
} catch (error) {
console.error('Error saving item:', error);
}
};
// Delete item
const deleteItem = async () => {
if (!confirm('Are you sure you want to delete this item?')) return;
try {
const response = await fetch(`/api/inventory/${itemId}`, {
method: 'DELETE'
});
if (response.ok) {
router.push('/');
}
} catch (error) {
console.error('Error deleting item:', error);
}
};
// Cancel editing
const cancelEdit = () => {
if (item) {
setFormData({
name: item.name,
quantity: item.quantity,
value: item.value,
location: item.location
});
}
setIsEditing(false);
};
// Format audit log message
const formatAuditMessage = (log: AuditLog) => {
if (log.action === 'CREATE') {
return log.new_value || 'Item created';
}
if (log.action === 'DELETE') {
return log.new_value || 'Item deleted';
}
if (log.action === 'UPDATE' && log.field_changed) {
return `Changed ${log.field_changed} from "${log.old_value}" to "${log.new_value}"`;
}
return 'Unknown change';
};
// Format timestamp
const formatTimestamp = (timestamp: string) => {
return new Date(timestamp).toLocaleString();
};
useEffect(() => {
const loadData = async () => {
if (!itemId) return;
setIsLoading(true);
await fetchItem();
await fetchAuditLogs();
setIsLoading(false);
};
loadData();
}, [itemId]);
if (isLoading) {
return (
<div className="container mx-auto p-4">
<div className="text-center">Loading...</div>
</div>
);
}
if (!item) {
return (
<div className="container mx-auto p-4">
<div className="text-center text-red-500">Item not found</div>
</div>
);
}
return (
<div className="container mx-auto p-4 max-w-4xl">
{/* Navigation */}
<div className="mb-6">
<button
onClick={() => router.push('/')}
className="text-blue-500 hover:text-blue-600 font-medium"
>
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">
{!isEditing ? (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded font-medium"
>
Edit
</button>
<button
onClick={deleteItem}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded font-medium"
>
Delete
</button>
</>
) : (
<>
<button
onClick={saveItem}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded font-medium"
>
Save
</button>
<button
onClick={cancelEdit}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded font-medium"
>
Cancel
</button>
</>
)}
</div>
</div>
{isEditing ? (
<form onSubmit={saveItem} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<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"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity
</label>
<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"
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 0 })}
required
/>
</div>
<div>
<label htmlFor="value" className="block text-sm font-medium text-gray-700 mb-1">
Value
</label>
<input
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"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div>
<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"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
/>
</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>
<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 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">Total Value</label>
<p className="text-lg font-semibold text-green-600">${(item.quantity * item.value).toFixed(2)}</p>
</div>
</div>
</div>
)}
<div className="mt-6 pt-4 border-t text-sm text-gray-500">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>Created: {new Date(item.created_at).toLocaleString()}</div>
<div>Last Updated: {new Date(item.updated_at).toLocaleString()}</div>
</div>
</div>
</div>
{/* Change History */}
<div className="bg-white shadow-md rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Change History</h2>
{auditLogs.length === 0 ? (
<p className="text-gray-500">No changes recorded for this item.</p>
) : (
<div className="space-y-4">
{auditLogs.map((log) => (
<div
key={log.id}
className="border-l-4 border-blue-400 bg-blue-50 p-4 rounded-r"
>
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-gray-800 mb-1">
{formatAuditMessage(log)}
</div>
<div className="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">
{log.action}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,103 +1,244 @@
import Image from "next/image"; 'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
interface InventoryItem {
id: number;
name: string;
quantity: number;
value: number;
location: string;
created_at: string;
updated_at: string;
}
export default function Home() { export default function Home() {
return ( const router = useRouter();
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> const [items, setItems] = useState<InventoryItem[]>([]);
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> const [isLoading, setIsLoading] = useState(true);
<Image const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
className="dark:invert" const [formData, setFormData] = useState({
src="/next.svg" name: '',
alt="Next.js logo" quantity: 0,
width={180} value: 0,
height={38} location: ''
priority });
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row"> // Fetch all inventory items
<a const fetchItems = async () => {
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto" try {
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" const response = await fetch('/api/inventory');
target="_blank" const data = await response.json();
rel="noopener noreferrer" setItems(data);
> } catch (error) {
<Image console.error('Error fetching items:', error);
className="dark:invert" } finally {
src="/vercel.svg" setIsLoading(false);
alt="Vercel logomark" }
width={20} };
height={20}
// Create or update item
const saveItem = async (e: React.FormEvent) => {
e.preventDefault();
try {
const method = editingItem ? 'PUT' : 'POST';
const url = editingItem ? `/api/inventory/${editingItem.id}` : '/api/inventory';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
await fetchItems();
resetForm();
}
} catch (error) {
console.error('Error saving item:', error);
}
};
// Delete item
const deleteItem = async (id: number) => {
if (!confirm('Are you sure you want to delete this item?')) return;
try {
const response = await fetch(`/api/inventory/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await fetchItems();
}
} catch (error) {
console.error('Error deleting item:', error);
}
};
// Edit item
const editItem = (item: InventoryItem) => {
setEditingItem(item);
setFormData({
name: item.name,
quantity: item.quantity,
value: item.value,
location: item.location
});
};
// Reset form
const resetForm = () => {
setEditingItem(null);
setFormData({ name: '', quantity: 0, value: 0, location: '' });
};
// Navigate to item page
const viewItem = (itemId: number) => {
router.push(`/inventory/${itemId}`);
};
useEffect(() => {
fetchItems();
}, []);
if (isLoading) {
return (
<div className="container mx-auto p-4">
<div className="text-center">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto p-4 max-w-6xl">
<h1 className="text-3xl font-bold mb-6">Inventory Management</h1>
{/* Add/Edit Form */}
<div className="bg-white shadow-md rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">
{editingItem ? 'Edit Item' : 'Add New Item'}
</h2>
<form onSubmit={saveItem} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<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"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/> />
Deploy now </div>
</a> <div>
<a <label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]" Quantity
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </label>
target="_blank" <input
rel="noopener noreferrer" id="quantity"
> type="number"
Read our docs className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
</a> value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 0 })}
required
/>
</div>
<div>
<label htmlFor="value" className="block text-sm font-medium text-gray-700 mb-1">
Value
</label>
<input
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"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div>
<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"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
/>
</div>
<div className="md:col-span-4 flex gap-2">
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md font-medium"
>
{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"
onClick={resetForm}
>
Cancel
</button>
)}
</div>
</form>
</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>
))}
</div>
{items.length === 0 && (
<div className="text-center text-gray-500 mt-8">
No inventory items found. Add your first item above!
</div> </div>
</main> )}
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div> </div>
); );
} }

85
src/lib/database.ts Normal file
View File

@@ -0,0 +1,85 @@
import Database from 'better-sqlite3';
import path from 'path';
// Create database connection
const dbPath = path.join(process.cwd(), 'database.db');
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Initialize database tables
export function initializeDatabase() {
// Create inventory table
const createInventoryTable = `
CREATE TABLE IF NOT EXISTS inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
value REAL NOT NULL DEFAULT 0,
location TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
// Create audit log table
const createAuditLogTable = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
action TEXT NOT NULL,
field_changed TEXT,
old_value TEXT,
new_value TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES inventory (id) ON DELETE CASCADE
)
`;
db.exec(createInventoryTable);
db.exec(createAuditLogTable);
console.log('Database initialized successfully');
}
// Initialize database immediately
initializeDatabase();
// Inventory operations (created after table initialization)
export const inventoryQueries = {
// Get all inventory items
getAll: db.prepare('SELECT * FROM inventory ORDER BY name ASC'),
// Get inventory item by ID
getById: db.prepare('SELECT * FROM inventory WHERE id = ?'),
// Search inventory by name
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 (?, ?, ?, ?)'),
// Update inventory item
update: db.prepare('UPDATE inventory SET name = ?, quantity = ?, value = ?, location = ?, 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 = ?'),
};
// Audit log operations
export const auditQueries = {
// Get audit log for specific item
getByItemId: db.prepare('SELECT * FROM audit_log WHERE item_id = ? ORDER BY timestamp DESC'),
// Create audit log entry
create: db.prepare('INSERT INTO audit_log (item_id, action, field_changed, old_value, new_value) VALUES (?, ?, ?, ?, ?)'),
// Get all audit logs
getAll: db.prepare('SELECT * FROM audit_log ORDER BY timestamp DESC'),
};
export default db;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}