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

This commit is contained in:
2026-06-01 15:35:53 +01:00
parent a63625d228
commit 9c14a19a87
127 changed files with 22255 additions and 0 deletions
+8
View File
@@ -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);
}
+20
View File
@@ -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,
});
}
+97
View File
@@ -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;
}
+139
View File
@@ -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;
}
+178
View File
@@ -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;
}
+135
View File
@@ -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;
}
+264
View File
@@ -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;
}
+263
View File
@@ -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
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}