connect with mongodb

This commit is contained in:
CelaniDe 2026-02-11 21:11:19 +01:00
parent 9507e8d6d6
commit 75bafa619f
14 changed files with 973 additions and 137 deletions

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npx tsx:*)",
"Bash(npm run build:*)"
]
}
}

View File

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/mongodb';
export async function GET() {
const db = await getDb();
const categories = await db.collection('categories')
.find()
.sort({ name: 1 })
.toArray();
return NextResponse.json(categories);
}
export async function POST(request: Request) {
const body = await request.json();
const { name } = body;
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const db = await getDb();
const now = new Date();
const result = await db.collection('categories').insertOne({
name,
createdAt: now,
updatedAt: now,
});
return NextResponse.json({ _id: result.insertedId, name, createdAt: now, updatedAt: now }, { status: 201 });
}

View File

@ -0,0 +1,121 @@
import { NextResponse } from 'next/server';
import { ObjectId } from 'mongodb';
import { getDb } from '@/lib/mongodb';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!ObjectId.isValid(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const db = await getDb();
const product = await db.collection('products').aggregate([
{ $match: { _id: new ObjectId(id) } },
{
$lookup: {
from: 'categories',
localField: 'categoryId',
foreignField: '_id',
as: 'categoryDoc',
},
},
{
$lookup: {
from: 'subcategories',
localField: 'subcategoryId',
foreignField: '_id',
as: 'subcategoryDoc',
},
},
{
$lookup: {
from: 'suppliers',
localField: 'supplierId',
foreignField: '_id',
as: 'supplierDoc',
},
},
{
$project: {
code: 1,
name: 1,
category: { $arrayElemAt: ['$categoryDoc.name', 0] },
subcategory: { $arrayElemAt: ['$subcategoryDoc.name', 0] },
quantity: 1,
unit: 1,
unitPrice: 1,
vat: 1,
supplier: { $arrayElemAt: ['$supplierDoc.name', 0] },
createdAt: 1,
updatedAt: 1,
},
},
]).next();
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
return NextResponse.json(product);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!ObjectId.isValid(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const update: Record<string, unknown> = { updatedAt: new Date() };
if (body.code !== undefined) update.code = body.code;
if (body.name !== undefined) update.name = body.name;
if (body.categoryId !== undefined) update.categoryId = new ObjectId(body.categoryId);
if (body.subcategoryId !== undefined) update.subcategoryId = new ObjectId(body.subcategoryId);
if (body.quantity !== undefined) update.quantity = body.quantity;
if (body.unit !== undefined) update.unit = body.unit;
if (body.unitPrice !== undefined) update.unitPrice = body.unitPrice;
if (body.vat !== undefined) update.vat = body.vat;
if (body.supplierId !== undefined) update.supplierId = new ObjectId(body.supplierId);
const db = await getDb();
const result = await db.collection('products').findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: update },
{ returnDocument: 'after' }
);
if (!result) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
return NextResponse.json(result);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!ObjectId.isValid(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
const db = await getDb();
const result = await db.collection('products').deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
}

119
app/api/products/route.ts Normal file
View File

