diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e8c6373 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc:*)", + "Bash(npx tsx:*)", + "Bash(npm run build:*)" + ] + } +} diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..e091b14 --- /dev/null +++ b/app/api/categories/route.ts @@ -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 }); +} diff --git a/app/api/products/[id]/route.ts b/app/api/products/[id]/route.ts new file mode 100644 index 0000000..da616ff --- /dev/null +++ b/app/api/products/[id]/route.ts @@ -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 = { 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 }); +} diff --git a/app/api/products/route.ts b/app/api/products/route.ts new file mode 100644 index 0000000..a1afdc8 --- /dev/null +++ b/app/api/products/route.ts @@ -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 = {}; + + 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 }); +} diff --git a/app/api/subcategories/route.ts b/app/api/subcategories/route.ts new file mode 100644 index 0000000..bcc7d5c --- /dev/null +++ b/app/api/subcategories/route.ts @@ -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 = {}; + + 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 }); +} diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts new file mode 100644 index 0000000..0e23fc9 --- /dev/null +++ b/app/api/suppliers/route.ts @@ -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 }); +} diff --git a/app/dashboard/products/page.tsx b/app/dashboard/products/page.tsx index 1e2cc7b..b044ed9 100644 --- a/app/dashboard/products/page.tsx +++ b/app/dashboard/products/page.tsx @@ -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([]); + 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([]); + const [allSubcategories, setAllSubcategories] = useState([]); + 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 (
@@ -87,7 +129,7 @@ export default function ProductsPage() {

KitchenOS

-
{/* Spacer for centering */} +
@@ -109,11 +151,14 @@ export default function ProductsPage() {
{/* Search */} -
+
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" />
-
+ {/* Filters Row */}
{/* Category Filter */} - 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" + > + + {categories.map((cat) => ( + + ))} {/* Subcategory Filter */} - 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" + > + + {filteredSubcategories.map((sub) => ( + + ))} {/* Bulk Actions */} @@ -161,56 +211,95 @@ export default function ProductsPage() {
{/* Active Filters */} -
- Active filters: -
- - Category: Dry Goods - - + {activeFilters.length > 0 && ( +
+ Active filters: +
+ {activeFilters.map((filter) => ( + + {filter.label} + + + ))} +
+
- -
+ )}
{/* Product Table */} - + {loading ? ( +
+

Loading products...

+
+ ) : products.length === 0 ? ( +
+

No products found.

+
+ ) : ( + + )} {/* Pagination */} -
-

- Showing 1 to{' '} - 5 of{' '} - 5 results -

-
- - - - - + {total > 0 && ( +
+

+ Showing {startItem} to{' '} + {endItem} of{' '} + {total} results +

+
+ + {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) => ( + + {idx > 0 && arr[idx - 1] !== p - 1 && ( + + )} + + + ))} + +
-
+ )}
diff --git a/components/ProductTable.tsx b/components/ProductTable.tsx index 6da07bd..d7df313 100644 --- a/components/ProductTable.tsx +++ b/components/ProductTable.tsx @@ -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) { 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) { {products.map((product) => ( - + 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" />

{product.name}

-

ID: {product.id}

+

ID: {product.code}

{product.category} • {product.quantity} {product.unit}

@@ -141,19 +143,19 @@ export default function ProductTable({ products }: ProductTableProps) { {/* Mobile Card View */}
{products.map((product) => ( -
+
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" />

{product.name}

-

ID: {product.id}

+

ID: {product.code}

Updated

-

{product.lastUpdated}

+

{new Date(product.updatedAt).toLocaleDateString()}

diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8eb4f62 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..0032cb4 --- /dev/null +++ b/lib/mongodb.ts @@ -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 { + 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; +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..4a3f94e --- /dev/null +++ b/lib/types.ts @@ -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; +} diff --git a/package-lock.json b/package-lock.json index f35c1a2..454bed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d46b77d..fb22ccb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "mongodb": "^7.1.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..97e4f89 --- /dev/null +++ b/scripts/seed.ts @@ -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); +});