chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user