@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { ObjectId } from 'mongodb';
import { getDb } from '@/lib/mongodb';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '10')));
const search = searchParams.get('search') || '';
const categoryId = searchParams.get('categoryId');
const subcategoryId = searchParams.get('subcategoryId');
const db = await getDb();
const matchStage: Record<string, unknown> = {};
if (categoryId) {
matchStage.categoryId = new ObjectId(categoryId);
}
if (subcategoryId) {
matchStage.subcategoryId = new ObjectId(subcategoryId);
}
if (search) {
matchStage.$or = [
{ name: { $regex: search, $options: 'i' } },
{ code: { $regex: search, $options: 'i' } },
];
}
const pipeline = [
{ $match: matchStage },
{
$lookup: {
from: 'categories',
localField: 'categoryId',
foreignField: '_id',
as: 'categoryDoc',
},
},
{
$lookup: {
from: 'subcategories',
localField: 'subcategoryId',
foreignField: '_id',
as: 'subcategoryDoc',
},
},
{
$lookup: {
from: 'suppliers',
localField: 'supplierId',
foreignField: '_id',
as: 'supplierDoc',
},
},
{
$project: {
code: 1,
name: 1,
category: { $arrayElemAt: ['$categoryDoc.name', 0] },
subcategory: { $arrayElemAt: ['$subcategoryDoc.name', 0] },
quantity: 1,
unit: 1,
unitPrice: 1,
vat: 1,
supplier: { $arrayElemAt: ['$supplierDoc.name', 0] },
createdAt: 1,
updatedAt: 1,
},
},
{ $sort: { createdAt: -1 as const } },
];
const [data, countResult] = await Promise.all([
db.collection('products')
.aggregate(pipeline)
.skip((page - 1) * limit)
.limit(limit)
.toArray(),
db.collection('products')
.countDocuments(matchStage),
]);
return NextResponse.json({
data,
total: countResult,
page,
limit,
});
}
export async function POST(request: Request) {
const body = await request.json();
const { code, name, categoryId, subcategoryId, quantity, unit, unitPrice, vat, supplierId } = body;
if (!code || !name || !categoryId || !subcategoryId || !quantity || !unit || unitPrice == null || vat == null || !supplierId) {
return NextResponse.json({ error: 'All fields are required' }, { status: 400 });
}
const db = await getDb();
const now = new Date();
const doc = {
code,
name,
categoryId: new ObjectId(categoryId),
subcategoryId: new ObjectId(subcategoryId),
quantity,
unit,
unitPrice,
vat,
supplierId: new ObjectId(supplierId),
createdAt: now,
updatedAt: now,
};
const result = await db.collection('products').insertOne(doc);
return NextResponse.json({ _id: result.insertedId, ...doc }, { status: 201 });
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { ObjectId } from 'mongodb';
import { getDb } from '@/lib/mongodb';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const categoryId = searchParams.get('categoryId');
const db = await getDb();
const filter: Record<string, unknown> = {};
if (categoryId) {
filter.categoryId = new ObjectId(categoryId);
}
const subcategories = await db.collection('subcategories')
.find(filter)
.sort({ name: 1 })
.toArray();
return NextResponse.json(subcategories);
}
export async function POST(request: Request) {
const body = await request.json();
const { name, categoryId } = body;
if (!name || !categoryId) {
return NextResponse.json({ error: 'Name and categoryId are required' }, { status: 400 });
}
const db = await getDb();
const now = new Date();
const result = await db.collection('subcategories').insertOne({
name,
categoryId: new ObjectId(categoryId),
createdAt: now,
updatedAt: now,
});
return NextResponse.json({ _id: result.insertedId, name, categoryId, createdAt: now, updatedAt: now }, { status: 201 });
}

View File

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/mongodb';
export async function GET() {
const db = await getDb();
const suppliers = await db.collection('suppliers')
.find()
.sort({ name: 1 })
.toArray();
return NextResponse.json(suppliers);
}
export async function POST(request: Request) {
const body = await request.json();
const { name } = body;
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const db = await getDb();
const now = new Date();
const result = await db.collection('suppliers').insertOne({
name,
createdAt: now,
updatedAt: now,
});
return NextResponse.json({ _id: result.insertedId, name, createdAt: now, updatedAt: now }, { status: 201 });
}

View File

