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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, use } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ArrowLeft,
|
||||||
|
AlertTriangle,
|
||||||
|
Euro,
|
||||||
|
Layers,
|
||||||
|
Hash,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { BomItemDialog } from "@/components/bom-item-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency, formatUnitPrice } from "@/lib/format";
|
||||||
|
import type { Product, BomItem } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function BomDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ productId: string }>;
|
||||||
|
}) {
|
||||||
|
const { productId } = use(params);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<BomItem | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<BomItem | null>(null);
|
||||||
|
|
||||||
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
||||||
|
const product = products.find((p) => p.id === Number(productId));
|
||||||
|
|
||||||
|
const { data: bomItems = [], mutate } = useSWR<BomItem[]>(
|
||||||
|
`/api/bom/${productId}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted: sortedBomItems, sort, toggleSort } = useTableSort(bomItems);
|
||||||
|
|
||||||
|
const totalCost = bomItems.reduce(
|
||||||
|
(sum, item) => sum + Number(item.line_cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalParts = bomItems.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: Partial<BomItem>) => {
|
||||||
|
const url = editingItem
|
||||||
|
? `/api/bom/${productId}/${editingItem.id}`
|
||||||
|
: `/api/bom/${productId}`;
|
||||||
|
const method = editingItem ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(err.error || "Failed to save");
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(editingItem ? "BOM item updated" : "Component added to BOM");
|
||||||
|
mutate();
|
||||||
|
},
|
||||||
|
[editingItem, productId, mutate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
await fetch(`/api/bom/${productId}/${deleteTarget.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
toast.success("BOM item removed");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
mutate();
|
||||||
|
}, [deleteTarget, productId, mutate]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: BomItem) {
|
||||||
|
setEditingItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Button variant="outline" size="icon" asChild>
|
||||||
|
<Link href="/bom">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Back</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
{product?.name || "Loading..."}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{product && (
|
||||||
|
<>
|
||||||
|
<span className="font-mono">{product.sku}</span>
|
||||||
|
{" "}
|
||||||
|
· v{product.version} · Bill of Materials
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Layers className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Unique Items</p>
|
||||||
|
<p className="text-2xl font-bold">{bomItems.length}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10">
|
||||||
|
<Hash className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Parts</p>
|
||||||
|
<p className="text-2xl font-bold">{totalParts}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/10">
|
||||||
|
<Euro className="h-5 w-5 text-warning" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total BOM Cost</p>
|
||||||
|
<p className="text-2xl font-bold font-mono">
|
||||||
|
{formatCurrency(totalCost)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM Table */}
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="reference_designator" label="Ref Des" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[140px]" />
|
||||||
|
<SortableTableHead column="component_name" label="Component" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="unit_price" label="Unit Price" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="line_cost" label="Line Cost" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="stock_quantity" label="Stock" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<TableHead className="w-[90px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedBomItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={9}
|
||||||
|
className="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
No items in this BOM yet. Click "Add Item" to add
|
||||||
|
components.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedBomItems.map((item) => {
|
||||||
|
const stockShort =
|
||||||
|
(item.stock_quantity || 0) < item.quantity;
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{item.reference_designator || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{item.part_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.component_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.category_name && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{item.category_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{item.quantity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatUnitPrice(Number(item.unit_price || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-medium">
|
||||||
|
{formatCurrency(Number(item.line_cost || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
stockShort
|
||||||
|
? "inline-flex items-center gap-1 font-semibold text-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stockShort && (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{item.stock_quantity ?? 0}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Remove</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BomItemDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
bomItem={editingItem}
|
||||||
|
productId={Number(productId)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove BOM Item</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to remove{" "}
|
||||||
|
<strong>{deleteTarget?.component_name}</strong> from this BOM?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ClipboardList, ArrowRight } from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Product } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
draft: "bg-muted text-muted-foreground",
|
||||||
|
active: "bg-accent/15 text-accent border-accent/30",
|
||||||
|
deprecated: "bg-warning/15 text-warning border-warning/30",
|
||||||
|
archived: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BomIndexPage() {
|
||||||
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Bill of Materials
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a product to view or manage its BOM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
No products created yet. Create a product first to manage its BOM.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/products">Go to Products</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{products.map((prod) => (
|
||||||
|
<Link key={prod.id} href={`/bom/${prod.id}`}>
|
||||||
|
<Card className="group hover:border-primary/50 transition-colors cursor-pointer h-full">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">{prod.name}</CardTitle>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
{prod.sku} · v{prod.version}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={statusStyles[prod.status] || ""}
|
||||||
|
>
|
||||||
|
{prod.status}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{prod.bom_count || 0} items
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 text-border">|</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
{formatCurrency(Number(prod.total_cost || 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Plus, Search, Pencil, Trash2, AlertTriangle, ExternalLink, PackagePlus, Printer, Box, ArrowRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { ComponentDialog } from "@/components/component-dialog";
|
||||||
|
import { AddStockDialog } from "@/components/add-stock-dialog";
|
||||||
|
import { generateLabelPdf } from "@/lib/generate-label-pdf";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatUnitPrice } from "@/lib/format";
|
||||||
|
import type { Component, Category, ComponentSpecs } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function ComponentsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
|
const [lowStockFilter, setLowStockFilter] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingComponent, setEditingComponent] = useState<Component | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Component | null>(null);
|
||||||
|
const [stockTarget, setStockTarget] = useState<Component | null>(null);
|
||||||
|
const [stockDialogOpen, setStockDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Component detail dialog state
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
const [detailComponent, setDetailComponent] = useState<Component | null>(null);
|
||||||
|
|
||||||
|
interface ProductUsage {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
version: string;
|
||||||
|
status: string;
|
||||||
|
quantity: number;
|
||||||
|
reference_designator: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: componentUsage = [], isLoading: usageLoading } = useSWR<ProductUsage[]>(
|
||||||
|
detailComponent ? `/api/components/${detailComponent.id}/usage` : null,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
function openDetail(comp: Component) {
|
||||||
|
setDetailComponent(comp);
|
||||||
|
setDetailDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label printing state
|
||||||
|
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
||||||
|
const [labelPartNumber, setLabelPartNumber] = useState("");
|
||||||
|
const [labelQuantity, setLabelQuantity] = useState<number>(1);
|
||||||
|
const [printingLabel, setPrintingLabel] = useState(false);
|
||||||
|
|
||||||
|
function openLabelDialog(comp: Component) {
|
||||||
|
setLabelPartNumber(comp.part_number);
|
||||||
|
setLabelQuantity(comp.stock_quantity);
|
||||||
|
setLabelDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrintLabel() {
|
||||||
|
if (!labelPartNumber) return;
|
||||||
|
setPrintingLabel(true);
|
||||||
|
try {
|
||||||
|
const doc = await generateLabelPdf({
|
||||||
|
partNumber: labelPartNumber,
|
||||||
|
quantity: labelQuantity,
|
||||||
|
});
|
||||||
|
doc.save(`label-${labelPartNumber}.pdf`);
|
||||||
|
toast.success("Label PDF generated");
|
||||||
|
setLabelDialogOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate label");
|
||||||
|
} finally {
|
||||||
|
setPrintingLabel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (categoryFilter && categoryFilter !== "all") params.set("category", categoryFilter);
|
||||||
|
if (lowStockFilter) params.set("lowStock", "true");
|
||||||
|
|
||||||
|
const { data: components = [], mutate } = useSWR<Component[]>(
|
||||||
|
`/api/components?${params.toString()}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const { data: categories = [] } = useSWR<Category[]>(
|
||||||
|
"/api/categories",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted: sortedComponents, sort, toggleSort } = useTableSort(components);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: Partial<Component>) => {
|
||||||
|
const url = editingComponent
|
||||||
|
? `/api/components/${editingComponent.id}`
|
||||||
|
: "/api/components";
|
||||||
|
const method = editingComponent ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
editingComponent ? "Component updated" : "Component created"
|
||||||
|
);
|
||||||
|
mutate();
|
||||||
|
},
|
||||||
|
[editingComponent, mutate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
await fetch(`/api/components/${deleteTarget.id}`, { method: "DELETE" });
|
||||||
|
toast.success("Component deleted");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
mutate();
|
||||||
|
}, [deleteTarget, mutate]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingComponent(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(comp: Component) {
|
||||||
|
setEditingComponent(comp);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddStock(comp: Component) {
|
||||||
|
setStockTarget(comp);
|
||||||
|
setStockDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Components
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your electronic component inventory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[240px] max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name, part number, or manufacturer..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="All Categories" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant={lowStockFilter ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLowStockFilter(!lowStockFilter)}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Low Stock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Components Table */}
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[140px]" />
|
||||||
|
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="customer_supplied" label="Source" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="shelf_location" label="Shelf Location" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="unit_price" label="Unit Price" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="stock_quantity" label="Stock" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="on_order" label="On Order" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="min_stock_level" label="Min Level" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<TableHead className="w-[140px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedComponents.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={10}
|
||||||
|
className="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{search || categoryFilter !== "all" || lowStockFilter
|
||||||
|
? "No components match your filters."
|
||||||
|
: "No components yet. Click \"Add Component\" to get started."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedComponents.map((comp) => {
|
||||||
|
const isLowStock =
|
||||||
|
comp.stock_quantity <= comp.min_stock_level &&
|
||||||
|
comp.min_stock_level > 0;
|
||||||
|
return (
|
||||||
|
<TableRow key={comp.id} className="cursor-pointer" onClick={() => openDetail(comp)}>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{comp.datasheet_url ? (
|
||||||
|
<a
|
||||||
|
href={comp.datasheet_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{comp.part_number}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
comp.part_number
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{comp.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{comp.category_name && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{comp.category_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{comp.customer_supplied ? (
|
||||||
|
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
||||||
|
{comp.customer_name || "Customer"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Standard</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{comp.shelf_location || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatUnitPrice(Number(comp.unit_price))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isLowStock
|
||||||
|
? "inline-flex items-center gap-1 font-semibold text-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLowStock && (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{comp.stock_quantity}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{Number((comp as Record<string, unknown>).on_order) > 0 ? (
|
||||||
|
<Badge variant="outline" className="font-mono text-xs text-primary border-primary/30">
|
||||||
|
{Number((comp as Record<string, unknown>).on_order)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{comp.min_stock_level}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-accent hover:text-accent"
|
||||||
|
onClick={() => openAddStock(comp)}
|
||||||
|
title="Add / Remove Stock"
|
||||||
|
>
|
||||||
|
<PackagePlus className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Add Stock</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openLabelDialog(comp)}
|
||||||
|
title="Print Label"
|
||||||
|
>
|
||||||
|
<Printer className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Print Label</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openEdit(comp)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(comp)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComponentDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
component={editingComponent}
|
||||||
|
categories={categories}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddStockDialog
|
||||||
|
open={stockDialogOpen}
|
||||||
|
onOpenChange={setStockDialogOpen}
|
||||||
|
component={stockTarget}
|
||||||
|
onSaved={() => mutate()}
|
||||||
|
onPrintLabel={(partNumber, quantity) => {
|
||||||
|
setLabelPartNumber(partNumber);
|
||||||
|
setLabelQuantity(quantity);
|
||||||
|
setLabelDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Component</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<strong>{deleteTarget?.name}</strong> ({deleteTarget?.part_number}
|
||||||
|
)? This action cannot be undone and will remove it from all BOMs.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Component Detail Dialog */}
|
||||||
|
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
|
||||||
|
{detailComponent && (() => {
|
||||||
|
const rawSpecs = detailComponent.specs;
|
||||||
|
const specs: ComponentSpecs = typeof rawSpecs === "string" ? JSON.parse(rawSpecs) : rawSpecs || {};
|
||||||
|
const hasSpecs = Object.values(specs).some((v) => v && String(v).trim() !== "");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3">
|
||||||
|
<span>{detailComponent.name}</span>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{detailComponent.part_number}</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground pt-1">
|
||||||
|
{detailComponent.category_name && (
|
||||||
|
<span>Category: <span className="text-foreground font-medium">{detailComponent.category_name}</span></span>
|
||||||
|
)}
|
||||||
|
{detailComponent.manufacturer && (
|
||||||
|
<span>Manufacturer: <span className="text-foreground font-medium">{detailComponent.manufacturer}</span></span>
|
||||||
|
)}
|
||||||
|
<span>Stock: <span className="text-foreground font-medium">{detailComponent.stock_quantity}</span></span>
|
||||||
|
{Number((detailComponent as Record<string, unknown>).on_order) > 0 && (
|
||||||
|
<span>On Order: <Badge variant="outline" className="font-mono text-xs text-primary border-primary/30 ml-0.5">{Number((detailComponent as Record<string, unknown>).on_order)}</Badge></span>
|
||||||
|
)}
|
||||||
|
{detailComponent.customer_supplied && (
|
||||||
|
<span>Customer Supplied: <Badge variant="outline" className="text-xs border-primary/30 text-primary ml-0.5">{detailComponent.customer_name || "Unknown"}</Badge></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Specifications */}
|
||||||
|
{hasSpecs && (
|
||||||
|
<div className="rounded-lg border p-3 bg-muted/30">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Specifications</p>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||||||
|
{specs.manufacturer_id && <span>Manufacturer ID: <span className="text-foreground font-mono text-xs">{specs.manufacturer_id}</span></span>}
|
||||||
|
{specs.value && <span>Value: <span className="text-foreground font-medium">{specs.value}</span></span>}
|
||||||
|
{specs.power && <span>Power: <span className="text-foreground font-medium">{specs.power}</span></span>}
|
||||||
|
{specs.voltage && <span>Voltage: <span className="text-foreground font-medium">{specs.voltage}</span></span>}
|
||||||
|
{specs.tolerance && <span>Tolerance: <span className="text-foreground font-medium">{specs.tolerance}</span></span>}
|
||||||
|
{specs.color && <span>Color: <span className="text-foreground font-medium">{specs.color}</span></span>}
|
||||||
|
{specs.no_of_pins && <span>Pins: <span className="text-foreground font-medium">{specs.no_of_pins}</span></span>}
|
||||||
|
{specs.no_of_rows && <span>Rows: <span className="text-foreground font-medium">{specs.no_of_rows}</span></span>}
|
||||||
|
{specs.connector_type && <span>Type: <span className="text-foreground font-medium">{specs.connector_type}</span></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supplier info */}
|
||||||
|
{(detailComponent.supplier || detailComponent.datasheet_url) && (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
|
{detailComponent.supplier && (
|
||||||
|
<span>Supplier: <span className="text-foreground font-medium">{detailComponent.supplier}</span></span>
|
||||||
|
)}
|
||||||
|
{detailComponent.supplier_part_number && (
|
||||||
|
<span>Supplier Part No.: <span className="text-foreground font-mono text-xs">{detailComponent.supplier_part_number}</span></span>
|
||||||
|
)}
|
||||||
|
{detailComponent.datasheet_url && (
|
||||||
|
<a href={detailComponent.datasheet_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
Supplier URL
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Box className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h3 className="text-sm font-semibold">Used in Products</h3>
|
||||||
|
<Badge variant="secondary" className="text-xs">{componentUsage.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usageLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : componentUsage.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Not used in any product BOM
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>SKU</TableHead>
|
||||||
|
<TableHead className="text-right">Qty per Unit</TableHead>
|
||||||
|
<TableHead>Ref. Designator</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{componentUsage.map((usage) => (
|
||||||
|
<TableRow key={usage.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{usage.product_name}</span>
|
||||||
|
{usage.version && (
|
||||||
|
<span className="text-xs text-muted-foreground">v{usage.version}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{usage.sku}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-semibold">
|
||||||
|
{Number(usage.quantity)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{usage.reference_designator || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/bom/${usage.product_id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center text-primary hover:text-primary/80"
|
||||||
|
title="View BOM"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Print Label Dialog */}
|
||||||
|
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Print Label</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="rounded-md border bg-background p-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">Code:</p>
|
||||||
|
<p className="font-mono text-sm font-bold truncate">{labelPartNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">Quantity:</p>
|
||||||
|
<p className="font-mono text-sm font-bold">{labelQuantity}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="comp-label-qty">Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="comp-label-qty"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={labelQuantity}
|
||||||
|
onChange={(e) => setLabelQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePrintLabel} disabled={printingLabel}>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
{printingLabel ? "Generating..." : "Download Label PDF"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { ArrowLeft, Plus, Trash2, Package, Save, Euro, Layers, Truck, FileDown } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Product, PurchaseOrder, PurchaseOrderItem } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface OrderLineItem {
|
||||||
|
product_id: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
confirmed: { label: "Confirmed", variant: "default" },
|
||||||
|
in_production: { label: "In Production", variant: "outline" },
|
||||||
|
shipped: { label: "Shipped", variant: "default" },
|
||||||
|
completed: { label: "Completed", variant: "default" },
|
||||||
|
cancelled: { label: "Cancelled", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const customerId = params.id as string;
|
||||||
|
const orderId = params.orderId as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
po_number: "",
|
||||||
|
customer_ref: "",
|
||||||
|
status: "draft",
|
||||||
|
order_date: "",
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [items, setItems] = useState<OrderLineItem[]>([]);
|
||||||
|
|
||||||
|
const { data: orderData, mutate } = useSWR<
|
||||||
|
PurchaseOrder & { items: PurchaseOrderItem[] }
|
||||||
|
>(`/api/orders/${orderId}`, fetcher);
|
||||||
|
|
||||||
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderData) {
|
||||||
|
setForm({
|
||||||
|
po_number: orderData.po_number || "",
|
||||||
|
customer_ref: orderData.customer_ref || "",
|
||||||
|
status: orderData.status || "draft",
|
||||||
|
order_date: orderData.order_date
|
||||||
|
? new Date(orderData.order_date).toISOString().slice(0, 10)
|
||||||
|
: "",
|
||||||
|
due_date: orderData.due_date
|
||||||
|
? new Date(orderData.due_date).toISOString().slice(0, 10)
|
||||||
|
: "",
|
||||||
|
notes: orderData.notes || "",
|
||||||
|
});
|
||||||
|
setItems(
|
||||||
|
(orderData.items || []).map((item) => ({
|
||||||
|
product_id: String(item.product_id),
|
||||||
|
quantity: String(item.quantity),
|
||||||
|
unit_price: String(item.unit_price),
|
||||||
|
notes: item.notes || "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [orderData]);
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setItems([
|
||||||
|
...items,
|
||||||
|
{ product_id: "", quantity: "1", unit_price: "", notes: "" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
setItems(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(index: number, field: keyof OrderLineItem, value: string) {
|
||||||
|
const updated = [...items];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setItems(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineTotal(item: OrderLineItem) {
|
||||||
|
return (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderTotal = items.reduce((sum, item) => sum + getLineTotal(item), 0);
|
||||||
|
|
||||||
|
async function handleDownloadPO() {
|
||||||
|
if (!orderData) return;
|
||||||
|
const { generatePoPdf } = await import("@/lib/generate-po-pdf");
|
||||||
|
const doc = generatePoPdf({
|
||||||
|
po_number: orderData.po_number,
|
||||||
|
order_date: orderData.order_date,
|
||||||
|
due_date: orderData.due_date,
|
||||||
|
customer_ref: orderData.customer_ref,
|
||||||
|
status: orderData.status,
|
||||||
|
customer_name: orderData.customer_name || "",
|
||||||
|
customer_code: orderData.customer_code || "",
|
||||||
|
customer_email: (orderData as Record<string, unknown>).customer_email as string | null,
|
||||||
|
customer_phone: (orderData as Record<string, unknown>).customer_phone as string | null,
|
||||||
|
customer_address: (orderData as Record<string, unknown>).customer_address as string | null,
|
||||||
|
customer_contact: (orderData as Record<string, unknown>).customer_contact as string | null,
|
||||||
|
notes: orderData.notes,
|
||||||
|
items: (orderData.items || []).map((item) => ({
|
||||||
|
product_name: item.product_name || "",
|
||||||
|
product_sku: item.product_sku || "",
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
unit_price: Number(item.unit_price),
|
||||||
|
line_total: Number(item.line_total || 0),
|
||||||
|
notes: item.notes,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
doc.save(`${orderData.po_number}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const validItems = items.filter((item) => item.product_id);
|
||||||
|
const res = await fetch(`/api/orders/${orderId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: Number(customerId),
|
||||||
|
...form,
|
||||||
|
due_date: form.due_date || null,
|
||||||
|
items: validItems.map((item) => ({
|
||||||
|
product_id: Number(item.product_id),
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
notes: item.notes || null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to update");
|
||||||
|
toast.success("Purchase order updated");
|
||||||
|
mutate();
|
||||||
|
setEditing(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update order");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderData) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<p className="text-muted-foreground">Loading order...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sc = statusConfig[orderData.status] || statusConfig.draft;
|
||||||
|
|
||||||
|
// VIEW MODE
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/customers/${customerId}`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight font-mono text-foreground">
|
||||||
|
{orderData.po_number}
|
||||||
|
</h1>
|
||||||
|
<Badge variant={sc.variant}>{sc.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Order for {orderData.customer_name} ({orderData.customer_code})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownloadPO}>
|
||||||
|
<FileDown className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Download PO
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Edit Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{(() => {
|
||||||
|
const itemsList = orderData.items || [];
|
||||||
|
const totalOrdered = itemsList.reduce((s, i) => s + Number(i.quantity), 0);
|
||||||
|
const totalShipped = itemsList.reduce((s, i) => s + Number(i.shipped_quantity || 0), 0);
|
||||||
|
const totalRemaining = totalOrdered - totalShipped;
|
||||||
|
const fulfillmentPct = totalOrdered > 0 ? Math.round((totalShipped / totalOrdered) * 100) : 0;
|
||||||
|
const isOverdue = orderData.due_date && new Date(orderData.due_date) < new Date() && fulfillmentPct < 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Order Date
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{new Date(orderData.order_date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Due Date
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className={`text-lg font-semibold ${isOverdue ? "text-destructive" : ""}`}>
|
||||||
|
{orderData.due_date
|
||||||
|
? new Date(orderData.due_date).toLocaleDateString()
|
||||||
|
: "Not set"}
|
||||||
|
{isOverdue && (
|
||||||
|
<span className="ml-1 text-xs font-normal">(overdue)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Order Total
|
||||||
|
</CardTitle>
|
||||||
|
<Euro className="h-4 w-4 text-accent" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrency(
|
||||||
|
itemsList.reduce((s, i) => s + Number(i.line_total || 0), 0)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{itemsList.length} line item{itemsList.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Units Remaining
|
||||||
|
</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold font-mono">
|
||||||
|
<span className={totalRemaining > 0 ? "text-foreground" : "text-accent"}>
|
||||||
|
{totalRemaining}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||||
|
/ {totalOrdered}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{totalShipped} shipped
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Fulfillment
|
||||||
|
</CardTitle>
|
||||||
|
<Truck className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className={`text-2xl font-bold font-mono ${fulfillmentPct === 100 ? "text-accent" : ""}`}>
|
||||||
|
{fulfillmentPct}%
|
||||||
|
</p>
|
||||||
|
<Progress value={fulfillmentPct} className="mt-2 h-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{orderData.customer_ref && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Customer Reference:{" "}
|
||||||
|
<span className="font-mono font-medium text-foreground">
|
||||||
|
{orderData.customer_ref}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderData.notes && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||||
|
{orderData.notes}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line Items Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
Order Items
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>SKU</TableHead>
|
||||||
|
<TableHead className="text-right">Ordered</TableHead>
|
||||||
|
<TableHead className="text-right">Shipped</TableHead>
|
||||||
|
<TableHead className="text-right">Remaining</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Fulfillment</TableHead>
|
||||||
|
<TableHead className="text-right">Unit Price</TableHead>
|
||||||
|
<TableHead className="text-right">Line Total</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(orderData.items || []).map(
|
||||||
|
(item: PurchaseOrderItem, idx: number) => {
|
||||||
|
const ordered = Number(item.quantity);
|
||||||
|
const shipped = Number(item.shipped_quantity || 0);
|
||||||
|
const remaining = ordered - shipped;
|
||||||
|
const pct = ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{idx + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.product_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{item.product_sku}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{ordered.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{shipped.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-semibold">
|
||||||
|
<span className={remaining > 0 ? "text-foreground" : "text-accent"}>
|
||||||
|
{remaining.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground">
|
||||||
|
{shipped}/{ordered}
|
||||||
|
</span>
|
||||||
|
<span className={pct === 100 ? "font-semibold text-accent" : "text-muted-foreground"}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatCurrency(Number(item.unit_price))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-semibold">
|
||||||
|
{formatCurrency(Number(item.line_total || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{item.notes || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EDIT MODE
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Edit Purchase Order
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{orderData.po_number} - {orderData.customer_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Order Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="po_number">PO Number</Label>
|
||||||
|
<Input
|
||||||
|
id="po_number"
|
||||||
|
value={form.po_number}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, po_number: e.target.value })
|
||||||
|
}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="customer_ref">Customer Reference</Label>
|
||||||
|
<Input
|
||||||
|
id="customer_ref"
|
||||||
|
value={form.customer_ref}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, customer_ref: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={form.status}
|
||||||
|
onValueChange={(v) => setForm({ ...form, status: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="in_production">In Production</SelectItem>
|
||||||
|
<SelectItem value="shipped">Shipped</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="order_date">Order Date</Label>
|
||||||
|
<Input
|
||||||
|
id="order_date"
|
||||||
|
type="date"
|
||||||
|
value={form.order_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, order_date: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={form.due_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, due_date: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
Line Items
|
||||||
|
</CardTitle>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Add Line
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">#</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Quantity</TableHead>
|
||||||
|
<TableHead className="w-[140px]">Unit Price</TableHead>
|
||||||
|
<TableHead className="w-[140px] text-right">
|
||||||
|
Line Total
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="text-center text-xs text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={item.product_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateLine(index, "product_id", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="Select product..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={String(p.id)}>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground mr-2">
|
||||||
|
{p.sku}
|
||||||
|
</span>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "quantity", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 font-mono"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
{'€'}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "unit_price", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 pl-7 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-semibold">
|
||||||
|
{formatCurrency(getLineTotal(item))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "notes", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
disabled={items.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableCell colSpan={4} className="text-right font-semibold">
|
||||||
|
Order Total
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-base font-bold text-primary">
|
||||||
|
{formatCurrency(orderTotal)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ArrowLeft, Plus, Trash2, Package, Save } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Customer, Product } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface OrderLineItem {
|
||||||
|
product_id: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewOrderPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const customerId = params.id as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
po_number: "",
|
||||||
|
customer_ref: "",
|
||||||
|
status: "draft",
|
||||||
|
order_date: new Date().toISOString().slice(0, 10),
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [items, setItems] = useState<OrderLineItem[]>([
|
||||||
|
{ product_id: "", quantity: "1", unit_price: "", notes: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: customer } = useSWR<Customer>(
|
||||||
|
`/api/customers/${customerId}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setItems([
|
||||||
|
...items,
|
||||||
|
{ product_id: "", quantity: "1", unit_price: "", notes: "" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
setItems(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(index: number, field: keyof OrderLineItem, value: string) {
|
||||||
|
const updated = [...items];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setItems(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineTotal(item: OrderLineItem) {
|
||||||
|
const qty = Number(item.quantity) || 0;
|
||||||
|
const price = Number(item.unit_price) || 0;
|
||||||
|
return qty * price;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderTotal = items.reduce((sum, item) => sum + getLineTotal(item), 0);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.po_number) {
|
||||||
|
toast.error("PO Number is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validItems = items.filter((item) => item.product_id);
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
toast.error("Add at least one product line item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/orders", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: Number(customerId),
|
||||||
|
...form,
|
||||||
|
due_date: form.due_date || null,
|
||||||
|
items: validItems.map((item) => ({
|
||||||
|
product_id: Number(item.product_id),
|
||||||
|
quantity: Number(item.quantity) || 1,
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
notes: item.notes || null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to create order");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Purchase order created");
|
||||||
|
router.push(`/customers/${customerId}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to create order"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push(`/customers/${customerId}`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
New Purchase Order
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{customer
|
||||||
|
? `Creating order for ${customer.name} (${customer.code})`
|
||||||
|
: "Loading..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
{/* Order Details */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Order Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="po_number">
|
||||||
|
PO Number <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="po_number"
|
||||||
|
value={form.po_number}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, po_number: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="PO-2026-001"
|
||||||
|
className="font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="customer_ref">Customer Reference</Label>
|
||||||
|
<Input
|
||||||
|
id="customer_ref"
|
||||||
|
value={form.customer_ref}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, customer_ref: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Their internal ref"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={form.status}
|
||||||
|
onValueChange={(v) => setForm({ ...form, status: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="in_production">In Production</SelectItem>
|
||||||
|
<SelectItem value="shipped">Shipped</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="order_date">Order Date</Label>
|
||||||
|
<Input
|
||||||
|
id="order_date"
|
||||||
|
type="date"
|
||||||
|
value={form.order_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, order_date: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={form.due_date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, due_date: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Internal notes about this order..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
Line Items
|
||||||
|
</CardTitle>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Add Line
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">#</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Quantity</TableHead>
|
||||||
|
<TableHead className="w-[140px]">Unit Price</TableHead>
|
||||||
|
<TableHead className="w-[140px] text-right">
|
||||||
|
Line Total
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="text-center text-xs text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={item.product_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateLine(index, "product_id", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="Select product..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={String(p.id)}>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground mr-2">
|
||||||
|
{p.sku}
|
||||||
|
</span>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "quantity", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 font-mono"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
{'€'}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "unit_price", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 pl-7 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-semibold">
|
||||||
|
{formatCurrency(getLineTotal(item))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={item.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(index, "notes", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
disabled={items.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableCell colSpan={4} className="text-right font-semibold">
|
||||||
|
Order Total
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-base font-bold text-primary">
|
||||||
|
{formatCurrency(orderTotal)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/customers/${customerId}`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "Creating..." : "Create Purchase Order"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
FileText,
|
||||||
|
Euro,
|
||||||
|
ExternalLink,
|
||||||
|
Package,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { CustomerDialog } from "@/components/customer-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Customer, PurchaseOrder } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
confirmed: { label: "Confirmed", variant: "default" },
|
||||||
|
in_production: { label: "In Production", variant: "outline" },
|
||||||
|
shipped: { label: "Shipped", variant: "default" },
|
||||||
|
completed: { label: "Completed", variant: "default" },
|
||||||
|
cancelled: { label: "Cancelled", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomerDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const customerId = params.id as string;
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOrderTarget, setDeleteOrderTarget] = useState<PurchaseOrder | null>(null);
|
||||||
|
const [statsPeriod, setStatsPeriod] = useState<"week" | "month" | "year">("month");
|
||||||
|
|
||||||
|
const { data: customer, mutate: mutateCustomer } = useSWR<Customer>(
|
||||||
|
`/api/customers/${customerId}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: orders = [], mutate: mutateOrders } = useSWR<PurchaseOrder[]>(
|
||||||
|
`/api/orders?customerId=${customerId}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted: sortedOrders, sort: orderSort, toggleSort: toggleOrderSort } = useTableSort(orders);
|
||||||
|
|
||||||
|
interface OnOrderItem {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
total_ordered: number;
|
||||||
|
total_shipped: number;
|
||||||
|
on_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProducedItem {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
total_produced: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineItem {
|
||||||
|
bucket: string;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsData {
|
||||||
|
onOrder: OnOrderItem[];
|
||||||
|
produced: ProducedItem[];
|
||||||
|
producedTimeline: TimelineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: statsData } = useSWR<StatsData>(
|
||||||
|
`/api/customers/${customerId}/stats?period=${statsPeriod}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleDeleteOrder() {
|
||||||
|
if (!deleteOrderTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${deleteOrderTarget.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete");
|
||||||
|
toast.success("Purchase order deleted");
|
||||||
|
mutateOrders();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete order");
|
||||||
|
} finally {
|
||||||
|
setDeleteOrderTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<p className="text-muted-foreground">Loading customer...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalValue = orders.reduce(
|
||||||
|
(sum, o) => sum + (o.status !== "cancelled" ? Number(o.total_value || 0) : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const activeOrders = orders.filter(
|
||||||
|
(o) => !["completed", "cancelled"].includes(o.status)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.push("/customers")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
{customer.name}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{customer.code}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customer since{" "}
|
||||||
|
{new Date(customer.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/customers/${customerId}/orders/new`}>
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
|
New Purchase Order
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Info + Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Contact Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{customer.contact_person && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{customer.contact_person}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<a
|
||||||
|
href={`mailto:${customer.email}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{customer.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{customer.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.address && (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<MapPin className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="whitespace-pre-wrap">{customer.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.notes && (
|
||||||
|
<div className="mt-2 rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||||
|
{customer.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!customer.contact_person &&
|
||||||
|
!customer.email &&
|
||||||
|
!customer.phone &&
|
||||||
|
!customer.address && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No contact information added yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Active Orders
|
||||||
|
</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{activeOrders}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
of {orders.length} total
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Revenue
|
||||||
|
</CardTitle>
|
||||||
|
<Euro className="h-4 w-4 text-accent" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrency(totalValue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
excl. cancelled orders
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* On Order + Production Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* On Order by Product */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-2 pb-3">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-medium">Units On Order</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Pending quantities by product across active orders
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{statsData?.onOrder && statsData.onOrder.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="text-right">Ordered</TableHead>
|
||||||
|
<TableHead className="text-right">Shipped</TableHead>
|
||||||
|
<TableHead className="text-right">Pending</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{statsData.onOrder.map((item) => (
|
||||||
|
<TableRow key={item.product_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{item.product_name}</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{item.sku}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{Number(item.total_ordered).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||||
|
{Number(item.total_shipped).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs font-bold text-primary border-primary/30">
|
||||||
|
{Number(item.on_order).toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No pending orders
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Produced / Shipped by Product */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-accent" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm font-medium">Units Produced</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Shipped quantities by product
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={statsPeriod}
|
||||||
|
onValueChange={(val) => setStatsPeriod(val as "week" | "month" | "year")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[110px] h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="week">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="month">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="year">Last year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{statsData?.produced && statsData.produced.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Bar chart */}
|
||||||
|
<div className="h-[240px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{statsPeriod === "year" && statsData.producedTimeline && statsData.producedTimeline.length > 0 ? (() => {
|
||||||
|
const BAR_COLORS = [
|
||||||
|
"#6366f1", "#22c55e", "#3b82f6",
|
||||||
|
"#f59e0b", "#a855f7", "#ef4444",
|
||||||
|
];
|
||||||
|
// Pivot timeline into {month, product1: qty, product2: qty, ...}
|
||||||
|
const productSkus = [...new Set(statsData.producedTimeline.map((t) => t.sku))];
|
||||||
|
const productNames: Record<string, string> = {};
|
||||||
|
statsData.producedTimeline.forEach((t) => { productNames[t.sku] = t.product_name; });
|
||||||
|
const bucketMap = new Map<string, Record<string, number | string>>();
|
||||||
|
statsData.producedTimeline.forEach((t) => {
|
||||||
|
const d = new Date(t.bucket);
|
||||||
|
const label = d.toLocaleDateString("en-GB", { month: "short", year: "2-digit" });
|
||||||
|
if (!bucketMap.has(label)) {
|
||||||
|
const row: Record<string, number | string> = { month: label };
|
||||||
|
productSkus.forEach((s) => { row[s] = 0; });
|
||||||
|
bucketMap.set(label, row);
|
||||||
|
}
|
||||||
|
const row = bucketMap.get(label)!;
|
||||||
|
row[t.sku] = (Number(row[t.sku]) || 0) + Number(t.quantity);
|
||||||
|
});
|
||||||
|
const chartData = Array.from(bucketMap.values());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BarChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
`${value.toLocaleString()} units`,
|
||||||
|
productNames[name] || name,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value: string) => productNames[value] || value}
|
||||||
|
wrapperStyle={{ fontSize: "11px" }}
|
||||||
|
/>
|
||||||
|
{productSkus.map((sku, i) => (
|
||||||
|
<Bar
|
||||||
|
key={sku}
|
||||||
|
dataKey={sku}
|
||||||
|
fill={BAR_COLORS[i % BAR_COLORS.length]}
|
||||||
|
radius={[3, 3, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
})() : (
|
||||||
|
<BarChart
|
||||||
|
data={statsData.produced.map((item) => ({
|
||||||
|
name: item.sku,
|
||||||
|
fullName: item.product_name,
|
||||||
|
units: Number(item.total_produced),
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 5, left: 0, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value: number, _name: string, props: { payload?: { fullName?: string } }) => [
|
||||||
|
`${value.toLocaleString()} units`,
|
||||||
|
props.payload?.fullName || "Units",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="units" radius={[4, 4, 0, 0]}>
|
||||||
|
{statsData.produced.map((_entry, index) => {
|
||||||
|
const colors = [
|
||||||
|
"#6366f1", "#22c55e", "#3b82f6",
|
||||||
|
"#f59e0b", "#a855f7", "#ef4444",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary table below chart */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{statsData.produced.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.product_id}
|
||||||
|
className="rounded-md border px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{item.sku}</p>
|
||||||
|
<p className="text-lg font-bold">{Number(item.total_produced).toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">units</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No production data for this period
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Purchase Orders Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Purchase Orders</CardTitle>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href={`/customers/${customerId}/orders/new`}>
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
|
New Order
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="po_number" label="PO Number" sort={orderSort} onToggle={toggleOrderSort} />
|
||||||
|
<SortableTableHead column="customer_ref" label="Customer Ref" sort={orderSort} onToggle={toggleOrderSort} />
|
||||||
|
<SortableTableHead column="status" label="Status" sort={orderSort} onToggle={toggleOrderSort} />
|
||||||
|
<SortableTableHead column="order_date" label="Order Date" sort={orderSort} onToggle={toggleOrderSort} />
|
||||||
|
<SortableTableHead column="due_date" label="Due Date" sort={orderSort} onToggle={toggleOrderSort} />
|
||||||
|
<SortableTableHead column="item_count" label="Items" sort={orderSort} onToggle={toggleOrderSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_value" label="Value" sort={orderSort} onToggle={toggleOrderSort} className="text-right" />
|
||||||
|
<TableHead className="w-[160px]">Fulfillment</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedOrders.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={9}
|
||||||
|
className="py-12 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No purchase orders yet. Click "New Order" to
|
||||||
|
create one.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedOrders.map((order) => {
|
||||||
|
const sc = statusConfig[order.status] || statusConfig.draft;
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-mono text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/customers/${customerId}/orders/${order.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{order.po_number}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{order.customer_ref || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sc.variant} className="text-xs">
|
||||||
|
{sc.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(order.order_date).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{order.due_date
|
||||||
|
? new Date(order.due_date).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{Number(order.item_count || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-medium">
|
||||||
|
{formatCurrency(Number(order.total_value || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{(() => {
|
||||||
|
const ordered = Number(order.total_ordered || 0);
|
||||||
|
const shipped = Number(order.total_shipped || 0);
|
||||||
|
const pct = ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
|
||||||
|
if (order.status === "cancelled") return <span className="text-xs text-muted-foreground">-</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground">{shipped}/{ordered}</span>
|
||||||
|
<span className={pct === 100 ? "font-semibold text-accent" : "text-muted-foreground"}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/customers/${customerId}/orders/${order.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteOrderTarget(order)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CustomerDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
customer={customer}
|
||||||
|
onSaved={() => mutateCustomer()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteOrderTarget}
|
||||||
|
onOpenChange={() => setDeleteOrderTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Purchase Order</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete PO "
|
||||||
|
{deleteOrderTarget?.po_number}"? This will also remove all
|
||||||
|
associated line items.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteOrder}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
Euro,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { CustomerDialog } from "@/components/customer-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Customer } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
const { data: customers = [], mutate } = useSWR<Customer[]>(
|
||||||
|
`/api/customers?search=${encodeURIComponent(search)}`,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted: sortedCustomers, sort, toggleSort } = useTableSort(customers);
|
||||||
|
|
||||||
|
function openEdit(customer: Customer) {
|
||||||
|
setEditingCustomer(customer);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingCustomer(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/customers/${deleteTarget.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to delete");
|
||||||
|
}
|
||||||
|
toast.success("Customer deleted");
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to delete customer"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCustomers = customers.length;
|
||||||
|
const totalRevenue = customers.reduce(
|
||||||
|
(sum, c) => sum + Number(c.total_revenue || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalOrders = customers.reduce(
|
||||||
|
(sum, c) => sum + Number(c.order_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Customers & Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage customers and their purchase orders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Customer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Customers
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{totalCustomers}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Orders
|
||||||
|
</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{totalOrders}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Revenue
|
||||||
|
</CardTitle>
|
||||||
|
<Euro className="h-4 w-4 text-accent" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrency(totalRevenue)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search customers..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="code" label="Code" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="name" label="Company Name" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="contact_person" label="Contact Person" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="email" label="Email" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="phone" label="Phone" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="order_count" label="Orders" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_revenue" label="Revenue" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<TableHead className="w-[140px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedCustomers.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={8}
|
||||||
|
className="py-12 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No customers found. Click "Add Customer" to
|
||||||
|
create one.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedCustomers.map((customer) => (
|
||||||
|
<TableRow key={customer.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{customer.code}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{customer.contact_person || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{customer.email || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{customer.phone || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{Number(customer.order_count || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatCurrency(Number(customer.total_revenue || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={`/customers/${customer.id}`}>
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">View Orders</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openEdit(customer)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(customer)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CustomerDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
customer={editingCustomer}
|
||||||
|
onSaved={() => mutate()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Customer</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 20% 98%;
|
||||||
|
--foreground: 220 20% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 220 20% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 220 20% 10%;
|
||||||
|
--primary: 186 78% 45%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 15% 93%;
|
||||||
|
--secondary-foreground: 220 20% 15%;
|
||||||
|
--muted: 210 15% 95%;
|
||||||
|
--muted-foreground: 215 10% 45%;
|
||||||
|
--accent: 160 50% 42%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 214 18% 88%;
|
||||||
|
--input: 214 18% 88%;
|
||||||
|
--ring: 186 78% 45%;
|
||||||
|
--chart-1: 186 78% 45%;
|
||||||
|
--chart-2: 160 50% 42%;
|
||||||
|
--chart-3: 35 90% 52%;
|
||||||
|
--chart-4: 0 72% 51%;
|
||||||
|
--chart-5: 270 50% 55%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: 220 20% 12%;
|
||||||
|
--sidebar-foreground: 210 15% 85%;
|
||||||
|
--sidebar-primary: 186 78% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 220 18% 18%;
|
||||||
|
--sidebar-accent-foreground: 210 15% 90%;
|
||||||
|
--sidebar-border: 220 15% 20%;
|
||||||
|
--sidebar-ring: 186 78% 45%;
|
||||||
|
--warning: 35 90% 52%;
|
||||||
|
--warning-foreground: 0 0% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Linse by EACH",
|
||||||
|
description:
|
||||||
|
"Manage electronic components inventory, products, and bill of materials",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||||
|
<body className="font-sans antialiased">
|
||||||
|
<AuthProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email || !password) {
|
||||||
|
toast.error("Please enter email and password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(data.error || "Login failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Login successful");
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex flex-col items-center justify-center px-4"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(circle at center, #0c1c28 0%, #05131d 100%)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* LINSE Title */}
|
||||||
|
<h1
|
||||||
|
className="text-4xl md:text-[38px] font-light mb-10"
|
||||||
|
style={{ color: "#7cecff", letterSpacing: "12px", opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
LINSE
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Circuit Logo */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<Image
|
||||||
|
src="/images/linse-symbol.png"
|
||||||
|
alt="Linse Symbol"
|
||||||
|
width={160}
|
||||||
|
height={160}
|
||||||
|
className="w-40 h-40"
|
||||||
|
style={{
|
||||||
|
filter: "drop-shadow(0 0 25px #18d1e4) brightness(0) saturate(100%) invert(73%) sepia(67%) saturate(1000%) hue-rotate(130deg) brightness(95%)"
|
||||||
|
}}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form Card */}
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[360px] rounded-[14px] p-9 text-left"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.04)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
boxShadow: "0 0 30px rgba(0, 0, 0, 0.3)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-[18px]">
|
||||||
|
{/* Email Input */}
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email or username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="h-[50px] rounded-lg text-[15px]"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.12)",
|
||||||
|
color: "#d8e8f0"
|
||||||
|
}}
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="h-[50px] rounded-lg text-[15px] pr-10"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.12)",
|
||||||
|
color: "#d8e8f0"
|
||||||
|
}}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 transition-colors"
|
||||||
|
style={{ color: "#9db4c1" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember me & Forgot password */}
|
||||||
|
<div className="flex items-center justify-between mb-[25px] text-[14px]">
|
||||||
|
<div className="flex items-center gap-[6px]">
|
||||||
|
<Checkbox
|
||||||
|
id="remember"
|
||||||
|
checked={rememberMe}
|
||||||
|
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
|
||||||
|
className="border-[#9db4c1] data-[state=checked]:bg-cyan-600 data-[state=checked]:border-cyan-600"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
style={{ color: "#d8e8f0" }}
|
||||||
|
>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline transition-colors"
|
||||||
|
style={{ color: "#8fd8e8" }}
|
||||||
|
onClick={() => toast.info("Contact your administrator to reset your password")}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full h-[50px] text-base font-semibold rounded-lg transition-all hover:scale-[1.02]"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, #1cb5c9, #0fa4b7)",
|
||||||
|
color: "#ffffff",
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Log in"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import SupplierPackingTab from "@/components/supplier-packing-tab";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
FileDown,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Package,
|
||||||
|
Hash,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { PurchaseOrder, PackingList } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface POItemForPacking {
|
||||||
|
id: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
product_sku: string;
|
||||||
|
quantity: number;
|
||||||
|
shipped_quantity: number;
|
||||||
|
remaining_quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogisticsPage() {
|
||||||
|
const { data: packingListsRaw, mutate } = useSWR<PackingList[]>(
|
||||||
|
"/api/packing-lists",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const packingLists = Array.isArray(packingListsRaw) ? packingListsRaw : [];
|
||||||
|
const { data: ordersRaw } = useSWR<PurchaseOrder[]>(
|
||||||
|
"/api/orders?status=confirmed,in_production",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const orders = Array.isArray(ordersRaw) ? ordersRaw : [];
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [viewTarget, setViewTarget] = useState<number | null>(null);
|
||||||
|
const [viewData, setViewData] = useState<(PackingList & { serials: { serial_number: string }[] }) | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<PackingList | null>(null);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [selectedOrderId, setSelectedOrderId] = useState("");
|
||||||
|
const [poItems, setPoItems] = useState<POItemForPacking[]>([]);
|
||||||
|
const [selectedPoItemId, setSelectedPoItemId] = useState("");
|
||||||
|
const [packingNumber, setPackingNumber] = useState("");
|
||||||
|
const [serialInput, setSerialInput] = useState("");
|
||||||
|
const [serialNumbers, setSerialNumbers] = useState<string[]>([]);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [loadingItems, setLoadingItems] = useState(false);
|
||||||
|
const [existingSerials, setExistingSerials] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Load PO items and existing serials when order changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedOrderId) {
|
||||||
|
setPoItems([]);
|
||||||
|
setSelectedPoItemId("");
|
||||||
|
setExistingSerials([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingItems(true);
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/orders/${selectedOrderId}/items`).then((r) => r.json()),
|
||||||
|
fetch(`/api/packing-lists?orderId=${selectedOrderId}`).then((r) => r.json()),
|
||||||
|
])
|
||||||
|
.then(([items, plsForOrder]) => {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
setPoItems(items.filter((i: POItemForPacking) => Number(i.remaining_quantity) > 0));
|
||||||
|
}
|
||||||
|
// Collect all serial numbers already used in this PO's packing lists
|
||||||
|
if (Array.isArray(plsForOrder)) {
|
||||||
|
const plIds = plsForOrder.map((pl: PackingList) => pl.id);
|
||||||
|
if (plIds.length > 0) {
|
||||||
|
Promise.all(
|
||||||
|
plIds.map((id: number) =>
|
||||||
|
fetch(`/api/packing-lists/${id}`).then((r) => r.json())
|
||||||
|
)
|
||||||
|
).then((details) => {
|
||||||
|
const allSerials: string[] = [];
|
||||||
|
for (const detail of details) {
|
||||||
|
if (detail.serials) {
|
||||||
|
for (const s of detail.serials) {
|
||||||
|
allSerials.push(s.serial_number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExistingSerials(allSerials);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setExistingSerials([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingItems(false));
|
||||||
|
}, [selectedOrderId]);
|
||||||
|
|
||||||
|
// Fetch packing list detail for view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewTarget) {
|
||||||
|
setViewData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/packing-lists/${viewTarget}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setViewData);
|
||||||
|
}, [viewTarget]);
|
||||||
|
|
||||||
|
// Generate packing number preview: PL-EACH-YYYY_MM_DD-RR-CUSTOMER
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedOrderId || !orders) {
|
||||||
|
setPackingNumber("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = orders.find((o) => String(o.id) === selectedOrderId);
|
||||||
|
if (!order) return;
|
||||||
|
const customerName = (order.customer_name || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
||||||
|
const now = new Date();
|
||||||
|
const yyyy = now.getFullYear();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(now.getDate()).padStart(2, "0");
|
||||||
|
const datePrefix = `PL-EACH-${yyyy}_${mm}_${dd}`;
|
||||||
|
// Count how many existing packing lists match this date + customer
|
||||||
|
const existing = (packingLists || []).filter((pl) =>
|
||||||
|
pl.packing_number.startsWith(datePrefix) && pl.packing_number.endsWith(`-${customerName}`)
|
||||||
|
).length;
|
||||||
|
const revision = String(existing + 1).padStart(2, "0");
|
||||||
|
setPackingNumber(`${datePrefix}-${revision}-${customerName}`);
|
||||||
|
}, [selectedOrderId, orders, packingLists]);
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
setSelectedOrderId("");
|
||||||
|
setPoItems([]);
|
||||||
|
setSelectedPoItemId("");
|
||||||
|
setSerialInput("");
|
||||||
|
setSerialNumbers([]);
|
||||||
|
setNotes("");
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSerial() {
|
||||||
|
const trimmed = serialInput.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (serialNumbers.includes(trimmed)) {
|
||||||
|
toast.error("Duplicate serial number in this list");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingSerials.includes(trimmed)) {
|
||||||
|
toast.error(`Serial number "${trimmed}" is already used in another packing list for this PO`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId);
|
||||||
|
if (selectedItem && serialNumbers.length >= Number(selectedItem.remaining_quantity)) {
|
||||||
|
toast.error(`Maximum ${selectedItem.remaining_quantity} serial numbers allowed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSerialNumbers((prev) => [...prev, trimmed]);
|
||||||
|
setSerialInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSerial(sn: string) {
|
||||||
|
setSerialNumbers((prev) => prev.filter((s) => s !== sn));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!selectedOrderId || !selectedPoItemId || !serialNumbers.length) {
|
||||||
|
toast.error("Select a PO, product, and add at least one serial number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/packing-lists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
purchase_order_id: Number(selectedOrderId),
|
||||||
|
po_item_id: Number(selectedPoItemId),
|
||||||
|
serial_numbers: serialNumbers,
|
||||||
|
notes: notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to create packing list");
|
||||||
|
}
|
||||||
|
toast.success("Packing list created");
|
||||||
|
mutate();
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Error creating packing list");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/packing-lists/${deleteTarget.id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to delete");
|
||||||
|
}
|
||||||
|
toast.success("Packing list deleted and shipped quantity reverted");
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Error deleting");
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadPdf = useCallback(async (plId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/packing-lists/${plId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const { generatePackingPdf } = await import("@/lib/generate-packing-pdf");
|
||||||
|
const doc = generatePackingPdf(data);
|
||||||
|
doc.save(`${data.packing_number}.pdf`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate PDF");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = (packingLists || []).filter((pl) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
pl.packing_number.toLowerCase().includes(s) ||
|
||||||
|
(pl.po_number || "").toLowerCase().includes(s) ||
|
||||||
|
(pl.customer_name || "").toLowerCase().includes(s) ||
|
||||||
|
(pl.product_name || "").toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sorted: sortedFiltered, sort, toggleSort } = useTableSort(filtered);
|
||||||
|
|
||||||
|
const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">Logistics</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create packing lists for customers and suppliers, and generate shipping documents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="customer" className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="customer">Customer Packing Lists</TabsTrigger>
|
||||||
|
<TabsTrigger value="supplier">Supplier Packing Lists</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="customer">
|
||||||
|
<div className="mb-6 flex justify-end">
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Packing List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="mb-6 grid gap-4 grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Truck className="h-8 w-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{filtered.length}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Packing Lists</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-8 w-8 text-accent" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{filtered.reduce((s, pl) => s + Number(pl.quantity), 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Units Shipped</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Hash className="h-8 w-8 text-warning" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{filtered.reduce((s, pl) => s + Number(pl.serial_count || pl.quantity), 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Serial Numbers Tracked</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by packing number, PO number, customer, or product..."
|
||||||
|
className="pl-10"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Packing Lists Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Truck className="h-4 w-4 text-primary" />
|
||||||
|
Packing Lists
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="packing_number" label="Packing No." sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="po_number" label="PO Number" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="customer_name" label="Customer" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="product_name" label="Product" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="serial_count" label="Serials" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} />
|
||||||
|
<TableHead className="w-[130px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedFiltered.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||||
|
No packing lists found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedFiltered.map((pl) => (
|
||||||
|
<TableRow key={pl.id}>
|
||||||
|
<TableCell className="font-mono text-sm font-semibold">
|
||||||
|
{pl.packing_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{pl.po_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{pl.customer_name}</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({pl.customer_code})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm">{pl.product_name}</span>
|
||||||
|
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||||
|
{pl.product_sku}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium">
|
||||||
|
{Number(pl.quantity)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{Number(pl.serial_count || pl.quantity)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(pl.created_at).toLocaleDateString("en-GB")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setViewTarget(pl.id)}
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-primary hover:text-primary"
|
||||||
|
onClick={() => handleDownloadPdf(pl.id)}
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
<FileDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(pl)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="supplier">
|
||||||
|
<SupplierPackingTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create Packing List Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Truck className="h-5 w-5 text-primary" />
|
||||||
|
New Packing List
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 py-2">
|
||||||
|
{/* Packing Number (auto-generated) */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Packing List Number</Label>
|
||||||
|
<Input
|
||||||
|
value={packingNumber || "Select a purchase order..."}
|
||||||
|
readOnly
|
||||||
|
className="font-mono bg-muted text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Auto-generated: PL-EACH-YYYY_MM_DD-revision-CUSTOMER
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select Purchase Order */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Purchase Order</Label>
|
||||||
|
<Select value={selectedOrderId} onValueChange={setSelectedOrderId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a purchase order..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(orders || []).map((o) => (
|
||||||
|
<SelectItem key={o.id} value={String(o.id)}>
|
||||||
|
{o.po_number} - {o.customer_name} ({o.customer_code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select PO Item (product) */}
|
||||||
|
{selectedOrderId && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Product Line</Label>
|
||||||
|
{loadingItems ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading items...</p>
|
||||||
|
) : poItems.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-accent" />
|
||||||
|
All items fully shipped on this order
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={selectedPoItemId} onValueChange={setSelectedPoItemId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a product..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{poItems.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={String(item.id)}>
|
||||||
|
{item.product_name} ({item.product_sku}) - {Number(item.remaining_quantity)} remaining
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remaining info */}
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{selectedItem.product_name} ({selectedItem.product_sku})
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Ordered: <strong className="text-foreground">{Number(selectedItem.quantity)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Shipped: <strong className="text-foreground">{Number(selectedItem.shipped_quantity)}</strong>
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{Number(selectedItem.remaining_quantity)} remaining
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Serial Numbers */}
|
||||||
|
{selectedPoItemId && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
Serial Numbers ({serialNumbers.length}
|
||||||
|
{selectedItem
|
||||||
|
? ` / ${Number(selectedItem.remaining_quantity)}`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
{selectedItem &&
|
||||||
|
serialNumbers.length >= Number(selectedItem.remaining_quantity) && (
|
||||||
|
<Badge className="bg-accent text-accent-foreground text-xs">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
All serial numbers entered
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter serial number and press Enter..."
|
||||||
|
className="font-mono"
|
||||||
|
value={serialInput}
|
||||||
|
onChange={(e) => setSerialInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addSerial();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!!selectedItem &&
|
||||||
|
serialNumbers.length >= Number(selectedItem.remaining_quantity)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addSerial}
|
||||||
|
disabled={
|
||||||
|
!serialInput.trim() ||
|
||||||
|
(!!selectedItem &&
|
||||||
|
serialNumbers.length >= Number(selectedItem.remaining_quantity))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serialNumbers.length > 0 && (
|
||||||
|
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">#</TableHead>
|
||||||
|
<TableHead>Serial Number</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{serialNumbers.map((sn, i) => (
|
||||||
|
<TableRow key={sn}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{sn}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive"
|
||||||
|
onClick={() => removeSerial(sn)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional shipping notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={creating || !serialNumbers.length || !selectedPoItemId}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{creating ? "Creating..." : `Create Packing List (${serialNumbers.length} units)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* View Detail Dialog */}
|
||||||
|
<Dialog open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-primary" />
|
||||||
|
{viewData?.packing_number || "Loading..."}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Customer</span>
|
||||||
|
<p className="font-medium">{viewData.customer_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">PO Number</span>
|
||||||
|
<p className="font-mono font-medium">{viewData.po_number}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Product</span>
|
||||||
|
<p className="font-medium">{viewData.product_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Quantity</span>
|
||||||
|
<p className="font-mono font-bold">{Number(viewData.quantity)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Date</span>
|
||||||
|
<p>{new Date(viewData.created_at).toLocaleDateString("en-GB")}</p>
|
||||||
|
</div>
|
||||||
|
{viewData.notes && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-muted-foreground">Notes</span>
|
||||||
|
<p>{viewData.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">
|
||||||
|
Serial Numbers ({viewData.serials?.length || 0})
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-60 overflow-y-auto rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">#</TableHead>
|
||||||
|
<TableHead>Serial Number</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(viewData.serials || []).map(
|
||||||
|
(s: { serial_number: string }, i: number) => (
|
||||||
|
<TableRow key={s.serial_number}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{s.serial_number}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setViewTarget(null)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleDownloadPdf(viewData.id)}>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Delete Packing List?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete packing list{" "}
|
||||||
|
<strong>{deleteTarget?.packing_number}</strong> and revert the
|
||||||
|
shipped quantity ({deleteTarget?.quantity} units) on the purchase
|
||||||
|
order. All serial numbers will be released. This action cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+582
@@ -0,0 +1,582 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Cpu,
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
Euro,
|
||||||
|
ArrowRight,
|
||||||
|
TrendingUp,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
ClipboardList,
|
||||||
|
ShoppingCart,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Component } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface OrderRow {
|
||||||
|
id: number;
|
||||||
|
po_number: string;
|
||||||
|
status: string;
|
||||||
|
order_date: string;
|
||||||
|
due_date: string | null;
|
||||||
|
customer_id: number;
|
||||||
|
customer_name: string;
|
||||||
|
customer_code: string;
|
||||||
|
total_ordered: number;
|
||||||
|
total_shipped: number;
|
||||||
|
total_remaining: number;
|
||||||
|
total_value: number;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
stats: {
|
||||||
|
total_components: number;
|
||||||
|
total_inventory_value: number;
|
||||||
|
low_stock_count: number;
|
||||||
|
total_products: number;
|
||||||
|
active_products: number;
|
||||||
|
draft_products: number;
|
||||||
|
active_orders: number;
|
||||||
|
in_production_orders: number;
|
||||||
|
confirmed_orders: number;
|
||||||
|
shipped_orders: number;
|
||||||
|
};
|
||||||
|
low_stock_items: Component[];
|
||||||
|
category_breakdown: { name: string; count: number; total_stock: number }[];
|
||||||
|
recent_orders: OrderRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
confirmed: { label: "Confirmed", variant: "default" },
|
||||||
|
in_production: { label: "In Production", variant: "outline" },
|
||||||
|
shipped: { label: "Shipped", variant: "default" },
|
||||||
|
completed: { label: "Completed", variant: "default" },
|
||||||
|
cancelled: { label: "Cancelled", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||||
|
const [poQuantities, setPoQuantities] = useState<Record<number, number>>({});
|
||||||
|
const [generatingPo, setGeneratingPo] = useState(false);
|
||||||
|
const { data, isLoading } = useSWR<DashboardData>("/api/dashboard", fetcher);
|
||||||
|
const { sorted: sortedLowStock, sort: lowStockSort, toggleSort: toggleLowStockSort } = useTableSort(data?.low_stock_items ?? []);
|
||||||
|
const { sorted: sortedOrders, sort: ordersSort, toggleSort: toggleOrdersSort } = useTableSort(data?.recent_orders ?? []);
|
||||||
|
|
||||||
|
function toggleItem(id: number) {
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(items: Component[]) {
|
||||||
|
if (selectedItems.size === items.length) {
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedItems(new Set(items.map((i) => i.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGeneratePo(lowStockItems: Component[]) {
|
||||||
|
const selected = lowStockItems.filter((i) => selectedItems.has(i.id));
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
setGeneratingPo(true);
|
||||||
|
try {
|
||||||
|
// Fetch suppliers to resolve IDs from names
|
||||||
|
const suppRes = await fetch("/api/suppliers");
|
||||||
|
const suppliers = await suppRes.json();
|
||||||
|
const supplierMap: Record<string, { id: number; name: string }> = {};
|
||||||
|
for (const s of Array.isArray(suppliers) ? suppliers : []) {
|
||||||
|
supplierMap[s.name] = { id: s.id, name: s.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by supplier
|
||||||
|
const grouped: Record<string, { supplier_id: number; supplier_name: string; items: Record<string, unknown>[] }> = {};
|
||||||
|
for (const item of selected) {
|
||||||
|
const supplierName = item.supplier || "Unknown Supplier";
|
||||||
|
if (!grouped[supplierName]) {
|
||||||
|
const sup = supplierMap[supplierName];
|
||||||
|
if (!sup) {
|
||||||
|
toast.error(`Supplier "${supplierName}" not found. Please add it first.`);
|
||||||
|
setGeneratingPo(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grouped[supplierName] = {
|
||||||
|
supplier_id: sup.id,
|
||||||
|
supplier_name: sup.name,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const qty = poQuantities[item.id] || Math.max(1, item.min_stock_level - item.stock_quantity);
|
||||||
|
grouped[supplierName].items.push({
|
||||||
|
component_id: item.id,
|
||||||
|
part_number: item.part_number,
|
||||||
|
component_name: item.name,
|
||||||
|
supplier_part_number: item.supplier_part_number || "",
|
||||||
|
quantity: qty,
|
||||||
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = Object.values(grouped);
|
||||||
|
const res = await fetch("/api/supplier-pos", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ orders }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create POs");
|
||||||
|
|
||||||
|
const created = await res.json();
|
||||||
|
const count = Array.isArray(created) ? created.length : 1;
|
||||||
|
toast.success(`${count} purchase order(s) created in Draft status`);
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
setPoQuantities({});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to generate POs");
|
||||||
|
} finally {
|
||||||
|
setGeneratingPo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = data?.stats;
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Overview of your component inventory and products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<div className="h-16 animate-pulse rounded bg-muted" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Overview of your component inventory and products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-5">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Cpu className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Components</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.total_components}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-5">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10">
|
||||||
|
<Euro className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Inventory Value</p>
|
||||||
|
<p className="text-2xl font-bold font-mono">
|
||||||
|
{formatCurrency(stats.total_inventory_value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-5">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-warning/10">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-warning" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Low Stock Alerts</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{stats.low_stock_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-5">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Package className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Products</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{stats.total_products}
|
||||||
|
{stats.active_products > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
|
({stats.active_products} active)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-5">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<ClipboardList className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Active Orders</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.active_orders}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{/* Low Stock Alerts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||||
|
Low Stock Alerts
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/components?lowStock=true">
|
||||||
|
View All
|
||||||
|
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sortedLowStock.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<TrendingUp className="h-8 w-8 text-accent mb-2" />
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
All stock levels are healthy
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No components are below their minimum stock level
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Generate PO action bar */}
|
||||||
|
{selectedItems.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-primary/20 bg-primary/5 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{selectedItems.size} component(s) selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleGeneratePo(data?.low_stock_items ?? [])}
|
||||||
|
disabled={generatingPo}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{generatingPo ? "Generating..." : "Generate PO"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">
|
||||||
|
<Checkbox
|
||||||
|
checked={sortedLowStock.length > 0 && selectedItems.size === sortedLowStock.slice(0, 8).length}
|
||||||
|
onCheckedChange={() => toggleAll(sortedLowStock.slice(0, 8))}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<SortableTableHead column="part_number" label="Part Number" sort={lowStockSort} onToggle={toggleLowStockSort} />
|
||||||
|
<SortableTableHead column="name" label="Name" sort={lowStockSort} onToggle={toggleLowStockSort} />
|
||||||
|
<SortableTableHead column="supplier" label="Supplier" sort={lowStockSort} onToggle={toggleLowStockSort} />
|
||||||
|
<SortableTableHead column="stock_quantity" label="Stock" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
|
||||||
|
<SortableTableHead column="min_stock_level" label="Min Level" sort={lowStockSort} onToggle={toggleLowStockSort} className="text-right" />
|
||||||
|
<TableHead>Level</TableHead>
|
||||||
|
<TableHead className="text-right w-[110px]">Order Qty</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedLowStock.slice(0, 8).map((item) => {
|
||||||
|
const pct =
|
||||||
|
item.min_stock_level > 0
|
||||||
|
? Math.min(
|
||||||
|
(item.stock_quantity / item.min_stock_level) * 100,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const isSelected = selectedItems.has(item.id);
|
||||||
|
const deficit = Math.max(1, item.min_stock_level - item.stock_quantity);
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id} className={isSelected ? "bg-primary/5" : ""}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleItem(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{item.part_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-sm">
|
||||||
|
{item.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{item.supplier || <span className="italic">No supplier</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold text-destructive">
|
||||||
|
{item.stock_quantity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">
|
||||||
|
{item.min_stock_level}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[100px]">
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
className="h-2 [&>div]:bg-destructive"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{isSelected ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-7 w-24 text-xs font-mono text-right ml-auto"
|
||||||
|
value={poQuantities[item.id] ?? deficit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPoQuantities((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.id]: Math.max(1, Number(e.target.value) || 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{deficit}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Purchase Orders Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
Purchase Order Status
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/customers">
|
||||||
|
View All
|
||||||
|
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sortedOrders.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<ClipboardList className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
No active purchase orders
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Purchase orders from customers will appear here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="po_number" label="PO Number" sort={ordersSort} onToggle={toggleOrdersSort} />
|
||||||
|
<SortableTableHead column="customer_name" label="Customer" sort={ordersSort} onToggle={toggleOrdersSort} />
|
||||||
|
<SortableTableHead column="status" label="Status" sort={ordersSort} onToggle={toggleOrdersSort} />
|
||||||
|
<SortableTableHead column="order_date" label="Order Date" sort={ordersSort} onToggle={toggleOrdersSort} />
|
||||||
|
<SortableTableHead column="due_date" label="Due Date" sort={ordersSort} onToggle={toggleOrdersSort} />
|
||||||
|
<SortableTableHead column="total_value" label="Value" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_ordered" label="Ordered" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_shipped" label="Shipped" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_remaining" label="Remaining" sort={ordersSort} onToggle={toggleOrdersSort} className="text-right" />
|
||||||
|
<TableHead className="w-[140px]">Fulfillment</TableHead>
|
||||||
|
<TableHead className="w-[60px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedOrders.map((order) => {
|
||||||
|
const ordered = Number(order.total_ordered);
|
||||||
|
const shipped = Number(order.total_shipped);
|
||||||
|
const remaining = Number(order.total_remaining);
|
||||||
|
const pct =
|
||||||
|
ordered > 0 ? Math.round((shipped / ordered) * 100) : 0;
|
||||||
|
const sc = statusConfig[order.status] || statusConfig.draft;
|
||||||
|
const isOverdue =
|
||||||
|
order.due_date &&
|
||||||
|
new Date(order.due_date) < new Date() &&
|
||||||
|
pct < 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-mono text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/customers/${order.customer_id}/orders/${order.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{order.po_number}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{order.customer_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{order.customer_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sc.variant} className="text-xs">
|
||||||
|
{sc.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(order.order_date).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{order.due_date ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isOverdue
|
||||||
|
? "font-medium text-destructive"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{new Date(order.due_date).toLocaleDateString()}
|
||||||
|
{isOverdue && " (overdue)"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatCurrency(Number(order.total_value))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{ordered}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm text-accent">
|
||||||
|
{shipped}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-semibold">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
remaining > 0
|
||||||
|
? "text-warning"
|
||||||
|
: "text-accent"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{remaining}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground">
|
||||||
|
{shipped}/{ordered}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
pct === 100
|
||||||
|
? "font-semibold text-accent"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/customers/${order.customer_id}/orders/${order.id}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Open order details</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Plus, Pencil, Trash2, ClipboardList } from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { ProductDialog } from "@/components/product-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Product } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
draft: "bg-muted text-muted-foreground",
|
||||||
|
active: "bg-accent/15 text-accent border-accent/30",
|
||||||
|
deprecated: "bg-warning/15 text-warning border-warning/30",
|
||||||
|
archived: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
const { data: products = [], mutate } = useSWR<Product[]>(
|
||||||
|
"/api/products",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted: sortedProducts, sort, toggleSort } = useTableSort(products);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: Partial<Product>) => {
|
||||||
|
const url = editingProduct
|
||||||
|
? `/api/products/${editingProduct.id}`
|
||||||
|
: "/api/products";
|
||||||
|
const method = editingProduct ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
editingProduct ? "Product updated" : "Product created"
|
||||||
|
);
|
||||||
|
mutate();
|
||||||
|
},
|
||||||
|
[editingProduct, mutate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
await fetch(`/api/products/${deleteTarget.id}`, { method: "DELETE" });
|
||||||
|
toast.success("Product deleted");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
mutate();
|
||||||
|
}, [deleteTarget, mutate]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingProduct(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(prod: Product) {
|
||||||
|
setEditingProduct(prod);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Products
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your products and their bill of materials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Product
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="sku" label="SKU" sort={sort} onToggle={toggleSort} className="w-[120px]" />
|
||||||
|
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="version" label="Version" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="status" label="Status" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="bom_count" label="BOM Items" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_cost" label="BOM Cost" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<TableHead className="w-[130px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedProducts.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
No products yet. Click "Add Product" to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedProducts.map((prod) => (
|
||||||
|
<TableRow key={prod.id}>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{prod.sku}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{prod.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
v{prod.version}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={statusStyles[prod.status] || ""}
|
||||||
|
>
|
||||||
|
{prod.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{prod.bom_count || 0}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatCurrency(Number(prod.total_cost || 0))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={`/bom/${prod.id}`}>
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">View BOM</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openEdit(prod)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(prod)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
product={editingProduct}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Product</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<strong>{deleteTarget?.name}</strong> ({deleteTarget?.sku})? This
|
||||||
|
will also delete all associated BOM items.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Plus, Pencil, Trash2, KeyRound, Shield, Users } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "dashboard", label: "Dashboard" },
|
||||||
|
{ id: "components", label: "Components" },
|
||||||
|
{ id: "products", label: "Products" },
|
||||||
|
{ id: "production", label: "Production" },
|
||||||
|
{ id: "supplier-pos", label: "Supplier POs" },
|
||||||
|
{ id: "customer-orders", label: "Customer Orders" },
|
||||||
|
{ id: "stock-history", label: "Stock History" },
|
||||||
|
{ id: "stock-take", label: "Stock Take" },
|
||||||
|
{ id: "logistics", label: "Logistics" },
|
||||||
|
{ id: "suppliers", label: "Suppliers" },
|
||||||
|
{ id: "customers", label: "Customers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
is_active: boolean;
|
||||||
|
permissions: {
|
||||||
|
sections: string[];
|
||||||
|
can_view_financial: boolean;
|
||||||
|
can_edit: boolean;
|
||||||
|
can_create: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
can_download: boolean;
|
||||||
|
};
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPermissions = {
|
||||||
|
sections: [] as string[],
|
||||||
|
can_view_financial: false,
|
||||||
|
can_edit: false,
|
||||||
|
can_create: false,
|
||||||
|
can_delete: false,
|
||||||
|
can_download: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const { data: users = [], mutate } = useSWR<UserData[]>("/api/users", fetcher);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserData | null>(null);
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
const [passwordUserId, setPasswordUserId] = useState<number | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
role: "user" as "admin" | "user",
|
||||||
|
permissions: { ...defaultPermissions },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if current user is admin
|
||||||
|
if (currentUser?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Access Denied</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You don't have permission to access user settings.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingUser(null);
|
||||||
|
setForm({
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
role: "user",
|
||||||
|
permissions: { ...defaultPermissions },
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(user: UserData) {
|
||||||
|
setEditingUser(user);
|
||||||
|
setForm({
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
password: "",
|
||||||
|
role: user.role,
|
||||||
|
permissions: {
|
||||||
|
...defaultPermissions,
|
||||||
|
...(user.permissions || {}),
|
||||||
|
sections: user.permissions?.sections || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(sectionId: string) {
|
||||||
|
setForm((prev) => {
|
||||||
|
const sections = prev.permissions.sections.includes(sectionId)
|
||||||
|
? prev.permissions.sections.filter((s) => s !== sectionId)
|
||||||
|
: [...prev.permissions.sections, sectionId];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
permissions: { ...prev.permissions, sections },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePermission(key: keyof typeof defaultPermissions) {
|
||||||
|
if (key === "sections") return;
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
permissions: {
|
||||||
|
...prev.permissions,
|
||||||
|
[key]: !prev.permissions[key],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.email || !form.name) {
|
||||||
|
toast.error("Email and name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingUser && !form.password) {
|
||||||
|
toast.error("Password is required for new users");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const url = editingUser ? `/api/users/${editingUser.id}` : "/api/users";
|
||||||
|
const method = editingUser ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
email: form.email,
|
||||||
|
name: form.name,
|
||||||
|
role: form.role,
|
||||||
|
permissions: form.permissions,
|
||||||
|
};
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Failed to save");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(editingUser ? "User updated" : "User created");
|
||||||
|
setDialogOpen(false);
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(userId: number) {
|
||||||
|
if (userId === currentUser?.id) {
|
||||||
|
toast.error("You cannot delete your own account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${userId}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Failed to delete");
|
||||||
|
}
|
||||||
|
toast.success("User deleted");
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPasswordDialog(userId: number) {
|
||||||
|
setPasswordUserId(userId);
|
||||||
|
setNewPassword("");
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
if (!newPassword || newPassword.length < 6) {
|
||||||
|
toast.error("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${passwordUserId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Failed to reset password");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Password updated");
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to reset password");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6" />
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage users, roles, and permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateDialog} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{users.length} user{users.length !== 1 ? "s" : ""} registered
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Sections</TableHead>
|
||||||
|
<TableHead>Permissions</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.role === "admin" ? (
|
||||||
|
<span className="text-xs text-muted-foreground">All</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.permissions?.sections?.length || 0} section{(user.permissions?.sections?.length || 0) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.role === "admin" ? (
|
||||||
|
<Badge variant="outline" className="text-xs">Full Access</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{user.permissions?.can_view_financial && <Badge variant="outline" className="text-xs">Financial</Badge>}
|
||||||
|
{user.permissions?.can_edit && <Badge variant="outline" className="text-xs">Edit</Badge>}
|
||||||
|
{user.permissions?.can_create && <Badge variant="outline" className="text-xs">Create</Badge>}
|
||||||
|
{user.permissions?.can_delete && <Badge variant="outline" className="text-xs">Delete</Badge>}
|
||||||
|
{user.permissions?.can_download && <Badge variant="outline" className="text-xs">Download</Badge>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openPasswordDialog(user.id)}
|
||||||
|
title="Reset Password"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(user)}
|
||||||
|
title="Edit User"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{user.id !== currentUser?.id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
title="Delete User"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* User Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingUser ? "Edit User" : "Create User"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
placeholder="user@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
{editingUser ? "New Password (leave blank to keep)" : "Password *"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
placeholder={editingUser ? "Leave blank to keep current" : "Min 6 characters"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role}
|
||||||
|
onValueChange={(val) => setForm({ ...form, role: val as "admin" | "user" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.role === "user" && (
|
||||||
|
<>
|
||||||
|
{/* Section Access */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Section Access</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select which sections this user can access
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded border"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`section-${section.id}`}
|
||||||
|
checked={form.permissions.sections.includes(section.id)}
|
||||||
|
onCheckedChange={() => toggleSection(section.id)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`section-${section.id}`}
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Permissions</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id="perm-financial"
|
||||||
|
checked={form.permissions.can_view_financial}
|
||||||
|
onCheckedChange={() => togglePermission("can_view_financial")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perm-financial" className="cursor-pointer">
|
||||||
|
View Financial Info
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
See prices, costs, and financial data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id="perm-edit"
|
||||||
|
checked={form.permissions.can_edit}
|
||||||
|
onCheckedChange={() => togglePermission("can_edit")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perm-edit" className="cursor-pointer">
|
||||||
|
Edit Records
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Modify existing data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id="perm-create"
|
||||||
|
checked={form.permissions.can_create}
|
||||||
|
onCheckedChange={() => togglePermission("can_create")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perm-create" className="cursor-pointer">
|
||||||
|
Create Records
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add new components, products, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id="perm-delete"
|
||||||
|
checked={form.permissions.can_delete}
|
||||||
|
onCheckedChange={() => togglePermission("can_delete")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perm-delete" className="cursor-pointer">
|
||||||
|
Delete Records
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remove data permanently
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border col-span-2">
|
||||||
|
<Checkbox
|
||||||
|
id="perm-download"
|
||||||
|
checked={form.permissions.can_download}
|
||||||
|
onCheckedChange={() => togglePermission("can_download")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perm-download" className="cursor-pointer">
|
||||||
|
Download Files
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Export CSVs and download reports (view only if disabled)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.role === "admin" && (
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/30">
|
||||||
|
<p className="text-sm font-medium">Admin users have full access</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Admins can access all sections, view financial data, and perform all actions including user management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : editingUser ? "Update User" : "Create User"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Password Reset Dialog */}
|
||||||
|
<Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reset Password</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Min 6 characters"
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPasswordDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleResetPassword} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Reset Password"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
History,
|
||||||
|
ShoppingCart,
|
||||||
|
ArrowUpCircle,
|
||||||
|
ArrowDownCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Euro,
|
||||||
|
Package,
|
||||||
|
TrendingUp,
|
||||||
|
Download,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Printer,
|
||||||
|
ExternalLink,
|
||||||
|
FileDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { generateLabelPdf } from "@/lib/generate-label-pdf";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { StockTransaction } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const typeConfig: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; color: string; icon: typeof ShoppingCart }
|
||||||
|
> = {
|
||||||
|
purchase: { label: "Purchase", color: "bg-accent text-accent-foreground", icon: ShoppingCart },
|
||||||
|
adjustment: { label: "Adjustment", color: "bg-primary text-primary-foreground", icon: ArrowUpCircle },
|
||||||
|
return: { label: "Return", color: "bg-warning text-warning-foreground", icon: RotateCcw },
|
||||||
|
consumption: { label: "Consumption", color: "bg-destructive text-destructive-foreground", icon: ArrowDownCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StockHistoryPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// PO detail dialog state
|
||||||
|
const [poDialogOpen, setPoDialogOpen] = useState(false);
|
||||||
|
const [poDetail, setPoDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [poLoading, setPoLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleOpenPo(poNumber: string) {
|
||||||
|
setPoLoading(true);
|
||||||
|
setPoDialogOpen(true);
|
||||||
|
try {
|
||||||
|
// Find PO by number from list
|
||||||
|
const listRes = await fetch("/api/supplier-pos");
|
||||||
|
const allPos = await listRes.json();
|
||||||
|
const match = (Array.isArray(allPos) ? allPos : []).find(
|
||||||
|
(p: Record<string, unknown>) => p.po_number === poNumber
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
toast.error("Purchase order not found");
|
||||||
|
setPoDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fetch full detail
|
||||||
|
const detailRes = await fetch(`/api/supplier-pos/${match.id}`);
|
||||||
|
const detail = await detailRes.json();
|
||||||
|
setPoDetail(detail);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load purchase order");
|
||||||
|
setPoDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setPoLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadPoPdf() {
|
||||||
|
if (!poDetail) return;
|
||||||
|
try {
|
||||||
|
const { generateSupplierPoPdf } = await import("@/lib/generate-supplier-po-pdf");
|
||||||
|
const doc = generateSupplierPoPdf({
|
||||||
|
po_number: String(poDetail.po_number),
|
||||||
|
date: String(poDetail.created_at),
|
||||||
|
status: String(poDetail.status),
|
||||||
|
supplier_name: String(poDetail.supplier_name),
|
||||||
|
supplier_code: String(poDetail.supplier_code),
|
||||||
|
supplier_email: poDetail.supplier_email as string | null,
|
||||||
|
supplier_phone: poDetail.supplier_phone as string | null,
|
||||||
|
supplier_address: poDetail.supplier_address as string | null,
|
||||||
|
supplier_contact: poDetail.supplier_contact as string | null,
|
||||||
|
supplier_country: poDetail.supplier_country as string | null,
|
||||||
|
supplier_currency: poDetail.supplier_currency as string | null,
|
||||||
|
shipping_cost: Number(poDetail.shipping_cost) || 0,
|
||||||
|
notes: poDetail.notes as string | null,
|
||||||
|
items: (Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>) => ({
|
||||||
|
part_number: String(item.part_number || ""),
|
||||||
|
component_name: String(item.component_name || ""),
|
||||||
|
supplier_part_number: String(item.supplier_part_number || ""),
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
unit_price: Number(item.unit_price),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
doc.save(`${poDetail.po_number}.pdf`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate PO PDF");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label printing state
|
||||||
|
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
||||||
|
const [labelPartNumber, setLabelPartNumber] = useState("");
|
||||||
|
const [labelQuantity, setLabelQuantity] = useState<number>(1);
|
||||||
|
const [printingLabel, setPrintingLabel] = useState(false);
|
||||||
|
|
||||||
|
function openLabelDialog(partNumber: string, defaultQty: number) {
|
||||||
|
setLabelPartNumber(partNumber);
|
||||||
|
setLabelQuantity(defaultQty);
|
||||||
|
setLabelDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrintLabel() {
|
||||||
|
if (!labelPartNumber) return;
|
||||||
|
setPrintingLabel(true);
|
||||||
|
try {
|
||||||
|
const doc = await generateLabelPdf({
|
||||||
|
partNumber: labelPartNumber,
|
||||||
|
quantity: labelQuantity,
|
||||||
|
});
|
||||||
|
doc.save(`label-${labelPartNumber}.pdf`);
|
||||||
|
toast.success("Label PDF generated");
|
||||||
|
setLabelDialogOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate label");
|
||||||
|
} finally {
|
||||||
|
setPrintingLabel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (typeFilter && typeFilter !== "all") params.set("type", typeFilter);
|
||||||
|
params.set("limit", String(PAGE_SIZE));
|
||||||
|
params.set("offset", String(page * PAGE_SIZE));
|
||||||
|
|
||||||
|
const { data } = useSWR<{
|
||||||
|
transactions: StockTransaction[];
|
||||||
|
total: number;
|
||||||
|
}>(`/api/stock?${params.toString()}`, fetcher, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = data?.transactions || [];
|
||||||
|
const total = data?.total || 0;
|
||||||
|
const { sorted: sortedTransactions, sort, toggleSort } = useTableSort(transactions);
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!transactions.length)
|
||||||
|
return {
|
||||||
|
totalTransactions: total,
|
||||||
|
purchaseCount: 0,
|
||||||
|
totalSpent: 0,
|
||||||
|
avgOrderSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const purchases = transactions.filter(
|
||||||
|
(t) => t.transaction_type === "purchase"
|
||||||
|
);
|
||||||
|
const totalSpent = purchases.reduce(
|
||||||
|
(sum, t) => sum + Number(t.total_cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTransactions: total,
|
||||||
|
purchaseCount: purchases.length,
|
||||||
|
totalSpent,
|
||||||
|
avgOrderSize:
|
||||||
|
purchases.length > 0 ? totalSpent / purchases.length : 0,
|
||||||
|
};
|
||||||
|
}, [transactions, total]);
|
||||||
|
|
||||||
|
function handleExportCSV() {
|
||||||
|
if (!transactions.length) return;
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
"Date",
|
||||||
|
"Type",
|
||||||
|
"Part Number",
|
||||||
|
"Component",
|
||||||
|
"Category",
|
||||||
|
"Quantity",
|
||||||
|
"Unit Cost",
|
||||||
|
"Total Cost",
|
||||||
|
"Supplier",
|
||||||
|
"Purchase Order",
|
||||||
|
"Notes",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = transactions.map((t) => [
|
||||||
|
new Date(t.created_at).toLocaleString(),
|
||||||
|
t.transaction_type,
|
||||||
|
t.part_number || "",
|
||||||
|
`"${t.component_name || ""}"`,
|
||||||
|
t.category_name || "",
|
||||||
|
t.quantity,
|
||||||
|
t.unit_cost ?? "",
|
||||||
|
t.total_cost ?? "",
|
||||||
|
t.supplier || "",
|
||||||
|
t.supplier_order_ref || "",
|
||||||
|
`"${(t.notes || "").replace(/"/g, '""')}"`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers, ...rows].map((r) => r.join(",")).join("\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `stock-history-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset page when filters change
|
||||||
|
function handleSearchChange(val: string) {
|
||||||
|
setSearch(val);
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(val: string) {
|
||||||
|
setTypeFilter(val);
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Purchase History
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Track all stock transactions, purchases, and inventory changes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={handleExportCSV} disabled={!transactions.length}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Transactions
|
||||||
|
</CardTitle>
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{stats.totalTransactions.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Purchases (This Page)
|
||||||
|
</CardTitle>
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{stats.purchaseCount}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Spent (This Page)
|
||||||
|
</CardTitle>
|
||||||
|
<Euro className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold font-mono">
|
||||||
|
{formatCurrency(stats.totalSpent)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg Purchase Cost
|
||||||
|
</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold font-mono">
|
||||||
|
{formatCurrency(stats.avgOrderSize)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[240px] max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by component name or part number..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={typeFilter} onValueChange={handleTypeChange}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="All Types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="purchase">Purchases</SelectItem>
|
||||||
|
<SelectItem value="adjustment">Adjustments</SelectItem>
|
||||||
|
<SelectItem value="return">Returns</SelectItem>
|
||||||
|
<SelectItem value="consumption">Consumption</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions Table */}
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} className="w-[160px]" />
|
||||||
|
<SortableTableHead column="transaction_type" label="Type" sort={sort} onToggle={toggleSort} className="w-[120px]" />
|
||||||
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} className="w-[130px]" />
|
||||||
|
<SortableTableHead column="component_name" label="Component" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="supplier" label="Supplier" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="supplier_order_ref" label="Purchase Order" sort={sort} onToggle={toggleSort} />
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedTransactions.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={8}
|
||||||
|
className="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{search || typeFilter !== "all"
|
||||||
|
? "No transactions match your filters."
|
||||||
|
: "No stock transactions yet. Use the \"Add Stock\" button on a component to record purchases."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedTransactions.map((txn) => {
|
||||||
|
const config = typeConfig[txn.transaction_type] || typeConfig.purchase;
|
||||||
|
const IconComponent = config.icon;
|
||||||
|
return (
|
||||||
|
<TableRow key={txn.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(txn.created_at).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground/60">
|
||||||
|
{new Date(txn.created_at).toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`${config.color} text-xs gap-1`}>
|
||||||
|
<IconComponent className="h-3 w-3" />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{txn.part_number || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-sm">
|
||||||
|
{txn.component_name || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
txn.transaction_type === "consumption"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-accent"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{txn.transaction_type === "consumption" ? "-" : "+"}
|
||||||
|
{Number(txn.quantity).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{txn.supplier || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="!text-left">
|
||||||
|
<div className="text-left">
|
||||||
|
{txn.supplier_order_ref ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenPo(txn.supplier_order_ref!)}
|
||||||
|
className="inline-flex items-center gap-1 font-mono text-xs text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{txn.supplier_order_ref}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{txn.part_number && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Print Label"
|
||||||
|
onClick={() => openLabelDialog(txn.part_number || "", Number(txn.quantity))}
|
||||||
|
>
|
||||||
|
<Printer className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {page * PAGE_SIZE + 1}-
|
||||||
|
{Math.min((page + 1) * PAGE_SIZE, total)} of{" "}
|
||||||
|
{total.toLocaleString()} transactions
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground px-2">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PO Detail Dialog */}
|
||||||
|
<Dialog open={poDialogOpen} onOpenChange={setPoDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] overflow-y-auto">
|
||||||
|
{poLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading purchase order...</p>
|
||||||
|
</div>
|
||||||
|
) : poDetail ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3">
|
||||||
|
{String(poDetail.po_number)}
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{String(poDetail.status || "").replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Supplier: <span className="font-medium text-foreground">{String(poDetail.supplier_name)}</span>
|
||||||
|
{" | "}
|
||||||
|
{new Date(String(poDetail.created_at)).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Part Number</TableHead>
|
||||||
|
<TableHead>Component</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Unit Price</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead className="text-right">Received</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(Array.isArray(poDetail.items) ? poDetail.items : []).map((item: Record<string, unknown>, idx: number) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell className="font-mono text-xs">{String(item.part_number || "")}</TableCell>
|
||||||
|
<TableCell className="text-sm">{String(item.component_name || "")}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{Number(item.quantity)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{formatCurrency(Number(item.unit_price))}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-semibold">
|
||||||
|
{formatCurrency(Number(item.quantity) * Number(item.unit_price))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{Number(item.received_quantity)}/{Number(item.quantity)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{poDetail.notes && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Notes:</span> {String(poDetail.notes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row gap-2 sm:justify-between">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownloadPoPdf}>
|
||||||
|
<FileDown className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setPoDialogOpen(false)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Print Label Dialog */}
|
||||||
|
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Print Label</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* Label preview */}
|
||||||
|
<div className="rounded-md border bg-background p-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">Code:</p>
|
||||||
|
<p className="font-mono text-sm font-bold truncate">{labelPartNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">Quantity:</p>
|
||||||
|
<p className="font-mono text-sm font-bold">{labelQuantity}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="label-qty">Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="label-qty"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={labelQuantity}
|
||||||
|
onChange={(e) => setLabelQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Label size: 49mm x 12mm. The QR code will contain: {labelPartNumber},{labelQuantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setLabelDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePrintLabel} disabled={printingLabel}>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
{printingLabel ? "Generating..." : "Download Label PDF"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
Download,
|
||||||
|
Search,
|
||||||
|
Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import type { Customer, Product, Component } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function StockTakePage() {
|
||||||
|
const { data: customers = [] } = useSWR<Customer[]>("/api/customers", fetcher);
|
||||||
|
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
|
||||||
|
|
||||||
|
const [source, setSource] = useState("all");
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState<Set<number>>(new Set());
|
||||||
|
const [queryKey, setQueryKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: results, isLoading } = useSWR<Component[]>(
|
||||||
|
queryKey,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sorted, sort, toggleSort } = useTableSort(results ?? []);
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (source !== "all") params.set("source", source);
|
||||||
|
if (selectedProducts.size > 0) {
|
||||||
|
params.set("productIds", Array.from(selectedProducts).join(","));
|
||||||
|
}
|
||||||
|
setQueryKey(`/api/stock-take?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProduct(id: number) {
|
||||||
|
setSelectedProducts((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExportCSV() {
|
||||||
|
if (!sorted || sorted.length === 0) return;
|
||||||
|
|
||||||
|
const headers = ["Part Number", "Name", "Shelf Location", "Current Stock"];
|
||||||
|
const rows = sorted.map((c) => [
|
||||||
|
c.part_number,
|
||||||
|
c.name,
|
||||||
|
c.shelf_location || "",
|
||||||
|
String(c.stock_quantity ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(","),
|
||||||
|
...rows.map((row) =>
|
||||||
|
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
link.download = `stock-take-${date}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResults = results && results.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Stock Take
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select components by source and product to generate a stock take list
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose which components to include in your stock take
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
{/* Source Filter */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Source</Label>
|
||||||
|
<Select value={source} onValueChange={setSource}>
|
||||||
|
<SelectTrigger className="w-full max-w-xs">
|
||||||
|
<SelectValue placeholder="All Sources" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Sources</SelectItem>
|
||||||
|
<SelectItem value="standard">Standard</SelectItem>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={`customer-${c.id}`}>
|
||||||
|
{c.code} - {c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Filter */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Products</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select products to only include components from their BOM. Leave empty for all components.
|
||||||
|
</p>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No products available</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||||
|
{products.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedProducts.has(p.id)}
|
||||||
|
onCheckedChange={() => toggleProduct(p.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Package className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0">{p.sku}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={handleGenerate} disabled={isLoading}>
|
||||||
|
<ClipboardCheck className="h-4 w-4 mr-2" />
|
||||||
|
{isLoading ? "Loading..." : "Generate Stock Take"}
|
||||||
|
</Button>
|
||||||
|
{selectedProducts.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedProducts(new Set())}
|
||||||
|
>
|
||||||
|
Clear products
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{queryKey && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
Stock Take Results
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isLoading
|
||||||
|
? "Loading..."
|
||||||
|
: `${sorted.length} component${sorted.length !== 1 ? "s" : ""} found`}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{hasResults && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportCSV}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="part_number" label="Part Number" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="category_name" label="Category" sort={sort} onToggle={toggleSort} />
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
|
<SortableTableHead column="shelf_location" label="Shelf Location" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="stock_quantity" label="Current Stock" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||||
|
Loading components...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||||
|
No components match the selected filters
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sorted.map((comp) => (
|
||||||
|
<TableRow key={comp.id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{comp.part_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{comp.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{comp.category_name ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{comp.category_name}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{comp.customer_supplied ? (
|
||||||
|
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
||||||
|
{comp.customer_name || "Customer"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Standard</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{comp.shelf_location || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono tabular-nums">
|
||||||
|
{comp.stock_quantity ?? 0}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { Component } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface AddStockDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
component: Component | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
onPrintLabel?: (partNumber: string, quantity: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionTypes = [
|
||||||
|
{ value: "purchase", label: "Purchase (New Stock)" },
|
||||||
|
{ value: "adjustment", label: "Adjustment (Correction)" },
|
||||||
|
{ value: "return", label: "Return (Supplier Return)" },
|
||||||
|
{ value: "consumption", label: "Consumption (Used/Removed)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AddStockDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
component,
|
||||||
|
onSaved,
|
||||||
|
onPrintLabel,
|
||||||
|
}: AddStockDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
transaction_type: "purchase",
|
||||||
|
quantity: "",
|
||||||
|
unit_cost: "",
|
||||||
|
supplier: "",
|
||||||
|
supplier_code: "",
|
||||||
|
supplier_order_ref: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch available supplier POs for dropdown
|
||||||
|
const { data: supplierPos = [] } = useSWR<Array<{ id: number; po_number: string; supplier_name: string; status: string }>>(
|
||||||
|
open ? "/api/supplier-pos" : null,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && component) {
|
||||||
|
setForm({
|
||||||
|
transaction_type: "purchase",
|
||||||
|
quantity: "",
|
||||||
|
unit_cost: String(component.unit_price || ""),
|
||||||
|
supplier: component.supplier || "",
|
||||||
|
supplier_code: component.supplier_code || "",
|
||||||
|
supplier_order_ref: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, component]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!component) return;
|
||||||
|
if (!form.quantity || Number(form.quantity) <= 0) {
|
||||||
|
toast.error("Quantity must be greater than 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
component_id: component.id,
|
||||||
|
transaction_type: form.transaction_type,
|
||||||
|
quantity: Number(form.quantity),
|
||||||
|
unit_cost: form.unit_cost ? Number(form.unit_cost) : null,
|
||||||
|
supplier: form.supplier || null,
|
||||||
|
supplier_order_ref: form.supplier_order_ref || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = transactionTypes.find(
|
||||||
|
(t) => t.value === form.transaction_type
|
||||||
|
)?.label;
|
||||||
|
toast.success(
|
||||||
|
`Stock ${form.transaction_type === "consumption" ? "reduced" : "added"}: ${form.quantity} units (${typeLabel})`
|
||||||
|
);
|
||||||
|
const qty = Number(form.quantity);
|
||||||
|
const partNum = component.part_number;
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
// Prompt label printing for non-consumption transactions
|
||||||
|
if (form.transaction_type !== "consumption" && onPrintLabel) {
|
||||||
|
onPrintLabel(partNum, qty);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to update stock"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost =
|
||||||
|
form.unit_cost && form.quantity
|
||||||
|
? (Number(form.unit_cost) * Number(form.quantity)).toFixed(2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{form.transaction_type === "consumption"
|
||||||
|
? "Remove Stock"
|
||||||
|
: "Add Stock"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{component?.name} ({component?.part_number}) - Current stock:{" "}
|
||||||
|
<strong>{component?.stock_quantity}</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="transaction_type">Transaction Type</Label>
|
||||||
|
<Select
|
||||||
|
value={form.transaction_type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm({ ...form, transaction_type: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{transactionTypes.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="quantity">Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={form.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, quantity: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. 100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="unit_cost">Unit Cost (EUR)</Label>
|
||||||
|
<Input
|
||||||
|
id="unit_cost"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.0001}
|
||||||
|
value={form.unit_cost}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, unit_cost: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalCost && (
|
||||||
|
<div className="rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
|
Total cost:{" "}
|
||||||
|
<span className="font-semibold font-mono">
|
||||||
|
{formatCurrency(Number(totalCost))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.transaction_type !== "consumption" && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier">Supplier</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier"
|
||||||
|
value={form.supplier}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, supplier: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. Digi-Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier_code">Supplier Code</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier_code"
|
||||||
|
value={form.supplier_code || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, supplier_code: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. C245215"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.transaction_type === "purchase" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier_order_ref">Purchase Order</Label>
|
||||||
|
<Select
|
||||||
|
value={form.supplier_order_ref}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm({ ...form, supplier_order_ref: v === "none" ? "" : v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="font-mono">
|
||||||
|
<SelectValue placeholder="Select PO (required for purchase)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No PO</SelectItem>
|
||||||
|
{supplierPos
|
||||||
|
.filter((po) => po.status === "pending" || po.status === "ordered" || po.status === "partial")
|
||||||
|
.map((po) => (
|
||||||
|
<SelectItem key={po.id} value={po.po_number}>
|
||||||
|
<span className="font-mono">{po.po_number}</span>
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
|
({po.supplier_name} - {po.status})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Only open POs (pending, ordered, partial) are shown</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Optional notes about this transaction..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
variant={
|
||||||
|
form.transaction_type === "consumption"
|
||||||
|
? "destructive"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "Saving..."
|
||||||
|
: form.transaction_type === "consumption"
|
||||||
|
? "Remove Stock"
|
||||||
|
: "Add Stock"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ["/login"];
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
||||||
|
|
||||||
|
// Don't show sidebar on public paths like login
|
||||||
|
if (isPublicPath) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
<AppSidebar />
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Cpu,
|
||||||
|
Package,
|
||||||
|
ClipboardList,
|
||||||
|
Factory,
|
||||||
|
History,
|
||||||
|
Users,
|
||||||
|
Truck,
|
||||||
|
Building2,
|
||||||
|
ClipboardCheck,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard, section: "dashboard" },
|
||||||
|
{ href: "/customers", label: "Customers & Orders", icon: Users, section: "customers" },
|
||||||
|
{ href: "/suppliers", label: "Suppliers", icon: Building2, section: "suppliers" },
|
||||||
|
{ href: "/products", label: "Products", icon: Package, section: "products" },
|
||||||
|
{ href: "/components", label: "Components", icon: Cpu, section: "components" },
|
||||||
|
{ href: "/bom", label: "Bill of Materials", icon: ClipboardList, section: "products" },
|
||||||
|
{ href: "/stock-history", label: "Purchase History", icon: History, section: "stock-history" },
|
||||||
|
{ href: "/production", label: "Production Planning", icon: Factory, section: "production" },
|
||||||
|
{ href: "/logistics", label: "Logistics", icon: Truck, section: "logistics" },
|
||||||
|
{ href: "/stock-take", label: "Stock Take", icon: ClipboardCheck, section: "stock-take" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, logout, canAccessSection } = useAuth();
|
||||||
|
|
||||||
|
// Filter nav items based on user permissions
|
||||||
|
const visibleNavItems = navItems.filter((item) => canAccessSection(item.section));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-screen w-64 flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="flex h-16 items-center gap-3 border-b border-sidebar-border px-6">
|
||||||
|
<Cpu className="h-7 w-7 text-sidebar-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-bold tracking-tight text-sidebar-primary-foreground">
|
||||||
|
Linse
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-sidebar-foreground/60">Component Manager</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{visibleNavItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== "/" && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2.5 text-base font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-sidebar-accent text-sidebar-primary"
|
||||||
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Settings - Admin Only */}
|
||||||
|
{user?.role === "admin" && (
|
||||||
|
<li className="mt-4 pt-4 border-t border-sidebar-border">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2.5 text-base font-medium transition-colors",
|
||||||
|
pathname === "/settings"
|
||||||
|
? "bg-sidebar-accent text-sidebar-primary"
|
||||||
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info and logout */}
|
||||||
|
<div className="border-t border-sidebar-border px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-sidebar-foreground truncate">
|
||||||
|
{user?.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-sidebar-foreground/50 truncate">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent flex-shrink-0"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { Search, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import type { BomItem, Component } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface BomItemDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
bomItem: BomItem | null;
|
||||||
|
productId: number;
|
||||||
|
onSave: (data: Partial<BomItem>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BomItemDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
bomItem,
|
||||||
|
onSave,
|
||||||
|
}: BomItemDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [componentSearch, setComponentSearch] = useState("");
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const { data: components = [] } = useSWR<Component[]>(
|
||||||
|
"/api/components",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredComponents = components.filter((comp) => {
|
||||||
|
if (!componentSearch.trim()) return true;
|
||||||
|
const q = componentSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
comp.part_number.toLowerCase().includes(q) ||
|
||||||
|
comp.name.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
component_id: "",
|
||||||
|
quantity: "1",
|
||||||
|
reference_designator: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bomItem) {
|
||||||
|
setForm({
|
||||||
|
component_id: String(bomItem.component_id),
|
||||||
|
quantity: String(bomItem.quantity),
|
||||||
|
reference_designator: bomItem.reference_designator || "",
|
||||||
|
notes: bomItem.notes || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
component_id: "",
|
||||||
|
quantity: "1",
|
||||||
|
reference_designator: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setComponentSearch("");
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}, [bomItem, open]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
component_id: Number(form.component_id),
|
||||||
|
quantity: Number(form.quantity),
|
||||||
|
reference_designator: form.reference_designator || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{bomItem ? "Edit BOM Item" : "Add Component to BOM"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bom_component">Component *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
>
|
||||||
|
{form.component_id ? (
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-mono text-xs mr-2">
|
||||||
|
{components.find((c) => String(c.id) === form.component_id)?.part_number}
|
||||||
|
</span>
|
||||||
|
{components.find((c) => String(c.id) === form.component_id)?.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Select a component</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Search by part number or name..."
|
||||||
|
value={componentSearch}
|
||||||
|
onChange={(e) => setComponentSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-y-auto p-1">
|
||||||
|
{filteredComponents.length === 0 ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No components found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredComponents.map((comp) => (
|
||||||
|
<button
|
||||||
|
key={comp.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent/50 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setForm({ ...form, component_id: String(comp.id) });
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setComponentSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.component_id === String(comp.id) ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-xs text-muted-foreground min-w-[80px]">
|
||||||
|
{comp.part_number}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{comp.name}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bom_qty">Quantity *</Label>
|
||||||
|
<Input
|
||||||
|
id="bom_qty"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={form.quantity}
|
||||||
|
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ref_des">Reference Designator</Label>
|
||||||
|
<Input
|
||||||
|
id="ref_des"
|
||||||
|
value={form.reference_designator}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, reference_designator: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. R1, C4, U2"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bom_notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bom_notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || !form.component_id}>
|
||||||
|
{loading ? "Saving..." : bomItem ? "Update" : "Add to BOM"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import type { Component, Category, Customer, ComponentSpecs } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
// Category-specific specification fields
|
||||||
|
const CATEGORY_SPECS: Record<string, { key: keyof ComponentSpecs; label: string; type?: string; options?: string[] }[]> = {
|
||||||
|
resistors: [
|
||||||
|
{ key: "value", label: "Value" },
|
||||||
|
{ key: "power", label: "Power" },
|
||||||
|
{ key: "voltage", label: "Voltage" },
|
||||||
|
{ key: "tolerance", label: "Tolerance" },
|
||||||
|
],
|
||||||
|
capacitors: [
|
||||||
|
{ key: "value", label: "Value" },
|
||||||
|
{ key: "power", label: "Power" },
|
||||||
|
{ key: "voltage", label: "Voltage" },
|
||||||
|
{ key: "tolerance", label: "Tolerance" },
|
||||||
|
],
|
||||||
|
inductors: [
|
||||||
|
{ key: "value", label: "Value" },
|
||||||
|
{ key: "tolerance", label: "Tolerance" },
|
||||||
|
],
|
||||||
|
leds: [
|
||||||
|
{ key: "color", label: "Color" },
|
||||||
|
],
|
||||||
|
connectors: [
|
||||||
|
{ key: "no_of_pins", label: "No. of Pins" },
|
||||||
|
{ key: "no_of_rows", label: "No. of Rows" },
|
||||||
|
{ key: "connector_type", label: "Type", type: "select", options: ["Female", "Male"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCategorySpecKey(categoryName: string | undefined): string | null {
|
||||||
|
if (!categoryName) return null;
|
||||||
|
const lower = categoryName.toLowerCase().trim();
|
||||||
|
// Match plural or singular forms
|
||||||
|
if (lower.includes("resistor")) return "resistors";
|
||||||
|
if (lower.includes("capacitor")) return "capacitors";
|
||||||
|
if (lower.includes("inductor")) return "inductors";
|
||||||
|
if (lower.includes("led")) return "leds";
|
||||||
|
if (lower.includes("connector")) return "connectors";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
component: Component | null;
|
||||||
|
categories: Category[];
|
||||||
|
onSave: (data: Partial<Component>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySpecs: ComponentSpecs = {};
|
||||||
|
|
||||||
|
export function ComponentDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
component,
|
||||||
|
categories,
|
||||||
|
onSave,
|
||||||
|
}: ComponentDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { data: customers = [] } = useSWR<Customer[]>("/api/customers", fetcher);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
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: false,
|
||||||
|
customer_supplied: false,
|
||||||
|
customer_id: "",
|
||||||
|
});
|
||||||
|
const [specs, setSpecs] = useState<ComponentSpecs>({});
|
||||||
|
|
||||||
|
// Determine the selected category name for spec fields
|
||||||
|
const selectedCategoryName = useMemo(() => {
|
||||||
|
if (!form.category_id) return undefined;
|
||||||
|
const cat = categories.find((c) => String(c.id) === form.category_id);
|
||||||
|
return cat?.name;
|
||||||
|
}, [form.category_id, categories]);
|
||||||
|
|
||||||
|
const specKey = getCategorySpecKey(selectedCategoryName);
|
||||||
|
const specFields = specKey ? CATEGORY_SPECS[specKey] : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (component) {
|
||||||
|
setForm({
|
||||||
|
part_number: component.part_number || "",
|
||||||
|
name: component.name || "",
|
||||||
|
description: component.description || "",
|
||||||
|
category_id: component.category_id ? String(component.category_id) : "",
|
||||||
|
manufacturer: component.manufacturer || "",
|
||||||
|
package_type: component.package_type || "",
|
||||||
|
unit_price: String(component.unit_price || ""),
|
||||||
|
stock_quantity: String(component.stock_quantity || ""),
|
||||||
|
min_stock_level: String(component.min_stock_level || ""),
|
||||||
|
datasheet_url: component.datasheet_url || "",
|
||||||
|
supplier: component.supplier || "",
|
||||||
|
supplier_part_number: component.supplier_part_number || "",
|
||||||
|
shelf_location: component.shelf_location || "",
|
||||||
|
is_internal: component.is_internal ?? false,
|
||||||
|
customer_supplied: component.customer_supplied ?? false,
|
||||||
|
customer_id: component.customer_id ? String(component.customer_id) : "",
|
||||||
|
});
|
||||||
|
const rawSpecs = component.specs;
|
||||||
|
setSpecs(typeof rawSpecs === "string" ? JSON.parse(rawSpecs) : rawSpecs || {});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
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: false,
|
||||||
|
customer_supplied: false,
|
||||||
|
customer_id: "",
|
||||||
|
});
|
||||||
|
setSpecs({});
|
||||||
|
}
|
||||||
|
}, [component, open]);
|
||||||
|
|
||||||
|
function updateSpec(key: keyof ComponentSpecs, value: string) {
|
||||||
|
setSpecs((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
...form,
|
||||||
|
category_id: form.category_id ? Number(form.category_id) : null,
|
||||||
|
unit_price: Number(form.unit_price) || 0,
|
||||||
|
stock_quantity: Number(form.stock_quantity) || 0,
|
||||||
|
min_stock_level: Number(form.min_stock_level) || 0,
|
||||||
|
is_internal: form.is_internal,
|
||||||
|
customer_supplied: form.customer_supplied,
|
||||||
|
customer_id: form.customer_supplied && form.customer_id ? Number(form.customer_id) : null,
|
||||||
|
specs,
|
||||||
|
} as unknown as Partial<Component>);
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{component ? "Edit Component" : "Add New Component"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2">
|
||||||
|
|
||||||
|
{/* 1. Part Number and Name */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="part_number">Part Number *</Label>
|
||||||
|
<Input
|
||||||
|
id="part_number"
|
||||||
|
value={form.part_number}
|
||||||
|
onChange={(e) => setForm({ ...form, part_number: e.target.value })}
|
||||||
|
placeholder="e.g. RC0805-10K"
|
||||||
|
className="font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="name">Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="e.g. 10K Ohm Resistor"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Description */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
placeholder="Component description..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Category, Manufacturer, Package */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<Select
|
||||||
|
value={form.category_id}
|
||||||
|
onValueChange={(val) => setForm({ ...form, category_id: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="manufacturer">Manufacturer</Label>
|
||||||
|
<Input
|
||||||
|
id="manufacturer"
|
||||||
|
value={form.manufacturer}
|
||||||
|
onChange={(e) => setForm({ ...form, manufacturer: e.target.value })}
|
||||||
|
placeholder="e.g. Vishay"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="package_type">Package</Label>
|
||||||
|
<Input
|
||||||
|
id="package_type"
|
||||||
|
value={form.package_type}
|
||||||
|
onChange={(e) => setForm({ ...form, package_type: e.target.value })}
|
||||||
|
placeholder="e.g. 0805"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3b. Manufacturer ID (all categories) */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="manufacturer_id">Manufacturer ID</Label>
|
||||||
|
<Input
|
||||||
|
id="manufacturer_id"
|
||||||
|
value={specs.manufacturer_id || ""}
|
||||||
|
onChange={(e) => updateSpec("manufacturer_id", e.target.value)}
|
||||||
|
placeholder="Manufacturer part/ID number"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Category-specific specifications */}
|
||||||
|
{specFields && specFields.length > 0 && (
|
||||||
|
<div className="rounded-lg border p-4 flex flex-col gap-3 bg-muted/30">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{selectedCategoryName} Specifications
|
||||||
|
</p>
|
||||||
|
<div className={`grid gap-4 ${specFields.length === 1 ? "grid-cols-1" : specFields.length === 2 ? "grid-cols-2" : specFields.length <= 4 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
|
||||||
|
{specFields.map((field) => (
|
||||||
|
<div key={field.key} className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`spec_${field.key}`} className="text-xs">{field.label}</Label>
|
||||||
|
{field.type === "select" && field.options ? (
|
||||||
|
<Select
|
||||||
|
value={specs[field.key] || ""}
|
||||||
|
onValueChange={(val) => updateSpec(field.key, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`spec_${field.key}`}
|
||||||
|
value={specs[field.key] || ""}
|
||||||
|
onChange={(e) => updateSpec(field.key, e.target.value)}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5. Unit Price, Stock Quantity, Min Stock Level */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="unit_price">Unit Price ($)</Label>
|
||||||
|
<Input
|
||||||
|
id="unit_price"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
value={form.unit_price}
|
||||||
|
onChange={(e) => setForm({ ...form, unit_price: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="stock_quantity">Stock Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="stock_quantity"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.stock_quantity}
|
||||||
|
onChange={(e) => setForm({ ...form, stock_quantity: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="min_stock_level">Min Stock Level</Label>
|
||||||
|
<Input
|
||||||
|
id="min_stock_level"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.min_stock_level}
|
||||||
|
onChange={(e) => setForm({ ...form, min_stock_level: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Supplier and Supplier Part No */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier">Supplier</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier"
|
||||||
|
value={form.supplier}
|
||||||
|
onChange={(e) => setForm({ ...form, supplier: e.target.value })}
|
||||||
|
placeholder="e.g. Digi-Key, Mouser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier_part_number">Supplier Part No.</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier_part_number"
|
||||||
|
value={form.supplier_part_number}
|
||||||
|
onChange={(e) => setForm({ ...form, supplier_part_number: e.target.value })}
|
||||||
|
placeholder="e.g. 311-10KARCT-ND"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7. Supplier URL */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="datasheet_url">Supplier URL</Label>
|
||||||
|
<Input
|
||||||
|
id="datasheet_url"
|
||||||
|
type="url"
|
||||||
|
value={form.datasheet_url}
|
||||||
|
onChange={(e) => setForm({ ...form, datasheet_url: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 8. Internal Production and Customer Supplied */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="is_internal"
|
||||||
|
checked={form.is_internal}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setForm({ ...form, is_internal: checked === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_internal" className="cursor-pointer text-sm font-medium">
|
||||||
|
Internal Production
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="customer_supplied"
|
||||||
|
checked={form.customer_supplied}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setForm({ ...form, customer_supplied: checked === true, customer_id: checked ? form.customer_id : "" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="customer_supplied" className="cursor-pointer text-sm font-medium">
|
||||||
|
Customer Supplied
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.customer_supplied && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="customer_id">Supplied by Customer *</Label>
|
||||||
|
<Select
|
||||||
|
value={form.customer_id}
|
||||||
|
onValueChange={(val) => setForm({ ...form, customer_id: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select customer" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customers.map((cust) => (
|
||||||
|
<SelectItem key={cust.id} value={String(cust.id)}>
|
||||||
|
{cust.code} - {cust.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 9. Shelf / Storage Location */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="shelf_location">Shelf / Storage Location</Label>
|
||||||
|
<Input
|
||||||
|
id="shelf_location"
|
||||||
|
value={form.shelf_location}
|
||||||
|
onChange={(e) => setForm({ ...form, shelf_location: e.target.value })}
|
||||||
|
placeholder="e.g. A3-R2-B5, Rack 1 Shelf 3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : component ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Customer } from "@/lib/types";
|
||||||
|
|
||||||
|
interface CustomerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
customer?: Customer | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
customer,
|
||||||
|
onSaved,
|
||||||
|
}: CustomerDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
contact_person: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customer) {
|
||||||
|
setForm({
|
||||||
|
name: customer.name || "",
|
||||||
|
code: customer.code || "",
|
||||||
|
email: customer.email || "",
|
||||||
|
phone: customer.phone || "",
|
||||||
|
address: customer.address || "",
|
||||||
|
contact_person: customer.contact_person || "",
|
||||||
|
notes: customer.notes || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
contact_person: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [customer, open]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name || !form.code) {
|
||||||
|
toast.error("Name and code are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const url = customer
|
||||||
|
? `/api/customers/${customer.id}`
|
||||||
|
: "/api/customers";
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: customer ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
toast.success(customer ? "Customer updated" : "Customer created");
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to save customer");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{customer ? "Edit Customer" : "Add Customer"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Company Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Acme Electronics"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="code">
|
||||||
|
Customer Code <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="ACME"
|
||||||
|
className="font-mono uppercase"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
placeholder="orders@acme.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
placeholder="+1 555-0123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="contact_person">Contact Person</Label>
|
||||||
|
<Input
|
||||||
|
id="contact_person"
|
||||||
|
value={form.contact_person}
|
||||||
|
onChange={(e) => setForm({ ...form, contact_person: e.target.value })}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="address">Address</Label>
|
||||||
|
<Textarea
|
||||||
|
id="address"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||||
|
placeholder="123 Main St, City, State 12345"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Internal notes about this customer..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : customer ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { Product } from "@/lib/types";
|
||||||
|
|
||||||
|
interface ProductDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
product: Product | null;
|
||||||
|
onSave: (data: Partial<Product>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
product,
|
||||||
|
onSave,
|
||||||
|
}: ProductDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
sku: "",
|
||||||
|
description: "",
|
||||||
|
version: "1.0",
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (product) {
|
||||||
|
setForm({
|
||||||
|
name: product.name || "",
|
||||||
|
sku: product.sku || "",
|
||||||
|
description: product.description || "",
|
||||||
|
version: product.version || "1.0",
|
||||||
|
status: product.status || "draft",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
sku: "",
|
||||||
|
description: "",
|
||||||
|
version: "1.0",
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [product, open]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave(form);
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{product ? "Edit Product" : "Add New Product"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="prod_name">Product Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="prod_name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="e.g. Sensor Board v2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="sku">SKU *</Label>
|
||||||
|
<Input
|
||||||
|
id="sku"
|
||||||
|
value={form.sku}
|
||||||
|
onChange={(e) => setForm({ ...form, sku: e.target.value })}
|
||||||
|
placeholder="e.g. SB-V2-001"
|
||||||
|
className="font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="prod_desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prod_desc"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Product description..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="version">Version</Label>
|
||||||
|
<Input
|
||||||
|
id="version"
|
||||||
|
value={form.version}
|
||||||
|
onChange={(e) => setForm({ ...form, version: e.target.value })}
|
||||||
|
placeholder="1.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={form.status}
|
||||||
|
onValueChange={(val) => setForm({ ...form, status: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="deprecated">Deprecated</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : product ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TableHead } from "@/components/ui/table";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||||
|
import type { SortState } from "@/hooks/use-table-sort";
|
||||||
|
|
||||||
|
interface SortableTableHeadProps {
|
||||||
|
column: string;
|
||||||
|
label: string;
|
||||||
|
sort: SortState;
|
||||||
|
onToggle: (column: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTableHead({
|
||||||
|
column,
|
||||||
|
label,
|
||||||
|
sort,
|
||||||
|
onToggle,
|
||||||
|
className = "",
|
||||||
|
}: SortableTableHeadProps) {
|
||||||
|
const isActive = sort.column === column;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
className={cn("cursor-pointer select-none hover:text-foreground transition-colors", className)}
|
||||||
|
onClick={() => onToggle(column)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
className?.includes("text-right") ? "ml-auto" : ""
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
{isActive && sort.direction === "asc" ? (
|
||||||
|
<ArrowUp className="h-3 w-3 text-primary" />
|
||||||
|
) : isActive && sort.direction === "desc" ? (
|
||||||
|
<ArrowDown className="h-3 w-3 text-primary" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Supplier } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SupplierDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
supplier?: Supplier | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
supplier,
|
||||||
|
onSaved,
|
||||||
|
}: SupplierDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
contact_person: "",
|
||||||
|
country: "",
|
||||||
|
currency: "EUR",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (supplier) {
|
||||||
|
setForm({
|
||||||
|
name: supplier.name || "",
|
||||||
|
code: supplier.code || "",
|
||||||
|
email: supplier.email || "",
|
||||||
|
phone: supplier.phone || "",
|
||||||
|
address: supplier.address || "",
|
||||||
|
contact_person: supplier.contact_person || "",
|
||||||
|
country: supplier.country || "",
|
||||||
|
currency: supplier.currency || "EUR",
|
||||||
|
notes: supplier.notes || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
contact_person: "",
|
||||||
|
country: "",
|
||||||
|
currency: "EUR",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [supplier, open]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name || !form.code) {
|
||||||
|
toast.error("Name and code are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const url = supplier
|
||||||
|
? `/api/suppliers/${supplier.id}`
|
||||||
|
: "/api/suppliers";
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: supplier ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
|
toast.success(supplier ? "Supplier updated" : "Supplier created");
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to save supplier");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{supplier ? "Edit Supplier" : "Add Supplier"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-name">
|
||||||
|
Supplier Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Mouser Electronics"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-code">
|
||||||
|
Supplier Code <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-code"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="MOUSER"
|
||||||
|
className="font-mono uppercase"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
placeholder="sales@mouser.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-phone"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
placeholder="+1 555-0123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-contact">Contact Person</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-contact"
|
||||||
|
value={form.contact_person}
|
||||||
|
onChange={(e) => setForm({ ...form, contact_person: e.target.value })}
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-country">Country</Label>
|
||||||
|
<Input
|
||||||
|
id="supplier-country"
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) => setForm({ ...form, country: e.target.value })}
|
||||||
|
placeholder="Germany"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-currency">Currency</Label>
|
||||||
|
<Select
|
||||||
|
value={form.currency}
|
||||||
|
onValueChange={(val) => setForm({ ...form, currency: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="supplier-currency">
|
||||||
|
<SelectValue placeholder="Select currency" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-address">Address</Label>
|
||||||
|
<Textarea
|
||||||
|
id="supplier-address"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||||
|
placeholder="1000 N Main St, Mansfield, TX 76063"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="supplier-notes">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="supplier-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Internal notes about this supplier..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : supplier ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useTableSort } from "@/hooks/use-table-sort";
|
||||||
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
FileDown,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Package,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
Cpu,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Supplier, SupplierPackingList, Component as ComponentType } from "@/lib/types";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
interface SupplierPackingItem {
|
||||||
|
id: number;
|
||||||
|
component_id: number;
|
||||||
|
quantity: number;
|
||||||
|
part_number?: string;
|
||||||
|
component_name?: string;
|
||||||
|
used_in_products?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineItem {
|
||||||
|
component_id: number;
|
||||||
|
component_name: string;
|
||||||
|
part_number: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplierPackingTab() {
|
||||||
|
const { data: supplierPackingLists, mutate } = useSWR<SupplierPackingList[]>(
|
||||||
|
"/api/supplier-packing-lists",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const { data: suppliers } = useSWR<Supplier[]>("/api/suppliers", fetcher);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [viewTarget, setViewTarget] = useState<number | null>(null);
|
||||||
|
const [viewData, setViewData] = useState<
|
||||||
|
(SupplierPackingList & { items: SupplierPackingItem[] }) | null
|
||||||
|
>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SupplierPackingList | null>(null);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [selectedSupplierId, setSelectedSupplierId] = useState("");
|
||||||
|
const [packingNumber, setPackingNumber] = useState("");
|
||||||
|
const [lineItems, setLineItems] = useState<LineItem[]>([]);
|
||||||
|
const [scanStatus, setScanStatus] = useState<string | null>(null);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const scanBufferRef = useRef("");
|
||||||
|
const scanTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const scanInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Generate packing number preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSupplierId || !suppliers) {
|
||||||
|
setPackingNumber("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const supplier = suppliers.find((s) => String(s.id) === selectedSupplierId);
|
||||||
|
if (!supplier) return;
|
||||||
|
const supplierName = supplier.name
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
|
.toUpperCase();
|
||||||
|
const now = new Date();
|
||||||
|
const yyyy = now.getFullYear();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(now.getDate()).padStart(2, "0");
|
||||||
|
const datePrefix = `PL-EACH-${yyyy}_${mm}_${dd}`;
|
||||||
|
const existing = (supplierPackingLists || []).filter(
|
||||||
|
(pl) =>
|
||||||
|
pl.packing_number.startsWith(datePrefix) &&
|
||||||
|
pl.packing_number.endsWith(`-${supplierName}`)
|
||||||
|
).length;
|
||||||
|
const revision = String(existing + 1).padStart(2, "0");
|
||||||
|
setPackingNumber(`${datePrefix}-${revision}-${supplierName}`);
|
||||||
|
}, [selectedSupplierId, suppliers, supplierPackingLists]);
|
||||||
|
|
||||||
|
// Fetch detail for view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewTarget) {
|
||||||
|
setViewData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/supplier-packing-lists/${viewTarget}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setViewData);
|
||||||
|
}, [viewTarget]);
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
setSelectedSupplierId("");
|
||||||
|
setLineItems([]);
|
||||||
|
scanBufferRef.current = "";
|
||||||
|
setScanStatus(null);
|
||||||
|
setNotes("");
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the scan buffer: parse tab-separated QR data and look up the component
|
||||||
|
const processScanBuffer = useCallback(async () => {
|
||||||
|
const raw = scanBufferRef.current;
|
||||||
|
scanBufferRef.current = "";
|
||||||
|
if (scanInputRef.current) scanInputRef.current.value = "";
|
||||||
|
|
||||||
|
const parts = raw.split("\t");
|
||||||
|
if (parts.length < 3) {
|
||||||
|
setScanStatus("Incomplete scan - expected 3 tab-separated fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partNumber = parts[1]?.trim();
|
||||||
|
const qty = Number(parts[2]?.trim());
|
||||||
|
|
||||||
|
if (!partNumber || !qty || qty <= 0) {
|
||||||
|
setScanStatus("Invalid scan data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/components?search=${encodeURIComponent(partNumber)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const comp = Array.isArray(data)
|
||||||
|
? data.find((c: ComponentType) => c.part_number === partNumber)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!comp) {
|
||||||
|
setScanStatus(`Component "${partNumber}" not found`);
|
||||||
|
toast.error(`Component "${partNumber}" not found in database`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLineItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
component_id: comp.id,
|
||||||
|
component_name: comp.name,
|
||||||
|
part_number: comp.part_number,
|
||||||
|
quantity: qty,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setScanStatus(`Added ${partNumber} x${qty}`);
|
||||||
|
toast.success(`Added ${comp.part_number} - ${comp.name} (x${qty})`);
|
||||||
|
} catch {
|
||||||
|
setScanStatus("Error looking up component");
|
||||||
|
toast.error("Error looking up component");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-focus the scan input for the next scan
|
||||||
|
scanInputRef.current?.focus();
|
||||||
|
}, [lineItems]);
|
||||||
|
|
||||||
|
// Handle all keydown events: build buffer from all keys, prevent Tab from moving focus
|
||||||
|
function handleScanKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
scanBufferRef.current += "\t";
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
|
||||||
|
processScanBuffer();
|
||||||
|
return;
|
||||||
|
} else if (e.key === "Backspace") {
|
||||||
|
scanBufferRef.current = scanBufferRef.current.slice(0, -1);
|
||||||
|
} else if (e.key.length === 1) {
|
||||||
|
// Regular printable character
|
||||||
|
scanBufferRef.current += e.key;
|
||||||
|
} else {
|
||||||
|
return; // Ignore other special keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the visible input to show non-tab content for user feedback
|
||||||
|
if (scanInputRef.current) {
|
||||||
|
scanInputRef.current.value = scanBufferRef.current.replace(/\t/g, " | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: process 200ms after last keystroke (scanners type fast)
|
||||||
|
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
|
||||||
|
scanTimerRef.current = setTimeout(() => {
|
||||||
|
processScanBuffer();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Prevent default for all keys so the input doesn't double-type
|
||||||
|
if (e.key.length === 1) e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLineItem(componentId: number) {
|
||||||
|
setLineItems((prev) => prev.filter((li) => li.component_id !== componentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!selectedSupplierId || !lineItems.length) {
|
||||||
|
toast.error("Select a supplier and add at least one component");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/supplier-packing-lists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
supplier_id: Number(selectedSupplierId),
|
||||||
|
items: lineItems.map((li) => ({
|
||||||
|
component_id: li.component_id,
|
||||||
|
quantity: li.quantity,
|
||||||
|
})),
|
||||||
|
notes: notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to create supplier packing list");
|
||||||
|
}
|
||||||
|
toast.success("Supplier packing list created");
|
||||||
|
mutate();
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Error creating supplier packing list"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier-packing-lists/${deleteTarget.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to delete");
|
||||||
|
}
|
||||||
|
toast.success("Supplier packing list deleted");
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Error deleting");
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadPdf = useCallback(async (plId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier-packing-lists/${plId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const { generateSupplierPackingPdf } = await import(
|
||||||
|
"@/lib/generate-supplier-packing-pdf"
|
||||||
|
);
|
||||||
|
const doc = generateSupplierPackingPdf(data);
|
||||||
|
doc.save(`${data.packing_number}.pdf`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate PDF");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = (supplierPackingLists || []).filter((pl) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
pl.packing_number.toLowerCase().includes(s) ||
|
||||||
|
(pl.supplier_name || "").toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sorted: sortedFiltered, sort, toggleSort } = useTableSort(filtered);
|
||||||
|
|
||||||
|
const totalItems = filtered.reduce(
|
||||||
|
(s, pl) => s + Number(pl.item_count || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalQty = filtered.reduce(
|
||||||
|
(s, pl) => s + Number(pl.total_quantity || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 flex justify-end">
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Supplier Packing List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="mb-6 grid gap-4 grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Building2 className="h-8 w-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{filtered.length}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Supplier Packing Lists
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Cpu className="h-8 w-8 text-accent" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{totalItems}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Total Component Lines
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-8 w-8 text-warning" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{totalQty}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Total Units
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by packing number or supplier..."
|
||||||
|
className="pl-10"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Building2 className="h-4 w-4 text-primary" />
|
||||||
|
Packing Lists
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableTableHead column="packing_number" label="Packing No." sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="supplier_name" label="Supplier" sort={sort} onToggle={toggleSort} />
|
||||||
|
<SortableTableHead column="component_count" label="Components" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="total_quantity" label="Total Qty" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||||
|
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} />
|
||||||
|
<TableHead className="w-[130px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedFiltered.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
No supplier packing lists found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedFiltered.map((pl) => (
|
||||||
|
<TableRow key={pl.id}>
|
||||||
|
<TableCell className="font-mono text-sm font-semibold">
|
||||||
|
{pl.packing_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{pl.supplier_name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({pl.supplier_code})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium">
|
||||||
|
{Number(pl.item_count || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium">
|
||||||
|
{Number(pl.total_quantity || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(pl.created_at).toLocaleDateString("en-GB")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setViewTarget(pl.id)}
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-primary hover:text-primary"
|
||||||
|
onClick={() => handleDownloadPdf(pl.id)}
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
<FileDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(pl)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
New Supplier Packing List
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 py-2">
|
||||||
|
{/* Packing Number (auto-generated) */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Packing List Number</Label>
|
||||||
|
<Input
|
||||||
|
value={packingNumber || "Select a supplier..."}
|
||||||
|
readOnly
|
||||||
|
className="font-mono bg-muted text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Auto-generated: PL-EACH-YYYY_MM_DD-revision-SUPPLIER
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select Supplier */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Supplier</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedSupplierId}
|
||||||
|
onValueChange={setSelectedSupplierId}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a supplier..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(suppliers || []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name} ({s.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan Components */}
|
||||||
|
{selectedSupplierId && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label>Scan Component QR Code</Label>
|
||||||
|
<Input
|
||||||
|
ref={scanInputRef}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Scan QR code here..."
|
||||||
|
className="font-mono"
|
||||||
|
onKeyDown={handleScanKeyDown}
|
||||||
|
onChange={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Format: Component	PartNumber	Quantity (tab-separated via QR scan)
|
||||||
|
</p>
|
||||||
|
{scanStatus && (
|
||||||
|
<p className="text-xs font-medium text-primary">{scanStatus}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lineItems.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">#</TableHead>
|
||||||
|
<TableHead>Part Number</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lineItems.map((li, i) => (
|
||||||
|
<TableRow key={li.component_id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{li.part_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{li.component_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium">
|
||||||
|
{li.quantity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
removeLineItem(li.component_id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lineItems.length > 0 && (
|
||||||
|
<div className="flex justify-end text-sm text-muted-foreground">
|
||||||
|
{lineItems.length} component(s),{" "}
|
||||||
|
{lineItems.reduce((s, li) => s + li.quantity, 0)} total units
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={creating || !lineItems.length || !selectedSupplierId}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{creating
|
||||||
|
? "Creating..."
|
||||||
|
: `Create Packing List (${lineItems.length} components)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* View Detail Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!viewTarget}
|
||||||
|
onOpenChange={(open) => !open && setViewTarget(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-primary" />
|
||||||
|
{viewData?.packing_number || "Loading..."}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewData && (
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Supplier</span>
|
||||||
|
<p className="font-medium">{viewData.supplier_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Date</span>
|
||||||
|
<p>
|
||||||
|
{new Date(viewData.created_at).toLocaleDateString("en-GB")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{viewData.notes && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-muted-foreground">Notes</span>
|
||||||
|
<p>{viewData.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">
|
||||||
|
Components ({viewData.items?.length || 0})
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-60 overflow-y-auto rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">#</TableHead>
|
||||||
|
<TableHead>Part Number</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead>Used In Products</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(viewData.items || []).map(
|
||||||
|
(item: SupplierPackingItem, i: number) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{item.part_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{item.component_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono font-medium">
|
||||||
|
{Number(item.quantity)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{item.used_in_products || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setViewTarget(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleDownloadPdf(viewData.id)}>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Delete Supplier Packing List?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete packing list{" "}
|
||||||
|
<strong>{deleteTarget?.packing_number}</strong> and all its
|
||||||
|
component line items. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
ThemeProvider as NextThemesProvider,
|
||||||
|
type ThemeProviderProps,
|
||||||
|
} from 'next-themes'
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AccordionItem.displayName = 'AccordionItem'
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
))
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
))
|
||||||
|
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = 'AlertTitle'
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = 'AlertDescription'
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn('aspect-square h-full w-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<'nav'> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = 'Breadcrumb'
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<'ol'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = 'BreadcrumbList'
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<'li'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn('inline-flex items-center gap-1.5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = 'BreadcrumbItem'
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<'a'> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'a'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn('transition-colors hover:text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = 'BreadcrumbLink'
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<'span'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn('font-normal text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = 'BreadcrumbPage'
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('[&>svg]:w-3.5 [&>svg]:h-3.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { DayPicker } from 'react-day-picker'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn('p-3', className)}
|
||||||
|
classNames={{
|
||||||
|
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||||
|
month: 'space-y-4',
|
||||||
|
caption: 'flex justify-center pt-1 relative items-center',
|
||||||
|
caption_label: 'text-sm font-medium',
|
||||||
|
nav: 'space-x-1 flex items-center',
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
),
|
||||||
|
nav_button_previous: 'absolute left-1',
|
||||||
|
nav_button_next: 'absolute right-1',
|
||||||
|
table: 'w-full border-collapse space-y-1',
|
||||||
|
head_row: 'flex',
|
||||||
|
head_cell:
|
||||||
|
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||||
|
row: 'flex w-full mt-2',
|
||||||
|
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
|
||||||
|
),
|
||||||
|
day_range_end: 'day-range-end',
|
||||||
|
day_selected:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||||
|
day_today: 'bg-accent text-accent-foreground',
|
||||||
|
day_outside:
|
||||||
|
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||||
|
day_disabled: 'text-muted-foreground opacity-50',
|
||||||
|
day_range_middle:
|
||||||
|
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||||
|
day_hidden: 'invisible',
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = 'Calendar'
|
||||||
|
|
||||||
|
export { Calendar }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from 'embla-carousel-react'
|
||||||
|
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCarousel must be used within a <Carousel />')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
orientation = 'horizontal',
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api)
|
||||||
|
api.on('reInit', onSelect)
|
||||||
|
api.on('select', onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off('select', onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn('relative', className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Carousel.displayName = 'Carousel'
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselContent.displayName = 'CarouselContent'
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 grow-0 basis-full',
|
||||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselItem.displayName = 'CarouselItem'
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-8 w-8 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? '-left-12 top-1/2 -translate-y-1/2'
|
||||||
|
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselPrevious.displayName = 'CarouselPrevious'
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute h-8 w-8 rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? '-right-12 top-1/2 -translate-y-1/2'
|
||||||
|
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselNext.displayName = 'CarouselNext'
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as RechartsPrimitive from 'recharts'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>['children']
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartContainer.displayName = 'Chart'
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([_, config]) => config.theme || config.color,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed'
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === 'string'
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn('font-medium', labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||||
|
{
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||||
|
indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 justify-between leading-none',
|
||||||
|
nestLabel ? 'items-end' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ChartTooltipContent.displayName = 'ChartTooltip'
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-4',
|
||||||
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || 'value'}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ChartLegendContent.displayName = 'ChartLegend'
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
'payload' in payload &&
|
||||||
|
typeof payload.payload === 'object' &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === 'string'
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn('flex items-center justify-center text-current')}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = 'CommandShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold text-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = 'ContextMenuShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
aria-describedby={undefined}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Drawer = ({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
|
<DrawerPrimitive.Root
|
||||||
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Drawer.displayName = 'Drawer'
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/80', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
))
|
||||||
|
DrawerContent.displayName = 'DrawerContent'
|
||||||
|
|
||||||
|
const DrawerHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerHeader.displayName = 'DrawerHeader'
|
||||||
|
|
||||||
|
const DrawerFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerFooter.displayName = 'DrawerFooter'
|
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from 'react-hook-form'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error('useFormField should be used within <FormField>')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = 'FormItem'
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && 'text-destructive', className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = 'FormLabel'
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = 'FormControl'
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = 'FormDescription'
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn('text-sm font-medium text-destructive', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = 'FormMessage'
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { OTPInput, OTPInputContext } from 'input-otp'
|
||||||
|
import { Dot } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const InputOTP = React.forwardRef<
|
||||||
|
React.ElementRef<typeof OTPInput>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||||
|
>(({ className, containerClassName, ...props }, ref) => (
|
||||||
|
<OTPInput
|
||||||
|
ref={ref}
|
||||||
|
containerClassName={cn(
|
||||||
|
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
className={cn('disabled:cursor-not-allowed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTP.displayName = 'InputOTP'
|
||||||
|
|
||||||
|
const InputOTPGroup = React.forwardRef<
|
||||||
|
React.ElementRef<'div'>,
|
||||||
|
React.ComponentPropsWithoutRef<'div'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||||
|
))
|
||||||
|
InputOTPGroup.displayName = 'InputOTPGroup'
|
||||||
|
|
||||||
|
const InputOTPSlot = React.forwardRef<
|
||||||
|
React.ElementRef<'div'>,
|
||||||
|
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||||
|
>(({ index, className, ...props }, ref) => {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||||
|
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InputOTPSlot.displayName = 'InputOTPSlot'
|
||||||
|
|
||||||
|
const InputOTPSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<'div'>,
|
||||||
|
React.ComponentPropsWithoutRef<'div'>
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<div ref={ref} role="separator" {...props}>
|
||||||
|
<Dot />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
InputOTPSeparator.displayName = 'InputOTPSeparator'
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as MenubarPrimitive from '@radix-ui/react-menubar'
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const MenubarMenu = MenubarPrimitive.Menu
|
||||||
|
|
||||||
|
const MenubarGroup = MenubarPrimitive.Group
|
||||||
|
|
||||||
|
const MenubarPortal = MenubarPrimitive.Portal
|
||||||
|
|
||||||
|
const MenubarSub = MenubarPrimitive.Sub
|
||||||
|
|
||||||
|
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const Menubar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const MenubarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const MenubarContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<MenubarPrimitive.Portal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPrimitive.Portal>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const MenubarItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const MenubarCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const MenubarRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const MenubarLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const MenubarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const MenubarShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MenubarShortcut.displayname = 'MenubarShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarShortcut,
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const NavigationMenu = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex max-w-max flex-1 items-center justify-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
))
|
||||||
|
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const NavigationMenuList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-1 list-none items-center justify-center space-x-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
|
|
||||||
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
|
||||||
|
)
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{' '}
|
||||||
|
<ChevronDown
|
||||||
|
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const NavigationMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||||
|
|
||||||
|
const NavigationMenuViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className={cn('absolute left-0 top-full flex justify-center')}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
NavigationMenuViewport.displayName =
|
||||||
|
NavigationMenuPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
))
|
||||||
|
NavigationMenuIndicator.displayName =
|
||||||
|
NavigationMenuPrimitive.Indicator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ButtonProps, buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
className={cn('mx-auto flex w-full justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Pagination.displayName = 'Pagination'
|
||||||
|
|
||||||
|
const PaginationContent = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<'ul'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-row items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
PaginationContent.displayName = 'PaginationContent'
|
||||||
|
|
||||||
|
const PaginationItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<'li'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn('', className)} {...props} />
|
||||||
|
))
|
||||||
|
PaginationItem.displayName = 'PaginationItem'
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<ButtonProps, 'size'> &
|
||||||
|
React.ComponentProps<'a'>
|
||||||
|
|
||||||
|
const PaginationLink = ({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = 'icon',
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) => (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? 'outline' : 'ghost',
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
PaginationLink.displayName = 'PaginationLink'
|
||||||
|
|
||||||
|
const PaginationPrevious = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn('gap-1 pl-2.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationPrevious.displayName = 'PaginationPrevious'
|
||||||
|
|
||||||
|
const PaginationNext = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn('gap-1 pr-2.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationNext.displayName = 'PaginationNext'
|
||||||
|
|
||||||
|
const PaginationEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
PaginationEllipsis.displayName = 'PaginationEllipsis'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||||
|
import { Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn('grid gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { GripVertical } from 'lucide-react'
|
||||||
|
import * as ResizablePrimitive from 'react-resizable-panels'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical' &&
|
||||||
|
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||||
|
orientation === 'horizontal' &&
|
||||||
|
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom:
|
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = 'SheetHeader'
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = 'SheetFooter'
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import { PanelLeft } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = 'sidebar:state'
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = '16rem'
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||||
|
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||||
|
|
||||||
|
type SidebarContext = {
|
||||||
|
state: 'expanded' | 'collapsed'
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebar must be used within a SidebarProvider.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarProvider = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === 'function' ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile
|
||||||
|
? setOpenMobile((open) => !open)
|
||||||
|
: setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? 'expanded' : 'collapsed'
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContext>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SidebarProvider.displayName = 'SidebarProvider'
|
||||||
|
|
||||||
|
const Sidebar = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
side?: 'left' | 'right'
|
||||||
|
variant?: 'sidebar' | 'floating' | 'inset'
|
||||||
|
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
side = 'left',
|
||||||
|
variant = 'sidebar',
|
||||||
|
collapsible = 'offcanvas',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === 'none') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="group peer hidden md:block text-sidebar-foreground"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Sidebar.displayName = 'Sidebar'
|
||||||
|
|
||||||
|
const SidebarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Button>,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, onClick, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-7 w-7', className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarTrigger.displayName = 'SidebarTrigger'
|
||||||
|
|
||||||
|
const SidebarRail = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<'button'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
|
||||||
|
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
|
||||||
|
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||||
|
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
|
||||||
|
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||||
|
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarRail.displayName = 'SidebarRail'
|
||||||
|
|
||||||
|
const SidebarInset = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'main'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-h-svh flex-1 flex-col bg-background',
|
||||||
|
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInset.displayName = 'SidebarInset'
|
||||||
|
|
||||||
|
const SidebarInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Input>,
|
||||||
|
React.ComponentProps<typeof Input>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInput.displayName = 'SidebarInput'
|
||||||
|
|
||||||
|
const SidebarHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarHeader.displayName = 'SidebarHeader'
|
||||||
|
|
||||||
|
const SidebarFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarFooter.displayName = 'SidebarFooter'
|
||||||
|
|
||||||
|
const SidebarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Separator>,
|
||||||
|
React.ComponentProps<typeof Separator>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn('mx-2 w-auto bg-sidebar-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarSeparator.displayName = 'SidebarSeparator'
|
||||||
|
|
||||||
|
const SidebarContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarContent.displayName = 'SidebarContent'
|
||||||
|
|
||||||
|
const SidebarGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroup.displayName = 'SidebarGroup'
|
||||||
|
|
||||||
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'div'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupLabel.displayName = 'SidebarGroupLabel'
|
||||||
|
|
||||||
|
const SidebarGroupAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<'button'> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
'after:absolute after:-inset-2 after:md:hidden',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupAction.displayName = 'SidebarGroupAction'
|
||||||
|
|
||||||
|
const SidebarGroupContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn('w-full text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarGroupContent.displayName = 'SidebarGroupContent'
|
||||||
|
|
||||||
|
const SidebarMenu = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<'ul'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenu.displayName = 'SidebarMenu'
|
||||||
|
|
||||||
|
const SidebarMenuItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<'li'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn('group/menu-item relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuItem.displayName = 'SidebarMenuItem'
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 text-sm',
|
||||||
|
sm: 'h-7 text-xs',
|
||||||
|
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const SidebarMenuButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<'button'> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === 'string') {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== 'collapsed' || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SidebarMenuButton.displayName = 'SidebarMenuButton'
|
||||||
|
|
||||||
|
const SidebarMenuAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<'button'> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
'after:absolute after:-inset-2 after:md:hidden',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
showOnHover &&
|
||||||
|
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuAction.displayName = 'SidebarMenuAction'
|
||||||
|
|
||||||
|
const SidebarMenuBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
|
||||||
|
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuBadge.displayName = 'SidebarMenuBadge'
|
||||||
|
|
||||||
|
const SidebarMenuSkeleton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, showIcon = false, ...props }, ref) => {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--skeleton-width': width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton'
|
||||||
|
|
||||||
|
const SidebarMenuSub = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<'ul'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuSub.displayName = 'SidebarMenuSub'
|
||||||
|
|
||||||
|
const SidebarMenuSubItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<'li'>
|
||||||
|
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||||
|
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem'
|
||||||
|
|
||||||
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentProps<'a'> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'a'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||||
|
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||||
|
size === 'sm' && 'text-xs',
|
||||||
|
size === 'md' && 'text-sm',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user