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,8 @@
|
||||
import { neon } from "@neondatabase/serverless";
|
||||
|
||||
export function getDb() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL environment variable is not set");
|
||||
}
|
||||
return neon(process.env.DATABASE_URL);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function formatCurrency(
|
||||
value: number,
|
||||
options?: { decimals?: number }
|
||||
): string {
|
||||
return value.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: options?.decimals ?? 2,
|
||||
maximumFractionDigits: options?.decimals ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUnitPrice(value: number): string {
|
||||
return value.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import jsPDF from "jspdf";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
interface LabelData {
|
||||
partNumber: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/** Load an image from a URL and return its base64 data URL */
|
||||
async function loadImageAsBase64(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas not supported"));
|
||||
ctx.drawImage(img, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 49mm x 12mm label PDF with:
|
||||
* - Company icon (left, square)
|
||||
* - Code, Quantity, Count date (center)
|
||||
* - QR code (right) containing "PartNumber,Quantity"
|
||||
*/
|
||||
export async function generateLabelPdf(data: LabelData): Promise<jsPDF> {
|
||||
const labelW = 49; // mm
|
||||
const labelH = 12; // mm
|
||||
const contentLeft = 1; // small padding
|
||||
const contentRight = labelW - 1;
|
||||
|
||||
const doc = new jsPDF({
|
||||
orientation: "landscape",
|
||||
unit: "mm",
|
||||
format: [labelW, labelH],
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const countDate = `${String(now.getDate()).padStart(2, "0")}/${String(now.getMonth() + 1).padStart(2, "0")}/${now.getFullYear()}`;
|
||||
|
||||
// --- Icon (left side, square, filling most of the label height) ---
|
||||
const iconSize = 9;
|
||||
const iconX = contentLeft;
|
||||
const iconY = (labelH - iconSize) / 2;
|
||||
try {
|
||||
const iconBase64 = await loadImageAsBase64("/images/linse-icon.png");
|
||||
doc.addImage(iconBase64, "PNG", iconX, iconY, iconSize, iconSize);
|
||||
} catch {
|
||||
// If icon fails, skip
|
||||
}
|
||||
|
||||
// --- Text (center, maximised for 12mm height) ---
|
||||
const textX = iconX + iconSize + 1.5;
|
||||
|
||||
// Code
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text(`Code: ${data.partNumber}`, textX, 3.8);
|
||||
|
||||
// Quantity
|
||||
doc.text(`Quantity: ${data.quantity}`, textX, 7.2);
|
||||
|
||||
// Count date
|
||||
doc.setFontSize(5.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(`Count date: ${countDate}`, textX, 10.4);
|
||||
|
||||
// --- QR Code (right side, filling most of the label height) ---
|
||||
const qrContent = `${data.partNumber},${data.quantity}`;
|
||||
try {
|
||||
const qrDataUrl = await QRCode.toDataURL(qrContent, {
|
||||
width: 200,
|
||||
margin: 0,
|
||||
errorCorrectionLevel: "M",
|
||||
color: { dark: "#000000", light: "#ffffff" },
|
||||
});
|
||||
|
||||
const qrSize = 10;
|
||||
const qrX = contentRight - qrSize;
|
||||
const qrY = (labelH - qrSize) / 2;
|
||||
doc.addImage(qrDataUrl, "PNG", qrX, qrY, qrSize, qrSize);
|
||||
} catch {
|
||||
doc.setFontSize(4);
|
||||
doc.text("QR Error", contentRight - 10, labelH / 2);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import autoTable from "jspdf-autotable";
|
||||
import {
|
||||
createBrandedPdf,
|
||||
addBrandedFooter,
|
||||
COLORS,
|
||||
TABLE_HEAD_STYLES,
|
||||
TABLE_BODY_STYLES,
|
||||
TABLE_STYLES,
|
||||
} from "./pdf-template";
|
||||
|
||||
interface PackingPdfData {
|
||||
packing_number: string;
|
||||
created_at: string;
|
||||
po_number: string;
|
||||
customer_ref?: string | null;
|
||||
customer_name: string;
|
||||
customer_code: string;
|
||||
customer_email?: string | null;
|
||||
customer_phone?: string | null;
|
||||
customer_address?: string | null;
|
||||
customer_contact?: string | null;
|
||||
product_name: string;
|
||||
product_sku: string;
|
||||
quantity: number;
|
||||
notes?: string | null;
|
||||
serials: { serial_number: string }[];
|
||||
}
|
||||
|
||||
export function generatePackingPdf(data: PackingPdfData) {
|
||||
const { doc, contentY } = createBrandedPdf({
|
||||
title: "PACKING LIST",
|
||||
documentNumber: data.packing_number,
|
||||
date: new Date(data.created_at).toLocaleDateString("en-GB"),
|
||||
recipient: {
|
||||
label: "SHIP TO",
|
||||
name: data.customer_name,
|
||||
code: data.customer_code,
|
||||
contact: data.customer_contact,
|
||||
address: data.customer_address,
|
||||
phone: data.customer_phone,
|
||||
email: data.customer_email,
|
||||
},
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 14;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
// --- Order details row ---
|
||||
let y = contentY;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, y, contentWidth, 16, 1.5, 1.5, "FD");
|
||||
|
||||
const colWidth = contentWidth / 4;
|
||||
const labels = ["PO Number", "Customer Ref", "Product", "Qty Shipped"];
|
||||
const values = [
|
||||
data.po_number,
|
||||
data.customer_ref || "-",
|
||||
`${data.product_name} (${data.product_sku})`,
|
||||
String(data.quantity),
|
||||
];
|
||||
|
||||
labels.forEach((label, i) => {
|
||||
const x = margin + 4 + i * colWidth;
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(label, x, y + 5);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
const val = doc.splitTextToSize(values[i], colWidth - 8);
|
||||
doc.text(val[0], x, y + 11);
|
||||
});
|
||||
|
||||
y += 22;
|
||||
|
||||
// --- Serial Numbers section (clean list layout like PO Order Items) ---
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text("Serial Numbers", margin, y);
|
||||
y += 4;
|
||||
|
||||
const tableData = data.serials.map((s, i) => [String(i + 1), s.serial_number]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["#", "Serial Number"]],
|
||||
body: tableData,
|
||||
theme: "plain",
|
||||
headStyles: {
|
||||
...TABLE_HEAD_STYLES,
|
||||
fillColor: COLORS.bgLight,
|
||||
lineWidth: 0,
|
||||
},
|
||||
bodyStyles: {
|
||||
...TABLE_BODY_STYLES,
|
||||
font: "courier",
|
||||
lineWidth: 0,
|
||||
},
|
||||
alternateRowStyles: { fillColor: [250, 250, 250] },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 14, halign: "center" },
|
||||
1: { cellWidth: "auto" },
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
styles: {
|
||||
lineColor: COLORS.border,
|
||||
lineWidth: 0,
|
||||
cellPadding: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// --- Notes ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const finalY = (doc as any).lastAutoTable?.finalY || y + 30;
|
||||
if (data.notes) {
|
||||
const noteY = finalY + 6;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, noteY, contentWidth, 16, 1.5, 1.5, "FD");
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text("NOTES", margin + 4, noteY + 5);
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
const noteLines = doc.splitTextToSize(data.notes, contentWidth - 8);
|
||||
doc.text(noteLines, margin + 4, noteY + 11);
|
||||
}
|
||||
|
||||
addBrandedFooter(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import autoTable from "jspdf-autotable";
|
||||
import {
|
||||
createBrandedPdf,
|
||||
addBrandedFooter,
|
||||
COLORS,
|
||||
TABLE_HEAD_STYLES,
|
||||
TABLE_BODY_STYLES,
|
||||
TABLE_STYLES,
|
||||
} from "./pdf-template";
|
||||
|
||||
interface PoItem {
|
||||
product_name: string;
|
||||
product_sku: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
line_total: number;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface PoPdfData {
|
||||
po_number: string;
|
||||
order_date: string;
|
||||
due_date?: string | null;
|
||||
customer_ref?: string | null;
|
||||
status: string;
|
||||
customer_name: string;
|
||||
customer_code: string;
|
||||
customer_email?: string | null;
|
||||
customer_phone?: string | null;
|
||||
customer_address?: string | null;
|
||||
customer_contact?: string | null;
|
||||
notes?: string | null;
|
||||
items: PoItem[];
|
||||
}
|
||||
|
||||
function formatCurrency(val: number): string {
|
||||
return new Intl.NumberFormat("en-GB", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(val);
|
||||
}
|
||||
|
||||
export function generatePoPdf(data: PoPdfData) {
|
||||
const { doc, contentY } = createBrandedPdf({
|
||||
title: "PURCHASE ORDER",
|
||||
documentNumber: data.po_number,
|
||||
date: new Date(data.order_date).toLocaleDateString("en-GB"),
|
||||
recipient: {
|
||||
label: "BILL TO",
|
||||
name: data.customer_name,
|
||||
code: data.customer_code,
|
||||
contact: data.customer_contact,
|
||||
address: data.customer_address,
|
||||
phone: data.customer_phone,
|
||||
email: data.customer_email,
|
||||
},
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 14;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
// --- Order details row ---
|
||||
let y = contentY;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, y, contentWidth, 14, 1.5, 1.5, "FD");
|
||||
|
||||
const detailItems = [
|
||||
{ label: "Customer Ref", value: data.customer_ref || "-" },
|
||||
{ label: "Due Date", value: data.due_date ? new Date(data.due_date).toLocaleDateString("en-GB") : "-" },
|
||||
{ label: "Status", value: data.status.charAt(0).toUpperCase() + data.status.slice(1).replace("_", " ") },
|
||||
];
|
||||
|
||||
detailItems.forEach((item, i) => {
|
||||
const x = margin + 4 + i * (contentWidth / 3);
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(item.label, x, y + 5);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(item.value, x, y + 11);
|
||||
});
|
||||
|
||||
y += 20;
|
||||
|
||||
// --- Line Items table ---
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text("Order Items", margin, y);
|
||||
y += 4;
|
||||
|
||||
const totalValue = data.items.reduce((sum, item) => sum + item.line_total, 0);
|
||||
const totalQty = data.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
const tableData = data.items.map((item, i) => [
|
||||
String(i + 1),
|
||||
`${item.product_name}\n${item.product_sku}`,
|
||||
String(item.quantity),
|
||||
formatCurrency(item.unit_price),
|
||||
formatCurrency(item.line_total),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["#", "Product", "Qty", "Unit Price", "Total"]],
|
||||
body: tableData,
|
||||
foot: [["", "", String(totalQty), "", formatCurrency(totalValue)]],
|
||||
theme: "grid",
|
||||
headStyles: TABLE_HEAD_STYLES,
|
||||
bodyStyles: TABLE_BODY_STYLES,
|
||||
footStyles: {
|
||||
fillColor: [247, 247, 247],
|
||||
textColor: [23, 23, 23],
|
||||
fontSize: 8,
|
||||
fontStyle: "bold",
|
||||
cellPadding: 3,
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 12, halign: "center" },
|
||||
1: { cellWidth: "auto" },
|
||||
2: { cellWidth: 20, halign: "center" },
|
||||
3: { cellWidth: 30, halign: "right" },
|
||||
4: { cellWidth: 30, halign: "right" },
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
styles: TABLE_STYLES,
|
||||
});
|
||||
|
||||
// --- Grand Total box ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const finalY = (doc as any).lastAutoTable?.finalY || y + 30;
|
||||
const totalBoxY = finalY + 4;
|
||||
const totalBoxW = 60;
|
||||
const totalBoxX = pageWidth - margin - totalBoxW;
|
||||
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(totalBoxX, totalBoxY, totalBoxW, 12, 1.5, 1.5, "FD");
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("TOTAL", totalBoxX + 4, totalBoxY + 5);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(formatCurrency(totalValue), totalBoxX + totalBoxW - 4, totalBoxY + 9.5, {
|
||||
align: "right",
|
||||
});
|
||||
|
||||
// --- Notes ---
|
||||
if (data.notes) {
|
||||
const noteY = totalBoxY + 18;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, noteY, contentWidth, 16, 1.5, 1.5, "FD");
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text("NOTES", margin + 4, noteY + 5);
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
const noteLines = doc.splitTextToSize(data.notes, contentWidth - 8);
|
||||
doc.text(noteLines, margin + 4, noteY + 11);
|
||||
}
|
||||
|
||||
addBrandedFooter(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import autoTable from "jspdf-autotable";
|
||||
import {
|
||||
createBrandedPdf,
|
||||
addBrandedFooter,
|
||||
COLORS,
|
||||
TABLE_HEAD_STYLES,
|
||||
TABLE_BODY_STYLES,
|
||||
TABLE_STYLES,
|
||||
} from "./pdf-template";
|
||||
|
||||
interface SupplierPackingPdfItem {
|
||||
part_number: string;
|
||||
component_name: string;
|
||||
quantity: number;
|
||||
used_in_products: string | null;
|
||||
}
|
||||
|
||||
interface SupplierPackingPdfData {
|
||||
packing_number: string;
|
||||
created_at: string;
|
||||
supplier_name: string;
|
||||
supplier_code: string;
|
||||
supplier_email?: string | null;
|
||||
supplier_phone?: string | null;
|
||||
supplier_address?: string | null;
|
||||
supplier_contact?: string | null;
|
||||
notes?: string | null;
|
||||
items: SupplierPackingPdfItem[];
|
||||
}
|
||||
|
||||
export function generateSupplierPackingPdf(data: SupplierPackingPdfData) {
|
||||
const { doc, contentY } = createBrandedPdf({
|
||||
title: "SUPPLIER PACKING LIST",
|
||||
documentNumber: data.packing_number,
|
||||
date: new Date(data.created_at).toLocaleDateString("en-GB"),
|
||||
recipient: {
|
||||
label: "SUPPLIER",
|
||||
name: data.supplier_name,
|
||||
code: data.supplier_code,
|
||||
contact: data.supplier_contact,
|
||||
address: data.supplier_address,
|
||||
phone: data.supplier_phone,
|
||||
email: data.supplier_email,
|
||||
},
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 14;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
// --- Summary bar ---
|
||||
let y = contentY;
|
||||
const totalQty = data.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, y, contentWidth, 14, 1.5, 1.5, "FD");
|
||||
|
||||
const summaryItems = [
|
||||
{ label: "Total Components", value: String(data.items.length) },
|
||||
{ label: "Total Quantity", value: String(totalQty) },
|
||||
];
|
||||
|
||||
summaryItems.forEach((item, i) => {
|
||||
const x = margin + 4 + i * (contentWidth / 2);
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(item.label, x, y + 5);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(item.value, x, y + 11);
|
||||
});
|
||||
|
||||
y += 20;
|
||||
|
||||
// --- Components table ---
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text("Components", margin, y);
|
||||
y += 4;
|
||||
|
||||
const tableData = data.items.map((item, i) => [
|
||||
String(i + 1),
|
||||
item.part_number,
|
||||
item.component_name,
|
||||
String(item.quantity),
|
||||
item.used_in_products || "-",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["#", "Part Number", "Name", "Qty", "Used In Products"]],
|
||||
body: tableData,
|
||||
theme: "grid",
|
||||
headStyles: TABLE_HEAD_STYLES,
|
||||
bodyStyles: TABLE_BODY_STYLES,
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 12, halign: "center" },
|
||||
1: { cellWidth: 32, font: "courier" },
|
||||
2: { cellWidth: 45 },
|
||||
3: { cellWidth: 16, halign: "center" },
|
||||
4: { cellWidth: "auto" },
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
styles: TABLE_STYLES,
|
||||
});
|
||||
|
||||
// --- Notes ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const finalY = (doc as any).lastAutoTable?.finalY || y + 30;
|
||||
if (data.notes) {
|
||||
const noteY = finalY + 6;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, noteY, contentWidth, 16, 1.5, 1.5, "FD");
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text("NOTES", margin + 4, noteY + 5);
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
const noteLines = doc.splitTextToSize(data.notes, contentWidth - 8);
|
||||
doc.text(noteLines, margin + 4, noteY + 11);
|
||||
}
|
||||
|
||||
addBrandedFooter(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
createBrandedPdf,
|
||||
addBrandedFooter,
|
||||
COLORS,
|
||||
} from "./pdf-template";
|
||||
|
||||
interface SupplierPoItem {
|
||||
part_number: string;
|
||||
component_name: string;
|
||||
supplier_part_number?: string | null;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
interface SupplierPoPdfData {
|
||||
po_number: string;
|
||||
date: string;
|
||||
status: string;
|
||||
supplier_name: string;
|
||||
supplier_code: string;
|
||||
supplier_email?: string | null;
|
||||
supplier_phone?: string | null;
|
||||
supplier_address?: string | null;
|
||||
supplier_contact?: string | null;
|
||||
supplier_country?: string | null;
|
||||
supplier_currency?: string | null;
|
||||
shipping_cost?: number;
|
||||
notes?: string | null;
|
||||
items: SupplierPoItem[];
|
||||
}
|
||||
|
||||
function makeCurrencyFormatter(currency: string) {
|
||||
return (val: number): string =>
|
||||
new Intl.NumberFormat("en-GB", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(val);
|
||||
}
|
||||
|
||||
export function generateSupplierPoPdf(data: SupplierPoPdfData) {
|
||||
const { doc, contentY } = createBrandedPdf({
|
||||
title: "PURCHASE ORDER",
|
||||
documentNumber: data.po_number,
|
||||
date: new Date(data.date).toLocaleDateString("en-GB"),
|
||||
recipient: {
|
||||
label: "SUPPLIER",
|
||||
name: data.supplier_name,
|
||||
code: data.supplier_code,
|
||||
contact: data.supplier_contact,
|
||||
address: data.supplier_address,
|
||||
phone: data.supplier_phone,
|
||||
email: data.supplier_email,
|
||||
},
|
||||
});
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 14;
|
||||
const currency = data.supplier_currency || "EUR";
|
||||
const formatCurrency = makeCurrencyFormatter(currency);
|
||||
const shippingCost = data.shipping_cost || 0;
|
||||
const isPortugal = (data.supplier_country || "").toLowerCase().trim() === "portugal";
|
||||
|
||||
// --- Order Items Section ---
|
||||
let y = contentY;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
const rightEdge = pageWidth - margin;
|
||||
|
||||
const totalValue = data.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0);
|
||||
const totalQty = data.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
// Section header
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text("ORDER ITEMS", margin, y);
|
||||
|
||||
y += 5;
|
||||
|
||||
// Column headers -- no grid, just text labels
|
||||
const colX = {
|
||||
num: margin + 1,
|
||||
part: margin + 10,
|
||||
name: margin + 42,
|
||||
ref: margin + 105,
|
||||
qty: rightEdge - 54,
|
||||
price: rightEdge - 30,
|
||||
total: rightEdge,
|
||||
};
|
||||
|
||||
doc.setFontSize(6);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("#", colX.num, y);
|
||||
doc.text("PART NUMBER", colX.part, y);
|
||||
doc.text("COMPONENT", colX.name, y);
|
||||
doc.text("SUPPLIER REF", colX.ref, y);
|
||||
doc.text("QTY", colX.qty, y, { align: "right" });
|
||||
doc.text("UNIT PRICE", colX.price, y, { align: "right" });
|
||||
doc.text("TOTAL", colX.total, y, { align: "right" });
|
||||
|
||||
y += 1.5;
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(margin, y, rightEdge, y);
|
||||
y += 3;
|
||||
|
||||
// Item rows -- clean card-like rows with left accent
|
||||
const rowH = 9;
|
||||
data.items.forEach((item, i) => {
|
||||
const isAlt = i % 2 === 1;
|
||||
const rowY = y;
|
||||
|
||||
// Subtle alternating background
|
||||
if (isAlt) {
|
||||
doc.setFillColor(250, 250, 252);
|
||||
doc.rect(margin, rowY - 2.5, contentWidth, rowH, "F");
|
||||
}
|
||||
|
||||
// Left accent bar (blue)
|
||||
doc.setFillColor(...COLORS.accent);
|
||||
doc.rect(margin, rowY - 2.5, 1.2, rowH, "F");
|
||||
|
||||
// Row number
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(String(i + 1), colX.num + 2, rowY + 1.5);
|
||||
|
||||
// Part number -- monospaced feel
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("courier", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(item.part_number, colX.part, rowY + 1.5);
|
||||
|
||||
// Component name
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
const nameMaxW = colX.ref - colX.name - 3;
|
||||
const truncatedName = doc.getTextWidth(item.component_name) > nameMaxW
|
||||
? item.component_name.substring(0, Math.floor(item.component_name.length * nameMaxW / doc.getTextWidth(item.component_name))) + "..."
|
||||
: item.component_name;
|
||||
doc.text(truncatedName, colX.name, rowY + 1.5);
|
||||
|
||||
// Supplier ref
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(item.supplier_part_number || "-", colX.ref, rowY + 1.5);
|
||||
|
||||
// Quantity -- emphasized
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(String(item.quantity), colX.qty, rowY + 1.5, { align: "right" });
|
||||
|
||||
// Unit price
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
doc.text(formatCurrency(item.unit_price), colX.price, rowY + 1.5, { align: "right" });
|
||||
|
||||
// Line total
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(formatCurrency(item.quantity * item.unit_price), colX.total, rowY + 1.5, { align: "right" });
|
||||
|
||||
y += rowH;
|
||||
});
|
||||
|
||||
// Bottom separator
|
||||
y += 1;
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(margin, y, rightEdge, y);
|
||||
|
||||
// --- Summary section ---
|
||||
y += 5;
|
||||
|
||||
const summaryW = 75;
|
||||
const summaryX = rightEdge - summaryW;
|
||||
const labelX = summaryX + 4;
|
||||
const valueX = rightEdge - 4;
|
||||
const lineSpacing = 6;
|
||||
|
||||
// Subtotal
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("Subtotal", labelX, y);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(formatCurrency(totalValue), valueX, y, { align: "right" });
|
||||
y += lineSpacing;
|
||||
|
||||
// Shipping
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("Shipping", labelX, y);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(formatCurrency(shippingCost), valueX, y, { align: "right" });
|
||||
y += lineSpacing;
|
||||
|
||||
// VAT for Portugal
|
||||
let vatAmount = 0;
|
||||
if (isPortugal) {
|
||||
vatAmount = (totalValue + shippingCost) * 0.23;
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("VAT (23%)", labelX, y);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(formatCurrency(vatAmount), valueX, y, { align: "right" });
|
||||
y += lineSpacing;
|
||||
}
|
||||
|
||||
// Separator line
|
||||
y += 1;
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(summaryX, y, rightEdge, y);
|
||||
y += 4;
|
||||
|
||||
// Grand total box
|
||||
const grandTotal = totalValue + shippingCost + vatAmount;
|
||||
const totalBoxW = summaryW;
|
||||
const totalBoxX = summaryX;
|
||||
const totalBoxY = y - 3;
|
||||
|
||||
doc.setFillColor(...COLORS.accent);
|
||||
doc.roundedRect(totalBoxX, totalBoxY, totalBoxW, 12, 2, 2, "F");
|
||||
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(220, 230, 245);
|
||||
doc.text("TOTAL", totalBoxX + 5, totalBoxY + 5);
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text(formatCurrency(grandTotal), totalBoxX + totalBoxW - 5, totalBoxY + 9, { align: "right" });
|
||||
|
||||
const finalY = totalBoxY + 14;
|
||||
|
||||
// --- Notes ---
|
||||
if (data.notes) {
|
||||
const noteY = totalBoxY + 18;
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.roundedRect(margin, noteY, contentWidth, 16, 1.5, 1.5, "FD");
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text("NOTES", margin + 4, noteY + 5);
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
const noteLines = doc.splitTextToSize(data.notes, contentWidth - 8);
|
||||
doc.text(noteLines, margin + 4, noteY + 11);
|
||||
}
|
||||
|
||||
addBrandedFooter(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import jsPDF from "jspdf";
|
||||
|
||||
// Cropped & compressed logo (3KB, whitespace trimmed)
|
||||
export const EACH_LOGO_BASE64 = "data:image/png;base64,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";
|
||||
|
||||
// Company details
|
||||
export const COMPANY = {
|
||||
name: "EACH, Lda",
|
||||
address: "Alameda Silva Porto 11",
|
||||
postalCity: "1685 - 636 Famoes",
|
||||
vat: "PT513346325",
|
||||
};
|
||||
|
||||
// Professional colour palette -- warm neutral tones
|
||||
export const COLORS = {
|
||||
primary: [23, 23, 23] as [number, number, number], // near-black
|
||||
secondary: [64, 64, 64] as [number, number, number], // dark grey
|
||||
muted: [115, 115, 115] as [number, number, number], // medium grey
|
||||
border: [210, 210, 210] as [number, number, number], // light grey
|
||||
bgLight: [247, 247, 247] as [number, number, number], // off-white
|
||||
white: [255, 255, 255] as [number, number, number],
|
||||
accent: [230, 126, 34] as [number, number, number], // warm orange
|
||||
};
|
||||
|
||||
export interface RecipientInfo {
|
||||
label: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
contact?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface TemplateOptions {
|
||||
title: string;
|
||||
documentNumber: string;
|
||||
date: string;
|
||||
recipient: RecipientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a jsPDF document with a unified professional branded layout.
|
||||
* Returns the doc and the Y position where content can start.
|
||||
*/
|
||||
export function createBrandedPdf(
|
||||
options: TemplateOptions
|
||||
): { doc: jsPDF; contentY: number } {
|
||||
const doc = new jsPDF({ format: "a4" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 14;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
const rightEdge = pageWidth - margin;
|
||||
|
||||
// =============================================
|
||||
// TOP SECTION: Logo left | Company info right
|
||||
// =============================================
|
||||
|
||||
// Company info (top-right) -- 4 rows of 3.5mm line height starting at Y=10
|
||||
const companyStartY = 10;
|
||||
const lineH = 3.8;
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(COMPANY.name, rightEdge, companyStartY, { align: "right" });
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(COMPANY.address, rightEdge, companyStartY + lineH, { align: "right" });
|
||||
doc.text(COMPANY.postalCity, rightEdge, companyStartY + lineH * 2, { align: "right" });
|
||||
doc.setFontSize(7);
|
||||
doc.text(`VAT: ${COMPANY.vat}`, rightEdge, companyStartY + lineH * 3, { align: "right" });
|
||||
|
||||
// Logo (top-left, sized to match the 4 rows of company info)
|
||||
const logoH = lineH * 4;
|
||||
const logoW = logoH * 2.9;
|
||||
const logoY = companyStartY - 1;
|
||||
try {
|
||||
doc.addImage(EACH_LOGO_BASE64, "PNG", margin, logoY, logoW, logoH);
|
||||
} catch {
|
||||
doc.setFontSize(16);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text("EACH", margin, companyStartY + 5);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text("CONNECTING SOLUTIONS", margin, companyStartY + 10);
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// THIN SEPARATOR
|
||||
// =============================================
|
||||
const sepY = logoY + logoH + 4;
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.line(margin, sepY, rightEdge, sepY);
|
||||
|
||||
// =============================================
|
||||
// DOCUMENT TITLE -- large, prominent label
|
||||
// =============================================
|
||||
const titleY = sepY + 9;
|
||||
doc.setFontSize(18);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text(options.title, pageWidth / 2, titleY, { align: "center" });
|
||||
|
||||
// Accent bar below title
|
||||
const underY = titleY + 3;
|
||||
const titleWidth = doc.getTextWidth(options.title);
|
||||
const underX = (pageWidth - titleWidth) / 2 - 6;
|
||||
doc.setDrawColor(...COLORS.accent);
|
||||
doc.setLineWidth(0.8);
|
||||
doc.line(underX, underY, underX + titleWidth + 12, underY);
|
||||
|
||||
// =============================================
|
||||
// DOCUMENT NUMBER + DATE
|
||||
// =============================================
|
||||
const infoY = underY + 8;
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("No.", margin, infoY);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(options.documentNumber, margin + 8, infoY);
|
||||
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text("Date:", rightEdge - 24, infoY);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(options.date, rightEdge, infoY, { align: "right" });
|
||||
|
||||
// =============================================
|
||||
// RECIPIENT BOX
|
||||
// =============================================
|
||||
const boxY = infoY + 5;
|
||||
const { recipient } = options;
|
||||
|
||||
// Calculate dynamic box height based on content
|
||||
let boxContentH = 18; // minimum
|
||||
if (recipient.address) boxContentH += 4;
|
||||
if (recipient.phone) boxContentH += 3.5;
|
||||
if (recipient.email) boxContentH += 3.5;
|
||||
if (recipient.code) boxContentH += 3.5;
|
||||
|
||||
doc.setFillColor(...COLORS.bgLight);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.roundedRect(margin, boxY, contentWidth, boxContentH, 1.5, 1.5, "FD");
|
||||
|
||||
// Left column -- recipient identity
|
||||
const innerPad = 5;
|
||||
let ly = boxY + 5;
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text(recipient.label, margin + innerPad, ly);
|
||||
ly += 4;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
doc.text(recipient.name, margin + innerPad, ly);
|
||||
ly += 4.5;
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
if (recipient.code) {
|
||||
doc.text(`Code: ${recipient.code}`, margin + innerPad, ly);
|
||||
ly += 3.5;
|
||||
}
|
||||
|
||||
// Right column -- contact details
|
||||
const rightColX = margin + contentWidth / 2 + innerPad;
|
||||
let ry = boxY + 5;
|
||||
doc.setFontSize(6.5);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(...COLORS.accent);
|
||||
doc.text("CONTACT", rightColX, ry);
|
||||
ry += 4;
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.primary);
|
||||
if (recipient.contact) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(recipient.contact, rightColX, ry);
|
||||
doc.setFont("helvetica", "normal");
|
||||
ry += 3.5;
|
||||
}
|
||||
doc.setTextColor(...COLORS.secondary);
|
||||
if (recipient.address) {
|
||||
const addrLines = doc.splitTextToSize(recipient.address, contentWidth / 2 - innerPad * 2);
|
||||
doc.text(addrLines, rightColX, ry);
|
||||
ry += addrLines.length * 3.5;
|
||||
}
|
||||
if (recipient.phone) {
|
||||
doc.text(`Tel: ${recipient.phone}`, rightColX, ry);
|
||||
ry += 3.5;
|
||||
}
|
||||
if (recipient.email) {
|
||||
doc.text(recipient.email, rightColX, ry);
|
||||
ry += 3.5;
|
||||
}
|
||||
|
||||
const contentY = boxY + boxContentH + 6;
|
||||
return { doc, contentY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds page numbers as a minimal footer to all pages.
|
||||
*/
|
||||
export function addBrandedFooter(doc: jsPDF) {
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 14;
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setDrawColor(...COLORS.border);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(margin, pageHeight - 12, pageWidth - margin, pageHeight - 12);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(...COLORS.muted);
|
||||
doc.text(
|
||||
`Page ${i} of ${pageCount}`,
|
||||
pageWidth / 2,
|
||||
pageHeight - 7,
|
||||
{ align: "center" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared table head styles for a unified look across all documents.
|
||||
*/
|
||||
export const TABLE_HEAD_STYLES = {
|
||||
fillColor: [247, 247, 247] as [number, number, number],
|
||||
textColor: [23, 23, 23] as [number, number, number],
|
||||
fontSize: 7.5,
|
||||
fontStyle: "bold" as const,
|
||||
cellPadding: 2.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared table body styles.
|
||||
*/
|
||||
export const TABLE_BODY_STYLES = {
|
||||
fontSize: 7.5,
|
||||
cellPadding: 2,
|
||||
textColor: [64, 64, 64] as [number, number, number],
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared table border/grid styles.
|
||||
*/
|
||||
export const TABLE_STYLES = {
|
||||
lineColor: [210, 210, 210] as [number, number, number],
|
||||
lineWidth: 0.2,
|
||||
};
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ComponentSpecs {
|
||||
// All categories
|
||||
manufacturer_id?: string;
|
||||
// Resistors & Capacitors
|
||||
value?: string;
|
||||
power?: string;
|
||||
voltage?: string;
|
||||
tolerance?: string;
|
||||
// LEDs
|
||||
color?: string;
|
||||
// Connectors
|
||||
no_of_pins?: string;
|
||||
no_of_rows?: string;
|
||||
connector_type?: string; // "Female" | "Male"
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
id: number;
|
||||
part_number: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category_id: number | null;
|
||||
category_name?: string;
|
||||
manufacturer: string | null;
|
||||
package_type: string | null;
|
||||
unit_price: number;
|
||||
stock_quantity: number;
|
||||
min_stock_level: number;
|
||||
datasheet_url: string | null;
|
||||
supplier: string | null;
|
||||
supplier_part_number: string | null;
|
||||
shelf_location: string | null;
|
||||
is_internal: boolean;
|
||||
customer_supplied: boolean;
|
||||
customer_id: number | null;
|
||||
customer_name?: string;
|
||||
specs: ComponentSpecs;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
description: string | null;
|
||||
version: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bom_count?: number;
|
||||
total_cost?: number;
|
||||
}
|
||||
|
||||
export interface BomItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
component_id: number;
|
||||
quantity: number;
|
||||
reference_designator: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
component_name?: string;
|
||||
part_number?: string;
|
||||
unit_price?: number;
|
||||
stock_quantity?: number;
|
||||
category_name?: string;
|
||||
line_cost?: number;
|
||||
}
|
||||
|
||||
export interface StockTransaction {
|
||||
id: number;
|
||||
component_id: number;
|
||||
transaction_type: "purchase" | "adjustment" | "return" | "consumption";
|
||||
quantity: number;
|
||||
unit_cost: number | null;
|
||||
total_cost: number | null;
|
||||
supplier: string | null;
|
||||
supplier_order_ref: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
component_name?: string;
|
||||
part_number?: string;
|
||||
category_name?: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
contact_person: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
order_count?: number;
|
||||
total_revenue?: number;
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
po_number: string;
|
||||
customer_ref: string | null;
|
||||
status: "draft" | "confirmed" | "in_production" | "shipped" | "completed" | "cancelled";
|
||||
order_date: string;
|
||||
due_date: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
customer_name?: string;
|
||||
customer_code?: string;
|
||||
item_count?: number;
|
||||
total_value?: number;
|
||||
total_ordered?: number;
|
||||
total_shipped?: number;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
id: number;
|
||||
purchase_order_id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
product_name?: string;
|
||||
product_sku?: string;
|
||||
line_total?: number;
|
||||
shipped_quantity?: number;
|
||||
remaining_quantity?: number;
|
||||
}
|
||||
|
||||
export interface PackingList {
|
||||
id: number;
|
||||
purchase_order_id: number;
|
||||
po_item_id: number;
|
||||
packing_number: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
po_number?: string;
|
||||
customer_name?: string;
|
||||
customer_code?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
customer_address?: string;
|
||||
product_name?: string;
|
||||
product_sku?: string;
|
||||
serial_count?: number;
|
||||
}
|
||||
|
||||
export interface PackingListSerial {
|
||||
id: number;
|
||||
packing_list_id: number;
|
||||
serial_number: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
contact_person: string | null;
|
||||
country: string;
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupplierPackingList {
|
||||
id: number;
|
||||
supplier_id: number;
|
||||
packing_number: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
supplier_name?: string;
|
||||
supplier_code?: string;
|
||||
supplier_email?: string | null;
|
||||
supplier_phone?: string | null;
|
||||
supplier_address?: string | null;
|
||||
supplier_contact?: string | null;
|
||||
item_count?: number;
|
||||
total_quantity?: number;
|
||||
}
|
||||
|
||||
export interface SupplierPackingListItem {
|
||||
id: number;
|
||||
supplier_packing_list_id: number;
|
||||
component_id: number;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
part_number?: string;
|
||||
component_name?: string;
|
||||
used_in_products?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user