@ -1,74 +1,116 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import Sidebar from '@/components/Sidebar';
import ProductTable, { Product } from '@/components/ProductTable';
const sampleProducts: Product[] = [
{
id: 'PRD-001',
name: 'Organic Butter',
category: 'Dairy',
subcategory: 'Butter & Spreads',
quantity: 25,
unit: 'kg',
unitPrice: 4.50,
vat: 9,
supplier: 'DairyFresh Co.',
lastUpdated: '2026-02-10',
},
{
id: 'PRD-002',
name: 'All-Purpose Flour',
category: 'Dry Goods',
subcategory: 'Flours',
quantity: 150,
unit: 'kg',
unitPrice: 0.85,
vat: 9,
supplier: 'GrainMasters',
lastUpdated: '2026-02-09',
},
{
id: 'PRD-003',
name: 'Atlantic Salmon Fillet',
category: 'Seafood',
subcategory: 'Fresh Fish',
quantity: 12,
unit: 'kg',
unitPrice: 18.90,
vat: 9,
supplier: 'OceanHarvest',
lastUpdated: '2026-02-11',
},
{
id: 'PRD-004',
name: 'Madagascar Vanilla Extract',
category: 'Dry Goods',
subcategory: 'Spices & Extracts',
quantity: 8,
unit: 'L',
unitPrice: 45.00,
vat: 21,
supplier: 'SpiceWorld Ltd',
lastUpdated: '2026-02-08',
},
{
id: 'PRD-005',
name: 'Fresh Avocados',
category: 'Produce',
subcategory: 'Fruits',
quantity: 40,
unit: 'pcs',
unitPrice: 1.20,
vat: 9,
supplier: 'FreshFarm Direct',
lastUpdated: '2026-02-11',
},
];
interface Category {
_id: string;
name: string;
}
interface Subcategory {
_id: string;
name: string;
categoryId: string;
}
interface ProductsResponse {
data: Product[];
total: number;
page: number;
limit: number;
}
export default function ProductsPage() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [categoryId, setCategoryId] = useState('');
const [subcategoryId, setSubcategoryId] = useState('');
const [categories, setCategories] = useState<Category[]>([]);
const [allSubcategories, setAllSubcategories] = useState<Subcategory[]>([]);
const [loading, setLoading] = useState(true);
const limit = 10;
const totalPages = Math.ceil(total / limit);
// Fetch categories and subcategories on mount
useEffect(() => {
Promise.all([
fetch('/api/categories').then(r => r.json()),
fetch('/api/subcategories').then(r => r.json()),
]).then(([cats, subs]) => {
setCategories(cats);
setAllSubcategories(subs);
});
}, []);
const filteredSubcategories = categoryId
? allSubcategories.filter(s => s.categoryId === categoryId)
: allSubcategories;
// Fetch products
const fetchProducts = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
params.set('page', String(page));
params.set('limit', String(limit));
if (search) params.set('search', search);
if (categoryId) params.set('categoryId', categoryId);
if (subcategoryId) params.set('subcategoryId', subcategoryId);
const res = await fetch(`/api/products?${params}`);
const data: ProductsResponse = await res.json();
setProducts(data.data);
setTotal(data.total);
setLoading(false);
}, [page, search, categoryId, subcategoryId]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [search, categoryId, subcategoryId]);
// Clear subcategory when category changes
useEffect(() => {
setSubcategoryId('');
}, [categoryId]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
};
const activeFilters: { label: string; onClear: () => void }[] = [];
if (categoryId) {
const cat = categories.find(c => c._id === categoryId);
if (cat) activeFilters.push({ label: `Category: ${cat.name}`, onClear: () => setCategoryId('') });
}
if (subcategoryId) {
const sub = allSubcategories.find(s => s._id === subcategoryId);
if (sub) activeFilters.push({ label: `Subcategory: ${sub.name}`, onClear: () => setSubcategoryId('') });
}
if (search) {
activeFilters.push({ label: `Search: "${search}"`, onClear: () => { setSearch(''); setSearchInput(''); } });
}
const clearAllFilters = () => {
setCategoryId('');
setSubcategoryId('');
setSearch('');
setSearchInput('');
};
const startItem = total === 0 ? 0 : (page - 1) * limit + 1;
const endItem = Math.min(page * limit, total);
return (
<div className="flex min-h-screen bg-gray-50">
@ -87,7 +129,7 @@ export default function ProductsPage() {
</svg>
</button>
<h1 className="text-lg font-bold text-gray-900">KitchenOS</h1>
<div className="w-6" /> {/* Spacer for centering */}
<div className="w-6" />
</div>
</div>
@ -109,11 +151,14 @@ export default function ProductsPage() {
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-6 mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-3 sm:gap-4 mb-4">
{/* Search */}
<div className="w-full sm:flex-1 sm:min-w-[250px]">
<form onSubmit={handleSearch} className="w-full sm:flex-1 sm:min-w-[250px]">
<div className="relative">
<input
type="text"
placeholder="Search by name, ID, or supplier…"
placeholder="Search by name or code…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onBlur={() => setSearch(searchInput)}
className="w-full px-4 py-2.5 pl-10 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
/>
<svg
@ -130,27 +175,32 @@ export default function ProductsPage() {
/>
</svg>
</div>
</div>
</form>
{/* Filters Row */}
<div className="grid grid-cols-2 sm:flex gap-2 sm:gap-3">
{/* Category Filter */}
<select className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base">
<option>Categories</option>
<option>Dairy</option>
<option>Dry Goods</option>
<option>Seafood</option>
<option>Produce</option>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
>
<option value="">Categories</option>
{categories.map((cat) => (
<option key={cat._id} value={cat._id}>{cat.name}</option>
))}
</select>
{/* Subcategory Filter */}
<select className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base">
<option>Subcategories</option>
<option>Butter & Spreads</option>
<option>Flours</option>
<option>Fresh Fish</option>
<option>Spices & Extracts</option>
<option>Fruits</option>
<select
value={subcategoryId}
onChange={(e) => setSubcategoryId(e.target.value)}
className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
>
<option value="">Subcategories</option>
{filteredSubcategories.map((sub) => (
<option key={sub._id} value={sub._id}>{sub.name}</option>
))}
</select>
{/* Bulk Actions */}
@ -161,12 +211,14 @@ export default function ProductsPage() {
</div>
{/* Active Filters */}
{activeFilters.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span className="text-sm text-gray-600">Active filters:</span>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs sm:text-sm font-medium">
Category: Dry Goods
<button className="hover:text-blue-900">
{activeFilters.map((filter) => (
<span key={filter.label} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs sm:text-sm font-medium">
{filter.label}
<button onClick={filter.onClear} className="hover:text-blue-900">
<svg className="w-3 h-3 sm:w-4 sm:h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@ -176,41 +228,78 @@ export default function ProductsPage() {
</svg>
</button>
</span>
))}
</div>
<button className="text-xs sm:text-sm text-blue-600 hover:text-blue-800 font-medium sm:ml-2 self-start">
<button onClick={clearAllFilters} className="text-xs sm:text-sm text-blue-600 hover:text-blue-800 font-medium sm:ml-2 self-start">
Clear all
</button>
</div>
)}
</div>
{/* Product Table */}
<ProductTable products={sampleProducts} />
{loading ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
<p className="text-gray-500">Loading products...</p>
</div>
) : products.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
<p className="text-gray-500">No products found.</p>
</div>
) : (
<ProductTable products={products} />
)}
{/* Pagination */}
{total > 0 && (
<div className="mt-4 sm:mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs sm:text-sm text-gray-600 text-center sm:text-left">
Showing <span className="font-medium">1</span> to{' '}
<span className="font-medium">5</span> of{' '}
<span className="font-medium">5</span> results
Showing <span className="font-medium">{startItem}</span> to{' '}
<span className="font-medium">{endItem}</span> of{' '}
<span className="font-medium">{total}</span> results
</p>
<div className="flex items-center gap-1 sm:gap-2">
<button className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm" disabled>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm"
>
Previous
</button>
<button className="px-2 sm:px-3 py-2 rounded-lg bg-blue-600 text-white font-medium text-xs sm:text-sm min-w-[32px] sm:min-w-[36px]">
1
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => {
// Show first, last, current, and neighbors
if (p === 1 || p === totalPages) return true;
if (Math.abs(p - page) <= 1) return true;
return false;
})
.map((p, idx, arr) => (
<React.Fragment key={p}>
{idx > 0 && arr[idx - 1] !== p - 1 && (
<span className="px-1 text-gray-400 text-xs"></span>
)}
<button
onClick={() => setPage(p)}
className={`px-2 sm:px-3 py-2 rounded-lg text-xs sm:text-sm min-w-[32px] sm:min-w-[36px] ${
p === page
? 'bg-blue-600 text-white font-medium'
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors'
}`}
>
{p}
</button>
<button className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-xs sm:text-sm min-w-[32px] sm:min-w-[36px]">
2
</button>
<button className="hidden sm:block px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-sm">
3
</button>
<button className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors text-xs sm:text-sm">
</React.Fragment>
))}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm"
>
Next
</button>
</div>
</div>
)}
</div>
</main>
</div>

View File

@ -3,7 +3,8 @@
import React, { useState } from 'react';
export interface Product {
id: string;
_id: string;
code: string;
name: string;
category: string;
subcategory: string;
@ -12,7 +13,8 @@ export interface Product {
unitPrice: number;
vat: number;
supplier: string;
lastUpdated: string;
createdAt: string;
updatedAt: string;
}
interface ProductTableProps {
@ -36,7 +38,7 @@ export default function ProductTable({ products }: ProductTableProps) {
if (selectedProducts.size === products.length) {
setSelectedProducts(new Set());
} else {
setSelectedProducts(new Set(products.map(p => p.id)));
setSelectedProducts(new Set(products.map(p => p._id)));
}
};
@ -55,7 +57,7 @@ export default function ProductTable({ products }: ProductTableProps) {
<th className="px-4 lg:px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedProducts.size === products.length}
checked={selectedProducts.size === products.length && products.length > 0}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
@ -91,19 +93,19 @@ export default function ProductTable({ products }: ProductTableProps) {
</thead>
<tbody className="divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50 transition-colors">
<tr key={product._id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 lg:px-6 py-4">
<input
type="checkbox"
checked={selectedProducts.has(product.id)}
onChange={() => toggleProduct(product.id)}
checked={selectedProducts.has(product._id)}
onChange={() => toggleProduct(product._id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-4 lg:px-6 py-4">
<div>
<p className="text-sm font-medium text-gray-900">{product.name}</p>
<p className="text-xs text-gray-500">ID: {product.id}</p>
<p className="text-xs text-gray-500">ID: {product.code}</p>
<p className="text-xs text-gray-500 lg:hidden mt-1">
{product.category} {product.quantity} {product.unit}
</p>
@ -141,19 +143,19 @@ export default function ProductTable({ products }: ProductTableProps) {
{/* Mobile Card View */}
<div className="md:hidden space-y-4">
{products.map((product) => (
<div key={product.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div key={product._id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selectedProducts.has(product.id)}
onChange={() => toggleProduct(product.id)}
checked={selectedProducts.has(product._id)}
onChange={() => toggleProduct(product._id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900">{product.name}</h3>
<p className="text-xs text-gray-500 mt-0.5">ID: {product.id}</p>
<p className="text-xs text-gray-500 mt-0.5">ID: {product.code}</p>
</div>
<button className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
@ -200,7 +202,7 @@ export default function ProductTable({ products }: ProductTableProps) {
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Updated</p>
<p className="text-xs text-gray-700 mt-0.5">{product.lastUpdated}</p>
<p className="text-xs text-gray-700 mt-0.5">{new Date(product.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</div>

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
services:
mongo:
image: mongo:7.0
container_name: mongo-icostpro
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: rootpassword
volumes:
- mongo_data:/data/db
command: ["mongod", "--auth"]
volumes:
mongo_data:

29
lib/mongodb.ts Normal file
View File

@ -0,0 +1,29 @@
import { MongoClient, Db } from 'mongodb';
const uri = process.env.MONGODB_URI!;
let client: MongoClient;
let db: Db;
declare global {
// eslint-disable-next-line no-var
var _mongoClient: MongoClient | undefined;
}
export async function getDb(): Promise<Db> {
if (db) return db;
if (process.env.NODE_ENV === 'development') {
if (!global._mongoClient) {
global._mongoClient = new MongoClient(uri);
await global._mongoClient.connect();
}
client = global._mongoClient;
} else {
client = new MongoClient(uri);
await client.connect();
}
db = client.db();
return db;
}

53
lib/types.ts Normal file
View File

@ -0,0 +1,53 @@
import { ObjectId } from 'mongodb';
export interface Category {
_id: ObjectId;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface Subcategory {
_id: ObjectId;
name: string;
categoryId: ObjectId;
createdAt: Date;
updatedAt: Date;
}
export interface Supplier {
_id: ObjectId;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface ProductDoc {
_id: ObjectId;
code: string;
name: string;
categoryId: ObjectId;
subcategoryId: ObjectId;
quantity: number;
unit: string;
unitPrice: number;
vat: number;
supplierId: ObjectId;
createdAt: Date;
updatedAt: Date;
}
export interface ProductWithRefs {
_id: string;
code: string;
name: string;
category: string;
subcategory: string;
quantity: number;
unit: string;
unitPrice: number;
vat: number;
supplier: string;
createdAt: string;
updatedAt: string;
}

143
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "icostpro",
"version": "0.1.0",
"dependencies": {
"mongodb": "^7.1.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
@ -1021,6 +1022,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -1575,6 +1585,21 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
@ -2473,6 +2498,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bson": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@ -4853,6 +4887,12 @@
"node": ">= 0.4"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -4900,6 +4940,65 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mongodb": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz",
"integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^7.1.1",
"mongodb-connection-string-url": "^7.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.806.0",
"@mongodb-js/zstd": "^7.0.0",
"gcp-metadata": "^7.0.1",
"kerberos": "^7.0.0",
"mongodb-client-encryption": ">=7.0.0 <7.1.0",
"snappy": "^7.3.2",
"socks": "^2.8.6"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^13.0.0",
"whatwg-url": "^14.1.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -5351,7 +5450,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -5812,6 +5910,15 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -6100,6 +6207,18 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@ -6376,6 +6495,28 @@
"punycode": "^2.1.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"mongodb": "^7.1.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"

151
scripts/seed.ts Normal file
View File

@ -0,0 +1,151 @@
import { MongoClient, ObjectId } from 'mongodb';
const uri = 'mongodb://root:rootpassword@localhost:27017/icostpro?authSource=admin';
async function seed() {
const client = new MongoClient(uri);
await client.connect();
const db = client.db();
console.log('Connected to MongoDB. Seeding...');
// Clear existing data
await Promise.all([
db.collection('categories').deleteMany({}),
db.collection('subcategories').deleteMany({}),
db.collection('suppliers').deleteMany({}),
db.collection('products').deleteMany({}),
]);
const now = new Date();
// Insert categories
const categoryIds = {
dairy: new ObjectId(),
dryGoods: new ObjectId(),
seafood: new ObjectId(),
produce: new ObjectId(),
};
await db.collection('categories').insertMany([
{ _id: categoryIds.dairy, name: 'Dairy', createdAt: now, updatedAt: now },
{ _id: categoryIds.dryGoods, name: 'Dry Goods', createdAt: now, updatedAt: now },
{ _id: categoryIds.seafood, name: 'Seafood', createdAt: now, updatedAt: now },
{ _id: categoryIds.produce, name: 'Produce', createdAt: now, updatedAt: now },
]);
console.log('Inserted 4 categories');
// Insert subcategories
const subcategoryIds = {
butterSpreads: new ObjectId(),
flours: new ObjectId(),
freshFish: new ObjectId(),
spicesExtracts: new ObjectId(),
fruits: new ObjectId(),
};
await db.collection('subcategories').insertMany([
{ _id: subcategoryIds.butterSpreads, name: 'Butter & Spreads', categoryId: categoryIds.dairy, createdAt: now, updatedAt: now },
{ _id: subcategoryIds.flours, name: 'Flours', categoryId: categoryIds.dryGoods, createdAt: now, updatedAt: now },
{ _id: subcategoryIds.freshFish, name: 'Fresh Fish', categoryId: categoryIds.seafood, createdAt: now, updatedAt: now },
{ _id: subcategoryIds.spicesExtracts, name: 'Spices & Extracts', categoryId: categoryIds.dryGoods, createdAt: now, updatedAt: now },
{ _id: subcategoryIds.fruits, name: 'Fruits', categoryId: categoryIds.produce, createdAt: now, updatedAt: now },
]);
console.log('Inserted 5 subcategories');
// Insert suppliers
const supplierIds = {
dairyFresh: new ObjectId(),
grainMasters: new ObjectId(),
oceanHarvest: new ObjectId(),
spiceWorld: new ObjectId(),
freshFarm: new ObjectId(),
};
await db.collection('suppliers').insertMany([
{ _id: supplierIds.dairyFresh, name: 'DairyFresh Co.', createdAt: now, updatedAt: now },
{ _id: supplierIds.grainMasters, name: 'GrainMasters', createdAt: now, updatedAt: now },
{ _id: supplierIds.oceanHarvest, name: 'OceanHarvest', createdAt: now, updatedAt: now },
{ _id: supplierIds.spiceWorld, name: 'SpiceWorld Ltd', createdAt: now, updatedAt: now },
{ _id: supplierIds.freshFarm, name: 'FreshFarm Direct', createdAt: now, updatedAt: now },
]);
console.log('Inserted 5 suppliers');
// Insert products
await db.collection('products').insertMany([
{
code: 'PRD-001',
name: 'Organic Butter',
categoryId: categoryIds.dairy,
subcategoryId: subcategoryIds.butterSpreads,
quantity: 25,
unit: 'kg',
unitPrice: 4.50,
vat: 9,
supplierId: supplierIds.dairyFresh,
createdAt: new Date('2026-02-10'),
updatedAt: new Date('2026-02-10'),
},
{
code: 'PRD-002',
name: 'All-Purpose Flour',
categoryId: categoryIds.dryGoods,
subcategoryId: subcategoryIds.flours,
quantity: 150,
unit: 'kg',
unitPrice: 0.85,
vat: 9,
supplierId: supplierIds.grainMasters,
createdAt: new Date('2026-02-09'),
updatedAt: new Date('2026-02-09'),
},
{
code: 'PRD-003',
name: 'Atlantic Salmon Fillet',
categoryId: categoryIds.seafood,
subcategoryId: subcategoryIds.freshFish,
quantity: 12,
unit: 'kg',
unitPrice: 18.90,
vat: 9,
supplierId: supplierIds.oceanHarvest,
createdAt: new Date('2026-02-11'),
updatedAt: new Date('2026-02-11'),
},
{
code: 'PRD-004',
name: 'Madagascar Vanilla Extract',
categoryId: categoryIds.dryGoods,
subcategoryId: subcategoryIds.spicesExtracts,
quantity: 8,
unit: 'L',
unitPrice: 45.00,
vat: 21,
supplierId: supplierIds.spiceWorld,
createdAt: new Date('2026-02-08'),
updatedAt: new Date('2026-02-08'),
},
{
code: 'PRD-005',
name: 'Fresh Avocados',
categoryId: categoryIds.produce,
subcategoryId: subcategoryIds.fruits,
quantity: 40,
unit: 'pcs',
unitPrice: 1.20,
vat: 9,
supplierId: supplierIds.freshFarm,
createdAt: new Date('2026-02-11'),
updatedAt: new Date('2026-02-11'),
},
]);
console.log('Inserted 5 products');
console.log('Seeding complete!');
await client.close();
}
seed().catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});