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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
1835
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/app/api/inventory/[id]/audit/route.ts
Normal file
20
src/app/api/inventory/[id]/audit/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/app/api/inventory/[id]/route.ts
Normal file
138
src/app/api/inventory/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/inventory/route.ts
Normal file
53
src/app/api/inventory/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
357
src/app/inventory/[id]/page.tsx
Normal file
357
src/app/inventory/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/app/page.tsx
331
src/app/page.tsx
@@ -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
85
src/lib/database.ts
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user