chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s

This commit is contained in:
2026-06-01 15:35:53 +01:00
parent a63625d228
commit 9c14a19a87
127 changed files with 22255 additions and 0 deletions
+105
View File
@@ -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 }
);
}
}
+25
View File
@@ -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 }
);
}
}
+70
View File
@@ -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 });
}
}
+37
View File
@@ -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 });
}
+53
View File
@@ -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 });
}
+8
View File
@@ -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);
}
+78
View File
@@ -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 });
}
+22
View File
@@ -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);
}
+104
View File
@@ -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 });
}
+89
View File
@@ -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 }
);
}
}
+84
View File
@@ -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 }
);
}
}
+78
View File
@@ -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 }
);
}
}
+99
View File
@@ -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 }
);
}
}
+34
View File
@@ -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 }
);
}
}
+112
View File
@@ -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 }
);
}
}
+129
View File
@@ -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 }
);
}
}
+92
View File
@@ -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 }
);
}
}
+174
View File
@@ -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 }
);
}
}
+67
View File
@@ -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 }
);
}
}
+27
View File
@@ -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 }
);
}
}
+132
View File
@@ -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 }
);
}
}
+198
View File
@@ -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<string, unknown>) => [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<string, unknown>;
batchSize: number;
items: Record<string, unknown>[];
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<string, number>) =>
sum + Number(item.line_cost),
0
);
const totalParts = bomItems.reduce(
(sum: number, item: Record<string, number>) =>
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,
})),
});
}
+37
View File
@@ -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 });
}
+37
View File
@@ -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 });
}
+60
View File
@@ -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 });
}
}
+146
View File
@@ -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 });
}
@@ -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 }
);
}
}
+125
View File
@@ -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 }
);
}
}
+314
View File
@@ -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 }
);
}
}
+124
View File
@@ -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<string, unknown>[] = [];
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 }
);
}
}
+77
View File
@@ -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 }
);
}
}
+57
View File
@@ -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 }
);
}
}
+143
View File
@@ -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 });
}
}
+138
View File
@@ -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<string, unknown>) => {
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 });
}
}
+323
View File
@@ -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<BomItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<BomItem | null>(null);
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
const product = products.find((p) => p.id === Number(productId));
const { data: bomItems = [], mutate } = useSWR<BomItem[]>(
`/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<BomItem>) => {
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 (
<div className="flex flex-col gap-6 p-6 lg:p-8">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<Button variant="outline" size="icon" asChild>
<Link href="/bom">
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{product?.name || "Loading..."}
</h1>
<p className="text-sm text-muted-foreground">
{product && (
<>
<span className="font-mono">{product.sku}</span>
{" "}
&middot; v{product.version} &middot; Bill of Materials
</>
)}
</p>
</div>
</div>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
{/* Summary Cards */}
<div className="grid gap-4 grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Layers className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Unique Items</p>
<p className="text-2xl font-bold">{bomItems.length}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10">
<Hash className="h-5 w-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">Total Parts</p>
<p className="text-2xl font-bold">{totalParts}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/10">
<Euro className="h-5 w-5 text-warning" />
</div>
<div>
<p className="text-sm text-muted-foreground">Total BOM Cost</p>
<p className="text-2xl font-bold font-mono">
{formatCurrency(totalCost)}
</p>
</div>
</CardContent>
</Card>
</div>
{/* BOM Table */}
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="reference_designator" label="Ref Des" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[140px]" />
<SortableTableHead column="component_name" label="Component" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="unit_price" label="Unit Price" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="line_cost" label="Line Cost" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="stock_quantity" label="Stock" sort={sort} onToggle={toggleSort} className="text-right" />
<TableHead className="w-[90px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedBomItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="text-center py-12 text-muted-foreground"
>
No items in this BOM yet. Click &quot;Add Item&quot; to add
components.
</TableCell>
</TableRow>
) : (
sortedBomItems.map((item) => {
const stockShort =
(item.stock_quantity || 0) < item.quantity;
return (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">
{item.reference_designator || "-"}
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number}
</TableCell>
<TableCell className="font-medium">
{item.component_name}
</TableCell>
<TableCell>
{item.category_name && (
<Badge variant="secondary" className="text-xs">
{item.category_name}
</Badge>
)}
</TableCell>
<TableCell className="text-right font-medium">
{item.quantity}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatUnitPrice(Number(item.unit_price || 0))}
</TableCell>
<TableCell className="text-right font-mono text-sm font-medium">
{formatCurrency(Number(item.line_cost || 0))}
</TableCell>
<TableCell className="text-right">
<span
className={
stockShort
? "inline-flex items-center gap-1 font-semibold text-destructive"
: ""
}
>
{stockShort && (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{item.stock_quantity ?? 0}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(item)}
>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Remove</span>
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<BomItemDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
bomItem={editingItem}
productId={Number(productId)}
onSave={handleSave}
/>
<AlertDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove BOM Item</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove{" "}
<strong>{deleteTarget?.component_name}</strong> from this BOM?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+87
View File
@@ -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<string, string> = {
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<Product[]>("/api/products", fetcher);
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Bill of Materials
</h1>
<p className="text-sm text-muted-foreground">
Select a product to view or manage its BOM
</p>
</div>
{products.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<ClipboardList className="h-12 w-12 text-muted-foreground/40 mb-4" />
<p className="text-muted-foreground mb-4">
No products created yet. Create a product first to manage its BOM.
</p>
<Button asChild>
<Link href="/products">Go to Products</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{products.map((prod) => (
<Link key={prod.id} href={`/bom/${prod.id}`}>
<Card className="group hover:border-primary/50 transition-colors cursor-pointer h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{prod.name}</CardTitle>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<p className="font-mono text-xs text-muted-foreground">
{prod.sku} &middot; v{prod.version}
</p>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge
variant="outline"
className={statusStyles[prod.status] || ""}
>
{prod.status}
</Badge>
<div className="text-right text-sm">
<span className="text-muted-foreground">
{prod.bom_count || 0} items
</span>
<span className="mx-2 text-border">|</span>
<span className="font-mono font-medium">
{formatCurrency(Number(prod.total_cost || 0))}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+617
View File
@@ -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<Component | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Component | null>(null);
const [stockTarget, setStockTarget] = useState<Component | null>(null);
const [stockDialogOpen, setStockDialogOpen] = useState(false);
// Component detail dialog state
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailComponent, setDetailComponent] = useState<Component | null>(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<ProductUsage[]>(
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<number>(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<Component[]>(
`/api/components?${params.toString()}`,
fetcher
);
const { data: categories = [] } = useSWR<Category[]>(
"/api/categories",
fetcher
);
const { sorted: sortedComponents, sort, toggleSort } = useTableSort(components);
const handleSave = useCallback(
async (data: Partial<Component>) => {
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 (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Components
</h1>
<p className="text-sm text-muted-foreground">
Manage your electronic component inventory
</p>
</div>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Component
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, part number, or manufacturer..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={lowStockFilter ? "destructive" : "outline"}
size="sm"
onClick={() => setLowStockFilter(!lowStockFilter)}
>
<AlertTriangle className="mr-1.5 h-3.5 w-3.5" />
Low Stock
</Button>
</div>
{/* Components Table */}
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[140px]" />
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="customer_supplied" label="Source" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="shelf_location" label="Shelf Location" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="unit_price" label="Unit Price" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="stock_quantity" label="Stock" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="on_order" label="On Order" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="min_stock_level" label="Min Level" sort={sort} onToggle={toggleSort} className="text-right" />
<TableHead className="w-[140px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedComponents.length === 0 ? (
<TableRow>
<TableCell
colSpan={10}
className="text-center py-12 text-muted-foreground"
>
{search || categoryFilter !== "all" || lowStockFilter
? "No components match your filters."
: "No components yet. Click \"Add Component\" to get started."}
</TableCell>
</TableRow>
) : (
sortedComponents.map((comp) => {
const isLowStock =
comp.stock_quantity <= comp.min_stock_level &&
comp.min_stock_level > 0;
return (
<TableRow key={comp.id} className="cursor-pointer" onClick={() => openDetail(comp)}>
<TableCell className="font-mono text-xs font-medium">
{comp.datasheet_url ? (
<a
href={comp.datasheet_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{comp.part_number}
<ExternalLink className="h-3 w-3" />
</a>
) : (
comp.part_number
)}
</TableCell>
<TableCell className="font-medium">{comp.name}</TableCell>
<TableCell>
{comp.category_name && (
<Badge variant="secondary" className="text-xs">
{comp.category_name}
</Badge>
)}
</TableCell>
<TableCell>
{comp.customer_supplied ? (
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
{comp.customer_name || "Customer"}
</Badge>
) : (
<span className="text-xs text-muted-foreground">Standard</span>
)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{comp.shelf_location || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatUnitPrice(Number(comp.unit_price))}
</TableCell>
<TableCell className="text-right">
<span
className={
isLowStock
? "inline-flex items-center gap-1 font-semibold text-destructive"
: ""
}
>
{isLowStock && (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{comp.stock_quantity}
</span>
</TableCell>
<TableCell className="text-right">
{Number((comp as Record<string, unknown>).on_order) > 0 ? (
<Badge variant="outline" className="font-mono text-xs text-primary border-primary/30">
{Number((comp as Record<string, unknown>).on_order)}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{comp.min_stock_level}
</TableCell>
<TableCell>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-accent hover:text-accent"
onClick={() => openAddStock(comp)}
title="Add / Remove Stock"
>
<PackagePlus className="h-3.5 w-3.5" />
<span className="sr-only">Add Stock</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openLabelDialog(comp)}
title="Print Label"
>
<Printer className="h-3.5 w-3.5" />
<span className="sr-only">Print Label</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(comp)}
>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(comp)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<ComponentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
component={editingComponent}
categories={categories}
onSave={handleSave}
/>
<AddStockDialog
open={stockDialogOpen}
onOpenChange={setStockDialogOpen}
component={stockTarget}
onSaved={() => mutate()}
onPrintLabel={(partNumber, quantity) => {
setLabelPartNumber(partNumber);
setLabelQuantity(quantity);
setLabelDialogOpen(true);
}}
/>
<AlertDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Component</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<strong>{deleteTarget?.name}</strong> ({deleteTarget?.part_number}
)? This action cannot be undone and will remove it from all BOMs.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Component Detail Dialog */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
{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 (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<span>{detailComponent.name}</span>
<Badge variant="outline" className="font-mono text-xs">{detailComponent.part_number}</Badge>
</DialogTitle>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground pt-1">
{detailComponent.category_name && (
<span>Category: <span className="text-foreground font-medium">{detailComponent.category_name}</span></span>
)}
{detailComponent.manufacturer && (
<span>Manufacturer: <span className="text-foreground font-medium">{detailComponent.manufacturer}</span></span>
)}
<span>Stock: <span className="text-foreground font-medium">{detailComponent.stock_quantity}</span></span>
{Number((detailComponent as Record<string, unknown>).on_order) > 0 && (
<span>On Order: <Badge variant="outline" className="font-mono text-xs text-primary border-primary/30 ml-0.5">{Number((detailComponent as Record<string, unknown>).on_order)}</Badge></span>
)}
{detailComponent.customer_supplied && (
<span>Customer Supplied: <Badge variant="outline" className="text-xs border-primary/30 text-primary ml-0.5">{detailComponent.customer_name || "Unknown"}</Badge></span>
)}
</div>
</DialogHeader>
{/* Specifications */}
{hasSpecs && (
<div className="rounded-lg border p-3 bg-muted/30">
<p className="text-xs font-medium text-muted-foreground mb-2">Specifications</p>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
{specs.manufacturer_id && <span>Manufacturer ID: <span className="text-foreground font-mono text-xs">{specs.manufacturer_id}</span></span>}
{specs.value && <span>Value: <span className="text-foreground font-medium">{specs.value}</span></span>}
{specs.power && <span>Power: <span className="text-foreground font-medium">{specs.power}</span></span>}
{specs.voltage && <span>Voltage: <span className="text-foreground font-medium">{specs.voltage}</span></span>}
{specs.tolerance && <span>Tolerance: <span className="text-foreground font-medium">{specs.tolerance}</span></span>}
{specs.color && <span>Color: <span className="text-foreground font-medium">{specs.color}</span></span>}
{specs.no_of_pins && <span>Pins: <span className="text-foreground font-medium">{specs.no_of_pins}</span></span>}
{specs.no_of_rows && <span>Rows: <span className="text-foreground font-medium">{specs.no_of_rows}</span></span>}
{specs.connector_type && <span>Type: <span className="text-foreground font-medium">{specs.connector_type}</span></span>}
</div>
</div>
)}
{/* Supplier info */}
{(detailComponent.supplier || detailComponent.datasheet_url) && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
{detailComponent.supplier && (
<span>Supplier: <span className="text-foreground font-medium">{detailComponent.supplier}</span></span>
)}
{detailComponent.supplier_part_number && (
<span>Supplier Part No.: <span className="text-foreground font-mono text-xs">{detailComponent.supplier_part_number}</span></span>
)}
{detailComponent.datasheet_url && (
<a href={detailComponent.datasheet_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Supplier URL
</a>
)}
</div>
)}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Box className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Used in Products</h3>
<Badge variant="secondary" className="text-xs">{componentUsage.length}</Badge>
</div>
{usageLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : componentUsage.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Not used in any product BOM
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Qty per Unit</TableHead>
<TableHead>Ref. Designator</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{componentUsage.map((usage) => (
<TableRow key={usage.id}>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-medium">{usage.product_name}</span>
{usage.version && (
<span className="text-xs text-muted-foreground">v{usage.version}</span>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs">{usage.sku}</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{Number(usage.quantity)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{usage.reference_designator || "-"}
</TableCell>
<TableCell>
<Link
href={`/bom/${usage.product_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center text-primary hover:text-primary/80"
title="View BOM"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>Close</Button>
</DialogFooter>
</>
);
})()}
</DialogContent>
</Dialog>
{/* Print Label Dialog */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Print Label</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-md border bg-background p-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">Code:</p>
<p className="font-mono text-sm font-bold truncate">{labelPartNumber}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Quantity:</p>
<p className="font-mono text-sm font-bold">{labelQuantity}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="comp-label-qty">Quantity</Label>
<Input
id="comp-label-qty"
type="number"
min={1}
value={labelQuantity}
onChange={(e) => setLabelQuantity(Math.max(1, Number(e.target.value) || 1))}
className="font-mono"
/>
</div>
<p className="text-xs text-muted-foreground">
Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handlePrintLabel} disabled={printingLabel}>
<Printer className="mr-2 h-4 w-4" />
{printingLabel ? "Generating..." : "Download Label PDF"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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<OrderLineItem[]>([]);
const { data: orderData, mutate } = useSWR<
PurchaseOrder & { items: PurchaseOrderItem[] }
>(`/api/orders/${orderId}`, fetcher);
const { data: products = [] } = useSWR<Product[]>("/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<string, unknown>).customer_email as string | null,
customer_phone: (orderData as Record<string, unknown>).customer_phone as string | null,
customer_address: (orderData as Record<string, unknown>).customer_address as string | null,
customer_contact: (orderData as Record<string, unknown>).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 (
<div className="flex items-center justify-center p-12">
<p className="text-muted-foreground">Loading order...</p>
</div>
);
}
const sc = statusConfig[orderData.status] || statusConfig.draft;
// VIEW MODE
if (!editing) {
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/customers/${customerId}`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight font-mono text-foreground">
{orderData.po_number}
</h1>
<Badge variant={sc.variant}>{sc.label}</Badge>
</div>
<p className="text-sm text-muted-foreground">
Order for {orderData.customer_name} ({orderData.customer_code})
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleDownloadPO}>
<FileDown className="mr-2 h-3.5 w-3.5" />
Download PO
</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
Edit Order
</Button>
</div>
</div>
{/* 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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Order Date
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg font-semibold">
{new Date(orderData.order_date).toLocaleDateString()}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Due Date
</CardTitle>
</CardHeader>
<CardContent>
<p className={`text-lg font-semibold ${isOverdue ? "text-destructive" : ""}`}>
{orderData.due_date
? new Date(orderData.due_date).toLocaleDateString()
: "Not set"}
{isOverdue && (
<span className="ml-1 text-xs font-normal">(overdue)</span>
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Order Total
</CardTitle>
<Euro className="h-4 w-4 text-accent" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{formatCurrency(
itemsList.reduce((s, i) => s + Number(i.line_total || 0), 0)
)}
</p>
<p className="text-xs text-muted-foreground">
{itemsList.length} line item{itemsList.length !== 1 ? "s" : ""}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Units Remaining
</CardTitle>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold font-mono">
<span className={totalRemaining > 0 ? "text-foreground" : "text-accent"}>
{totalRemaining}
</span>
<span className="text-sm font-normal text-muted-foreground ml-1">
/ {totalOrdered}
</span>
</p>
<p className="text-xs text-muted-foreground">
{totalShipped} shipped
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Fulfillment
</CardTitle>
<Truck className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<p className={`text-2xl font-bold font-mono ${fulfillmentPct === 100 ? "text-accent" : ""}`}>
{fulfillmentPct}%
</p>
<Progress value={fulfillmentPct} className="mt-2 h-2" />
</CardContent>
</Card>
</div>
);
})()}
{orderData.customer_ref && (
<div className="text-sm text-muted-foreground">
Customer Reference:{" "}
<span className="font-mono font-medium text-foreground">
{orderData.customer_ref}
</span>
</div>
)}
{orderData.notes && (
<Card>
<CardContent className="p-4 text-sm text-muted-foreground">
{orderData.notes}
</CardContent>
</Card>
)}
{/* Line Items Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4 text-primary" />
Order Items
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Ordered</TableHead>
<TableHead className="text-right">Shipped</TableHead>
<TableHead className="text-right">Remaining</TableHead>
<TableHead className="w-[120px]">Fulfillment</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">Line Total</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(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 (
<TableRow key={item.id}>
<TableCell className="text-xs text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-medium">
{item.product_name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{item.product_sku}
</TableCell>
<TableCell className="text-right font-mono">
{ordered.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{shipped.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
<span className={remaining > 0 ? "text-foreground" : "text-accent"}>
{remaining.toLocaleString()}
</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="font-mono text-muted-foreground">
{shipped}/{ordered}
</span>
<span className={pct === 100 ? "font-semibold text-accent" : "text-muted-foreground"}>
{pct}%
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(Number(item.unit_price))}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(Number(item.line_total || 0))}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.notes || "-"}
</TableCell>
</TableRow>
);
}
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
// EDIT MODE
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => setEditing(false)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Edit Purchase Order
</h1>
<p className="text-sm text-muted-foreground">
{orderData.po_number} - {orderData.customer_name}
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Order Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="po_number">PO Number</Label>
<Input
id="po_number"
value={form.po_number}
onChange={(e) =>
setForm({ ...form, po_number: e.target.value })
}
className="font-mono"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="customer_ref">Customer Reference</Label>
<Input
id="customer_ref"
value={form.customer_ref}
onChange={(e) =>
setForm({ ...form, customer_ref: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.status}
onValueChange={(v) => setForm({ ...form, status: v })}
>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="in_production">In Production</SelectItem>
<SelectItem value="shipped">Shipped</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="order_date">Order Date</Label>
<Input
id="order_date"
type="date"
value={form.order_date}
onChange={(e) =>
setForm({ ...form, order_date: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={form.due_date}
onChange={(e) =>
setForm({ ...form, due_date: e.target.value })
}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={2}
/>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4 text-primary" />
Line Items
</CardTitle>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Line
</Button>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>Product</TableHead>
<TableHead className="w-[120px]">Quantity</TableHead>
<TableHead className="w-[140px]">Unit Price</TableHead>
<TableHead className="w-[140px] text-right">
Line Total
</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
<TableCell className="text-center text-xs text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>
<Select
value={item.product_id}
onValueChange={(v) =>
updateLine(index, "product_id", v)
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select product..." />
</SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
<span className="font-mono text-xs text-muted-foreground mr-2">
{p.sku}
</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateLine(index, "quantity", e.target.value)
}
className="h-9 font-mono"
/>
</TableCell>
<TableCell>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
{'€'}
</span>
<Input
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(e) =>
updateLine(index, "unit_price", e.target.value)
}
className="h-9 pl-7 font-mono"
/>
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{formatCurrency(getLineTotal(item))}
</TableCell>
<TableCell>
<Input
value={item.notes}
onChange={(e) =>
updateLine(index, "notes", e.target.value)
}
placeholder="Optional"
className="h-9 text-xs"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeLine(index)}
disabled={items.length <= 1}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50">
<TableCell colSpan={4} className="text-right font-semibold">
Order Total
</TableCell>
<TableCell className="text-right font-mono text-base font-bold text-primary">
{formatCurrency(orderTotal)}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading}>
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useState } 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 { 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 { ArrowLeft, Plus, Trash2, Package, Save } from "lucide-react";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
import type { Customer, Product } 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;
}
export default function NewOrderPage() {
const params = useParams();
const router = useRouter();
const customerId = params.id as string;
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
po_number: "",
customer_ref: "",
status: "draft",
order_date: new Date().toISOString().slice(0, 10),
due_date: "",
notes: "",
});
const [items, setItems] = useState<OrderLineItem[]>([
{ product_id: "", quantity: "1", unit_price: "", notes: "" },
]);
const { data: customer } = useSWR<Customer>(
`/api/customers/${customerId}`,
fetcher
);
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
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) {
const qty = Number(item.quantity) || 0;
const price = Number(item.unit_price) || 0;
return qty * price;
}
const orderTotal = items.reduce((sum, item) => sum + getLineTotal(item), 0);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.po_number) {
toast.error("PO Number is required");
return;
}
const validItems = items.filter((item) => item.product_id);
if (validItems.length === 0) {
toast.error("Add at least one product line item");
return;
}
setLoading(true);
try {
const res = await fetch("/api/orders", {
method: "POST",
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) {
const err = await res.json();
throw new Error(err.error || "Failed to create order");
}
toast.success("Purchase order created");
router.push(`/customers/${customerId}`);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to create order"
);
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-6 p-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/customers/${customerId}`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
New Purchase Order
</h1>
<p className="text-sm text-muted-foreground">
{customer
? `Creating order for ${customer.name} (${customer.code})`
: "Loading..."}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Order Details */}
<Card>
<CardHeader>
<CardTitle className="text-base">Order Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="po_number">
PO Number <span className="text-destructive">*</span>
</Label>
<Input
id="po_number"
value={form.po_number}
onChange={(e) =>
setForm({ ...form, po_number: e.target.value })
}
placeholder="PO-2026-001"
className="font-mono"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="customer_ref">Customer Reference</Label>
<Input
id="customer_ref"
value={form.customer_ref}
onChange={(e) =>
setForm({ ...form, customer_ref: e.target.value })
}
placeholder="Their internal ref"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="status">Status</Label>
<Select
value={form.status}
onValueChange={(v) => setForm({ ...form, status: v })}
>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="in_production">In Production</SelectItem>
<SelectItem value="shipped">Shipped</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="order_date">Order Date</Label>
<Input
id="order_date"
type="date"
value={form.order_date}
onChange={(e) =>
setForm({ ...form, order_date: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={form.due_date}
onChange={(e) =>
setForm({ ...form, due_date: e.target.value })
}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Internal notes about this order..."
rows={2}
/>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4 text-primary" />
Line Items
</CardTitle>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Line
</Button>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>Product</TableHead>
<TableHead className="w-[120px]">Quantity</TableHead>
<TableHead className="w-[140px]">Unit Price</TableHead>
<TableHead className="w-[140px] text-right">
Line Total
</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
<TableCell className="text-center text-xs text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>
<Select
value={item.product_id}
onValueChange={(v) =>
updateLine(index, "product_id", v)
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select product..." />
</SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
<span className="font-mono text-xs text-muted-foreground mr-2">
{p.sku}
</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateLine(index, "quantity", e.target.value)
}
className="h-9 font-mono"
/>
</TableCell>
<TableCell>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
{'€'}
</span>
<Input
type="number"
step="0.01"
min="0"
value={item.unit_price}
onChange={(e) =>
updateLine(index, "unit_price", e.target.value)
}
className="h-9 pl-7 font-mono"
/>
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{formatCurrency(getLineTotal(item))}
</TableCell>
<TableCell>
<Input
value={item.notes}
onChange={(e) =>
updateLine(index, "notes", e.target.value)
}
placeholder="Optional"
className="h-9 text-xs"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeLine(index)}
disabled={items.length <= 1}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50">
<TableCell colSpan={4} className="text-right font-semibold">
Order Total
</TableCell>
<TableCell className="text-right font-mono text-base font-bold text-primary">
{formatCurrency(orderTotal)}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/customers/${customerId}`)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
<Save className="mr-2 h-4 w-4" />
{loading ? "Creating..." : "Create Purchase Order"}
</Button>
</div>
</form>
</div>
);
}
+677
View File
@@ -0,0 +1,677 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import useSWR from "swr";
import Link from "next/link";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 {
ArrowLeft,
Plus,
Pencil,
Trash2,
Mail,
Phone,
MapPin,
User,
FileText,
Euro,
ExternalLink,
Package,
BarChart3,
} from "lucide-react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from "recharts";
import { Progress } from "@/components/ui/progress";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { CustomerDialog } from "@/components/customer-dialog";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
import type { Customer, PurchaseOrder } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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 CustomerDetailPage() {
const params = useParams();
const router = useRouter();
const customerId = params.id as string;
const [editOpen, setEditOpen] = useState(false);
const [deleteOrderTarget, setDeleteOrderTarget] = useState<PurchaseOrder | null>(null);
const [statsPeriod, setStatsPeriod] = useState<"week" | "month" | "year">("month");
const { data: customer, mutate: mutateCustomer } = useSWR<Customer>(
`/api/customers/${customerId}`,
fetcher
);
const { data: orders = [], mutate: mutateOrders } = useSWR<PurchaseOrder[]>(
`/api/orders?customerId=${customerId}`,
fetcher
);
const { sorted: sortedOrders, sort: orderSort, toggleSort: toggleOrderSort } = useTableSort(orders);
interface OnOrderItem {
product_id: number;
product_name: string;
sku: string;
total_ordered: number;
total_shipped: number;
on_order: number;
}
interface ProducedItem {
product_id: number;
product_name: string;
sku: string;
total_produced: number;
}
interface TimelineItem {
bucket: string;
product_name: string;
sku: string;
quantity: number;
}
interface StatsData {
onOrder: OnOrderItem[];
produced: ProducedItem[];
producedTimeline: TimelineItem[];
}
const { data: statsData } = useSWR<StatsData>(
`/api/customers/${customerId}/stats?period=${statsPeriod}`,
fetcher
);
async function handleDeleteOrder() {
if (!deleteOrderTarget) return;
try {
const res = await fetch(`/api/orders/${deleteOrderTarget.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete");
toast.success("Purchase order deleted");
mutateOrders();
} catch {
toast.error("Failed to delete order");
} finally {
setDeleteOrderTarget(null);
}
}
if (!customer) {
return (
<div className="flex items-center justify-center p-12">
<p className="text-muted-foreground">Loading customer...</p>
</div>
);
}
const totalValue = orders.reduce(
(sum, o) => sum + (o.status !== "cancelled" ? Number(o.total_value || 0) : 0),
0
);
const activeOrders = orders.filter(
(o) => !["completed", "cancelled"].includes(o.status)
).length;
return (
<div className="flex flex-col gap-6 p-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{customer.name}
</h1>
<Badge variant="outline" className="font-mono text-xs">
{customer.code}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Customer since{" "}
{new Date(customer.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</Button>
<Button size="sm" asChild>
<Link href={`/customers/${customerId}/orders/new`}>
<Plus className="mr-2 h-3.5 w-3.5" />
New Purchase Order
</Link>
</Button>
</div>
</div>
{/* Customer Info + Stats */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Contact Information
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{customer.contact_person && (
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span>{customer.contact_person}</span>
</div>
)}
{customer.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<a
href={`mailto:${customer.email}`}
className="text-primary hover:underline"
>
{customer.email}
</a>
</div>
)}
{customer.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{customer.phone}</span>
</div>
)}
{customer.address && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="mt-0.5 h-4 w-4 text-muted-foreground" />
<span className="whitespace-pre-wrap">{customer.address}</span>
</div>
)}
{customer.notes && (
<div className="mt-2 rounded-md bg-muted p-3 text-sm text-muted-foreground">
{customer.notes}
</div>
)}
{!customer.contact_person &&
!customer.email &&
!customer.phone &&
!customer.address && (
<p className="text-sm text-muted-foreground italic">
No contact information added yet.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Orders
</CardTitle>
<FileText className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{activeOrders}</p>
<p className="text-xs text-muted-foreground">
of {orders.length} total
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Revenue
</CardTitle>
<Euro className="h-4 w-4 text-accent" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{formatCurrency(totalValue)}
</p>
<p className="text-xs text-muted-foreground">
excl. cancelled orders
</p>
</CardContent>
</Card>
</div>
{/* On Order + Production Stats */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* On Order by Product */}
<Card>
<CardHeader className="flex flex-row items-center gap-2 pb-3">
<Package className="h-4 w-4 text-primary" />
<div>
<CardTitle className="text-sm font-medium">Units On Order</CardTitle>
<CardDescription className="text-xs">
Pending quantities by product across active orders
</CardDescription>
</div>
</CardHeader>
<CardContent className="p-0">
{statsData?.onOrder && statsData.onOrder.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="text-right">Ordered</TableHead>
<TableHead className="text-right">Shipped</TableHead>
<TableHead className="text-right">Pending</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{statsData.onOrder.map((item) => (
<TableRow key={item.product_id}>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.product_name}</span>
<span className="font-mono text-xs text-muted-foreground">{item.sku}</span>
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{Number(item.total_ordered).toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{Number(item.total_shipped).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<Badge variant="outline" className="font-mono text-xs font-bold text-primary border-primary/30">
{Number(item.on_order).toLocaleString()}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No pending orders
</div>
)}
</CardContent>
</Card>
{/* Produced / Shipped by Product */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-accent" />
<div>
<CardTitle className="text-sm font-medium">Units Produced</CardTitle>
<CardDescription className="text-xs">
Shipped quantities by product
</CardDescription>
</div>
</div>
<Select
value={statsPeriod}
onValueChange={(val) => setStatsPeriod(val as "week" | "month" | "year")}
>
<SelectTrigger className="w-[110px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="week">Last 7 days</SelectItem>
<SelectItem value="month">Last 30 days</SelectItem>
<SelectItem value="year">Last year</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{statsData?.produced && statsData.produced.length > 0 ? (
<div className="flex flex-col gap-4">
{/* Bar chart */}
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
{statsPeriod === "year" && statsData.producedTimeline && statsData.producedTimeline.length > 0 ? (() => {
const BAR_COLORS = [
"#6366f1", "#22c55e", "#3b82f6",
"#f59e0b", "#a855f7", "#ef4444",
];
// Pivot timeline into {month, product1: qty, product2: qty, ...}
const productSkus = [...new Set(statsData.producedTimeline.map((t) => t.sku))];
const productNames: Record<string, string> = {};
statsData.producedTimeline.forEach((t) => { productNames[t.sku] = t.product_name; });
const bucketMap = new Map<string, Record<string, number | string>>();
statsData.producedTimeline.forEach((t) => {
const d = new Date(t.bucket);
const label = d.toLocaleDateString("en-GB", { month: "short", year: "2-digit" });
if (!bucketMap.has(label)) {
const row: Record<string, number | string> = { month: label };
productSkus.forEach((s) => { row[s] = 0; });
bucketMap.set(label, row);
}
const row = bucketMap.get(label)!;
row[t.sku] = (Number(row[t.sku]) || 0) + Number(t.quantity);
});
const chartData = Array.from(bucketMap.values());
return (
<BarChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="month"
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value: number, name: string) => [
`${value.toLocaleString()} units`,
productNames[name] || name,
]}
/>
<Legend
formatter={(value: string) => productNames[value] || value}
wrapperStyle={{ fontSize: "11px" }}
/>
{productSkus.map((sku, i) => (
<Bar
key={sku}
dataKey={sku}
fill={BAR_COLORS[i % BAR_COLORS.length]}
radius={[3, 3, 0, 0]}
/>
))}
</BarChart>
);
})() : (
<BarChart
data={statsData.produced.map((item) => ({
name: item.sku,
fullName: item.product_name,
units: Number(item.total_produced),
}))}
margin={{ top: 5, right: 5, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value: number, _name: string, props: { payload?: { fullName?: string } }) => [
`${value.toLocaleString()} units`,
props.payload?.fullName || "Units",
]}
/>
<Bar dataKey="units" radius={[4, 4, 0, 0]}>
{statsData.produced.map((_entry, index) => {
const colors = [
"#6366f1", "#22c55e", "#3b82f6",
"#f59e0b", "#a855f7", "#ef4444",
];
return (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
);
})}
</Bar>
</BarChart>
)}
</ResponsiveContainer>
</div>
{/* Summary table below chart */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{statsData.produced.map((item) => (
<div
key={item.product_id}
className="rounded-md border px-3 py-2 text-center"
>
<p className="font-mono text-xs text-muted-foreground">{item.sku}</p>
<p className="text-lg font-bold">{Number(item.total_produced).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">units</p>
</div>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No production data for this period
</div>
)}
</CardContent>
</Card>
</div>
{/* Purchase Orders Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Purchase Orders</CardTitle>
<Button size="sm" asChild>
<Link href={`/customers/${customerId}/orders/new`}>
<Plus className="mr-2 h-3.5 w-3.5" />
New Order
</Link>
</Button>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="po_number" label="PO Number" sort={orderSort} onToggle={toggleOrderSort} />
<SortableTableHead column="customer_ref" label="Customer Ref" sort={orderSort} onToggle={toggleOrderSort} />
<SortableTableHead column="status" label="Status" sort={orderSort} onToggle={toggleOrderSort} />
<SortableTableHead column="order_date" label="Order Date" sort={orderSort} onToggle={toggleOrderSort} />
<SortableTableHead column="due_date" label="Due Date" sort={orderSort} onToggle={toggleOrderSort} />
<SortableTableHead column="item_count" label="Items" sort={orderSort} onToggle={toggleOrderSort} className="text-right" />
<SortableTableHead column="total_value" label="Value" sort={orderSort} onToggle={toggleOrderSort} className="text-right" />
<TableHead className="w-[160px]">Fulfillment</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedOrders.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="py-12 text-center text-muted-foreground"
>
No purchase orders yet. Click &quot;New Order&quot; to
create one.
</TableCell>
</TableRow>
) : (
sortedOrders.map((order) => {
const sc = statusConfig[order.status] || statusConfig.draft;
return (
<TableRow key={order.id}>
<TableCell className="font-mono text-sm font-medium">
<Link
href={`/customers/${customerId}/orders/${order.id}`}
className="flex items-center gap-1.5 text-primary hover:underline"
>
{order.po_number}
<ExternalLink className="h-3 w-3" />
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{order.customer_ref || "-"}
</TableCell>
<TableCell>
<Badge variant={sc.variant} className="text-xs">
{sc.label}
</Badge>
</TableCell>
<TableCell className="text-sm">
{new Date(order.order_date).toLocaleDateString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{order.due_date
? new Date(order.due_date).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{Number(order.item_count || 0)}
</TableCell>
<TableCell className="text-right font-mono text-sm font-medium">
{formatCurrency(Number(order.total_value || 0))}
</TableCell>
<TableCell>
{(() => {
const ordered = Number(order.total_ordered || 0);
const shipped = Number(order.total_shipped || 0);
const pct = ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
if (order.status === "cancelled") return <span className="text-xs text-muted-foreground">-</span>;
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="font-mono text-muted-foreground">{shipped}/{ordered}</span>
<span className={pct === 100 ? "font-semibold text-accent" : "text-muted-foreground"}>{pct}%</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/customers/${customerId}/orders/${order.id}`}
>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteOrderTarget(order)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<CustomerDialog
open={editOpen}
onOpenChange={setEditOpen}
customer={customer}
onSaved={() => mutateCustomer()}
/>
<AlertDialog
open={!!deleteOrderTarget}
onOpenChange={() => setDeleteOrderTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Purchase Order</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete PO &quot;
{deleteOrderTarget?.po_number}&quot;? This will also remove all
associated line items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOrder}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import Link from "next/link";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
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,
Search,
Pencil,
Trash2,
Users,
Euro,
FileText,
ExternalLink,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { CustomerDialog } from "@/components/customer-dialog";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
import type { Customer } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function CustomersPage() {
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const { data: customers = [], mutate } = useSWR<Customer[]>(
`/api/customers?search=${encodeURIComponent(search)}`,
fetcher
);
const { sorted: sortedCustomers, sort, toggleSort } = useTableSort(customers);
function openEdit(customer: Customer) {
setEditingCustomer(customer);
setDialogOpen(true);
}
function openCreate() {
setEditingCustomer(null);
setDialogOpen(true);
}
async function handleDelete() {
if (!deleteTarget) return;
try {
const res = await fetch(`/api/customers/${deleteTarget.id}`, {
method: "DELETE",
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete");
}
toast.success("Customer deleted");
mutate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to delete customer"
);
} finally {
setDeleteTarget(null);
}
}
const totalCustomers = customers.length;
const totalRevenue = customers.reduce(
(sum, c) => sum + Number(c.total_revenue || 0),
0
);
const totalOrders = customers.reduce(
(sum, c) => sum + Number(c.order_count || 0),
0
);
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Customers & Orders
</h1>
<p className="text-sm text-muted-foreground">
Manage customers and their purchase orders
</p>
</div>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Customer
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Customers
</CardTitle>
<Users className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalCustomers}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Orders
</CardTitle>
<FileText className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{totalOrders}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Revenue
</CardTitle>
<Euro className="h-4 w-4 text-accent" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{formatCurrency(totalRevenue)}
</p>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search customers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Customer Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="code" label="Code" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="name" label="Company Name" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="contact_person" label="Contact Person" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="email" label="Email" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="phone" label="Phone" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="order_count" label="Orders" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="total_revenue" label="Revenue" sort={sort} onToggle={toggleSort} className="text-right" />
<TableHead className="w-[140px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedCustomers.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="py-12 text-center text-muted-foreground"
>
No customers found. Click &quot;Add Customer&quot; to
create one.
</TableCell>
</TableRow>
) : (
sortedCustomers.map((customer) => (
<TableRow key={customer.id}>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{customer.code}
</Badge>
</TableCell>
<TableCell className="font-medium">
<Link
href={`/customers/${customer.id}`}
className="flex items-center gap-1.5 text-primary hover:underline"
>
{customer.name}
<ExternalLink className="h-3 w-3" />
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{customer.contact_person || "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{customer.email || "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{customer.phone || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{Number(customer.order_count || 0)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(Number(customer.total_revenue || 0))}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link href={`/customers/${customer.id}`}>
<FileText className="h-3.5 w-3.5" />
<span className="sr-only">View Orders</span>
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(customer)}
>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(customer)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<CustomerDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
customer={editingCustomer}
onSaved={() => mutate()}
/>
<AlertDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Customer</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 210 20% 98%;
--foreground: 220 20% 10%;
--card: 0 0% 100%;
--card-foreground: 220 20% 10%;
--popover: 0 0% 100%;
--popover-foreground: 220 20% 10%;
--primary: 186 78% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 210 15% 93%;
--secondary-foreground: 220 20% 15%;
--muted: 210 15% 95%;
--muted-foreground: 215 10% 45%;
--accent: 160 50% 42%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 214 18% 88%;
--input: 214 18% 88%;
--ring: 186 78% 45%;
--chart-1: 186 78% 45%;
--chart-2: 160 50% 42%;
--chart-3: 35 90% 52%;
--chart-4: 0 72% 51%;
--chart-5: 270 50% 55%;
--radius: 0.5rem;
--sidebar-background: 220 20% 12%;
--sidebar-foreground: 210 15% 85%;
--sidebar-primary: 186 78% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 220 18% 18%;
--sidebar-accent-foreground: 210 15% 90%;
--sidebar-border: 220 15% 20%;
--sidebar-ring: 186 78% 45%;
--warning: 35 90% 52%;
--warning-foreground: 0 0% 100%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+35
View File
@@ -0,0 +1,35 @@
import React from "react"
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { AuthProvider } from "@/contexts/auth-context";
import { AppShell } from "@/components/app-shell";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
export const metadata: Metadata = {
title: "Linse by EACH",
description:
"Manage electronic components inventory, products, and bill of materials",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans antialiased">
<AuthProvider>
<AppShell>{children}</AppShell>
</AuthProvider>
</body>
</html>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { toast } from "sonner";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || !password) {
toast.error("Please enter email and password");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || "Login failed");
return;
}
toast.success("Login successful");
window.location.href = "/";
} catch {
toast.error("An error occurred");
} finally {
setLoading(false);
}
}
return (
<div
className="min-h-screen flex flex-col items-center justify-center px-4"
style={{
background: "radial-gradient(circle at center, #0c1c28 0%, #05131d 100%)"
}}
>
{/* LINSE Title */}
<h1
className="text-4xl md:text-[38px] font-light mb-10"
style={{ color: "#7cecff", letterSpacing: "12px", opacity: 0.9 }}
>
LINSE
</h1>
{/* Circuit Logo */}
<div className="mb-10">
<Image
src="/images/linse-symbol.png"
alt="Linse Symbol"
width={160}
height={160}
className="w-40 h-40"
style={{
filter: "drop-shadow(0 0 25px #18d1e4) brightness(0) saturate(100%) invert(73%) sepia(67%) saturate(1000%) hue-rotate(130deg) brightness(95%)"
}}
priority
/>
</div>
{/* Login Form Card */}
<div
className="w-full max-w-[360px] rounded-[14px] p-9 text-left"
style={{
background: "rgba(255, 255, 255, 0.04)",
border: "1px solid rgba(255, 255, 255, 0.06)",
backdropFilter: "blur(10px)",
boxShadow: "0 0 30px rgba(0, 0, 0, 0.3)"
}}
>
<form onSubmit={handleSubmit} className="space-y-[18px]">
{/* Email Input */}
<Input
type="email"
placeholder="Email or username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-[50px] rounded-lg text-[15px]"
style={{
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.12)",
color: "#d8e8f0"
}}
autoComplete="email"
disabled={loading}
/>
{/* Password Input */}
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-[50px] rounded-lg text-[15px] pr-10"
style={{
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.12)",
color: "#d8e8f0"
}}
autoComplete="current-password"
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-colors"
style={{ color: "#9db4c1" }}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
{/* Remember me & Forgot password */}
<div className="flex items-center justify-between mb-[25px] text-[14px]">
<div className="flex items-center gap-[6px]">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
className="border-[#9db4c1] data-[state=checked]:bg-cyan-600 data-[state=checked]:border-cyan-600"
/>
<label
htmlFor="remember"
className="cursor-pointer select-none"
style={{ color: "#d8e8f0" }}
>
Remember me
</label>
</div>
<button
type="button"
className="hover:underline transition-colors"
style={{ color: "#8fd8e8" }}
onClick={() => toast.info("Contact your administrator to reset your password")}
>
Forgot password?
</button>
</div>
{/* Login Button */}
<Button
type="submit"
className="w-full h-[50px] text-base font-semibold rounded-lg transition-all hover:scale-[1.02]"
style={{
background: "linear-gradient(90deg, #1cb5c9, #0fa4b7)",
color: "#ffffff",
}}
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logging in...
</>
) : (
"Log in"
)}
</Button>
</form>
</div>
</div>
);
}
+817
View File
@@ -0,0 +1,817 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import useSWR from "swr";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import SupplierPackingTab from "@/components/supplier-packing-tab";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Truck,
Plus,
Search,
FileDown,
Trash2,
Eye,
Package,
Hash,
X,
AlertTriangle,
CheckCircle2,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { toast } from "sonner";
import type { PurchaseOrder, PackingList } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
interface POItemForPacking {
id: number;
product_id: number;
product_name: string;
product_sku: string;
quantity: number;
shipped_quantity: number;
remaining_quantity: number;
}
export default function LogisticsPage() {
const { data: packingListsRaw, mutate } = useSWR<PackingList[]>(
"/api/packing-lists",
fetcher
);
const packingLists = Array.isArray(packingListsRaw) ? packingListsRaw : [];
const { data: ordersRaw } = useSWR<PurchaseOrder[]>(
"/api/orders?status=confirmed,in_production",
fetcher
);
const orders = Array.isArray(ordersRaw) ? ordersRaw : [];
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [viewTarget, setViewTarget] = useState<number | null>(null);
const [viewData, setViewData] = useState<(PackingList & { serials: { serial_number: string }[] }) | null>(null);
const [deleteTarget, setDeleteTarget] = useState<PackingList | null>(null);
// Create form state
const [selectedOrderId, setSelectedOrderId] = useState("");
const [poItems, setPoItems] = useState<POItemForPacking[]>([]);
const [selectedPoItemId, setSelectedPoItemId] = useState("");
const [packingNumber, setPackingNumber] = useState("");
const [serialInput, setSerialInput] = useState("");
const [serialNumbers, setSerialNumbers] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [creating, setCreating] = useState(false);
const [loadingItems, setLoadingItems] = useState(false);
const [existingSerials, setExistingSerials] = useState<string[]>([]);
// Load PO items and existing serials when order changes
useEffect(() => {
if (!selectedOrderId) {
setPoItems([]);
setSelectedPoItemId("");
setExistingSerials([]);
return;
}
setLoadingItems(true);
Promise.all([
fetch(`/api/orders/${selectedOrderId}/items`).then((r) => r.json()),
fetch(`/api/packing-lists?orderId=${selectedOrderId}`).then((r) => r.json()),
])
.then(([items, plsForOrder]) => {
if (Array.isArray(items)) {
setPoItems(items.filter((i: POItemForPacking) => Number(i.remaining_quantity) > 0));
}
// Collect all serial numbers already used in this PO's packing lists
if (Array.isArray(plsForOrder)) {
const plIds = plsForOrder.map((pl: PackingList) => pl.id);
if (plIds.length > 0) {
Promise.all(
plIds.map((id: number) =>
fetch(`/api/packing-lists/${id}`).then((r) => r.json())
)
).then((details) => {
const allSerials: string[] = [];
for (const detail of details) {
if (detail.serials) {
for (const s of detail.serials) {
allSerials.push(s.serial_number);
}
}
}
setExistingSerials(allSerials);
});
} else {
setExistingSerials([]);
}
}
})
.finally(() => setLoadingItems(false));
}, [selectedOrderId]);
// Fetch packing list detail for view
useEffect(() => {
if (!viewTarget) {
setViewData(null);
return;
}
fetch(`/api/packing-lists/${viewTarget}`)
.then((r) => r.json())
.then(setViewData);
}, [viewTarget]);
// Generate packing number preview: PL-EACH-YYYY_MM_DD-RR-CUSTOMER
useEffect(() => {
if (!selectedOrderId || !orders) {
setPackingNumber("");
return;
}
const order = orders.find((o) => String(o.id) === selectedOrderId);
if (!order) return;
const customerName = (order.customer_name || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
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}`;
// Count how many existing packing lists match this date + customer
const existing = (packingLists || []).filter((pl) =>
pl.packing_number.startsWith(datePrefix) && pl.packing_number.endsWith(`-${customerName}`)
).length;
const revision = String(existing + 1).padStart(2, "0");
setPackingNumber(`${datePrefix}-${revision}-${customerName}`);
}, [selectedOrderId, orders, packingLists]);
function resetCreateForm() {
setSelectedOrderId("");
setPoItems([]);
setSelectedPoItemId("");
setSerialInput("");
setSerialNumbers([]);
setNotes("");
setCreating(false);
}
function addSerial() {
const trimmed = serialInput.trim();
if (!trimmed) return;
if (serialNumbers.includes(trimmed)) {
toast.error("Duplicate serial number in this list");
return;
}
if (existingSerials.includes(trimmed)) {
toast.error(`Serial number "${trimmed}" is already used in another packing list for this PO`);
return;
}
const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId);
if (selectedItem && serialNumbers.length >= Number(selectedItem.remaining_quantity)) {
toast.error(`Maximum ${selectedItem.remaining_quantity} serial numbers allowed`);
return;
}
setSerialNumbers((prev) => [...prev, trimmed]);
setSerialInput("");
}
function removeSerial(sn: string) {
setSerialNumbers((prev) => prev.filter((s) => s !== sn));
}
async function handleCreate() {
if (!selectedOrderId || !selectedPoItemId || !serialNumbers.length) {
toast.error("Select a PO, product, and add at least one serial number");
return;
}
setCreating(true);
try {
const res = await fetch("/api/packing-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
purchase_order_id: Number(selectedOrderId),
po_item_id: Number(selectedPoItemId),
serial_numbers: serialNumbers,
notes: notes || null,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to create packing list");
}
toast.success("Packing list created");
mutate();
setCreateOpen(false);
resetCreateForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Error creating packing list");
} finally {
setCreating(false);
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
const res = await fetch(`/api/packing-lists/${deleteTarget.id}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete");
}
toast.success("Packing list deleted and shipped quantity reverted");
mutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Error deleting");
} finally {
setDeleteTarget(null);
}
}
const handleDownloadPdf = useCallback(async (plId: number) => {
try {
const res = await fetch(`/api/packing-lists/${plId}`);
const data = await res.json();
const { generatePackingPdf } = await import("@/lib/generate-packing-pdf");
const doc = generatePackingPdf(data);
doc.save(`${data.packing_number}.pdf`);
} catch {
toast.error("Failed to generate PDF");
}
}, []);
const filtered = (packingLists || []).filter((pl) => {
if (!search) return true;
const s = search.toLowerCase();
return (
pl.packing_number.toLowerCase().includes(s) ||
(pl.po_number || "").toLowerCase().includes(s) ||
(pl.customer_name || "").toLowerCase().includes(s) ||
(pl.product_name || "").toLowerCase().includes(s)
);
});
const { sorted: sortedFiltered, sort, toggleSort } = useTableSort(filtered);
const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId);
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Logistics</h1>
<p className="text-sm text-muted-foreground">
Create packing lists for customers and suppliers, and generate shipping documents
</p>
</div>
<Tabs defaultValue="customer" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="customer">Customer Packing Lists</TabsTrigger>
<TabsTrigger value="supplier">Supplier Packing Lists</TabsTrigger>
</TabsList>
<TabsContent value="customer">
<div className="mb-6 flex justify-end">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Packing List
</Button>
</div>
{/* Summary Cards */}
<div className="mb-6 grid gap-4 grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Truck className="h-8 w-8 text-primary" />
<div>
<p className="text-2xl font-bold">{filtered.length}</p>
<p className="text-xs text-muted-foreground">Total Packing Lists</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-accent" />
<div>
<p className="text-2xl font-bold">
{filtered.reduce((s, pl) => s + Number(pl.quantity), 0)}
</p>
<p className="text-xs text-muted-foreground">Units Shipped</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Hash className="h-8 w-8 text-warning" />
<div>
<p className="text-2xl font-bold">
{filtered.reduce((s, pl) => s + Number(pl.serial_count || pl.quantity), 0)}
</p>
<p className="text-xs text-muted-foreground">Serial Numbers Tracked</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<Card className="mb-6">
<CardContent className="py-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by packing number, PO number, customer, or product..."
className="pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Packing Lists Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="h-4 w-4 text-primary" />
Packing Lists
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="packing_number" label="Packing No." sort={sort} onToggle={toggleSort} />
<SortableTableHead column="po_number" label="PO Number" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="customer_name" label="Customer" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="product_name" label="Product" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="serial_count" label="Serials" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} />
<TableHead className="w-[130px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedFiltered.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
No packing lists found
</TableCell>
</TableRow>
) : (
sortedFiltered.map((pl) => (
<TableRow key={pl.id}>
<TableCell className="font-mono text-sm font-semibold">
{pl.packing_number}
</TableCell>
<TableCell className="font-mono text-xs">
{pl.po_number}
</TableCell>
<TableCell>
<div>
<span className="font-medium text-sm">{pl.customer_name}</span>
<span className="ml-2 text-xs text-muted-foreground">
({pl.customer_code})
</span>
</div>
</TableCell>
<TableCell>
<div>
<span className="text-sm">{pl.product_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{pl.product_sku}
</span>
</div>
</TableCell>
<TableCell className="text-right font-mono font-medium">
{Number(pl.quantity)}
</TableCell>
<TableCell className="text-right font-mono">
{Number(pl.serial_count || pl.quantity)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(pl.created_at).toLocaleDateString("en-GB")}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setViewTarget(pl.id)}
title="View details"
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-primary hover:text-primary"
onClick={() => handleDownloadPdf(pl.id)}
title="Download PDF"
>
<FileDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(pl)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="supplier">
<SupplierPackingTab />
</TabsContent>
</Tabs>
{/* Create Packing List Dialog */}
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Truck className="h-5 w-5 text-primary" />
New Packing List
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5 py-2">
{/* Packing Number (auto-generated) */}
<div className="flex flex-col gap-2">
<Label>Packing List Number</Label>
<Input
value={packingNumber || "Select a purchase order..."}
readOnly
className="font-mono bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
Auto-generated: PL-EACH-YYYY_MM_DD-revision-CUSTOMER
</p>
</div>
{/* Select Purchase Order */}
<div className="flex flex-col gap-2">
<Label>Purchase Order</Label>
<Select value={selectedOrderId} onValueChange={setSelectedOrderId}>
<SelectTrigger>
<SelectValue placeholder="Select a purchase order..." />
</SelectTrigger>
<SelectContent>
{(orders || []).map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.po_number} - {o.customer_name} ({o.customer_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Select PO Item (product) */}
{selectedOrderId && (
<div className="flex flex-col gap-2">
<Label>Product Line</Label>
{loadingItems ? (
<p className="text-sm text-muted-foreground">Loading items...</p>
) : poItems.length === 0 ? (
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-accent" />
All items fully shipped on this order
</div>
) : (
<Select value={selectedPoItemId} onValueChange={setSelectedPoItemId}>
<SelectTrigger>
<SelectValue placeholder="Select a product..." />
</SelectTrigger>
<SelectContent>
{poItems.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.product_name} ({item.product_sku}) - {Number(item.remaining_quantity)} remaining
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Remaining info */}
{selectedItem && (
<div className="rounded-md border border-border bg-muted/50 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedItem.product_name} ({selectedItem.product_sku})
</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Ordered: <strong className="text-foreground">{Number(selectedItem.quantity)}</strong>
</span>
<span className="text-muted-foreground">
Shipped: <strong className="text-foreground">{Number(selectedItem.shipped_quantity)}</strong>
</span>
<Badge variant="secondary">
{Number(selectedItem.remaining_quantity)} remaining
</Badge>
</div>
</div>
</div>
)}
{/* Serial Numbers */}
{selectedPoItemId && (
<div className="flex flex-col gap-2">
<Label className="flex items-center justify-between">
<span>
Serial Numbers ({serialNumbers.length}
{selectedItem
? ` / ${Number(selectedItem.remaining_quantity)}`
: ""}
)
</span>
{selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity) && (
<Badge className="bg-accent text-accent-foreground text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
All serial numbers entered
</Badge>
)}
</Label>
<div className="flex gap-2">
<Input
placeholder="Enter serial number and press Enter..."
className="font-mono"
value={serialInput}
onChange={(e) => setSerialInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSerial();
}
}}
disabled={
!!selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity)
}
/>
<Button
type="button"
variant="secondary"
onClick={addSerial}
disabled={
!serialInput.trim() ||
(!!selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity))
}
>
Add
</Button>
</div>
{serialNumbers.length > 0 && (
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{serialNumbers.map((sn, i) => (
<TableRow key={sn}>
<TableCell className="text-xs text-muted-foreground">
{i + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{sn}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => removeSerial(sn)}
>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{/* Notes */}
<div className="flex flex-col gap-2">
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Additional shipping notes..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false);
resetCreateForm();
}}
>
Cancel
</Button>
<Button
disabled={creating || !serialNumbers.length || !selectedPoItemId}
onClick={handleCreate}
>
{creating ? "Creating..." : `Create Packing List (${serialNumbers.length} units)`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* View Detail Dialog */}
<Dialog open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-primary" />
{viewData?.packing_number || "Loading..."}
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="flex flex-col gap-4 py-2">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Customer</span>
<p className="font-medium">{viewData.customer_name}</p>
</div>
<div>
<span className="text-muted-foreground">PO Number</span>
<p className="font-mono font-medium">{viewData.po_number}</p>
</div>
<div>
<span className="text-muted-foreground">Product</span>
<p className="font-medium">{viewData.product_name}</p>
</div>
<div>
<span className="text-muted-foreground">Quantity</span>
<p className="font-mono font-bold">{Number(viewData.quantity)}</p>
</div>
<div>
<span className="text-muted-foreground">Date</span>
<p>{new Date(viewData.created_at).toLocaleDateString("en-GB")}</p>
</div>
{viewData.notes && (
<div className="col-span-2">
<span className="text-muted-foreground">Notes</span>
<p>{viewData.notes}</p>
</div>
)}
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">
Serial Numbers ({viewData.serials?.length || 0})
</h4>
<div className="max-h-60 overflow-y-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Serial Number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(viewData.serials || []).map(
(s: { serial_number: string }, i: number) => (
<TableRow key={s.serial_number}>
<TableCell className="text-xs text-muted-foreground">
{i + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{s.serial_number}
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => setViewTarget(null)}
>
Close
</Button>
<Button onClick={() => handleDownloadPdf(viewData.id)}>
<FileDown className="mr-2 h-4 w-4" />
Download PDF
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Delete Packing List?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete packing list{" "}
<strong>{deleteTarget?.packing_number}</strong> and revert the
shipped quantity ({deleteTarget?.quantity} units) on the purchase
order. All serial numbers will be released. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+582
View File
@@ -0,0 +1,582 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Cpu,
Package,
AlertTriangle,
Euro,
ArrowRight,
TrendingUp,
FileText,
ExternalLink,
ClipboardList,
ShoppingCart,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { formatCurrency } from "@/lib/format";
import { toast } from "sonner";
import type { Component } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
interface OrderRow {
id: number;
po_number: string;
status: string;
order_date: string;
due_date: string | null;
customer_id: number;
customer_name: string;
customer_code: string;
total_ordered: number;
total_shipped: number;
total_remaining: number;
total_value: number;
item_count: number;
}
interface DashboardData {
stats: {
total_components: number;
total_inventory_value: number;
low_stock_count: number;
total_products: number;
active_products: number;
draft_products: number;
active_orders: number;
in_production_orders: number;
confirmed_orders: number;
shipped_orders: number;
};
low_stock_items: Component[];
category_breakdown: { name: string; count: number; total_stock: number }[];
recent_orders: OrderRow[];
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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 DashboardPage() {
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [poQuantities, setPoQuantities] = useState<Record<number, number>>({});
const [generatingPo, setGeneratingPo] = useState(false);
const { data, isLoading } = useSWR<DashboardData>("/api/dashboard", fetcher);
const { sorted: sortedLowStock, sort: lowStockSort, toggleSort: toggleLowStockSort } = useTableSort(data?.low_stock_items ?? []);
const { sorted: sortedOrders, sort: ordersSort, toggleSort: toggleOrdersSort } = useTableSort(data?.recent_orders ?? []);
function toggleItem(id: number) {
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function toggleAll(items: Component[]) {
if (selectedItems.size === items.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(items.map((i) => i.id)));
}
}
async function handleGeneratePo(lowStockItems: Component[]) {
const selected = lowStockItems.filter((i) => selectedItems.has(i.id));
if (selected.length === 0) return;
setGeneratingPo(true);
try {
// Fetch suppliers to resolve IDs from names
const suppRes = await fetch("/api/suppliers");
const suppliers = await suppRes.json();
const supplierMap: Record<string, { id: number; name: string }> = {};
for (const s of Array.isArray(suppliers) ? suppliers : []) {
supplierMap[s.name] = { id: s.id, name: s.name };
}
// Group items by supplier
const grouped: Record<string, { supplier_id: number; supplier_name: string; items: Record<string, unknown>[] }> = {};
for (const item of selected) {
const supplierName = item.supplier || "Unknown Supplier";
if (!grouped[supplierName]) {
const sup = supplierMap[supplierName];
if (!sup) {
toast.error(`Supplier "${supplierName}" not found. Please add it first.`);
setGeneratingPo(false);
return;
}
grouped[supplierName] = {
supplier_id: sup.id,
supplier_name: sup.name,
items: [],
};
}
const qty = poQuantities[item.id] || Math.max(1, item.min_stock_level - item.stock_quantity);
grouped[supplierName].items.push({
component_id: item.id,
part_number: item.part_number,
component_name: item.name,
supplier_part_number: item.supplier_part_number || "",
quantity: qty,
unit_price: Number(item.unit_price) || 0,
});
}
const orders = Object.values(grouped);
const res = await fetch("/api/supplier-pos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orders }),
});
if (!res.ok) throw new Error("Failed to create POs");
const created = await res.json();
const count = Array.isArray(created) ? created.length : 1;
toast.success(`${count} purchase order(s) created in Draft status`);
setSelectedItems(new Set());
setPoQuantities({});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to generate POs");
} finally {
setGeneratingPo(false);
}
}
const stats = data?.stats;
if (isLoading || !data) {
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="text-sm text-muted-foreground">
Overview of your component inventory and products
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="py-6">
<div className="h-16 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="text-sm text-muted-foreground">
Overview of your component inventory and products
</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<Cpu className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Total Components</p>
<p className="text-2xl font-bold">{stats.total_components}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10">
<Euro className="h-5 w-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">Inventory Value</p>
<p className="text-2xl font-bold font-mono">
{formatCurrency(stats.total_inventory_value)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-warning/10">
<AlertTriangle className="h-5 w-5 text-warning" />
</div>
<div>
<p className="text-sm text-muted-foreground">Low Stock Alerts</p>
<p className="text-2xl font-bold">
{stats.low_stock_count}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Products</p>
<p className="text-2xl font-bold">
{stats.total_products}
{stats.active_products > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({stats.active_products} active)
</span>
)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Active Orders</p>
<p className="text-2xl font-bold">{stats.active_orders}</p>
</div>
</CardContent>
</Card>
</div>
<div>
{/* Low Stock Alerts */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Low Stock Alerts
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/components?lowStock=true">
View All
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{sortedLowStock.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<TrendingUp className="h-8 w-8 text-accent mb-2" />
<p className="text-sm font-medium text-foreground">
All stock levels are healthy
</p>
<p className="text-xs text-muted-foreground">
No components are below their minimum stock level
</p>
</div>
) : (
<div className="space-y-3">
{/* Generate PO action bar */}
{selectedItems.size > 0 && (
<div className="flex items-center justify-between rounded-md border border-primary/20 bg-primary/5 px-4 py-2.5">
<span className="text-sm font-medium text-primary">
{selectedItems.size} component(s) selected
</span>
<Button
size="sm"
onClick={() => handleGeneratePo(data?.low_stock_items ?? [])}
disabled={generatingPo}
>
<ShoppingCart className="mr-1.5 h-3.5 w-3.5" />
{generatingPo ? "Generating..." : "Generate PO"}
</Button>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={sortedLowStock.length > 0 && selectedItems.size === sortedLowStock.slice(0, 8).length}
onCheckedChange={() => toggleAll(sortedLowStock.slice(0, 8))}
/>
</TableHead>
<SortableTableHead column="part_number" label="Part Number" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="name" label="Name" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="supplier" label="Supplier" sort={lowStockSort} onToggle={toggleLowStockSort} />
<SortableTableHead column="stock_quantity" label="Stock" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
<SortableTableHead column="min_stock_level" label="Min Level" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
<TableHead>Level</TableHead>
<TableHead className="text-right w-[110px]">Order Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLowStock.slice(0, 8).map((item) => {
const pct =
item.min_stock_level > 0
? Math.min(
(item.stock_quantity / item.min_stock_level) * 100,
100
)
: 0;
const isSelected = selectedItems.has(item.id);
const deficit = Math.max(1, item.min_stock_level - item.stock_quantity);
return (
<TableRow key={item.id} className={isSelected ? "bg-primary/5" : ""}>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleItem(item.id)}
/>
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number}
</TableCell>
<TableCell className="font-medium text-sm">
{item.name}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.supplier || <span className="italic">No supplier</span>}
</TableCell>
<TableCell className="text-right font-semibold text-destructive">
{item.stock_quantity}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{item.min_stock_level}
</TableCell>
<TableCell className="w-[100px]">
<Progress
value={pct}
className="h-2 [&>div]:bg-destructive"
/>
</TableCell>
<TableCell className="text-right">
{isSelected ? (
<Input
type="number"
min={1}
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={poQuantities[item.id] ?? deficit}
onChange={(e) =>
setPoQuantities((prev) => ({
...prev,
[item.id]: Math.max(1, Number(e.target.value) || 1),
}))
}
/>
) : (
<span className="text-xs text-muted-foreground font-mono">{deficit}</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
{/* Purchase Orders Status */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-primary" />
Purchase Order Status
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/customers">
View All
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{sortedOrders.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm font-medium text-foreground">
No active purchase orders
</p>
<p className="text-xs text-muted-foreground">
Purchase orders from customers will appear here
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="po_number" label="PO Number" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="customer_name" label="Customer" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="status" label="Status" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="order_date" label="Order Date" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="due_date" label="Due Date" sort={ordersSort} onToggle={toggleOrdersSort} />
<SortableTableHead column="total_value" label="Value" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_ordered" label="Ordered" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_shipped" label="Shipped" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<SortableTableHead column="total_remaining" label="Remaining" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
<TableHead className="w-[140px]">Fulfillment</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{sortedOrders.map((order) => {
const ordered = Number(order.total_ordered);
const shipped = Number(order.total_shipped);
const remaining = Number(order.total_remaining);
const pct =
ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
const sc = statusConfig[order.status] || statusConfig.draft;
const isOverdue =
order.due_date &&
new Date(order.due_date) < new Date() &&
pct < 100;
return (
<TableRow key={order.id}>
<TableCell className="font-mono text-sm font-medium">
<Link
href={`/customers/${order.customer_id}/orders/${order.id}`}
className="flex items-center gap-1.5 text-primary hover:underline"
>
{order.po_number}
<ExternalLink className="h-3 w-3" />
</Link>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-medium">
{order.customer_name}
</span>
<span className="text-xs text-muted-foreground font-mono">
{order.customer_code}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={sc.variant} className="text-xs">
{sc.label}
</Badge>
</TableCell>
<TableCell className="text-sm">
{new Date(order.order_date).toLocaleDateString()}
</TableCell>
<TableCell className="text-sm">
{order.due_date ? (
<span
className={
isOverdue
? "font-medium text-destructive"
: "text-muted-foreground"
}
>
{new Date(order.due_date).toLocaleDateString()}
{isOverdue && " (overdue)"}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(Number(order.total_value))}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{ordered}
</TableCell>
<TableCell className="text-right font-mono text-sm text-accent">
{shipped}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
<span
className={
remaining > 0
? "text-warning"
: "text-accent"
}
>
{remaining}
</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="font-mono text-muted-foreground">
{shipped}/{ordered}
</span>
<span
className={
pct === 100
? "font-semibold text-accent"
: "text-muted-foreground"
}
>
{pct}%
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/customers/${order.customer_id}/orders/${order.id}`}
>
<ExternalLink className="h-3.5 w-3.5" />
<span className="sr-only">Open order details</span>
</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useState, useCallback } from "react";
import useSWR from "swr";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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, ClipboardList } from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { ProductDialog } from "@/components/product-dialog";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
import type { Product } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const statusStyles: Record<string, string> = {
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 ProductsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
const { data: products = [], mutate } = useSWR<Product[]>(
"/api/products",
fetcher
);
const { sorted: sortedProducts, sort, toggleSort } = useTableSort(products);
const handleSave = useCallback(
async (data: Partial<Product>) => {
const url = editingProduct
? `/api/products/${editingProduct.id}`
: "/api/products";
const method = editingProduct ? "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(
editingProduct ? "Product updated" : "Product created"
);
mutate();
},
[editingProduct, mutate]
);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
await fetch(`/api/products/${deleteTarget.id}`, { method: "DELETE" });
toast.success("Product deleted");
setDeleteTarget(null);
mutate();
}, [deleteTarget, mutate]);
function openCreate() {
setEditingProduct(null);
setDialogOpen(true);
}
function openEdit(prod: Product) {
setEditingProduct(prod);
setDialogOpen(true);
}
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Products
</h1>
<p className="text-sm text-muted-foreground">
Manage your products and their bill of materials
</p>
</div>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Product
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="sku" label="SKU" sort={sort} onToggle={toggleSort} className="w-[120px]" />
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="version" label="Version" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="status" label="Status" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="bom_count" label="BOM Items" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="total_cost" label="BOM Cost" sort={sort} onToggle={toggleSort} className="text-right" />
<TableHead className="w-[130px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedProducts.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-12 text-muted-foreground"
>
No products yet. Click &quot;Add Product&quot; to get started.
</TableCell>
</TableRow>
) : (
sortedProducts.map((prod) => (
<TableRow key={prod.id}>
<TableCell className="font-mono text-xs font-medium">
{prod.sku}
</TableCell>
<TableCell className="font-medium">{prod.name}</TableCell>
<TableCell className="text-muted-foreground">
v{prod.version}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={statusStyles[prod.status] || ""}
>
{prod.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{prod.bom_count || 0}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(Number(prod.total_cost || 0))}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link href={`/bom/${prod.id}`}>
<ClipboardList className="h-3.5 w-3.5" />
<span className="sr-only">View BOM</span>
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(prod)}
>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(prod)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<ProductDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
product={editingProduct}
onSave={handleSave}
/>
<AlertDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Product</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<strong>{deleteTarget?.name}</strong> ({deleteTarget?.sku})? This
will also delete all associated BOM items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+586
View File
@@ -0,0 +1,586 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { useAuth } from "@/contexts/auth-context";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Pencil, Trash2, KeyRound, Shield, Users } from "lucide-react";
import { toast } from "sonner";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const SECTIONS = [
{ id: "dashboard", label: "Dashboard" },
{ id: "components", label: "Components" },
{ id: "products", label: "Products" },
{ id: "production", label: "Production" },
{ id: "supplier-pos", label: "Supplier POs" },
{ id: "customer-orders", label: "Customer Orders" },
{ id: "stock-history", label: "Stock History" },
{ id: "stock-take", label: "Stock Take" },
{ id: "logistics", label: "Logistics" },
{ id: "suppliers", label: "Suppliers" },
{ id: "customers", label: "Customers" },
];
interface UserData {
id: number;
email: string;
name: string;
role: "admin" | "user";
is_active: boolean;
permissions: {
sections: string[];
can_view_financial: boolean;
can_edit: boolean;
can_create: boolean;
can_delete: boolean;
can_download: boolean;
};
created_at: string;
}
const defaultPermissions = {
sections: [] as string[],
can_view_financial: false,
can_edit: false,
can_create: false,
can_delete: false,
can_download: false,
};
export default function SettingsPage() {
const { user: currentUser } = useAuth();
const { data: users = [], mutate } = useSWR<UserData[]>("/api/users", fetcher);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserData | null>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordUserId, setPasswordUserId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState("");
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
email: "",
name: "",
password: "",
role: "user" as "admin" | "user",
permissions: { ...defaultPermissions },
});
// Check if current user is admin
if (currentUser?.role !== "admin") {
return (
<div className="p-8">
<Card>
<CardContent className="py-12 text-center">
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h2 className="text-xl font-semibold mb-2">Access Denied</h2>
<p className="text-muted-foreground">
You don&apos;t have permission to access user settings.
</p>
</CardContent>
</Card>
</div>
);
}
function openCreateDialog() {
setEditingUser(null);
setForm({
email: "",
name: "",
password: "",
role: "user",
permissions: { ...defaultPermissions },
});
setDialogOpen(true);
}
function openEditDialog(user: UserData) {
setEditingUser(user);
setForm({
email: user.email,
name: user.name,
password: "",
role: user.role,
permissions: {
...defaultPermissions,
...(user.permissions || {}),
sections: user.permissions?.sections || [],
},
});
setDialogOpen(true);
}
function toggleSection(sectionId: string) {
setForm((prev) => {
const sections = prev.permissions.sections.includes(sectionId)
? prev.permissions.sections.filter((s) => s !== sectionId)
: [...prev.permissions.sections, sectionId];
return {
...prev,
permissions: { ...prev.permissions, sections },
};
});
}
function togglePermission(key: keyof typeof defaultPermissions) {
if (key === "sections") return;
setForm((prev) => ({
...prev,
permissions: {
...prev.permissions,
[key]: !prev.permissions[key],
},
}));
}
async function handleSave() {
if (!form.email || !form.name) {
toast.error("Email and name are required");
return;
}
if (!editingUser && !form.password) {
toast.error("Password is required for new users");
return;
}
setSaving(true);
try {
const url = editingUser ? `/api/users/${editingUser.id}` : "/api/users";
const method = editingUser ? "PUT" : "POST";
const payload: Record<string, unknown> = {
email: form.email,
name: form.name,
role: form.role,
permissions: form.permissions,
};
if (form.password) {
payload.password = form.password;
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save");
}
toast.success(editingUser ? "User updated" : "User created");
setDialogOpen(false);
mutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
}
async function handleDelete(userId: number) {
if (userId === currentUser?.id) {
toast.error("You cannot delete your own account");
return;
}
if (!confirm("Are you sure you want to delete this user?")) return;
try {
const res = await fetch(`/api/users/${userId}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to delete");
}
toast.success("User deleted");
mutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete");
}
}
function openPasswordDialog(userId: number) {
setPasswordUserId(userId);
setNewPassword("");
setPasswordDialogOpen(true);
}
async function handleResetPassword() {
if (!newPassword || newPassword.length < 6) {
toast.error("Password must be at least 6 characters");
return;
}
setSaving(true);
try {
const res = await fetch(`/api/users/${passwordUserId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to reset password");
}
toast.success("Password updated");
setPasswordDialogOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to reset password");
} finally {
setSaving(false);
}
}
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Users className="h-6 w-6" />
User Management
</h1>
<p className="text-muted-foreground mt-1">
Manage users, roles, and permissions
</p>
</div>
<Button onClick={openCreateDialog} className="gap-2">
<Plus className="h-4 w-4" />
Add User
</Button>
</div>
{/* Users Table */}
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>
{users.length} user{users.length !== 1 ? "s" : ""} registered
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Permissions</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role}
</Badge>
</TableCell>
<TableCell>
{user.role === "admin" ? (
<span className="text-xs text-muted-foreground">All</span>
) : (
<span className="text-xs text-muted-foreground">
{user.permissions?.sections?.length || 0} section{(user.permissions?.sections?.length || 0) !== 1 ? "s" : ""}
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.role === "admin" ? (
<Badge variant="outline" className="text-xs">Full Access</Badge>
) : (
<>
{user.permissions?.can_view_financial && <Badge variant="outline" className="text-xs">Financial</Badge>}
{user.permissions?.can_edit && <Badge variant="outline" className="text-xs">Edit</Badge>}
{user.permissions?.can_create && <Badge variant="outline" className="text-xs">Create</Badge>}
{user.permissions?.can_delete && <Badge variant="outline" className="text-xs">Delete</Badge>}
{user.permissions?.can_download && <Badge variant="outline" className="text-xs">Download</Badge>}
</>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openPasswordDialog(user.id)}
title="Reset Password"
>
<KeyRound className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(user)}
title="Edit User"
>
<Pencil className="h-4 w-4" />
</Button>
{user.id !== currentUser?.id && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
title="Delete User"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* User Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingUser ? "Edit User" : "Create User"}
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="user@company.com"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">
{editingUser ? "New Password (leave blank to keep)" : "Password *"}
</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder={editingUser ? "Leave blank to keep current" : "Min 6 characters"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.role}
onValueChange={(val) => setForm({ ...form, role: val as "admin" | "user" })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{form.role === "user" && (
<>
{/* Section Access */}
<div className="space-y-3">
<Label>Section Access</Label>
<p className="text-xs text-muted-foreground">
Select which sections this user can access
</p>
<div className="grid grid-cols-3 gap-2">
{SECTIONS.map((section) => (
<div
key={section.id}
className="flex items-center gap-2 p-2 rounded border"
>
<Checkbox
id={`section-${section.id}`}
checked={form.permissions.sections.includes(section.id)}
onCheckedChange={() => toggleSection(section.id)}
/>
<Label
htmlFor={`section-${section.id}`}
className="text-sm cursor-pointer"
>
{section.label}
</Label>
</div>
))}
</div>
</div>
{/* Permissions */}
<div className="space-y-3">
<Label>Permissions</Label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 p-3 rounded border">
<Checkbox
id="perm-financial"
checked={form.permissions.can_view_financial}
onCheckedChange={() => togglePermission("can_view_financial")}
/>
<div>
<Label htmlFor="perm-financial" className="cursor-pointer">
View Financial Info
</Label>
<p className="text-xs text-muted-foreground">
See prices, costs, and financial data
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 rounded border">
<Checkbox
id="perm-edit"
checked={form.permissions.can_edit}
onCheckedChange={() => togglePermission("can_edit")}
/>
<div>
<Label htmlFor="perm-edit" className="cursor-pointer">
Edit Records
</Label>
<p className="text-xs text-muted-foreground">
Modify existing data
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 rounded border">
<Checkbox
id="perm-create"
checked={form.permissions.can_create}
onCheckedChange={() => togglePermission("can_create")}
/>
<div>
<Label htmlFor="perm-create" className="cursor-pointer">
Create Records
</Label>
<p className="text-xs text-muted-foreground">
Add new components, products, etc.
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 rounded border">
<Checkbox
id="perm-delete"
checked={form.permissions.can_delete}
onCheckedChange={() => togglePermission("can_delete")}
/>
<div>
<Label htmlFor="perm-delete" className="cursor-pointer">
Delete Records
</Label>
<p className="text-xs text-muted-foreground">
Remove data permanently
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 rounded border col-span-2">
<Checkbox
id="perm-download"
checked={form.permissions.can_download}
onCheckedChange={() => togglePermission("can_download")}
/>
<div>
<Label htmlFor="perm-download" className="cursor-pointer">
Download Files
</Label>
<p className="text-xs text-muted-foreground">
Export CSVs and download reports (view only if disabled)
</p>
</div>
</div>
</div>
</div>
</>
)}
{form.role === "admin" && (
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-sm font-medium">Admin users have full access</p>
<p className="text-xs text-muted-foreground mt-1">
Admins can access all sections, view financial data, and perform all actions including user management.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : editingUser ? "Update User" : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Password Reset Dialog */}
<Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<div className="py-4">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 6 characters"
className="mt-2"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPasswordDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleResetPassword} disabled={saving}>
{saving ? "Saving..." : "Reset Password"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+632
View File
@@ -0,0 +1,632 @@
"use client";
import { useState, useMemo } 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 { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Search,
History,
ShoppingCart,
ArrowUpCircle,
ArrowDownCircle,
RotateCcw,
Euro,
Package,
TrendingUp,
Download,
ChevronLeft,
ChevronRight,
Printer,
ExternalLink,
FileDown,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { formatCurrency } from "@/lib/format";
import { generateLabelPdf } from "@/lib/generate-label-pdf";
import { toast } from "sonner";
import type { StockTransaction } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const PAGE_SIZE = 25;
const typeConfig: Record<
string,
{ label: string; color: string; icon: typeof ShoppingCart }
> = {
purchase: { label: "Purchase", color: "bg-accent text-accent-foreground", icon: ShoppingCart },
adjustment: { label: "Adjustment", color: "bg-primary text-primary-foreground", icon: ArrowUpCircle },
return: { label: "Return", color: "bg-warning text-warning-foreground", icon: RotateCcw },
consumption: { label: "Consumption", color: "bg-destructive text-destructive-foreground", icon: ArrowDownCircle },
};
export default function StockHistoryPage() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [page, setPage] = useState(0);
// PO detail dialog state
const [poDialogOpen, setPoDialogOpen] = useState(false);
const [poDetail, setPoDetail] = useState<Record<string, unknown> | null>(null);
const [poLoading, setPoLoading] = useState(false);
async function handleOpenPo(poNumber: string) {
setPoLoading(true);
setPoDialogOpen(true);
try {
// Find PO by number from list
const listRes = await fetch("/api/supplier-pos");
const allPos = await listRes.json();
const match = (Array.isArray(allPos) ? allPos : []).find(
(p: Record<string, unknown>) => p.po_number === poNumber
);
if (!match) {
toast.error("Purchase order not found");
setPoDialogOpen(false);
return;
}
// Fetch full detail
const detailRes = await fetch(`/api/supplier-pos/${match.id}`);
const detail = await detailRes.json();
setPoDetail(detail);
} catch {
toast.error("Failed to load purchase order");
setPoDialogOpen(false);
} finally {
setPoLoading(false);
}
}
async function handleDownloadPoPdf() {
if (!poDetail) return;
try {
const { generateSupplierPoPdf } = await import("@/lib/generate-supplier-po-pdf");
const doc = generateSupplierPoPdf({
po_number: String(poDetail.po_number),
date: String(poDetail.created_at),
status: String(poDetail.status),
supplier_name: String(poDetail.supplier_name),
supplier_code: String(poDetail.supplier_code),
supplier_email: poDetail.supplier_email as string | null,
supplier_phone: poDetail.supplier_phone as string | null,
supplier_address: poDetail.supplier_address as string | null,
supplier_contact: poDetail.supplier_contact as string | null,
supplier_country: poDetail.supplier_country as string | null,
supplier_currency: poDetail.supplier_currency as string | null,
shipping_cost: Number(poDetail.shipping_cost) || 0,
notes: poDetail.notes as string | null,
items: (Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>) => ({
part_number: String(item.part_number || ""),
component_name: String(item.component_name || ""),
supplier_part_number: String(item.supplier_part_number || ""),
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
})),
});
doc.save(`${poDetail.po_number}.pdf`);
} catch {
toast.error("Failed to generate PO PDF");
}
}
// Label printing state
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [labelPartNumber, setLabelPartNumber] = useState("");
const [labelQuantity, setLabelQuantity] = useState<number>(1);
const [printingLabel, setPrintingLabel] = useState(false);
function openLabelDialog(partNumber: string, defaultQty: number) {
setLabelPartNumber(partNumber);
setLabelQuantity(defaultQty);
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 (typeFilter && typeFilter !== "all") params.set("type", typeFilter);
params.set("limit", String(PAGE_SIZE));
params.set("offset", String(page * PAGE_SIZE));
const { data } = useSWR<{
transactions: StockTransaction[];
total: number;
}>(`/api/stock?${params.toString()}`, fetcher, {
keepPreviousData: true,
});
const transactions = data?.transactions || [];
const total = data?.total || 0;
const { sorted: sortedTransactions, sort, toggleSort } = useTableSort(transactions);
const totalPages = Math.ceil(total / PAGE_SIZE);
// Summary stats
const stats = useMemo(() => {
if (!transactions.length)
return {
totalTransactions: total,
purchaseCount: 0,
totalSpent: 0,
avgOrderSize: 0,
};
const purchases = transactions.filter(
(t) => t.transaction_type === "purchase"
);
const totalSpent = purchases.reduce(
(sum, t) => sum + Number(t.total_cost || 0),
0
);
return {
totalTransactions: total,
purchaseCount: purchases.length,
totalSpent,
avgOrderSize:
purchases.length > 0 ? totalSpent / purchases.length : 0,
};
}, [transactions, total]);
function handleExportCSV() {
if (!transactions.length) return;
const headers = [
"Date",
"Type",
"Part Number",
"Component",
"Category",
"Quantity",
"Unit Cost",
"Total Cost",
"Supplier",
"Purchase Order",
"Notes",
];
const rows = transactions.map((t) => [
new Date(t.created_at).toLocaleString(),
t.transaction_type,
t.part_number || "",
`"${t.component_name || ""}"`,
t.category_name || "",
t.quantity,
t.unit_cost ?? "",
t.total_cost ?? "",
t.supplier || "",
t.supplier_order_ref || "",
`"${(t.notes || "").replace(/"/g, '""')}"`,
]);
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `stock-history-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Reset page when filters change
function handleSearchChange(val: string) {
setSearch(val);
setPage(0);
}
function handleTypeChange(val: string) {
setTypeFilter(val);
setPage(0);
}
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Purchase History
</h1>
<p className="text-sm text-muted-foreground">
Track all stock transactions, purchases, and inventory changes
</p>
</div>
<Button variant="outline" onClick={handleExportCSV} disabled={!transactions.length}>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Transactions
</CardTitle>
<History className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{stats.totalTransactions.toLocaleString()}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Purchases (This Page)
</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stats.purchaseCount}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Spent (This Page)
</CardTitle>
<Euro className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold font-mono">
{formatCurrency(stats.totalSpent)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg Purchase Cost
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold font-mono">
{formatCurrency(stats.avgOrderSize)}
</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by component name or part number..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select value={typeFilter} onValueChange={handleTypeChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="purchase">Purchases</SelectItem>
<SelectItem value="adjustment">Adjustments</SelectItem>
<SelectItem value="return">Returns</SelectItem>
<SelectItem value="consumption">Consumption</SelectItem>
</SelectContent>
</Select>
</div>
{/* Transactions Table */}
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} className="w-[160px]" />
<SortableTableHead column="transaction_type" label="Type" sort={sort} onToggle={toggleSort} className="w-[120px]" />
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[130px]" />
<SortableTableHead column="component_name" label="Component" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="supplier" label="Supplier" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="supplier_order_ref" label="Purchase Order" sort={sort} onToggle={toggleSort} />
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{sortedTransactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center py-12 text-muted-foreground"
>
{search || typeFilter !== "all"
? "No transactions match your filters."
: "No stock transactions yet. Use the \"Add Stock\" button on a component to record purchases."}
</TableCell>
</TableRow>
) : (
sortedTransactions.map((txn) => {
const config = typeConfig[txn.transaction_type] || typeConfig.purchase;
const IconComponent = config.icon;
return (
<TableRow key={txn.id}>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(txn.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
<br />
<span className="text-muted-foreground/60">
{new Date(txn.created_at).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</TableCell>
<TableCell>
<Badge className={`${config.color} text-xs gap-1`}>
<IconComponent className="h-3 w-3" />
{config.label}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{txn.part_number || "-"}
</TableCell>
<TableCell className="font-medium text-sm">
{txn.component_name || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm">
<span
className={
txn.transaction_type === "consumption"
? "text-destructive"
: "text-accent"
}
>
{txn.transaction_type === "consumption" ? "-" : "+"}
{Number(txn.quantity).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{txn.supplier || "-"}
</TableCell>
<TableCell className="!text-left">
<div className="text-left">
{txn.supplier_order_ref ? (
<button
type="button"
onClick={() => handleOpenPo(txn.supplier_order_ref!)}
className="inline-flex items-center gap-1 font-mono text-xs text-primary hover:underline cursor-pointer"
>
{txn.supplier_order_ref}
<ExternalLink className="h-3 w-3" />
</button>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell>
{txn.part_number && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Print Label"
onClick={() => openLabelDialog(txn.part_number || "", Number(txn.quantity))}
>
<Printer className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-
{Math.min((page + 1) * PAGE_SIZE, total)} of{" "}
{total.toLocaleString()} transactions
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground px-2">
Page {page + 1} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* PO Detail Dialog */}
<Dialog open={poDialogOpen} onOpenChange={setPoDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
{poLoading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">Loading purchase order...</p>
</div>
) : poDetail ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
{String(poDetail.po_number)}
<Badge variant="outline" className="text-xs capitalize">
{String(poDetail.status || "").replace(/_/g, " ")}
</Badge>
</DialogTitle>
<p className="text-sm text-muted-foreground">
Supplier: <span className="font-medium text-foreground">{String(poDetail.supplier_name)}</span>
{" | "}
{new Date(String(poDetail.created_at)).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })}
</p>
</DialogHeader>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>, idx: number) => (
<TableRow key={idx}>
<TableCell className="font-mono text-xs">{String(item.part_number || "")}</TableCell>
<TableCell className="text-sm">{String(item.component_name || "")}</TableCell>
<TableCell className="text-right font-mono">{Number(item.quantity)}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(Number(item.unit_price))}</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(Number(item.quantity) * Number(item.unit_price))}
</TableCell>
<TableCell className="text-right font-mono">
{Number(item.received_quantity)}/{Number(item.quantity)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{poDetail.notes && (
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">Notes:</span> {String(poDetail.notes)}
</div>
)}
<DialogFooter className="flex-row gap-2 sm:justify-between">
<Button variant="outline" size="sm" onClick={handleDownloadPoPdf}>
<FileDown className="mr-2 h-3.5 w-3.5" />
Download PDF
</Button>
<Button variant="outline" onClick={() => setPoDialogOpen(false)}>Close</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
{/* Print Label Dialog */}
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Print Label</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Label preview */}
<div className="rounded-md border bg-background p-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">Code:</p>
<p className="font-mono text-sm font-bold truncate">{labelPartNumber}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Quantity:</p>
<p className="font-mono text-sm font-bold">{labelQuantity}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label-qty">Quantity</Label>
<Input
id="label-qty"
type="number"
min={1}
value={labelQuantity}
onChange={(e) => setLabelQuantity(Math.max(1, Number(e.target.value) || 1))}
className="font-mono"
/>
</div>
<p className="text-xs text-muted-foreground">
Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handlePrintLabel} disabled={printingLabel}>
<Printer className="mr-2 h-4 w-4" />
{printingLabel ? "Generating..." : "Download Label PDF"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ClipboardCheck,
Download,
Search,
Package,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import type { Customer, Product, Component } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function StockTakePage() {
const { data: customers = [] } = useSWR<Customer[]>("/api/customers", fetcher);
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
const [source, setSource] = useState("all");
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
const [queryKey, setQueryKey] = useState<string | null>(null);
const { data: results, isLoading } = useSWR<Component[]>(
queryKey,
fetcher
);
const { sorted, sort, toggleSort } = useTableSort(results ?? []);
function handleGenerate() {
const params = new URLSearchParams();
if (source !== "all") params.set("source", source);
if (selectedProducts.size > 0) {
params.set("productIds", Array.from(selectedProducts).join(","));
}
setQueryKey(`/api/stock-take?${params.toString()}`);
}
function toggleProduct(id: number) {
setSelectedProducts((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function handleExportCSV() {
if (!sorted || sorted.length === 0) return;
const headers = ["Part Number", "Name", "Shelf Location", "Current Stock"];
const rows = sorted.map((c) => [
c.part_number,
c.name,
c.shelf_location || "",
String(c.stock_quantity ?? 0),
]);
const csvContent = [
headers.join(","),
...rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const date = new Date().toISOString().slice(0, 10);
link.download = `stock-take-${date}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
const hasResults = results && results.length > 0;
return (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Stock Take
</h1>
<p className="text-sm text-muted-foreground">
Select components by source and product to generate a stock take list
</p>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Search className="h-4 w-4" />
Filters
</CardTitle>
<CardDescription>
Choose which components to include in your stock take
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{/* Source Filter */}
<div className="flex flex-col gap-2">
<Label>Source</Label>
<Select value={source} onValueChange={setSource}>
<SelectTrigger className="w-full max-w-xs">
<SelectValue placeholder="All Sources" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sources</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={`customer-${c.id}`}>
{c.code} - {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Product Filter */}
<div className="flex flex-col gap-2">
<Label>Products</Label>
<p className="text-xs text-muted-foreground">
Select products to only include components from their BOM. Leave empty for all components.
</p>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No products available</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
{products.map((p) => (
<label
key={p.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedProducts.has(p.id)}
onCheckedChange={() => toggleProduct(p.id)}
/>
<div className="flex items-center gap-2 min-w-0">
<Package className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{p.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">{p.sku}</span>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button onClick={handleGenerate} disabled={isLoading}>
<ClipboardCheck className="h-4 w-4 mr-2" />
{isLoading ? "Loading..." : "Generate Stock Take"}
</Button>
{selectedProducts.size > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProducts(new Set())}
>
Clear products
</Button>
)}
</div>
</CardContent>
</Card>
{/* Results */}
{queryKey && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
Stock Take Results
</CardTitle>
<CardDescription>
{isLoading
? "Loading..."
: `${sorted.length} component${sorted.length !== 1 ? "s" : ""} found`}
</CardDescription>
</div>
{hasResults && (
<Button variant="outline" size="sm" onClick={handleExportCSV}>
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
)}
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
<TableHead>Source</TableHead>
<SortableTableHead column="shelf_location" label="Shelf Location" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="stock_quantity" label="Current Stock" sort={sort} onToggle={toggleSort} className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
Loading components...
</TableCell>
</TableRow>
) : sorted.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
No components match the selected filters
</TableCell>
</TableRow>
) : (
sorted.map((comp) => (
<TableRow key={comp.id}>
<TableCell className="font-mono text-xs">
{comp.part_number}
</TableCell>
<TableCell className="font-medium">
{comp.name}
</TableCell>
<TableCell>
{comp.category_name ? (
<Badge variant="secondary" className="text-xs">
{comp.category_name}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{comp.customer_supplied ? (
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
{comp.customer_name || "Customer"}
</Badge>
) : (
<span className="text-xs text-muted-foreground">Standard</span>
)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{comp.shelf_location || "-"}
</TableCell>
<TableCell className="text-right font-mono tabular-nums">
{comp.stock_quantity ?? 0}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff