diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..bbb7fed --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import bcrypt from "bcryptjs"; +import { cookies } from "next/headers"; +import { randomUUID } from "crypto"; + +export async function POST(request: Request) { + try { + const sql = getDb(); + const { email, password } = await request.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + // Find user + const users = await sql( + `SELECT u.*, + up.can_view_financial, up.can_edit, up.can_create, up.can_delete, up.can_download, + up.can_view_dashboard, up.can_view_components, up.can_view_products, up.can_view_customers, + up.can_view_suppliers, up.can_view_production, up.can_view_stock_history, up.can_view_logistics, + up.can_view_stock_take, up.can_view_settings + FROM users u + LEFT JOIN user_permissions up ON u.id = up.user_id + WHERE u.email = $1 AND u.is_active = true`, + [email.toLowerCase()] + ); + + if (users.length === 0) { + return NextResponse.json( + { error: "Invalid email or password" }, + { status: 401 } + ); + } + + const user = users[0]; + + // Verify password + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + return NextResponse.json( + { error: "Invalid email or password" }, + { status: 401 } + ); + } + + // Create session + const sessionToken = randomUUID(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await sql( + `INSERT INTO sessions (user_id, token, expires_at) VALUES ($1, $2, $3)`, + [user.id, sessionToken, expiresAt.toISOString()] + ); + + // Set cookie + const cookieStore = await cookies(); + cookieStore.set("session_token", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + expires: expiresAt, + path: "/", + }); + + // Build sections array from individual flags + const sections: string[] = []; + if (user.can_view_dashboard) sections.push("dashboard"); + if (user.can_view_components) sections.push("components"); + if (user.can_view_products) sections.push("products"); + if (user.can_view_customers) sections.push("customers"); + if (user.can_view_suppliers) sections.push("suppliers"); + if (user.can_view_production) sections.push("production"); + if (user.can_view_stock_history) sections.push("stock-history"); + if (user.can_view_logistics) sections.push("logistics"); + if (user.can_view_stock_take) sections.push("stock-take"); + if (user.can_view_settings || user.role === "admin") sections.push("settings"); + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + permissions: { + sections, + can_view_financial: user.can_view_financial ?? false, + can_edit: user.can_edit ?? false, + can_create: user.can_create ?? false, + can_delete: user.can_delete ?? false, + can_download: user.can_download ?? false, + }, + }, + }); + } catch (error) { + console.error("Login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..bf78f98 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { cookies } from "next/headers"; + +export async function POST() { + try { + const sql = getDb(); + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("session_token")?.value; + + if (sessionToken) { + await sql(`DELETE FROM sessions WHERE token = $1`, [sessionToken]); + } + + cookieStore.delete("session_token"); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Logout error:", error); + return NextResponse.json( + { error: "Failed to logout" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..9f45b66 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { cookies } from "next/headers"; + +export async function GET() { + try { + const sql = getDb(); + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("session_token")?.value; + + if (!sessionToken) { + return NextResponse.json({ user: null }); + } + + // Find session and user + const sessions = await sql( + `SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.is_active, + up.can_view_financial, up.can_edit, up.can_create, up.can_delete, up.can_download, + up.can_view_dashboard, up.can_view_components, up.can_view_products, up.can_view_customers, + up.can_view_suppliers, up.can_view_production, up.can_view_stock_history, up.can_view_logistics, + up.can_view_stock_take, up.can_view_settings + FROM sessions s + JOIN users u ON s.user_id = u.id + LEFT JOIN user_permissions up ON u.id = up.user_id + WHERE s.token = $1 AND s.expires_at > NOW()`, + [sessionToken] + ); + + if (sessions.length === 0 || !sessions[0].is_active) { + // Clear invalid session cookie + cookieStore.delete("session_token"); + return NextResponse.json({ user: null }); + } + + const session = sessions[0]; + + // Build sections array from individual flags + const sections: string[] = []; + if (session.can_view_dashboard) sections.push("dashboard"); + if (session.can_view_components) sections.push("components"); + if (session.can_view_products) sections.push("products"); + if (session.can_view_customers) sections.push("customers"); + if (session.can_view_suppliers) sections.push("suppliers"); + if (session.can_view_production) sections.push("production"); + if (session.can_view_stock_history) sections.push("stock-history"); + if (session.can_view_logistics) sections.push("logistics"); + if (session.can_view_stock_take) sections.push("stock-take"); + if (session.can_view_settings || session.role === "admin") sections.push("settings"); + + return NextResponse.json({ + user: { + id: session.user_id, + email: session.email, + name: session.name, + role: session.role, + permissions: { + sections, + can_view_financial: session.can_view_financial ?? false, + can_edit: session.can_edit ?? false, + can_create: session.can_create ?? false, + can_delete: session.can_delete ?? false, + can_download: session.can_download ?? false, + }, + }, + }); + } catch (error) { + console.error("Session error:", error); + return NextResponse.json({ user: null }); + } +} diff --git a/app/api/bom/[productId]/[itemId]/route.ts b/app/api/bom/[productId]/[itemId]/route.ts new file mode 100644 index 0000000..ac17e30 --- /dev/null +++ b/app/api/bom/[productId]/[itemId]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ productId: string; itemId: string }> } +) { + const sql = getDb(); + const { itemId } = await params; + const body = await request.json(); + const { component_id, quantity, reference_designator, notes } = body; + + const rows = await sql( + `UPDATE bom_items SET + component_id = $1, quantity = $2, reference_designator = $3, notes = $4 + WHERE id = $5 + RETURNING *`, + [component_id, quantity || 1, reference_designator || null, notes || null, Number(itemId)] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "BOM item not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ productId: string; itemId: string }> } +) { + const sql = getDb(); + const { itemId } = await params; + + await sql(`DELETE FROM bom_items WHERE id = $1`, [Number(itemId)]); + return NextResponse.json({ success: true }); +} diff --git a/app/api/bom/[productId]/route.ts b/app/api/bom/[productId]/route.ts new file mode 100644 index 0000000..651c9c1 --- /dev/null +++ b/app/api/bom/[productId]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ productId: string }> } +) { + const sql = getDb(); + const { productId } = await params; + + const rows = await sql( + `SELECT bi.*, + c.name as component_name, + c.part_number, + c.unit_price, + c.stock_quantity, + cat.name as category_name, + (bi.quantity * c.unit_price) as line_cost + FROM bom_items bi + JOIN components c ON bi.component_id = c.id + LEFT JOIN categories cat ON c.category_id = cat.id + WHERE bi.product_id = $1 + ORDER BY bi.reference_designator ASC, c.name ASC`, + [Number(productId)] + ); + + return NextResponse.json(rows); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ productId: string }> } +) { + const sql = getDb(); + const { productId } = await params; + const body = await request.json(); + const { component_id, quantity, reference_designator, notes } = body; + + const rows = await sql( + `INSERT INTO bom_items (product_id, component_id, quantity, reference_designator, notes) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + Number(productId), + component_id, + quantity || 1, + reference_designator || null, + notes || null, + ] + ); + + return NextResponse.json(rows[0], { status: 201 }); +} diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..846cbfc --- /dev/null +++ b/app/api/categories/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET() { + const sql = getDb(); + const rows = await sql(`SELECT * FROM categories ORDER BY name ASC`); + return NextResponse.json(rows); +} diff --git a/app/api/components/[id]/route.ts b/app/api/components/[id]/route.ts new file mode 100644 index 0000000..826db1f --- /dev/null +++ b/app/api/components/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { + part_number, + name, + description, + category_id, + manufacturer, + package_type, + unit_price, + stock_quantity, + min_stock_level, + datasheet_url, + supplier, + supplier_part_number, + shelf_location, + is_internal, + customer_supplied, + customer_id: cust_id, + specs, + } = body; + + const rows = await sql( + `UPDATE components SET + part_number = $1, name = $2, description = $3, category_id = $4, + manufacturer = $5, package_type = $6, unit_price = $7, stock_quantity = $8, + min_stock_level = $9, datasheet_url = $10, supplier = $11, supplier_part_number = $12, + shelf_location = $13, is_internal = $14, customer_supplied = $15, customer_id = $16, + specs = $17, updated_at = NOW() + WHERE id = $18 + RETURNING *`, + [ + part_number, + name, + description || null, + category_id || null, + manufacturer || null, + package_type || null, + unit_price || 0, + stock_quantity || 0, + min_stock_level || 0, + datasheet_url || null, + supplier || null, + supplier_part_number || null, + shelf_location || null, + is_internal ?? false, + customer_supplied ?? false, + customer_supplied ? (cust_id || null) : null, + JSON.stringify(specs || {}), + Number(id), + ] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Component not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const sql = getDb(); + const { id } = await params; + + await sql(`DELETE FROM components WHERE id = $1`, [Number(id)]); + return NextResponse.json({ success: true }); +} diff --git a/app/api/components/[id]/usage/route.ts b/app/api/components/[id]/usage/route.ts new file mode 100644 index 0000000..3bc2d67 --- /dev/null +++ b/app/api/components/[id]/usage/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const sql = getDb(); + const { id } = await params; + + const rows = await sql( + `SELECT bi.id, bi.quantity, bi.reference_designator, bi.notes, + p.id AS product_id, p.name AS product_name, p.sku, p.version, p.status + FROM bom_items bi + JOIN products p ON p.id = bi.product_id + WHERE bi.component_id = $1 + ORDER BY p.name ASC, bi.reference_designator ASC`, + [Number(id)] + ); + + return NextResponse.json(rows); +} diff --git a/app/api/components/route.ts b/app/api/components/route.ts new file mode 100644 index 0000000..b718a53 --- /dev/null +++ b/app/api/components/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || ""; + const category = searchParams.get("category") || ""; + const lowStock = searchParams.get("lowStock") === "true"; + const supplierName = searchParams.get("supplier_name") || ""; + + let query = ` + SELECT c.*, cat.name as category_name, + cust.name as customer_name, + COALESCE(( + SELECT SUM(spi.quantity - spi.received_quantity) + FROM supplier_po_items spi + JOIN supplier_pos sp ON sp.id = spi.supplier_po_id + WHERE spi.component_id = c.id + AND sp.status IN ('ordered', 'partially_received') + AND spi.quantity > spi.received_quantity + ), 0)::int AS on_order + FROM components c + LEFT JOIN categories cat ON c.category_id = cat.id + LEFT JOIN customers cust ON c.customer_id = cust.id + WHERE 1=1 + `; + const params: (string | number)[] = []; + + if (search) { + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + query += ` AND (c.name ILIKE $${params.length - 2} OR c.part_number ILIKE $${params.length - 1} OR c.manufacturer ILIKE $${params.length})`; + } + + if (category) { + params.push(Number(category)); + query += ` AND c.category_id = $${params.length}`; + } + + if (lowStock) { + query += ` AND c.stock_quantity <= c.min_stock_level`; + } + + if (supplierName) { + params.push(supplierName); + query += ` AND c.supplier = $${params.length}`; + } + + query += ` ORDER BY c.updated_at DESC`; + + const rows = await sql(query, params); + return NextResponse.json(rows); +} + +export async function POST(request: NextRequest) { + const sql = getDb(); + const body = await request.json(); + const { + part_number, + name, + description, + category_id, + manufacturer, + package_type, + unit_price, + stock_quantity, + min_stock_level, + datasheet_url, + supplier, + supplier_part_number, + shelf_location, + is_internal, + customer_supplied, + customer_id, + specs, + } = body; + + const rows = await sql( + `INSERT INTO components (part_number, name, description, category_id, manufacturer, package_type, unit_price, stock_quantity, min_stock_level, datasheet_url, supplier, supplier_part_number, shelf_location, is_internal, customer_supplied, customer_id, specs) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING *`, + [ + part_number, + name, + description || null, + category_id || null, + manufacturer || null, + package_type || null, + unit_price || 0, + stock_quantity || 0, + min_stock_level || 0, + datasheet_url || null, + supplier || null, + supplier_part_number || null, + shelf_location || null, + is_internal ?? false, + customer_supplied ?? false, + customer_supplied ? (customer_id || null) : null, + JSON.stringify(specs || {}), + ] + ); + + return NextResponse.json(rows[0], { status: 201 }); +} diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts new file mode 100644 index 0000000..129e63f --- /dev/null +++ b/app/api/customers/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const rows = await sql(`SELECT * FROM customers WHERE id = $1`, [Number(id)]); + + if (rows.length === 0) { + return NextResponse.json({ error: "Customer not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[v0] Customer GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch customer" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { name, code, email, phone, address, contact_person, notes } = body; + + const rows = await sql( + `UPDATE customers SET + name = $1, code = $2, email = $3, phone = $4, + address = $5, contact_person = $6, notes = $7, updated_at = NOW() + WHERE id = $8 + RETURNING *`, + [name, code, email || null, phone || null, address || null, contact_person || null, notes || null, Number(id)] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Customer not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[v0] Customer PUT error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update customer" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const orderCheck = await sql( + `SELECT COUNT(*) as count FROM purchase_orders WHERE customer_id = $1`, + [Number(id)] + ); + + if (Number(orderCheck[0].count) > 0) { + return NextResponse.json( + { error: "Cannot delete customer with existing orders. Delete orders first." }, + { status: 400 } + ); + } + + await sql(`DELETE FROM customers WHERE id = $1`, [Number(id)]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[v0] Customer DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete customer" }, + { status: 500 } + ); + } +} diff --git a/app/api/customers/[id]/stats/route.ts b/app/api/customers/[id]/stats/route.ts new file mode 100644 index 0000000..6ac73b6 --- /dev/null +++ b/app/api/customers/[id]/stats/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const { searchParams } = new URL(request.url); + const period = searchParams.get("period") || "month"; // week | month | year + + // --- On Order by product (not completed/cancelled) --- + const onOrder = await sql( + `SELECT p.id AS product_id, p.name AS product_name, p.sku, + SUM(poi.quantity) AS total_ordered, + SUM(COALESCE(poi.shipped_quantity, 0)) AS total_shipped, + SUM(poi.quantity - COALESCE(poi.shipped_quantity, 0)) AS on_order + FROM purchase_order_items poi + JOIN purchase_orders po ON po.id = poi.purchase_order_id + JOIN products p ON p.id = poi.product_id + WHERE po.customer_id = $1 + AND po.status NOT IN ('completed', 'cancelled') + AND poi.quantity > COALESCE(poi.shipped_quantity, 0) + GROUP BY p.id, p.name, p.sku + ORDER BY on_order DESC`, + [Number(id)] + ); + + // --- Produced/shipped by product over time --- + let dateFilter = ""; + if (period === "week") { + dateFilter = "AND pl.created_at >= NOW() - INTERVAL '7 days'"; + } else if (period === "month") { + dateFilter = "AND pl.created_at >= NOW() - INTERVAL '30 days'"; + } else if (period === "year") { + dateFilter = "AND pl.created_at >= NOW() - INTERVAL '365 days'"; + } + + // Aggregate shipped quantities by product (from packing lists) + const produced = await sql( + `SELECT p.id AS product_id, p.name AS product_name, p.sku, + SUM(pl.quantity) AS total_produced + FROM packing_lists pl + JOIN purchase_orders po ON po.id = pl.purchase_order_id + JOIN purchase_order_items poi ON poi.id = pl.po_item_id + JOIN products p ON p.id = poi.product_id + WHERE po.customer_id = $1 + ${dateFilter} + GROUP BY p.id, p.name, p.sku + ORDER BY total_produced DESC`, + [Number(id)] + ); + + // Produced over time bucketed by date for chart + let dateTrunc = "day"; + if (period === "year") dateTrunc = "month"; + + const producedTimeline = await sql( + `SELECT DATE_TRUNC('${dateTrunc}', pl.created_at) AS bucket, + p.name AS product_name, + p.sku, + SUM(pl.quantity) AS quantity + FROM packing_lists pl + JOIN purchase_orders po ON po.id = pl.purchase_order_id + JOIN purchase_order_items poi ON poi.id = pl.po_item_id + JOIN products p ON p.id = poi.product_id + WHERE po.customer_id = $1 + ${dateFilter} + GROUP BY bucket, p.id, p.name, p.sku + ORDER BY bucket ASC`, + [Number(id)] + ); + + return NextResponse.json({ onOrder, produced, producedTimeline }); + } catch (error) { + console.error("[v0] Customer stats GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch customer stats" }, + { status: 500 } + ); + } +} diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts new file mode 100644 index 0000000..bcbafe2 --- /dev/null +++ b/app/api/customers/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || ""; + + let rows; + if (search) { + rows = await sql( + `SELECT c.*, + COUNT(po.id) AS order_count, + COALESCE(SUM( + CASE WHEN po.status != 'cancelled' THEN + (SELECT COALESCE(SUM(poi.quantity * poi.unit_price), 0) FROM purchase_order_items poi WHERE poi.purchase_order_id = po.id) + ELSE 0 END + ), 0) AS total_revenue + FROM customers c + LEFT JOIN purchase_orders po ON po.customer_id = c.id + WHERE c.name ILIKE $1 OR c.code ILIKE $1 OR c.email ILIKE $1 OR c.contact_person ILIKE $1 + GROUP BY c.id + ORDER BY c.name ASC`, + [`%${search}%`] + ); + } else { + rows = await sql( + `SELECT c.*, + COUNT(po.id) AS order_count, + COALESCE(SUM( + CASE WHEN po.status != 'cancelled' THEN + (SELECT COALESCE(SUM(poi.quantity * poi.unit_price), 0) FROM purchase_order_items poi WHERE poi.purchase_order_id = po.id) + ELSE 0 END + ), 0) AS total_revenue + FROM customers c + LEFT JOIN purchase_orders po ON po.customer_id = c.id + GROUP BY c.id + ORDER BY c.name ASC` + ); + } + + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] Customers GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch customers" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { name, code, email, phone, address, contact_person, notes } = body; + + if (!name || !code) { + return NextResponse.json({ error: "Name and code are required" }, { status: 400 }); + } + + const rows = await sql( + `INSERT INTO customers (name, code, email, phone, address, contact_person, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [name, code, email || null, phone || null, address || null, contact_person || null, notes || null] + ); + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[v0] Customers POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create customer" }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..d78e249 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET() { + try { + const sql = getDb(); + + const [componentStats, productStats, lowStockItems, categoryBreakdown, recentOrders, orderStats] = + await Promise.all([ + sql(` + SELECT + COUNT(*) as total_components, + COALESCE(SUM(stock_quantity * unit_price), 0) as total_inventory_value, + COUNT(CASE WHEN stock_quantity <= min_stock_level THEN 1 END) as low_stock_count + FROM components + `), + sql(` + SELECT + COUNT(*) as total_products, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_products, + COUNT(CASE WHEN status = 'draft' THEN 1 END) as draft_products + FROM products + `), + sql(` + SELECT c.*, cat.name as category_name + FROM components c + LEFT JOIN categories cat ON c.category_id = cat.id + WHERE c.stock_quantity <= c.min_stock_level + ORDER BY (c.stock_quantity::float / GREATEST(c.min_stock_level, 1)) ASC + LIMIT 20 + `), + sql(` + SELECT cat.name, COUNT(c.id) as count, COALESCE(SUM(c.stock_quantity), 0) as total_stock + FROM categories cat + LEFT JOIN components c ON cat.id = c.category_id + GROUP BY cat.id, cat.name + ORDER BY count DESC + `), + sql(` + SELECT po.id, po.po_number, po.status, po.order_date, po.due_date, + po.customer_id, + c.name AS customer_name, c.code AS customer_code, + COALESCE(SUM(poi.quantity), 0) AS total_ordered, + COALESCE(SUM(poi.shipped_quantity), 0) AS total_shipped, + COALESCE(SUM(poi.quantity), 0) - COALESCE(SUM(poi.shipped_quantity), 0) AS total_remaining, + COALESCE(SUM(poi.quantity * poi.unit_price), 0) AS total_value, + COUNT(poi.id) AS item_count + FROM purchase_orders po + JOIN customers c ON c.id = po.customer_id + LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id + WHERE po.status NOT IN ('completed', 'cancelled') + GROUP BY po.id, po.po_number, po.status, po.order_date, po.due_date, po.customer_id, c.name, c.code + ORDER BY + CASE po.status + WHEN 'in_production' THEN 1 + WHEN 'confirmed' THEN 2 + WHEN 'shipped' THEN 3 + WHEN 'draft' THEN 4 + END, + po.due_date ASC NULLS LAST, + po.order_date DESC + LIMIT 15 + `), + sql(` + SELECT + COUNT(*) AS total_orders, + COUNT(CASE WHEN status NOT IN ('completed', 'cancelled') THEN 1 END) AS active_orders, + COUNT(CASE WHEN status = 'in_production' THEN 1 END) AS in_production, + COUNT(CASE WHEN status = 'confirmed' THEN 1 END) AS confirmed, + COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped + FROM purchase_orders + `), + ]); + + return NextResponse.json({ + stats: { + total_components: Number(componentStats[0].total_components), + total_inventory_value: Number(componentStats[0].total_inventory_value), + low_stock_count: Number(componentStats[0].low_stock_count), + total_products: Number(productStats[0].total_products), + active_products: Number(productStats[0].active_products), + draft_products: Number(productStats[0].draft_products), + active_orders: Number(orderStats[0].active_orders), + in_production_orders: Number(orderStats[0].in_production), + confirmed_orders: Number(orderStats[0].confirmed), + shipped_orders: Number(orderStats[0].shipped), + }, + low_stock_items: lowStockItems, + category_breakdown: categoryBreakdown, + recent_orders: recentOrders, + }); + } catch (error) { + console.error("[v0] Dashboard API error:", error); + return NextResponse.json( + { error: "Failed to load dashboard data" }, + { status: 500 } + ); + } +} diff --git a/app/api/orders/[id]/items/route.ts b/app/api/orders/[id]/items/route.ts new file mode 100644 index 0000000..fcd0451 --- /dev/null +++ b/app/api/orders/[id]/items/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const rows = await sql( + `SELECT poi.*, + p.name AS product_name, + p.sku AS product_sku, + (poi.quantity * poi.unit_price) AS line_total, + COALESCE(poi.shipped_quantity, 0) AS shipped_quantity, + (poi.quantity - COALESCE(poi.shipped_quantity, 0)) AS remaining_quantity + FROM purchase_order_items poi + JOIN products p ON p.id = poi.product_id + WHERE poi.purchase_order_id = $1 + ORDER BY poi.id ASC`, + [Number(id)] + ); + + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] PO items GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch PO items" }, + { status: 500 } + ); + } +} diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts new file mode 100644 index 0000000..b8f3d13 --- /dev/null +++ b/app/api/orders/[id]/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const orderRows = await sql( + `SELECT po.*, c.name AS customer_name, c.code AS customer_code, + c.email AS customer_email, c.phone AS customer_phone, + c.address AS customer_address, c.contact_person AS customer_contact + FROM purchase_orders po + JOIN customers c ON c.id = po.customer_id + WHERE po.id = $1`, + [Number(id)] + ); + + if (orderRows.length === 0) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + const items = await sql( + `SELECT poi.*, p.name AS product_name, p.sku AS product_sku, + (poi.quantity * poi.unit_price) AS line_total, + COALESCE(poi.shipped_quantity, 0) AS shipped_quantity, + (poi.quantity - COALESCE(poi.shipped_quantity, 0)) AS remaining_quantity + FROM purchase_order_items poi + JOIN products p ON p.id = poi.product_id + WHERE poi.purchase_order_id = $1 + ORDER BY poi.created_at ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...orderRows[0], items }); + } catch (error) { + console.error("[v0] Order GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch order" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { customer_id, po_number, customer_ref, status, order_date, due_date, notes, items } = body; + + const orderRows = await sql( + `UPDATE purchase_orders SET + customer_id = $1, po_number = $2, customer_ref = $3, status = $4, + order_date = $5, due_date = $6, notes = $7, updated_at = NOW() + WHERE id = $8 + RETURNING *`, + [customer_id, po_number, customer_ref || null, status, order_date, due_date || null, notes || null, Number(id)] + ); + + if (orderRows.length === 0) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + // Replace items + if (items && Array.isArray(items)) { + await sql(`DELETE FROM purchase_order_items WHERE purchase_order_id = $1`, [Number(id)]); + for (const item of items) { + await sql( + `INSERT INTO purchase_order_items (purchase_order_id, product_id, quantity, unit_price, notes) + VALUES ($1, $2, $3, $4, $5)`, + [Number(id), item.product_id, item.quantity, item.unit_price || 0, item.notes || null] + ); + } + } + + return NextResponse.json(orderRows[0]); + } catch (error) { + console.error("[v0] Order PUT error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update order" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + await sql(`DELETE FROM purchase_order_items WHERE purchase_order_id = $1`, [Number(id)]); + await sql(`DELETE FROM purchase_orders WHERE id = $1`, [Number(id)]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[v0] Order DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete order" }, + { status: 500 } + ); + } +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts new file mode 100644 index 0000000..23ace79 --- /dev/null +++ b/app/api/orders/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const customerId = searchParams.get("customerId"); + const status = searchParams.get("status"); + const search = searchParams.get("search") || ""; + + let query = ` + SELECT po.*, + c.name AS customer_name, + c.code AS customer_code, + COUNT(poi.id) AS item_count, + COALESCE(SUM(poi.quantity * poi.unit_price), 0) AS total_value, + COALESCE(SUM(poi.quantity), 0) AS total_ordered, + COALESCE(SUM(poi.shipped_quantity), 0) AS total_shipped + FROM purchase_orders po + JOIN customers c ON c.id = po.customer_id + LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id + `; + + const conditions: string[] = []; + const params: (string | number)[] = []; + let idx = 1; + + if (customerId) { + conditions.push(`po.customer_id = $${idx++}`); + params.push(Number(customerId)); + } + if (status) { + const statuses = status.split(",").map((s) => s.trim()); + const placeholders = statuses.map(() => `$${idx++}`).join(", "); + conditions.push(`po.status IN (${placeholders})`); + params.push(...statuses); + } + if (search) { + conditions.push( + `(po.po_number ILIKE $${idx} OR c.name ILIKE $${idx} OR po.customer_ref ILIKE $${idx})` + ); + params.push(`%${search}%`); + idx++; + } + + if (conditions.length > 0) { + query += ` WHERE ${conditions.join(" AND ")}`; + } + + query += ` GROUP BY po.id, c.name, c.code ORDER BY po.created_at DESC`; + + const rows = await sql(query, params); + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] Orders GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch orders" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { + customer_id, + po_number, + customer_ref, + status, + order_date, + due_date, + notes, + items, + } = body; + + if (!customer_id || !po_number) { + return NextResponse.json( + { error: "Customer and PO number are required" }, + { status: 400 } + ); + } + + // Create the order + const orderRows = await sql( + `INSERT INTO purchase_orders (customer_id, po_number, customer_ref, status, order_date, due_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + customer_id, + po_number, + customer_ref || null, + status || "draft", + order_date || new Date().toISOString().slice(0, 10), + due_date || null, + notes || null, + ] + ); + + const order = orderRows[0]; + + // Create items if provided + if (items && Array.isArray(items) && items.length > 0) { + for (const item of items) { + await sql( + `INSERT INTO purchase_order_items (purchase_order_id, product_id, quantity, unit_price, notes) + VALUES ($1, $2, $3, $4, $5)`, + [ + order.id, + item.product_id, + item.quantity, + item.unit_price || 0, + item.notes || null, + ] + ); + } + } + + return NextResponse.json(order, { status: 201 }); + } catch (error) { + console.error("[v0] Orders POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create order" }, + { status: 500 } + ); + } +} diff --git a/app/api/packing-lists/[id]/route.ts b/app/api/packing-lists/[id]/route.ts new file mode 100644 index 0000000..8b806f1 --- /dev/null +++ b/app/api/packing-lists/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const plRows = await sql( + `SELECT pl.*, + po.po_number, + po.customer_ref, + po.order_date, + c.name AS customer_name, + c.code AS customer_code, + c.email AS customer_email, + c.phone AS customer_phone, + c.address AS customer_address, + c.contact_person AS customer_contact, + p.name AS product_name, + p.sku AS product_sku + FROM packing_lists pl + JOIN purchase_orders po ON po.id = pl.purchase_order_id + JOIN customers c ON c.id = po.customer_id + JOIN purchase_order_items poi ON poi.id = pl.po_item_id + JOIN products p ON p.id = poi.product_id + WHERE pl.id = $1`, + [Number(id)] + ); + + if (plRows.length === 0) { + return NextResponse.json({ error: "Packing list not found" }, { status: 404 }); + } + + const serials = await sql( + `SELECT * FROM packing_list_serials WHERE packing_list_id = $1 ORDER BY id ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...plRows[0], serials }); + } catch (error) { + console.error("[v0] Packing list detail GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch packing list" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + // Get the packing list details to reverse shipped_quantity + const plRows = await sql( + `SELECT * FROM packing_lists WHERE id = $1`, + [Number(id)] + ); + if (plRows.length === 0) { + return NextResponse.json({ error: "Packing list not found" }, { status: 404 }); + } + + const pl = plRows[0]; + + // Delete serials + await sql(`DELETE FROM packing_list_serials WHERE packing_list_id = $1`, [Number(id)]); + + // Delete packing list + await sql(`DELETE FROM packing_lists WHERE id = $1`, [Number(id)]); + + // Reverse the shipped_quantity + await sql( + `UPDATE purchase_order_items SET shipped_quantity = GREATEST(0, COALESCE(shipped_quantity, 0) - $1) WHERE id = $2`, + [Number(pl.quantity), Number(pl.po_item_id)] + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[v0] Packing list DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete packing list" }, + { status: 500 } + ); + } +} diff --git a/app/api/packing-lists/route.ts b/app/api/packing-lists/route.ts new file mode 100644 index 0000000..c6d607a --- /dev/null +++ b/app/api/packing-lists/route.ts @@ -0,0 +1,174 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const search = searchParams.get("search"); + const orderId = searchParams.get("orderId"); + + let query = ` + SELECT pl.*, + po.po_number, + c.name AS customer_name, + c.code AS customer_code, + c.email AS customer_email, + c.phone AS customer_phone, + c.address AS customer_address, + p.name AS product_name, + p.sku AS product_sku, + (SELECT COUNT(*) FROM packing_list_serials pls WHERE pls.packing_list_id = pl.id) AS serial_count + FROM packing_lists pl + JOIN purchase_orders po ON po.id = pl.purchase_order_id + JOIN customers c ON c.id = po.customer_id + JOIN purchase_order_items poi ON poi.id = pl.po_item_id + JOIN products p ON p.id = poi.product_id + `; + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (orderId) { + params.push(Number(orderId)); + conditions.push(`pl.purchase_order_id = $${params.length}`); + } + if (search) { + params.push(`%${search}%`); + const i = params.length; + conditions.push( + `(pl.packing_number ILIKE $${i} OR po.po_number ILIKE $${i} OR c.name ILIKE $${i} OR p.name ILIKE $${i})` + ); + } + + if (conditions.length > 0) { + query += ` WHERE ${conditions.join(" AND ")}`; + } + query += ` ORDER BY pl.created_at DESC`; + + const rows = await sql(query, params); + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] Packing list GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch packing lists" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { purchase_order_id, po_item_id, serial_numbers, notes } = body; + + if (!purchase_order_id || !po_item_id || !serial_numbers?.length) { + return NextResponse.json( + { error: "purchase_order_id, po_item_id, and serial_numbers are required" }, + { status: 400 } + ); + } + + const quantity = serial_numbers.length; + + // Fetch customer name for packing number generation + const custRows = await sql( + `SELECT c.name FROM purchase_orders po JOIN customers c ON c.id = po.customer_id WHERE po.id = $1`, + [purchase_order_id] + ); + if (custRows.length === 0) { + return NextResponse.json({ error: "Purchase order not found" }, { status: 404 }); + } + const customerName = String(custRows[0].name).replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); + + // Generate packing number: PL-EACH-YYYY_MM_DD-RR-CUSTOMERNAME + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const datePrefix = `PL-EACH-${yyyy}_${mm}_${dd}`; + const dateSuffix = `-${customerName}`; + const pattern = `${datePrefix}-__-${customerName}`; + + // Count existing packing lists with same date + customer to determine revision + const existingRows = await sql( + `SELECT COUNT(*) AS cnt FROM packing_lists WHERE packing_number LIKE $1`, + [`${datePrefix}-%${dateSuffix}`] + ); + const revision = Number(existingRows[0].cnt) + 1; + const packing_number = `${datePrefix}-${String(revision).padStart(2, "0")}${dateSuffix}`; + + // Check remaining capacity on the PO item + const poItemRows = await sql( + `SELECT poi.quantity, COALESCE(poi.shipped_quantity, 0) AS shipped_quantity, poi.product_id, p.name AS product_name + FROM purchase_order_items poi + JOIN products p ON p.id = poi.product_id + WHERE poi.id = $1`, + [po_item_id] + ); + if (poItemRows.length === 0) { + return NextResponse.json({ error: "PO item not found" }, { status: 404 }); + } + const poItem = poItemRows[0]; + const productId = poItemRows[0].product_id; + const remaining = Number(poItem.quantity) - Number(poItem.shipped_quantity); + if (quantity > remaining) { + return NextResponse.json( + { error: `Only ${remaining} units remaining to ship for ${poItem.product_name}` }, + { status: 400 } + ); + } + + // Check for duplicate serial numbers within the same Purchase Order + if (serial_numbers.length > 0) { + const placeholders = serial_numbers.map((_: string, i: number) => `$${i + 2}`).join(", "); + const dupes = await sql( + `SELECT pls.serial_number + FROM packing_list_serials pls + JOIN packing_lists pl ON pl.id = pls.packing_list_id + WHERE pl.purchase_order_id = $1 + AND pls.serial_number IN (${placeholders})`, + [purchase_order_id, ...serial_numbers] + ); + if (dupes.length > 0) { + const dupeList = dupes.map((d: { serial_number: string }) => d.serial_number).join(", "); + return NextResponse.json( + { error: `Duplicate serial number(s) in this PO: ${dupeList}` }, + { status: 400 } + ); + } + } + + // Create packing list + const plRows = await sql( + `INSERT INTO packing_lists (purchase_order_id, po_item_id, packing_number, quantity, notes) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [purchase_order_id, po_item_id, packing_number, quantity, notes || null] + ); + const packingList = plRows[0]; + + // Insert serial numbers + for (const sn of serial_numbers) { + await sql( + `INSERT INTO packing_list_serials (packing_list_id, serial_number) + VALUES ($1, $2)`, + [packingList.id, sn] + ); + } + + // Update shipped_quantity on the PO item + await sql( + `UPDATE purchase_order_items SET shipped_quantity = COALESCE(shipped_quantity, 0) + $1 WHERE id = $2`, + [quantity, po_item_id] + ); + + return NextResponse.json(packingList, { status: 201 }); + } catch (error) { + console.error("[v0] Packing list POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create packing list" }, + { status: 500 } + ); + } +} diff --git a/app/api/planning-history/[id]/route.ts b/app/api/planning-history/[id]/route.ts new file mode 100644 index 0000000..693b7e9 --- /dev/null +++ b/app/api/planning-history/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const sql = getDb(); + + const planRows = await sql( + `SELECT * FROM planning_history WHERE id = $1`, + [Number(id)] + ); + if (planRows.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + // Fetch items with PO info only if the component actually exists in the PO + const items = await sql( + `SELECT phi.*, phi.stock_at_time AS stock_snapshot, + (SELECT sp.po_number FROM supplier_pos sp + JOIN supplier_po_items spi ON spi.supplier_po_id = sp.id + WHERE sp.planning_id = phi.planning_id + AND spi.component_id = phi.component_id + ORDER BY sp.created_at DESC + LIMIT 1) AS po_number, + (SELECT sp.status FROM supplier_pos sp + JOIN supplier_po_items spi ON spi.supplier_po_id = sp.id + WHERE sp.planning_id = phi.planning_id + AND spi.component_id = phi.component_id + ORDER BY sp.created_at DESC + LIMIT 1) AS po_status + FROM planning_history_items phi + WHERE phi.planning_id = $1 ORDER BY phi.component_name ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...planRows[0], items }); + } catch (error) { + console.error("Error fetching planning detail:", error); + return NextResponse.json( + { error: "Failed to fetch planning detail" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const sql = getDb(); + await sql(`DELETE FROM planning_history_items WHERE planning_id = $1`, [Number(id)]); + await sql(`DELETE FROM planning_history WHERE id = $1`, [Number(id)]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting planning:", error); + return NextResponse.json( + { error: "Failed to delete planning" }, + { status: 500 } + ); + } +} diff --git a/app/api/planning-history/route.ts b/app/api/planning-history/route.ts new file mode 100644 index 0000000..aa27ed0 --- /dev/null +++ b/app/api/planning-history/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET() { + try { + const sql = getDb(); + const rows = await sql( + `SELECT ph.*, + COUNT(phi.id) AS item_count, + COALESCE(SUM(phi.quantity_needed), 0) AS total_quantity, + COUNT(CASE WHEN phi.shortage > 0 THEN 1 END) AS shortage_count, + (SELECT string_agg(DISTINCT sp.po_number, ', ' ORDER BY sp.po_number) + FROM supplier_pos sp WHERE sp.planning_id = ph.id) AS linked_po_numbers + FROM planning_history ph + LEFT JOIN planning_history_items phi ON phi.planning_id = ph.id + GROUP BY ph.id + ORDER BY ph.created_at DESC` + ); + return NextResponse.json(rows); + } catch (error) { + console.error("Error fetching planning history:", error); + return NextResponse.json( + { error: "Failed to fetch planning history" }, + { status: 500 } + ); + } +} diff --git a/app/api/production/accept/route.ts b/app/api/production/accept/route.ts new file mode 100644 index 0000000..5c30da5 --- /dev/null +++ b/app/api/production/accept/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +interface SourceInfo { + product_name: string; + sku: string; + qty_per_unit: number; + batch_size: number; + subtotal: number; +} + +interface AcceptItem { + component_id: number; + part_number: string; + component_name: string; + category_name: string | null; + package_type: string | null; + shelf_location: string | null; + unit_price: number; + stock_quantity: number; + total_needed: number; + shortage: number; + is_internal: boolean; + manufacturer: string | null; + supplier: string | null; + supplier_part_number: string | null; + sources: SourceInfo[]; +} + +interface AcceptProduct { + product_name: string; + sku: string; + batch_size: number; +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { name, items, products, summary, link_po_ids } = body as { + name: string; + items: AcceptItem[]; + products: AcceptProduct[]; + summary: { + totalProducts: number; + totalComponents: number; + totalParts: number; + totalCost: number; + shortageCount: number; + }; + link_po_ids?: number[]; + }; + + if (!items || items.length === 0) { + return NextResponse.json( + { error: "No items to accept" }, + { status: 400 } + ); + } + + const planName = + name || + `Production Plan ${new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })}`; + + // 1. Create the planning history record + const phRows = await sql( + `INSERT INTO planning_history (name, status, products_json, summary_json) + VALUES ($1, $2, $3, $4) RETURNING *`, + [planName, "accepted", JSON.stringify(products || []), JSON.stringify(summary || {})] + ); + const plan = phRows[0]; + + // 2. Insert all items with full data snapshots + for (const item of items) { + await sql( + `INSERT INTO planning_history_items + (planning_id, component_id, part_number, component_name, category_name, + package_type, shelf_location, unit_price, stock_at_time, + quantity_needed, shortage, is_internal, + manufacturer, supplier, supplier_part_number, sources_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + [ + plan.id, + item.component_id, + item.part_number, + item.component_name, + item.category_name || null, + item.package_type || null, + item.shelf_location || null, + item.unit_price || 0, + item.stock_quantity || 0, + item.total_needed, + item.shortage || 0, + item.is_internal, + item.manufacturer || null, + item.supplier || null, + item.supplier_part_number || null, + JSON.stringify(item.sources || []), + ] + ); + } + + // 3. Decrement stock for ALL components (both internal and external) + for (const item of items) { + await sql( + `UPDATE components SET stock_quantity = GREATEST(0, stock_quantity - $1) WHERE id = $2`, + [item.total_needed, item.component_id] + ); + } + + // 4. Link selected Supplier POs to this planning record + if (link_po_ids && link_po_ids.length > 0) { + for (const poId of link_po_ids) { + await sql( + `UPDATE supplier_pos SET planning_id = $1 WHERE id = $2`, + [plan.id, poId] + ); + } + } + + return NextResponse.json({ + plan, + message: "Planning accepted. History saved and stock decremented.", + }, { status: 201 }); + } catch (error) { + console.error("Error accepting production plan:", error); + return NextResponse.json( + { error: "Failed to accept production plan" }, + { status: 500 } + ); + } +} diff --git a/app/api/production/route.ts b/app/api/production/route.ts new file mode 100644 index 0000000..1f6c452 --- /dev/null +++ b/app/api/production/route.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function POST(request: NextRequest) { + const sql = getDb(); + const body = await request.json(); + const entries: { productId: number; batchSize: number }[] = body.entries; + + if (!entries || entries.length === 0) { + return NextResponse.json( + { error: "At least one product entry is required" }, + { status: 400 } + ); + } + + // Fetch product info for all selected products + const productIds = entries.map((e) => e.productId); + const products = await sql( + `SELECT * FROM products WHERE id = ANY($1::int[])`, + [productIds] + ); + const productMap = new Map( + products.map((p: Record) => [Number(p.id), p]) + ); + + // Per-product breakdowns and aggregate component demand + const componentDemand = new Map< + number, + { + component_id: number; + part_number: string; + component_name: string; + manufacturer: string | null; + supplier: string | null; + supplier_part_number: string | null; + shelf_location: string | null; + unit_price: number; + stock_quantity: number; + package_type: string | null; + category_name: string | null; + is_internal: boolean; + total_needed: number; + sources: { product_name: string; sku: string; qty_per_unit: number; batch_size: number; subtotal: number }[]; + } + >(); + + const perProduct: { + product: Record; + batchSize: number; + items: Record[]; + totalCost: number; + totalParts: number; + }[] = []; + + for (const entry of entries) { + const product = productMap.get(entry.productId); + if (!product) continue; + + const bomItems = await sql( + `SELECT + bi.id as bom_item_id, + bi.quantity as quantity_per_unit, + bi.reference_designator, + c.id as component_id, + c.part_number, + c.name as component_name, + c.manufacturer, + c.supplier, + c.supplier_part_number, + c.shelf_location, + c.unit_price, + c.stock_quantity, + c.package_type, + c.is_internal, + cat.name as category_name, + (bi.quantity * $2) as total_needed, + (bi.quantity * $2 * c.unit_price) as line_cost + FROM bom_items bi + JOIN components c ON bi.component_id = c.id + LEFT JOIN categories cat ON c.category_id = cat.id + WHERE bi.product_id = $1 + ORDER BY c.name ASC`, + [entry.productId, entry.batchSize] + ); + + const totalCost = bomItems.reduce( + (sum: number, item: Record) => + sum + Number(item.line_cost), + 0 + ); + const totalParts = bomItems.reduce( + (sum: number, item: Record) => + sum + Number(item.total_needed), + 0 + ); + + perProduct.push({ + product, + batchSize: entry.batchSize, + items: bomItems, + totalCost, + totalParts, + }); + + // Aggregate component demand across products + for (const item of bomItems) { + const cid = Number(item.component_id); + const existing = componentDemand.get(cid); + const needed = Number(item.total_needed); + if (existing) { + existing.total_needed += needed; + existing.sources.push({ + product_name: product.name as string, + sku: product.sku as string, + qty_per_unit: Number(item.quantity_per_unit), + batch_size: entry.batchSize, + subtotal: needed, + }); + } else { + componentDemand.set(cid, { + component_id: cid, + part_number: item.part_number as string, + component_name: item.component_name as string, + manufacturer: (item.manufacturer as string) || null, + supplier: (item.supplier as string) || null, + supplier_part_number: (item.supplier_part_number as string) || null, + shelf_location: (item.shelf_location as string) || null, + unit_price: Number(item.unit_price), + stock_quantity: Number(item.stock_quantity), + package_type: (item.package_type as string) || null, + category_name: (item.category_name as string) || null, + is_internal: Boolean(item.is_internal), + total_needed: needed, + sources: [ + { + product_name: product.name as string, + sku: product.sku as string, + qty_per_unit: Number(item.quantity_per_unit), + batch_size: entry.batchSize, + subtotal: needed, + }, + ], + }); + } + } + } + + // Compute shortages against shared stock + const aggregatedItems = Array.from(componentDemand.values()).map((item) => ({ + ...item, + shortage: Math.max(0, item.total_needed - item.stock_quantity), + line_cost: item.total_needed * item.unit_price, + })); + + aggregatedItems.sort((a, b) => a.component_name.localeCompare(b.component_name)); + + const shortageItems = aggregatedItems.filter((item) => item.shortage > 0); + + const totalComponents = aggregatedItems.length; + const totalParts = aggregatedItems.reduce((s, i) => s + i.total_needed, 0); + const totalCost = aggregatedItems.reduce((s, i) => s + i.line_cost, 0); + + return NextResponse.json({ + entries: perProduct.map((p) => ({ + product: p.product, + batchSize: p.batchSize, + totalCost: p.totalCost, + totalParts: p.totalParts, + itemCount: p.items.length, + })), + summary: { + totalProducts: perProduct.length, + totalComponents, + totalParts, + totalCost, + shortageCount: shortageItems.length, + allInStock: shortageItems.length === 0, + }, + items: aggregatedItems, + purchaseList: shortageItems.map((item) => ({ + component_id: item.component_id, + part_number: item.part_number, + component_name: item.component_name, + manufacturer: item.manufacturer, + supplier: item.supplier || "N/A", + supplier_part_number: item.supplier_part_number || "N/A", + shelf_location: item.shelf_location || "N/A", + unit_price: item.unit_price, + stock_quantity: item.stock_quantity, + total_needed: item.total_needed, + shortage: item.shortage, + purchase_cost: item.shortage * item.unit_price, + category_name: item.category_name, + is_internal: item.is_internal, + sources: item.sources, + })), + }); +} diff --git a/app/api/products/[id]/route.ts b/app/api/products/[id]/route.ts new file mode 100644 index 0000000..698202a --- /dev/null +++ b/app/api/products/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { name, sku, description, version, status } = body; + + const rows = await sql( + `UPDATE products SET + name = $1, sku = $2, description = $3, version = $4, status = $5, updated_at = NOW() + WHERE id = $6 + RETURNING *`, + [name, sku, description || null, version || "1.0", status || "draft", Number(id)] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Product not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const sql = getDb(); + const { id } = await params; + + await sql(`DELETE FROM products WHERE id = $1`, [Number(id)]); + 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..5125a81 --- /dev/null +++ b/app/api/products/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET() { + const sql = getDb(); + const rows = await sql(` + SELECT p.*, + COALESCE(bom.bom_count, 0) as bom_count, + COALESCE(bom.total_cost, 0) as total_cost + FROM products p + LEFT JOIN ( + SELECT product_id, + COUNT(*) as bom_count, + SUM(bi.quantity * c.unit_price) as total_cost + FROM bom_items bi + JOIN components c ON bi.component_id = c.id + GROUP BY product_id + ) bom ON p.id = bom.product_id + ORDER BY p.updated_at DESC + `); + return NextResponse.json(rows); +} + +export async function POST(request: NextRequest) { + const sql = getDb(); + const body = await request.json(); + const { name, sku, description, version, status } = body; + + const rows = await sql( + `INSERT INTO products (name, sku, description, version, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [name, sku, description || null, version || "1.0", status || "draft"] + ); + + return NextResponse.json(rows[0], { status: 201 }); +} diff --git a/app/api/stock-take/route.ts b/app/api/stock-take/route.ts new file mode 100644 index 0000000..bb12d52 --- /dev/null +++ b/app/api/stock-take/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const source = searchParams.get("source") || "all"; + const productIds = searchParams.get("productIds") || ""; + + let query = ` + SELECT DISTINCT c.id, c.part_number, c.name, c.stock_quantity, c.shelf_location, + c.customer_supplied, c.customer_id, c.min_stock_level, + cat.name as category_name, + cust.name as customer_name + FROM components c + LEFT JOIN categories cat ON c.category_id = cat.id + LEFT JOIN customers cust ON c.customer_id = cust.id + `; + + const params: (string | number)[] = []; + const conditions: string[] = []; + + // If product filter is specified, join through BOM + if (productIds) { + const ids = productIds.split(",").map(Number).filter(Boolean); + if (ids.length > 0) { + query += ` INNER JOIN bom_items bi ON bi.component_id = c.id`; + const placeholders = ids.map((id, idx) => { + params.push(id); + return `$${params.length}`; + }); + conditions.push(`bi.product_id IN (${placeholders.join(",")})`); + } + } + + // Source filter + if (source === "standard") { + conditions.push(`(c.customer_supplied = false OR c.customer_supplied IS NULL)`); + } else if (source.startsWith("customer-")) { + const customerId = Number(source.replace("customer-", "")); + if (customerId) { + params.push(customerId); + conditions.push(`c.customer_supplied = true AND c.customer_id = $${params.length}`); + } + } + + if (conditions.length > 0) { + query += ` WHERE ` + conditions.join(" AND "); + } + + query += ` ORDER BY c.part_number ASC`; + + const rows = await sql(query, params); + return NextResponse.json(rows); + } catch (error) { + console.error("Stock take error:", error); + return NextResponse.json({ error: "Failed to fetch stock take data" }, { status: 500 }); + } +} diff --git a/app/api/stock/route.ts b/app/api/stock/route.ts new file mode 100644 index 0000000..01be622 --- /dev/null +++ b/app/api/stock/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const componentId = searchParams.get("componentId"); + const search = searchParams.get("search"); + const type = searchParams.get("type"); + const limit = Math.min(Number(searchParams.get("limit") || 50), 200); + const offset = Number(searchParams.get("offset") || 0); + + let query = ` + SELECT + st.*, + c.name AS component_name, + c.part_number, + cat.name AS category_name + FROM stock_transactions st + JOIN components c ON st.component_id = c.id + LEFT JOIN categories cat ON c.category_id = cat.id + WHERE 1=1 + `; + const params: (string | number)[] = []; + let paramIdx = 1; + + if (componentId) { + query += ` AND st.component_id = $${paramIdx++}`; + params.push(Number(componentId)); + } + + if (search) { + query += ` AND (c.name ILIKE $${paramIdx} OR c.part_number ILIKE $${paramIdx})`; + params.push(`%${search}%`); + paramIdx++; + } + + if (type && type !== "all") { + query += ` AND st.transaction_type = $${paramIdx++}`; + params.push(type); + } + + query += ` ORDER BY st.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`; + params.push(limit, offset); + + const rows = await sql(query, params); + + // Also get total count for pagination + let countQuery = ` + SELECT COUNT(*) as total + FROM stock_transactions st + JOIN components c ON st.component_id = c.id + WHERE 1=1 + `; + const countParams: (string | number)[] = []; + let countIdx = 1; + + if (componentId) { + countQuery += ` AND st.component_id = $${countIdx++}`; + countParams.push(Number(componentId)); + } + if (search) { + countQuery += ` AND (c.name ILIKE $${countIdx} OR c.part_number ILIKE $${countIdx})`; + countParams.push(`%${search}%`); + countIdx++; + } + if (type && type !== "all") { + countQuery += ` AND st.transaction_type = $${countIdx++}`; + countParams.push(type); + } + + const countResult = await sql(countQuery, countParams); + + return NextResponse.json({ + transactions: rows, + total: Number(countResult[0].total), + }); +} + +export async function POST(request: NextRequest) { + const sql = getDb(); + const body = await request.json(); + const { + component_id, + transaction_type, + quantity, + unit_cost, + supplier, + supplier_order_ref, + notes, + } = body; + + if (!component_id || !transaction_type || !quantity) { + return NextResponse.json( + { error: "component_id, transaction_type, and quantity are required" }, + { status: 400 } + ); + } + + // Validate supplier_order_ref if provided - must match an existing PO + if (supplier_order_ref && supplier_order_ref.trim()) { + const poCheck = await sql( + `SELECT id FROM supplier_pos WHERE po_number = $1`, + [supplier_order_ref.trim()] + ); + if (poCheck.length === 0) { + return NextResponse.json( + { error: `Purchase order "${supplier_order_ref}" not found. Please select a valid PO or leave the field empty.` }, + { status: 400 } + ); + } + } + + const total_cost = unit_cost ? Number(unit_cost) * Number(quantity) : null; + + // Insert the stock transaction record + const insertParams = [ + component_id, + transaction_type, + Number(quantity), + unit_cost ? Number(unit_cost) : null, + total_cost, + supplier || null, + supplier_order_ref || null, + notes || null, + ]; + const rows = await sql( + `INSERT INTO stock_transactions (component_id, transaction_type, quantity, unit_cost, total_cost, supplier, supplier_order_ref, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + insertParams + ); + + // Update the component's stock_quantity + const qtyChange = + transaction_type === "consumption" + ? -Math.abs(Number(quantity)) + : Math.abs(Number(quantity)); + + await sql( + `UPDATE components SET stock_quantity = stock_quantity + $1, updated_at = NOW() WHERE id = $2`, + [qtyChange, component_id] + ); + + return NextResponse.json(rows[0], { status: 201 }); +} diff --git a/app/api/supplier-packing-lists/[id]/route.ts b/app/api/supplier-packing-lists/[id]/route.ts new file mode 100644 index 0000000..6fb2a85 --- /dev/null +++ b/app/api/supplier-packing-lists/[id]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const plRows = await sql( + `SELECT spl.*, + s.name AS supplier_name, + s.code AS supplier_code, + s.email AS supplier_email, + s.phone AS supplier_phone, + s.address AS supplier_address, + s.contact_person AS supplier_contact + FROM supplier_packing_lists spl + JOIN suppliers s ON s.id = spl.supplier_id + WHERE spl.id = $1`, + [Number(id)] + ); + + if (plRows.length === 0) { + return NextResponse.json({ error: "Supplier packing list not found" }, { status: 404 }); + } + + // Fetch items with component details and products where each component is used + const items = await sql( + `SELECT spli.*, + c.part_number, + c.name AS component_name, + ( + SELECT STRING_AGG(DISTINCT p.name, ', ' ORDER BY p.name) + FROM bom_items bi + JOIN products p ON p.id = bi.product_id + WHERE bi.component_id = spli.component_id + ) AS used_in_products + FROM supplier_packing_list_items spli + JOIN components c ON c.id = spli.component_id + WHERE spli.supplier_packing_list_id = $1 + ORDER BY spli.id ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...plRows[0], items }); + } catch (error) { + console.error("[v0] Supplier packing list detail GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch supplier packing list" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const plRows = await sql( + `SELECT * FROM supplier_packing_lists WHERE id = $1`, + [Number(id)] + ); + if (plRows.length === 0) { + return NextResponse.json({ error: "Supplier packing list not found" }, { status: 404 }); + } + + // Delete items then packing list + await sql(`DELETE FROM supplier_packing_list_items WHERE supplier_packing_list_id = $1`, [Number(id)]); + await sql(`DELETE FROM supplier_packing_lists WHERE id = $1`, [Number(id)]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[v0] Supplier packing list DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete supplier packing list" }, + { status: 500 } + ); + } +} diff --git a/app/api/supplier-packing-lists/route.ts b/app/api/supplier-packing-lists/route.ts new file mode 100644 index 0000000..60f4459 --- /dev/null +++ b/app/api/supplier-packing-lists/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const search = searchParams.get("search"); + + let query = ` + SELECT spl.*, + s.name AS supplier_name, + s.code AS supplier_code, + s.email AS supplier_email, + s.phone AS supplier_phone, + s.address AS supplier_address, + s.contact_person AS supplier_contact, + (SELECT COUNT(*) FROM supplier_packing_list_items spli WHERE spli.supplier_packing_list_id = spl.id) AS item_count, + (SELECT COALESCE(SUM(spli.quantity), 0) FROM supplier_packing_list_items spli WHERE spli.supplier_packing_list_id = spl.id) AS total_quantity + FROM supplier_packing_lists spl + JOIN suppliers s ON s.id = spl.supplier_id + `; + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (search) { + params.push(`%${search}%`); + const i = params.length; + conditions.push( + `(spl.packing_number ILIKE $${i} OR s.name ILIKE $${i})` + ); + } + + if (conditions.length > 0) { + query += ` WHERE ${conditions.join(" AND ")}`; + } + query += ` ORDER BY spl.created_at DESC`; + + const rows = await sql(query, params); + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] Supplier packing list GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch supplier packing lists" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { supplier_id, items, notes } = body; + + if (!supplier_id || !items?.length) { + return NextResponse.json( + { error: "supplier_id and items are required" }, + { status: 400 } + ); + } + + // Validate items have component_id and quantity + for (const item of items) { + if (!item.component_id || !item.quantity || item.quantity <= 0) { + return NextResponse.json( + { error: "Each item must have a component_id and a positive quantity" }, + { status: 400 } + ); + } + } + + // Fetch supplier name for packing number generation + const supplierRows = await sql( + `SELECT name FROM suppliers WHERE id = $1`, + [supplier_id] + ); + if (supplierRows.length === 0) { + return NextResponse.json({ error: "Supplier not found" }, { status: 404 }); + } + const supplierName = String(supplierRows[0].name).replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); + + // Generate packing number: PL-EACH-YYYY_MM_DD-RR-SUPPLIERNAME + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const datePrefix = `PL-EACH-${yyyy}_${mm}_${dd}`; + const dateSuffix = `-${supplierName}`; + + // Count existing supplier packing lists with same date + supplier to determine revision + const existingRows = await sql( + `SELECT COUNT(*) AS cnt FROM supplier_packing_lists WHERE packing_number LIKE $1`, + [`${datePrefix}-%${dateSuffix}`] + ); + const revision = Number(existingRows[0].cnt) + 1; + const packing_number = `${datePrefix}-${String(revision).padStart(2, "0")}${dateSuffix}`; + + // Create supplier packing list + const plRows = await sql( + `INSERT INTO supplier_packing_lists (supplier_id, packing_number, notes) + VALUES ($1, $2, $3) + RETURNING *`, + [supplier_id, packing_number, notes || null] + ); + const packingList = plRows[0]; + + // Insert items + for (const item of items) { + await sql( + `INSERT INTO supplier_packing_list_items (supplier_packing_list_id, component_id, quantity) + VALUES ($1, $2, $3)`, + [packingList.id, item.component_id, item.quantity] + ); + } + + return NextResponse.json(packingList, { status: 201 }); + } catch (error) { + console.error("[v0] Supplier packing list POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create supplier packing list" }, + { status: 500 } + ); + } +} diff --git a/app/api/supplier-pos/[id]/route.ts b/app/api/supplier-pos/[id]/route.ts new file mode 100644 index 0000000..fd6b0c4 --- /dev/null +++ b/app/api/supplier-pos/[id]/route.ts @@ -0,0 +1,314 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + const poRows = await sql( + `SELECT sp.*, s.name AS supplier_name, s.code AS supplier_code, + s.email AS supplier_email, s.phone AS supplier_phone, + s.address AS supplier_address, s.contact_person AS supplier_contact, + s.country AS supplier_country, s.currency AS supplier_currency + FROM supplier_pos sp + JOIN suppliers s ON s.id = sp.supplier_id + WHERE sp.id = $1`, + [Number(id)] + ); + + if (poRows.length === 0) { + return NextResponse.json({ error: "Supplier PO not found" }, { status: 404 }); + } + + const items = await sql( + `SELECT * FROM supplier_po_items WHERE supplier_po_id = $1 ORDER BY id ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...poRows[0], items }); + } catch (error) { + console.error("Supplier PO GET error:", error); + return NextResponse.json( + { error: "Failed to fetch supplier PO" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { status, notes, received_items } = body; + + // Validate forward-only status transitions (plus cancelled from ordered) + if (status) { + const STATUS_ORDER = ["draft", "ordered", "partially_received", "received"]; + const currentRows = await sql(`SELECT status FROM supplier_pos WHERE id = $1`, [Number(id)]); + if (currentRows.length > 0) { + const currentStatus = currentRows[0].status as string; + + // Special case: cancellation is only allowed from "ordered" + if (status === "cancelled") { + if (currentStatus !== "ordered") { + return NextResponse.json( + { error: `Cannot cancel a PO with status "${currentStatus}". Only ordered POs (with no received quantities) can be cancelled.` }, + { status: 400 } + ); + } + // Verify no quantities have been received + const receivedCheck = await sql( + `SELECT COALESCE(SUM(received_quantity), 0) AS total_received FROM supplier_po_items WHERE supplier_po_id = $1`, + [Number(id)] + ); + if (Number(receivedCheck[0].total_received) > 0) { + return NextResponse.json( + { error: "Cannot cancel a PO that has already received quantities." }, + { status: 400 } + ); + } + } else { + const currentIdx = STATUS_ORDER.indexOf(currentStatus); + const newIdx = STATUS_ORDER.indexOf(status); + if (newIdx < 0 || newIdx <= currentIdx) { + return NextResponse.json( + { error: `Cannot change status from "${currentStatus}" to "${status}". Status can only move forward.` }, + { status: 400 } + ); + } + } + } + } + + // Update received quantities per item if provided, adjust stock and log transactions + if (received_items && Array.isArray(received_items)) { + // Fetch PO info for transaction records + const poInfo = await sql( + `SELECT sp.po_number, s.name AS supplier_name + FROM supplier_pos sp JOIN suppliers s ON s.id = sp.supplier_id + WHERE sp.id = $1`, + [Number(id)] + ); + const poNumber = poInfo[0]?.po_number || ""; + const supplierName = poInfo[0]?.supplier_name || ""; + + for (const ri of received_items) { + const newReceivedQty = Number(ri.received_quantity); + + // Get current received qty and component_id before updating + const currentItem = await sql( + `SELECT received_quantity, component_id, unit_price FROM supplier_po_items WHERE id = $1 AND supplier_po_id = $2`, + [Number(ri.id), Number(id)] + ); + if (currentItem.length === 0) continue; + + const prevReceivedQty = Number(currentItem[0].received_quantity) || 0; + const componentId = currentItem[0].component_id; + const unitPrice = Number(currentItem[0].unit_price) || 0; + const delta = newReceivedQty - prevReceivedQty; + + // Update the received quantity on the PO item + await sql( + `UPDATE supplier_po_items SET received_quantity = $1 WHERE id = $2 AND supplier_po_id = $3`, + [newReceivedQty, Number(ri.id), Number(id)] + ); + + // If there's a positive delta and a valid component, update stock and log transaction + if (delta > 0 && componentId) { + console.log(`[PO Receive] Adding stock: component=${componentId}, delta=${delta}, prevReceived=${prevReceivedQty}, newReceived=${newReceivedQty}, PO=${poNumber}`); + + await sql( + `UPDATE components SET stock_quantity = stock_quantity + $1, updated_at = NOW() WHERE id = $2`, + [delta, Number(componentId)] + ); + + await sql( + `INSERT INTO stock_transactions (component_id, transaction_type, quantity, unit_cost, total_cost, supplier, supplier_order_ref, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + Number(componentId), + "purchase", + delta, + unitPrice, + delta * unitPrice, + supplierName, + poNumber, + `Received from PO ${poNumber}`, + ] + ); + } else if (delta < 0 && componentId) { + // If quantity was reduced (correction), reverse the stock + await sql( + `UPDATE components SET stock_quantity = GREATEST(0, stock_quantity + $1), updated_at = NOW() WHERE id = $2`, + [delta, Number(componentId)] + ); + + await sql( + `INSERT INTO stock_transactions (component_id, transaction_type, quantity, unit_cost, total_cost, supplier, supplier_order_ref, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + Number(componentId), + "purchase", + delta, + unitPrice, + delta * unitPrice, + supplierName, + poNumber, + `Correction on PO ${poNumber}`, + ] + ); + } + } + } + + // When status changes to "received", auto-receive all remaining items + // This only triggers when NO received_items array is provided (e.g., status dropdown change) + if (status === "received" && !(received_items && Array.isArray(received_items) && received_items.length > 0)) { + console.log(`[PO Auto-Receive] Status changed to received without received_items, auto-completing remaining items for PO ${id}`); + const poInfo = await sql( + `SELECT sp.po_number, s.name AS supplier_name + FROM supplier_pos sp JOIN suppliers s ON s.id = sp.supplier_id + WHERE sp.id = $1`, + [Number(id)] + ); + const poNumber = poInfo[0]?.po_number || ""; + const supplierName = poInfo[0]?.supplier_name || ""; + + const allItems = await sql( + `SELECT id, component_id, quantity, received_quantity, unit_price FROM supplier_po_items WHERE supplier_po_id = $1`, + [Number(id)] + ); + + for (const item of allItems) { + const ordered = Number(item.quantity); + const prevReceived = Number(item.received_quantity) || 0; + const delta = ordered - prevReceived; + const componentId = item.component_id; + const unitPrice = Number(item.unit_price) || 0; + + if (delta > 0) { + console.log(`[PO Auto-Receive] Item ${item.id}: ordered=${ordered}, prevReceived=${prevReceived}, delta=${delta}, component=${componentId}, PO=${poNumber}`); + + // Mark as fully received + await sql( + `UPDATE supplier_po_items SET received_quantity = $1 WHERE id = $2`, + [ordered, Number(item.id)] + ); + + if (componentId) { + await sql( + `UPDATE components SET stock_quantity = stock_quantity + $1, updated_at = NOW() WHERE id = $2`, + [delta, Number(componentId)] + ); + + await sql( + `INSERT INTO stock_transactions (component_id, transaction_type, quantity, unit_cost, total_cost, supplier, supplier_order_ref, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + Number(componentId), + "purchase", + delta, + unitPrice, + delta * unitPrice, + supplierName, + poNumber, + `Received from PO ${poNumber}`, + ] + ); + } + } else { + console.log(`[PO Auto-Receive] Item ${item.id}: no delta (ordered=${ordered}, prevReceived=${prevReceived}), skipping stock update`); + } + } + } + + // Replace all items on a draft PO (full item edit) + const { draft_items } = body; + if (draft_items && Array.isArray(draft_items)) { + const currentRows = await sql(`SELECT status FROM supplier_pos WHERE id = $1`, [Number(id)]); + if (currentRows.length > 0 && currentRows[0].status !== "draft") { + return NextResponse.json({ error: "Items can only be edited on draft POs" }, { status: 400 }); + } + await sql(`DELETE FROM supplier_po_items WHERE supplier_po_id = $1`, [Number(id)]); + for (const item of draft_items) { + await sql( + `INSERT INTO supplier_po_items (supplier_po_id, component_id, part_number, component_name, supplier_part_number, quantity, unit_price, received_quantity) + VALUES ($1, $2, $3, $4, $5, $6, $7, 0)`, + [ + Number(id), + Number(item.component_id) || null, + item.part_number || "", + item.component_name || "", + item.supplier_part_number || "", + Number(item.quantity), + Number(item.unit_price) || 0, + ] + ); + } + } + + const { shipping_cost } = body; + const rows = await sql( + `UPDATE supplier_pos SET status = COALESCE($1, status), notes = COALESCE($2, notes), + shipping_cost = COALESCE($3, shipping_cost) + WHERE id = $4 RETURNING *`, + [status || null, notes !== undefined ? notes : null, shipping_cost !== undefined ? Number(shipping_cost) : null, Number(id)] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Supplier PO not found" }, { status: 404 }); + } + + // Return full PO with items + const items = await sql( + `SELECT * FROM supplier_po_items WHERE supplier_po_id = $1 ORDER BY id ASC`, + [Number(id)] + ); + + return NextResponse.json({ ...rows[0], items }); + } catch (error) { + console.error("Supplier PO PUT error:", error); + return NextResponse.json( + { error: "Failed to update supplier PO" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + // Only allow deleting draft POs + const currentRows = await sql(`SELECT status FROM supplier_pos WHERE id = $1`, [Number(id)]); + if (currentRows.length > 0 && currentRows[0].status !== "draft") { + return NextResponse.json( + { error: "Only draft POs can be deleted" }, + { status: 400 } + ); + } + + await sql(`DELETE FROM supplier_po_items WHERE supplier_po_id = $1`, [Number(id)]); + await sql(`DELETE FROM supplier_pos WHERE id = $1`, [Number(id)]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Supplier PO DELETE error:", error); + return NextResponse.json( + { error: "Failed to delete supplier PO" }, + { status: 500 } + ); + } +} diff --git a/app/api/supplier-pos/route.ts b/app/api/supplier-pos/route.ts new file mode 100644 index 0000000..1f78d94 --- /dev/null +++ b/app/api/supplier-pos/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const supplierId = searchParams.get("supplier_id"); + const statusFilter = searchParams.get("status"); // "active" = draft,ordered,partially_received | "received" | null = all + + const baseSelect = `SELECT sp.*, s.name AS supplier_name, s.code AS supplier_code, + (SELECT COUNT(*) FROM supplier_po_items WHERE supplier_po_id = sp.id) AS item_count, + (SELECT COALESCE(SUM(quantity * unit_price), 0) FROM supplier_po_items WHERE supplier_po_id = sp.id) AS total_value, + (SELECT COALESCE(SUM(received_quantity), 0) FROM supplier_po_items WHERE supplier_po_id = sp.id) AS total_received, + (SELECT COALESCE(SUM(quantity), 0) FROM supplier_po_items WHERE supplier_po_id = sp.id) AS total_ordered + FROM supplier_pos sp + JOIN suppliers s ON s.id = sp.supplier_id`; + + const conditions: string[] = []; + const values: (string | number)[] = []; + let paramIdx = 1; + + if (supplierId) { + conditions.push(`sp.supplier_id = $${paramIdx++}`); + values.push(Number(supplierId)); + } + if (statusFilter === "active") { + conditions.push(`sp.status IN ('draft', 'ordered', 'partially_received')`); + } else if (statusFilter === "received") { + conditions.push(`sp.status IN ('received', 'cancelled')`); + } + + const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""; + const rows = await sql(`${baseSelect}${where} ORDER BY sp.created_at DESC`, values); + + return NextResponse.json(rows); + } catch (error) { + console.error("Supplier POs GET error:", error); + return NextResponse.json( + { error: "Failed to fetch supplier POs" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { orders, planning_id } = body; + // orders: Array<{ supplier_id, supplier_name, items: Array<{ component_id, part_number, component_name, supplier_part_number, quantity, unit_price }> }> + + if (!orders || !Array.isArray(orders) || orders.length === 0) { + return NextResponse.json({ error: "No orders provided" }, { status: 400 }); + } + + const createdPos: Record[] = []; + + for (const order of orders) { + // Generate PO number: PO-EACH-YYYY_MM_DD-RR-SUPPLIERNAME (matching packing list pattern) + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const supplierSlug = (order.supplier_name || "UNKNOWN") + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 20); + const datePrefix = `PO-EACH-${yyyy}_${mm}_${dd}`; + const existingRows = await sql( + `SELECT COUNT(*) AS cnt FROM supplier_pos WHERE po_number LIKE $1`, + [`${datePrefix}%-${supplierSlug}`] + ); + const revision = String(Number(existingRows[0].cnt) + 1).padStart(2, "0"); + const poNumber = `${datePrefix}-${revision}-${supplierSlug}`; + + const poRows = await sql( + `INSERT INTO supplier_pos (po_number, supplier_id, planning_id, status, notes) + VALUES ($1, $2, $3, 'draft', $4) + RETURNING *`, + [ + poNumber, + Number(order.supplier_id), + planning_id ? Number(planning_id) : null, + order.notes || null, + ] + ); + + const po = poRows[0]; + + for (const item of order.items) { + await sql( + `INSERT INTO supplier_po_items + (supplier_po_id, component_id, part_number, component_name, + supplier_part_number, quantity, unit_price) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + po.id, + Number(item.component_id), + item.part_number, + item.component_name, + item.supplier_part_number || null, + Number(item.quantity), + Number(item.unit_price), + ] + ); + } + + createdPos.push({ + ...po, + supplier_name: order.supplier_name, + item_count: order.items.length, + }); + } + + return NextResponse.json(createdPos, { status: 201 }); + } catch (error) { + console.error("Supplier POs POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create supplier POs" }, + { status: 500 } + ); + } +} diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts new file mode 100644 index 0000000..4a420bf --- /dev/null +++ b/app/api/suppliers/[id]/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const rows = await sql(`SELECT * FROM suppliers WHERE id = $1`, [Number(id)]); + + if (rows.length === 0) { + return NextResponse.json({ error: "Supplier not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[v0] Supplier GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch supplier" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { name, code, email, phone, address, contact_person, country, currency, notes } = body; + + const rows = await sql( + `UPDATE suppliers SET + name = $1, code = $2, email = $3, phone = $4, + address = $5, contact_person = $6, country = $7, currency = $8, notes = $9, updated_at = NOW() + WHERE id = $10 + RETURNING *`, + [name, code, email || null, phone || null, address || null, contact_person || null, country || "", currency || "EUR", notes || null, Number(id)] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: "Supplier not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[v0] Supplier PUT error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update supplier" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + await sql(`DELETE FROM suppliers WHERE id = $1`, [Number(id)]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[v0] Supplier DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to delete supplier" }, + { status: 500 } + ); + } +} diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts new file mode 100644 index 0000000..66c71ea --- /dev/null +++ b/app/api/suppliers/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const sql = getDb(); + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || ""; + + let rows; + if (search) { + rows = await sql( + `SELECT * FROM suppliers + WHERE name ILIKE $1 OR code ILIKE $1 OR email ILIKE $1 OR contact_person ILIKE $1 + ORDER BY name ASC`, + [`%${search}%`] + ); + } else { + rows = await sql(`SELECT * FROM suppliers ORDER BY name ASC`); + } + + return NextResponse.json(rows); + } catch (error) { + console.error("[v0] Suppliers GET error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch suppliers" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const sql = getDb(); + const body = await request.json(); + const { name, code, email, phone, address, contact_person, country, currency, notes } = body; + + if (!name || !code) { + return NextResponse.json({ error: "Name and code are required" }, { status: 400 }); + } + + const rows = await sql( + `INSERT INTO suppliers (name, code, email, phone, address, contact_person, country, currency, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [name, code, email || null, phone || null, address || null, contact_person || null, country || "", currency || "EUR", notes || null] + ); + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[v0] Suppliers POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create supplier" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..24d13d5 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + const body = await request.json(); + const { email, name, role, is_active, permissions, password, new_password } = body; + const passwordToUpdate = password || new_password; + + // Update user + await sql( + `UPDATE users SET email = $1, name = $2, role = $3, is_active = $4, updated_at = NOW() + WHERE id = $5`, + [email?.toLowerCase(), name, role || "user", is_active ?? true, Number(id)] + ); + + // Update password if provided + if (passwordToUpdate) { + const passwordHash = await bcrypt.hash(passwordToUpdate, 10); + await sql(`UPDATE users SET password_hash = $1 WHERE id = $2`, [ + passwordHash, + Number(id), + ]); + } + + // Upsert permissions - convert sections array to individual flags + if (permissions) { + const sections: string[] = permissions.sections || []; + + // First check if user_permissions record exists + const existingPerms = await sql( + `SELECT id FROM user_permissions WHERE user_id = $1`, + [Number(id)] + ); + + if (existingPerms.length === 0) { + // Insert new permissions record + await sql( + `INSERT INTO user_permissions ( + user_id, can_view_financial, can_edit, can_create, can_delete, can_download, + can_view_dashboard, can_view_components, can_view_products, can_view_customers, + can_view_suppliers, can_view_production, can_view_stock_history, can_view_logistics, + can_view_stock_take, can_view_settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + [ + Number(id), + permissions.can_view_financial ?? false, + permissions.can_edit ?? false, + permissions.can_create ?? false, + permissions.can_delete ?? false, + permissions.can_download ?? false, + sections.includes("dashboard"), + sections.includes("components"), + sections.includes("products"), + sections.includes("customers"), + sections.includes("suppliers"), + sections.includes("production"), + sections.includes("stock-history"), + sections.includes("logistics"), + sections.includes("stock-take"), + sections.includes("settings") || role === "admin", + ] + ); + } else { + // Update existing permissions record + await sql( + `UPDATE user_permissions + SET can_view_financial = $1, can_edit = $2, can_create = $3, can_delete = $4, can_download = $5, + can_view_dashboard = $6, can_view_components = $7, can_view_products = $8, can_view_customers = $9, + can_view_suppliers = $10, can_view_production = $11, can_view_stock_history = $12, can_view_logistics = $13, + can_view_stock_take = $14, can_view_settings = $15, updated_at = NOW() + WHERE user_id = $16`, + [ + permissions.can_view_financial ?? false, + permissions.can_edit ?? false, + permissions.can_create ?? false, + permissions.can_delete ?? false, + permissions.can_download ?? false, + sections.includes("dashboard"), + sections.includes("components"), + sections.includes("products"), + sections.includes("customers"), + sections.includes("suppliers"), + sections.includes("production"), + sections.includes("stock-history"), + sections.includes("logistics"), + sections.includes("stock-take"), + sections.includes("settings") || role === "admin", + Number(id), + ] + ); + } + } + + // Fetch updated user + const users = await sql( + `SELECT u.id, u.email, u.name, u.role, u.is_active, u.created_at, + up.can_view_financial, up.can_edit, up.can_create, up.can_delete, up.can_download, + up.can_view_dashboard, up.can_view_components, up.can_view_products, up.can_view_customers, + up.can_view_suppliers, up.can_view_production, up.can_view_stock_history, up.can_view_logistics, + up.can_view_stock_take, up.can_view_settings + FROM users u + LEFT JOIN user_permissions up ON u.id = up.user_id + WHERE u.id = $1`, + [Number(id)] + ); + + return NextResponse.json(users[0]); + } catch (error) { + console.error("PUT user error:", error); + return NextResponse.json({ error: "Failed to update user" }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const sql = getDb(); + const { id } = await params; + + // Delete sessions first + await sql(`DELETE FROM sessions WHERE user_id = $1`, [Number(id)]); + + // Delete permissions + await sql(`DELETE FROM user_permissions WHERE user_id = $1`, [Number(id)]); + + // Delete user + await sql(`DELETE FROM users WHERE id = $1`, [Number(id)]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("DELETE user error:", error); + return NextResponse.json({ error: "Failed to delete user" }, { status: 500 }); + } +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..da417de --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function GET() { + try { + const sql = getDb(); + const rows = await sql( + `SELECT u.id, u.email, u.name, u.role, u.is_active, u.created_at, + up.can_view_financial, up.can_edit, up.can_create, up.can_delete, up.can_download, + up.can_view_dashboard, up.can_view_components, up.can_view_products, up.can_view_customers, + up.can_view_suppliers, up.can_view_production, up.can_view_stock_history, up.can_view_logistics, + up.can_view_stock_take, up.can_view_settings + FROM users u + LEFT JOIN user_permissions up ON u.id = up.user_id + ORDER BY u.created_at DESC` + ); + + // Transform rows to include permissions object with sections array + const users = rows.map((row: Record) => { + const sections: string[] = []; + if (row.can_view_dashboard) sections.push("dashboard"); + if (row.can_view_components) sections.push("components"); + if (row.can_view_products) sections.push("products"); + if (row.can_view_customers) sections.push("customers"); + if (row.can_view_suppliers) sections.push("suppliers"); + if (row.can_view_production) sections.push("production"); + if (row.can_view_stock_history) sections.push("stock-history"); + if (row.can_view_logistics) sections.push("logistics"); + if (row.can_view_stock_take) sections.push("stock-take"); + if (row.can_view_settings) sections.push("settings"); + + return { + id: row.id, + email: row.email, + name: row.name, + role: row.role, + is_active: row.is_active, + created_at: row.created_at, + permissions: { + sections, + can_view_financial: row.can_view_financial ?? false, + can_edit: row.can_edit ?? false, + can_create: row.can_create ?? false, + can_delete: row.can_delete ?? false, + can_download: row.can_download ?? false, + }, + }; + }); + + return NextResponse.json(users); + } catch (error) { + console.error("GET users error:", error); + return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const sql = getDb(); + const body = await request.json(); + const { email, name, password, role, permissions } = body; + + if (!email || !name || !password) { + return NextResponse.json( + { error: "Email, name, and password are required" }, + { status: 400 } + ); + } + + // Check if email exists + const existing = await sql(`SELECT id FROM users WHERE email = $1`, [ + email.toLowerCase(), + ]); + if (existing.length > 0) { + return NextResponse.json( + { error: "Email already exists" }, + { status: 400 } + ); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create user + const users = await sql( + `INSERT INTO users (email, name, password_hash, role) + VALUES ($1, $2, $3, $4) + RETURNING id, email, name, role, is_active, created_at`, + [email.toLowerCase(), name, passwordHash, role || "user"] + ); + + const user = users[0]; + + // Create permissions - convert sections array to individual flags + const sections: string[] = permissions?.sections || []; + await sql( + `INSERT INTO user_permissions ( + user_id, can_view_financial, can_edit, can_create, can_delete, can_download, + can_view_dashboard, can_view_components, can_view_products, can_view_customers, + can_view_suppliers, can_view_production, can_view_stock_history, can_view_logistics, + can_view_stock_take, can_view_settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + [ + user.id, + permissions?.can_view_financial ?? false, + permissions?.can_edit ?? false, + permissions?.can_create ?? false, + permissions?.can_delete ?? false, + permissions?.can_download ?? false, + sections.includes("dashboard"), + sections.includes("components"), + sections.includes("products"), + sections.includes("customers"), + sections.includes("suppliers"), + sections.includes("production"), + sections.includes("stock-history"), + sections.includes("logistics"), + sections.includes("stock-take"), + sections.includes("settings") || role === "admin", + ] + ); + + return NextResponse.json({ + ...user, + permissions: permissions || { + can_view_financial: false, + can_edit: false, + can_create: false, + can_delete: false, + can_download: false, + }, + }); + } catch (error) { + console.error("POST user error:", error); + return NextResponse.json({ error: "Failed to create user" }, { status: 500 }); + } +} diff --git a/app/bom/[productId]/page.tsx b/app/bom/[productId]/page.tsx new file mode 100644 index 0000000..b1e900f --- /dev/null +++ b/app/bom/[productId]/page.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState, useCallback, use } from "react"; +import useSWR from "swr"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Plus, + Pencil, + Trash2, + ArrowLeft, + AlertTriangle, + Euro, + Layers, + Hash, +} from "lucide-react"; +import { useTableSort } from "@/hooks/use-table-sort"; +import { SortableTableHead } from "@/components/sortable-table-head"; +import { BomItemDialog } from "@/components/bom-item-dialog"; +import { toast } from "sonner"; +import { formatCurrency, formatUnitPrice } from "@/lib/format"; +import type { Product, BomItem } from "@/lib/types"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function BomDetailPage({ + params, +}: { + params: Promise<{ productId: string }>; +}) { + const { productId } = use(params); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data: products = [] } = useSWR("/api/products", fetcher); + const product = products.find((p) => p.id === Number(productId)); + + const { data: bomItems = [], mutate } = useSWR( + `/api/bom/${productId}`, + fetcher + ); + + const { sorted: sortedBomItems, sort, toggleSort } = useTableSort(bomItems); + + const totalCost = bomItems.reduce( + (sum, item) => sum + Number(item.line_cost || 0), + 0 + ); + const totalParts = bomItems.reduce((sum, item) => sum + item.quantity, 0); + + const handleSave = useCallback( + async (data: Partial) => { + const url = editingItem + ? `/api/bom/${productId}/${editingItem.id}` + : `/api/bom/${productId}`; + const method = editingItem ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const err = await res.json(); + toast.error(err.error || "Failed to save"); + throw new Error(err.error || "Failed to save"); + } + + toast.success(editingItem ? "BOM item updated" : "Component added to BOM"); + mutate(); + }, + [editingItem, productId, mutate] + ); + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return; + await fetch(`/api/bom/${productId}/${deleteTarget.id}`, { + method: "DELETE", + }); + toast.success("BOM item removed"); + setDeleteTarget(null); + mutate(); + }, [deleteTarget, productId, mutate]); + + function openCreate() { + setEditingItem(null); + setDialogOpen(true); + } + + function openEdit(item: BomItem) { + setEditingItem(item); + setDialogOpen(true); + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ {product?.name || "Loading..."} +

+

+ {product && ( + <> + {product.sku} + {" "} + · v{product.version} · Bill of Materials + + )} +

+
+
+ +
+ + {/* Summary Cards */} +
+ + +
+ +
+
+

Unique Items

+

{bomItems.length}

+
+
+
+ + +
+ +
+
+

Total Parts

+

{totalParts}

+
+
+
+ + +
+ +
+
+

Total BOM Cost

+

+ {formatCurrency(totalCost)} +

+
+
+
+
+ + {/* BOM Table */} +
+ + + + + + + + + + + + Actions + + + + {sortedBomItems.length === 0 ? ( + + + No items in this BOM yet. Click "Add Item" to add + components. + + + ) : ( + sortedBomItems.map((item) => { + const stockShort = + (item.stock_quantity || 0) < item.quantity; + return ( + + + {item.reference_designator || "-"} + + + {item.part_number} + + + {item.component_name} + + + {item.category_name && ( + + {item.category_name} + + )} + + + {item.quantity} + + + {formatUnitPrice(Number(item.unit_price || 0))} + + + {formatCurrency(Number(item.line_cost || 0))} + + + + {stockShort && ( + + )} + {item.stock_quantity ?? 0} + + + +
+ + +
+
+
+ ); + }) + )} +
+
+
+ + + + setDeleteTarget(null)} + > + + + Remove BOM Item + + Are you sure you want to remove{" "} + {deleteTarget?.component_name} from this BOM? + + + + Cancel + + Remove + + + + +
+ ); +} diff --git a/app/bom/page.tsx b/app/bom/page.tsx new file mode 100644 index 0000000..ff46f9f --- /dev/null +++ b/app/bom/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import useSWR from "swr"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ClipboardList, ArrowRight } from "lucide-react"; +import { formatCurrency } from "@/lib/format"; +import type { Product } from "@/lib/types"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +const statusStyles: Record = { + draft: "bg-muted text-muted-foreground", + active: "bg-accent/15 text-accent border-accent/30", + deprecated: "bg-warning/15 text-warning border-warning/30", + archived: "bg-muted text-muted-foreground", +}; + +export default function BomIndexPage() { + const { data: products = [] } = useSWR("/api/products", fetcher); + + return ( +
+
+

+ Bill of Materials +

+

+ Select a product to view or manage its BOM +

+
+ + {products.length === 0 ? ( + + + +

+ No products created yet. Create a product first to manage its BOM. +

+ +
+
+ ) : ( +
+ {products.map((prod) => ( + + + +
+ {prod.name} + +
+

+ {prod.sku} · v{prod.version} +

+
+ +
+ + {prod.status} + +
+ + {prod.bom_count || 0} items + + | + + {formatCurrency(Number(prod.total_cost || 0))} + +
+
+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/app/components/page.tsx b/app/components/page.tsx new file mode 100644 index 0000000..0be02ff --- /dev/null +++ b/app/components/page.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { useState, useCallback } from "react"; +import useSWR from "swr"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Plus, Search, Pencil, Trash2, AlertTriangle, ExternalLink, PackagePlus, Printer, Box, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { useTableSort } from "@/hooks/use-table-sort"; +import { SortableTableHead } from "@/components/sortable-table-head"; +import { ComponentDialog } from "@/components/component-dialog"; +import { AddStockDialog } from "@/components/add-stock-dialog"; +import { generateLabelPdf } from "@/lib/generate-label-pdf"; +import { toast } from "sonner"; +import { formatUnitPrice } from "@/lib/format"; +import type { Component, Category, ComponentSpecs } from "@/lib/types"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function ComponentsPage() { + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [lowStockFilter, setLowStockFilter] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingComponent, setEditingComponent] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [stockTarget, setStockTarget] = useState(null); + const [stockDialogOpen, setStockDialogOpen] = useState(false); + + // Component detail dialog state + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [detailComponent, setDetailComponent] = useState(null); + + interface ProductUsage { + id: number; + product_id: number; + product_name: string; + sku: string; + version: string; + status: string; + quantity: number; + reference_designator: string | null; + notes: string | null; + } + + const { data: componentUsage = [], isLoading: usageLoading } = useSWR( + detailComponent ? `/api/components/${detailComponent.id}/usage` : null, + fetcher + ); + + function openDetail(comp: Component) { + setDetailComponent(comp); + setDetailDialogOpen(true); + } + + // Label printing state + const [labelDialogOpen, setLabelDialogOpen] = useState(false); + const [labelPartNumber, setLabelPartNumber] = useState(""); + const [labelQuantity, setLabelQuantity] = useState(1); + const [printingLabel, setPrintingLabel] = useState(false); + + function openLabelDialog(comp: Component) { + setLabelPartNumber(comp.part_number); + setLabelQuantity(comp.stock_quantity); + setLabelDialogOpen(true); + } + + async function handlePrintLabel() { + if (!labelPartNumber) return; + setPrintingLabel(true); + try { + const doc = await generateLabelPdf({ + partNumber: labelPartNumber, + quantity: labelQuantity, + }); + doc.save(`label-${labelPartNumber}.pdf`); + toast.success("Label PDF generated"); + setLabelDialogOpen(false); + } catch { + toast.error("Failed to generate label"); + } finally { + setPrintingLabel(false); + } + } + + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (categoryFilter && categoryFilter !== "all") params.set("category", categoryFilter); + if (lowStockFilter) params.set("lowStock", "true"); + + const { data: components = [], mutate } = useSWR( + `/api/components?${params.toString()}`, + fetcher + ); + const { data: categories = [] } = useSWR( + "/api/categories", + fetcher + ); + + const { sorted: sortedComponents, sort, toggleSort } = useTableSort(components); + + const handleSave = useCallback( + async (data: Partial) => { + const url = editingComponent + ? `/api/components/${editingComponent.id}` + : "/api/components"; + const method = editingComponent ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to save"); + } + + toast.success( + editingComponent ? "Component updated" : "Component created" + ); + mutate(); + }, + [editingComponent, mutate] + ); + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return; + await fetch(`/api/components/${deleteTarget.id}`, { method: "DELETE" }); + toast.success("Component deleted"); + setDeleteTarget(null); + mutate(); + }, [deleteTarget, mutate]); + + function openCreate() { + setEditingComponent(null); + setDialogOpen(true); + } + + function openEdit(comp: Component) { + setEditingComponent(comp); + setDialogOpen(true); + } + + function openAddStock(comp: Component) { + setStockTarget(comp); + setStockDialogOpen(true); + } + + return ( +
+
+
+

+ Components +

+

+ Manage your electronic component inventory +

+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ + {/* Components Table */} +
+ + + + + + + + + + + + + Actions + + + + {sortedComponents.length === 0 ? ( + + + {search || categoryFilter !== "all" || lowStockFilter + ? "No components match your filters." + : "No components yet. Click \"Add Component\" to get started."} + + + ) : ( + sortedComponents.map((comp) => { + const isLowStock = + comp.stock_quantity <= comp.min_stock_level && + comp.min_stock_level > 0; + return ( + openDetail(comp)}> + + {comp.datasheet_url ? ( + e.stopPropagation()} + > + {comp.part_number} + + + ) : ( + comp.part_number + )} + + {comp.name} + + {comp.category_name && ( + + {comp.category_name} + + )} + + + {comp.customer_supplied ? ( + + {comp.customer_name || "Customer"} + + ) : ( + Standard + )} + + + {comp.shelf_location || "-"} + + + {formatUnitPrice(Number(comp.unit_price))} + + + + {isLowStock && ( + + )} + {comp.stock_quantity} + + + + {Number((comp as Record).on_order) > 0 ? ( + + {Number((comp as Record).on_order)} + + ) : ( + - + )} + + + {comp.min_stock_level} + + +
e.stopPropagation()}> + + + + +
+
+
+ ); + }) + )} +
+
+
+ + + + mutate()} + onPrintLabel={(partNumber, quantity) => { + setLabelPartNumber(partNumber); + setLabelQuantity(quantity); + setLabelDialogOpen(true); + }} + /> + + setDeleteTarget(null)} + > + + + Delete Component + + Are you sure you want to delete{" "} + {deleteTarget?.name} ({deleteTarget?.part_number} + )? This action cannot be undone and will remove it from all BOMs. + + + + Cancel + + Delete + + + + + + {/* Component Detail Dialog */} + + + {detailComponent && (() => { + const rawSpecs = detailComponent.specs; + const specs: ComponentSpecs = typeof rawSpecs === "string" ? JSON.parse(rawSpecs) : rawSpecs || {}; + const hasSpecs = Object.values(specs).some((v) => v && String(v).trim() !== ""); + return ( + <> + + + {detailComponent.name} + {detailComponent.part_number} + +
+ {detailComponent.category_name && ( + Category: {detailComponent.category_name} + )} + {detailComponent.manufacturer && ( + Manufacturer: {detailComponent.manufacturer} + )} + Stock: {detailComponent.stock_quantity} + {Number((detailComponent as Record).on_order) > 0 && ( + On Order: {Number((detailComponent as Record).on_order)} + )} + {detailComponent.customer_supplied && ( + Customer Supplied: {detailComponent.customer_name || "Unknown"} + )} +
+
+ + {/* Specifications */} + {hasSpecs && ( +
+

Specifications

+
+ {specs.manufacturer_id && Manufacturer ID: {specs.manufacturer_id}} + {specs.value && Value: {specs.value}} + {specs.power && Power: {specs.power}} + {specs.voltage && Voltage: {specs.voltage}} + {specs.tolerance && Tolerance: {specs.tolerance}} + {specs.color && Color: {specs.color}} + {specs.no_of_pins && Pins: {specs.no_of_pins}} + {specs.no_of_rows && Rows: {specs.no_of_rows}} + {specs.connector_type && Type: {specs.connector_type}} +
+
+ )} + + {/* Supplier info */} + {(detailComponent.supplier || detailComponent.datasheet_url) && ( +
+ {detailComponent.supplier && ( + Supplier: {detailComponent.supplier} + )} + {detailComponent.supplier_part_number && ( + Supplier Part No.: {detailComponent.supplier_part_number} + )} + {detailComponent.datasheet_url && ( + + Supplier URL + + )} +
+ )} + +
+
+ +

Used in Products

+ {componentUsage.length} +
+ + {usageLoading ? ( +
+ Loading... +
+ ) : componentUsage.length === 0 ? ( +
+ Not used in any product BOM +
+ ) : ( +
+ + + + Product + SKU + Qty per Unit + Ref. Designator + + + + + {componentUsage.map((usage) => ( + + +
+ {usage.product_name} + {usage.version && ( + v{usage.version} + )} +
+
+ {usage.sku} + + {Number(usage.quantity)} + + + {usage.reference_designator || "-"} + + + e.stopPropagation()} + className="inline-flex items-center text-primary hover:text-primary/80" + title="View BOM" + > + + + +
+ ))} +
+
+
+ )} +
+ + + + + + ); + })()} +
+
+ + {/* Print Label Dialog */} + + + + Print Label + +
+
+
+

Code:

+

{labelPartNumber}

+
+
+

Quantity:

+

{labelQuantity}

+
+
+ +
+ + setLabelQuantity(Math.max(1, Number(e.target.value) || 1))} + className="font-mono" + /> +
+ +

+ Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity} +

+
+ + + + +
+
+
+ ); +} diff --git a/app/customers/[id]/orders/[orderId]/page.tsx b/app/customers/[id]/orders/[orderId]/page.tsx new file mode 100644 index 0000000..53f9598 --- /dev/null +++ b/app/customers/[id]/orders/[orderId]/page.tsx @@ -0,0 +1,679 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import useSWR from "swr"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Progress } from "@/components/ui/progress"; +import { ArrowLeft, Plus, Trash2, Package, Save, Euro, Layers, Truck, FileDown } from "lucide-react"; +import { toast } from "sonner"; +import { formatCurrency } from "@/lib/format"; +import type { Product, PurchaseOrder, PurchaseOrderItem } from "@/lib/types"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +interface OrderLineItem { + product_id: string; + quantity: string; + unit_price: string; + notes: string; +} + +const statusConfig: Record = { + draft: { label: "Draft", variant: "secondary" }, + confirmed: { label: "Confirmed", variant: "default" }, + in_production: { label: "In Production", variant: "outline" }, + shipped: { label: "Shipped", variant: "default" }, + completed: { label: "Completed", variant: "default" }, + cancelled: { label: "Cancelled", variant: "destructive" }, +}; + +export default function OrderDetailPage() { + const params = useParams(); + const router = useRouter(); + const customerId = params.id as string; + const orderId = params.orderId as string; + + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(false); + + const [form, setForm] = useState({ + po_number: "", + customer_ref: "", + status: "draft", + order_date: "", + due_date: "", + notes: "", + }); + + const [items, setItems] = useState([]); + + const { data: orderData, mutate } = useSWR< + PurchaseOrder & { items: PurchaseOrderItem[] } + >(`/api/orders/${orderId}`, fetcher); + + const { data: products = [] } = useSWR("/api/products", fetcher); + + useEffect(() => { + if (orderData) { + setForm({ + po_number: orderData.po_number || "", + customer_ref: orderData.customer_ref || "", + status: orderData.status || "draft", + order_date: orderData.order_date + ? new Date(orderData.order_date).toISOString().slice(0, 10) + : "", + due_date: orderData.due_date + ? new Date(orderData.due_date).toISOString().slice(0, 10) + : "", + notes: orderData.notes || "", + }); + setItems( + (orderData.items || []).map((item) => ({ + product_id: String(item.product_id), + quantity: String(item.quantity), + unit_price: String(item.unit_price), + notes: item.notes || "", + })) + ); + } + }, [orderData]); + + function addLine() { + setItems([ + ...items, + { product_id: "", quantity: "1", unit_price: "", notes: "" }, + ]); + } + + function removeLine(index: number) { + if (items.length <= 1) return; + setItems(items.filter((_, i) => i !== index)); + } + + function updateLine(index: number, field: keyof OrderLineItem, value: string) { + const updated = [...items]; + updated[index] = { ...updated[index], [field]: value }; + setItems(updated); + } + + function getLineTotal(item: OrderLineItem) { + return (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + } + + const orderTotal = items.reduce((sum, item) => sum + getLineTotal(item), 0); + + async function handleDownloadPO() { + if (!orderData) return; + const { generatePoPdf } = await import("@/lib/generate-po-pdf"); + const doc = generatePoPdf({ + po_number: orderData.po_number, + order_date: orderData.order_date, + due_date: orderData.due_date, + customer_ref: orderData.customer_ref, + status: orderData.status, + customer_name: orderData.customer_name || "", + customer_code: orderData.customer_code || "", + customer_email: (orderData as Record).customer_email as string | null, + customer_phone: (orderData as Record).customer_phone as string | null, + customer_address: (orderData as Record).customer_address as string | null, + customer_contact: (orderData as Record).customer_contact as string | null, + notes: orderData.notes, + items: (orderData.items || []).map((item) => ({ + product_name: item.product_name || "", + product_sku: item.product_sku || "", + quantity: Number(item.quantity), + unit_price: Number(item.unit_price), + line_total: Number(item.line_total || 0), + notes: item.notes, + })), + }); + doc.save(`${orderData.po_number}.pdf`); + } + + async function handleSave() { + setLoading(true); + try { + const validItems = items.filter((item) => item.product_id); + const res = await fetch(`/api/orders/${orderId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customer_id: Number(customerId), + ...form, + due_date: form.due_date || null, + items: validItems.map((item) => ({ + product_id: Number(item.product_id), + quantity: Number(item.quantity) || 1, + unit_price: Number(item.unit_price) || 0, + notes: item.notes || null, + })), + }), + }); + + if (!res.ok) throw new Error("Failed to update"); + toast.success("Purchase order updated"); + mutate(); + setEditing(false); + } catch { + toast.error("Failed to update order"); + } finally { + setLoading(false); + } + } + + if (!orderData) { + return ( +
+

Loading order...

+
+ ); + } + + const sc = statusConfig[orderData.status] || statusConfig.draft; + + // VIEW MODE + if (!editing) { + return ( +
+
+
+ +
+
+

+ {orderData.po_number} +

+ {sc.label} +
+

+ Order for {orderData.customer_name} ({orderData.customer_code}) +

+
+
+
+ + +
+
+ + {/* Summary Cards */} + {(() => { + const itemsList = orderData.items || []; + const totalOrdered = itemsList.reduce((s, i) => s + Number(i.quantity), 0); + const totalShipped = itemsList.reduce((s, i) => s + Number(i.shipped_quantity || 0), 0); + const totalRemaining = totalOrdered - totalShipped; + const fulfillmentPct = totalOrdered > 0 ? Math.round((totalShipped / totalOrdered) * 100) : 0; + const isOverdue = orderData.due_date && new Date(orderData.due_date) < new Date() && fulfillmentPct < 100; + + return ( +
+ + + + Order Date + + + +

+ {new Date(orderData.order_date).toLocaleDateString()} +

+
+
+ + + + Due Date + + + +

+ {orderData.due_date + ? new Date(orderData.due_date).toLocaleDateString() + : "Not set"} + {isOverdue && ( + (overdue) + )} +

+
+
+ + + + Order Total + + + + +

+ {formatCurrency( + itemsList.reduce((s, i) => s + Number(i.line_total || 0), 0) + )} +

+

+ {itemsList.length} line item{itemsList.length !== 1 ? "s" : ""} +

+
+
+ + + + Units Remaining + + + + +

+ 0 ? "text-foreground" : "text-accent"}> + {totalRemaining} + + + / {totalOrdered} + +

+

+ {totalShipped} shipped +

+
+
+ + + + Fulfillment + + + + +

+ {fulfillmentPct}% +

+ +
+
+
+ ); + })()} + + {orderData.customer_ref && ( +
+ Customer Reference:{" "} + + {orderData.customer_ref} + +
+ )} + + {orderData.notes && ( + + + {orderData.notes} + + + )} + + {/* Line Items Table */} + + + + + Order Items + + + + + + + # + Product + SKU + Ordered + Shipped + Remaining + Fulfillment + Unit Price + Line Total + Notes + + + + {(orderData.items || []).map( + (item: PurchaseOrderItem, idx: number) => { + const ordered = Number(item.quantity); + const shipped = Number(item.shipped_quantity || 0); + const remaining = ordered - shipped; + const pct = ordered > 0 ? Math.round((shipped / ordered) * 100) : 0; + + return ( + + + {idx + 1} + + + {item.product_name} + + + {item.product_sku} + + + {ordered.toLocaleString()} + + + {shipped.toLocaleString()} + + + 0 ? "text-foreground" : "text-accent"}> + {remaining.toLocaleString()} + + + +
+
+ + {shipped}/{ordered} + + + {pct}% + +
+ +
+
+ + {formatCurrency(Number(item.unit_price))} + + + {formatCurrency(Number(item.line_total || 0))} + + + {item.notes || "-"} + +
+ ); + } + )} +
+
+
+
+
+ ); + } + + // EDIT MODE + return ( +
+
+ +
+

+ Edit Purchase Order +

+

+ {orderData.po_number} - {orderData.customer_name} +

+
+
+ + + + Order Details + + +
+
+ + + setForm({ ...form, po_number: e.target.value }) + } + className="font-mono" + /> +
+
+ + + setForm({ ...form, customer_ref: e.target.value }) + } + /> +
+
+ + +
+
+
+
+ + + setForm({ ...form, order_date: e.target.value }) + } + /> +
+
+ + + setForm({ ...form, due_date: e.target.value }) + } + /> +
+
+
+ +