From ce21ad4a4c972de51d60ec00bb17d977d717a119 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 09:22:39 -0500 Subject: [PATCH] next slice --- AGENTS.md | 15 +- CHANGELOG.md | 32 ++ INSTRUCTIONS.md | 8 +- README.md | 23 +- ROADMAP.md | 21 +- STRUCTURE.md | 5 + UNRAID.md | 5 + client/src/lib/api.ts | 39 +++ .../modules/purchasing/PurchaseDetailPage.tsx | 31 ++ client/src/modules/sales/SalesDetailPage.tsx | 34 ++ server/src/modules/documents/router.ts | 293 ++++++++++++++++++ server/src/modules/purchasing/service.ts | 113 +++++++ server/src/modules/sales/service.ts | 113 +++++++ 13 files changed, 708 insertions(+), 24 deletions(-) create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 5e99f40..932ce26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,14 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a Read these before major work: +- [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) - [README.md](D:/CODING/mrp-codex/README.md) - [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md) - [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md) - [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) - [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) -If implementation changes invalidate those docs, update them in the same change set. +If implementation changes invalidate those docs, update them in the same change set. Keep `CHANGELOG.md` current for shipped features, behavior changes, and notable operational updates. ## Architecture rules @@ -113,11 +114,11 @@ If implementation changes invalidate those docs, update them in the same change Near-term priorities are: -1. Purchase receiving flow and vendor-side operational depth -2. Sales and purchasing PDF templates -3. Shipping labels, bills of lading, and logistics attachments -4. Projects and program management -5. Manufacturing execution +1. Shipping labels, bills of lading, and logistics attachments +2. Projects and program management +3. Manufacturing execution +4. Vendor invoice/supporting-document attachments and broader vendor-side operational depth +5. Sales approvals and document revision history When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. @@ -135,7 +136,7 @@ If you cannot run one of those checks, say so explicitly. ## Git and workflow expectations - Keep commits focused and source-only; do not commit generated local build artifacts -- Update roadmap/docs when major work shifts priorities or architecture +- Update roadmap/docs and `CHANGELOG.md` when major work shifts priorities, architecture, or shipped functionality - Do not remove or overwrite user changes without explicit instruction - If a task reveals a persistent operational issue, document it rather than leaving it tribal knowledge diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..042d841 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +This file is the running release and change log for MRP Codex. Keep it updated whenever shipped functionality, architecture expectations, deployment behavior, or operator-facing workflows materially change. + +## Unreleased + +### Added + +- Purchase receiving foundation tied to purchase orders, including warehouse/location receipt posting, receipt history, and per-line received and remaining quantity tracking +- Branded quote, sales-order, and purchase-order PDFs through the shared backend Puppeteer document pipeline + +### Changed + +- Purchasing detail workflows now support operational receiving directly from the purchase-order record +- Sales and purchasing detail pages now expose one-click PDF rendering actions +- Roadmap and project docs now treat shipping/logistics documents as the next active priority after receiving and commercial PDFs + +## 2026-03-14 + +### Added + +- Foundation release for auth/RBAC, company settings, CRM, inventory, sales, purchasing, shipping, attachments, and Docker deployment +- Inventory default-price support flowing into sales documents +- Purchase-order line restrictions to purchasable inventory items only +- Shipping foundation with sales-order linkage and packing-slip PDFs + +### Known follow-up areas + +- Shipping labels, bills of lading, and logistics attachments +- Vendor invoice/supporting-document attachments +- Sales approvals and document revision history +- Projects, manufacturing execution, and planning depth diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 3520636..34a8943 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -1,5 +1,10 @@ # Development Instructions +## Documentation maintenance + +- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated whenever shipped functionality, architecture expectations, deployment behavior, or user-facing workflows materially change. +- If a change invalidates [README.md](D:/CODING/mrp-codex/README.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), or [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), update those files in the same change set. + ## Current milestone This repository implements the platform foundation milestone: @@ -14,6 +19,7 @@ This repository implements the platform foundation milestone: - purchase orders with quick actions and searchable vendor/SKU entry - purchase orders restricted to inventory items flagged as purchasable - purchase receiving foundation with inventory posting and receipt history +- branded sales and purchasing PDFs through the shared Puppeteer document pipeline - shipping shipments linked to sales orders and packing-slip PDFs - Dockerized single-container deployment - Puppeteer PDF pipeline foundation @@ -50,10 +56,10 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- sales and purchasing document templates - shipping labels, bills of lading, and logistics attachments - projects and program management - manufacturing execution - vendor invoice/supporting-document attachments and broader vendor-side operational depth +- sales approvals and document revision history - planning and gantt scheduling with live project/manufacturing data - broader audit and operations maturity diff --git a/README.md b/README.md index c7797d0..ffcddc7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment. +## Documentation Maintenance + +- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated for shipped features, workflow changes, and notable operational updates. +- Keep [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) aligned when changes affect their scope. + Current foundation scope includes: - authentication and RBAC @@ -13,6 +18,7 @@ Current foundation scope includes: - sales quotes and sales orders with searchable customer and SKU entry - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - purchase receiving with warehouse/location posting and receipt history against purchase orders +- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline - shipping shipments linked to sales orders with packing-slip PDFs - file storage and PDF rendering @@ -35,18 +41,17 @@ Planned cross-module execution areas: Near-term priorities: -1. Sales and purchasing PDF templates -2. Shipping labels, bills of lading, and logistics attachments -3. Projects and program management -4. Manufacturing execution -5. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth +1. Shipping labels, bills of lading, and logistics attachments +2. Projects and program management +3. Manufacturing execution +4. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth +5. Sales approvals and revision history Revisit / deferred items: - local Windows Prisma migration reliability - frontend code-splitting and bundle-size reduction - sales approvals and revision history -- broader branded PDFs beyond company profile and packing slips - inventory transfers, reservations, and deeper stock controls - deeper audit-trail coverage - projects are not yet first-class records even though planning/manufacturing flows will need them @@ -205,6 +210,7 @@ The current sales foundation supports: - status quick actions directly from quote and order detail pages - quote conversion into a sales order - line-level unit prices populated from the selected inventory item default price +- branded quote and sales-order PDFs through the shared document pipeline QOL direction: @@ -226,11 +232,11 @@ The current purchasing foundation supports: - document-level tax, freight, subtotal, and total calculations - quick status actions directly from purchase-order detail pages - vendor payment terms and currency surfaced on purchase-order forms and details +- branded purchase-order PDFs through the shared document pipeline QOL direction: - vendor invoice/supporting-document attachments -- purchasing PDFs through the shared document pipeline - richer dashboard widgets for vendor queues and inbound material exceptions This module introduces `purchasing.read` and `purchasing.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role. @@ -293,6 +299,7 @@ As of March 14, 2026, the latest committed domain migrations include: - sales quote and sales-order foundation - purchase-order foundation - purchase receiving foundation +- branded sales and purchasing PDF templates - inventory default price support - sales totals and commercial fields - shipping foundation @@ -309,4 +316,4 @@ Recent roadmap-driving migrations should always be applied before validating new ## PDF Generation -Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview and shipment packing slips. The Docker image includes Chromium runtime dependencies required for headless execution. +Puppeteer is used by the backend to render HTML templates into professional PDFs. The current PDF surface includes the branded company-profile preview, sales quotes, sales orders, purchase orders, and shipment packing slips. The Docker image includes Chromium runtime dependencies required for headless execution. diff --git a/ROADMAP.md b/ROADMAP.md index ef4916e..d908862 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # Roadmap +## Documentation maintenance + +- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated alongside roadmap-driving feature completion, priority shifts, and notable delivery milestones. +- When roadmap changes affect implementation guidance or deployment expectations, update the companion docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [STRUCTURE.md](D:/CODING/mrp-codex/STRUCTURE.md), and [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md) in the same change set. + ## Product direction MRP Codex is being built as a streamlined, modular manufacturing resource planning platform with strong branding controls, fast operational workflows, and a single-container deployment model that is simple to back up and upgrade. @@ -31,6 +36,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Purchase orders with vendor lookup, item lines, totals, and quick status actions - Purchase-order line selection restricted to inventory items flagged as purchasable - Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking +- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline - Shipping shipment records linked to sales orders - Packing-slip PDF rendering for shipments - SKU-searchable BOM component selection for inventory-scale datasets @@ -46,7 +52,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution - The frontend bundle is functional but should be code-split later, especially around the gantt module - CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later -- The current sales/purchasing/shipping foundation still does not include approvals, revisions, shipment labels, vendor-side attachment handling, or broader branded transactional PDF coverage beyond packing slips +- The current sales/purchasing/shipping foundation still does not include approvals, revisions, shipment labels, or vendor-side attachment handling - The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added ## Dashboard Plan @@ -224,8 +230,7 @@ QOL subfeatures: - Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper - Frontend bundle splitting is still deferred; the Vite chunk-size warning remains - Sales approvals and document revision history were planned but not yet built -- Broader branded PDFs for quotes and sales orders still need to be added -- Purchasing PDFs and vendor invoice/supporting-document attachments still need to be added +- Vendor invoice/supporting-document attachments still need to be added - Shipping is now linked to sales orders, but labels, bills of lading, and logistics attachments are still pending - Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up - CRM document rollups and broader account-role depth were deferred until more downstream modules exist @@ -246,8 +251,8 @@ QOL subfeatures: ## Near-term priority order -1. Sales and purchasing PDF templates -2. Shipping labels, bills of lading, and logistics attachments -3. Projects and program management -4. Manufacturing execution -5. Vendor invoice/supporting-document attachments and broader vendor-side operational depth +1. Shipping labels, bills of lading, and logistics attachments +2. Projects and program management +3. Manufacturing execution +4. Vendor invoice/supporting-document attachments and broader vendor-side operational depth +5. Sales approvals and document revision history diff --git a/STRUCTURE.md b/STRUCTURE.md index 3fabec6..f5ad246 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -1,5 +1,10 @@ # Project Structure +## Documentation maintenance + +- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when structural or implementation changes materially affect shipped behavior. +- If structure guidance changes, update the related source-of-truth docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md), [UNRAID.md](D:/CODING/mrp-codex/UNRAID.md), and [AGENTS.md](D:/CODING/mrp-codex/AGENTS.md) as needed. + ## Top-level layout - `client/`: frontend application diff --git a/UNRAID.md b/UNRAID.md index 9c47cd0..c7f26f9 100644 --- a/UNRAID.md +++ b/UNRAID.md @@ -1,5 +1,10 @@ # Unraid Install Guide +## Documentation maintenance + +- Keep [CHANGELOG.md](D:/CODING/mrp-codex/CHANGELOG.md) updated when deployment behavior, startup flow, persistence expectations, or operator-facing install steps materially change. +- If Unraid deployment guidance changes, update the companion project docs in [README.md](D:/CODING/mrp-codex/README.md), [INSTRUCTIONS.md](D:/CODING/mrp-codex/INSTRUCTIONS.md), and [ROADMAP.md](D:/CODING/mrp-codex/ROADMAP.md) in the same change set when relevant. + ## Purpose This guide explains how to deploy MRP Codex on an Unraid server using the Unraid Docker GUI rather than command-line Docker management. diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 611e9e4..9a4e655 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -521,6 +521,45 @@ export const api = { return response.blob(); }, + async getQuotePdf(token: string, quoteId: string) { + const response = await fetch(`/api/v1/documents/sales/quotes/${quoteId}/document.pdf`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new ApiError("Unable to render quote PDF.", "QUOTE_PDF_FAILED"); + } + + return response.blob(); + }, + async getSalesOrderPdf(token: string, orderId: string) { + const response = await fetch(`/api/v1/documents/sales/orders/${orderId}/document.pdf`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new ApiError("Unable to render sales order PDF.", "SALES_ORDER_PDF_FAILED"); + } + + return response.blob(); + }, + async getPurchaseOrderPdf(token: string, orderId: string) { + const response = await fetch(`/api/v1/documents/purchasing/orders/${orderId}/document.pdf`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new ApiError("Unable to render purchase order PDF.", "PURCHASE_ORDER_PDF_FAILED"); + } + + return response.blob(); + }, async getCompanyProfilePreviewPdf(token: string) { const response = await fetch("/api/v1/documents/company-profile-preview.pdf", { headers: { diff --git a/client/src/modules/purchasing/PurchaseDetailPage.tsx b/client/src/modules/purchasing/PurchaseDetailPage.tsx index b58c382..3e71dc8 100644 --- a/client/src/modules/purchasing/PurchaseDetailPage.tsx +++ b/client/src/modules/purchasing/PurchaseDetailPage.tsx @@ -21,6 +21,7 @@ export function PurchaseDetailPage() { const [isSavingReceipt, setIsSavingReceipt] = useState(false); const [status, setStatus] = useState("Loading purchase order..."); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [isOpeningPdf, setIsOpeningPdf] = useState(false); const canManage = user?.permissions.includes("purchasing.write") ?? false; const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false); @@ -158,6 +159,28 @@ export function PurchaseDetailPage() { } } + async function handleOpenPdf() { + if (!token) { + return; + } + + setIsOpeningPdf(true); + setStatus("Rendering purchase order PDF..."); + + try { + const blob = await api.getPurchaseOrderPdf(token, activeDocument.id); + const objectUrl = URL.createObjectURL(blob); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + setStatus("Purchase order PDF ready."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to render purchase order PDF."; + setStatus(message); + } finally { + setIsOpeningPdf(false); + } + } + return (
@@ -174,6 +197,14 @@ export function PurchaseDetailPage() { Back to purchase orders + {canManage ? ( Edit purchase order diff --git a/client/src/modules/sales/SalesDetailPage.tsx b/client/src/modules/sales/SalesDetailPage.tsx index a3c3787..584512e 100644 --- a/client/src/modules/sales/SalesDetailPage.tsx +++ b/client/src/modules/sales/SalesDetailPage.tsx @@ -20,6 +20,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isConverting, setIsConverting] = useState(false); + const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [shipments, setShipments] = useState([]); const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; @@ -93,6 +94,31 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { } } + async function handleOpenPdf() { + if (!token) { + return; + } + + setIsOpeningPdf(true); + setStatus(`Rendering ${config.singularLabel.toLowerCase()} PDF...`); + + try { + const blob = + entity === "quote" + ? await api.getQuotePdf(token, activeDocument.id) + : await api.getSalesOrderPdf(token, activeDocument.id); + const objectUrl = URL.createObjectURL(blob); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + setStatus(`${config.singularLabel} PDF ready.`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : `Unable to render ${config.singularLabel.toLowerCase()} PDF.`; + setStatus(message); + } finally { + setIsOpeningPdf(false); + } + } + return (
@@ -109,6 +135,14 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { Back to {config.collectionLabel.toLowerCase()} + {canManage ? ( <> diff --git a/server/src/modules/documents/router.ts b/server/src/modules/documents/router.ts index 4b674f4..4362dfc 100644 --- a/server/src/modules/documents/router.ts +++ b/server/src/modules/documents/router.ts @@ -3,6 +3,8 @@ import { Router } from "express"; import { renderPdf } from "../../lib/pdf.js"; import { requirePermissions } from "../../lib/rbac.js"; +import { getPurchaseOrderPdfData } from "../purchasing/service.js"; +import { getSalesDocumentPdfData } from "../sales/service.js"; import { getShipmentPackingSlipData } from "../shipping/service.js"; import { getActiveCompanyProfile } from "../settings/service.js"; @@ -17,6 +19,123 @@ function escapeHtml(value: string) { .replaceAll("'", "'"); } +function formatDate(value: string | null | undefined) { + return value ? new Date(value).toLocaleDateString() : "N/A"; +} + +function buildAddressLines(record: { + name: string; + addressLine1: string; + addressLine2: string; + city: string; + state: string; + postalCode: string; + country: string; +}) { + return [ + record.name, + record.addressLine1, + record.addressLine2, + `${record.city}, ${record.state} ${record.postalCode}`.trim(), + record.country, + ].filter((line) => line.trim().length > 0); +} + +function renderCommercialDocumentPdf(options: { + company: Awaited>; + title: string; + documentNumber: string; + issueDate: string; + status: string; + partyTitle: string; + partyLines: string[]; + partyMeta: Array<{ label: string; value: string }>; + documentMeta: Array<{ label: string; value: string }>; + rows: string; + totalsRows: string; + notes: string; +}) { + const { company } = options; + + return renderPdf(` + + + + + +
+
+
+

${escapeHtml(company.companyName)}

+

${escapeHtml(company.addressLine1)}${company.addressLine2 ? `
${escapeHtml(company.addressLine2)}` : ""}
${escapeHtml(company.city)}, ${escapeHtml(company.state)} ${escapeHtml(company.postalCode)}
${escapeHtml(company.country)}

+
+
+
Document
${escapeHtml(options.title)}
+
Number
${escapeHtml(options.documentNumber)}
+
Issue Date
${escapeHtml(formatDate(options.issueDate))}
+
Status
${escapeHtml(options.status)}
+ ${options.documentMeta.map((entry) => `
${escapeHtml(entry.label)}
${escapeHtml(entry.value)}
`).join("")} +
+
+
+
+
${escapeHtml(options.partyTitle)}
+
${options.partyLines.map((line) => `
${escapeHtml(line)}
`).join("")}
+
+
+
Contact
+
${options.partyMeta.map((entry) => `
${escapeHtml(entry.label)}: ${escapeHtml(entry.value)}
`).join("")}
+
+
+ + + + + + + + + + + + ${options.rows} +
SKUDescriptionQtyUOMUnitLine Total
+
+ + ${options.totalsRows} +
+
+
Notes
${escapeHtml(options.notes || "No notes recorded for this document.")}
+
+ + + `); +} + documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => { const profile = await getActiveCompanyProfile(); const pdf = await renderPdf(` @@ -58,6 +177,180 @@ documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissi return response.send(pdf); }); +documentsRouter.get( + "/sales/quotes/:quoteId/document.pdf", + requirePermissions([permissions.salesRead]), + async (request, response) => { + const quoteId = typeof request.params.quoteId === "string" ? request.params.quoteId : null; + if (!quoteId) { + response.status(400); + return response.send("Invalid quote id."); + } + + const [profile, quote] = await Promise.all([getActiveCompanyProfile(), getSalesDocumentPdfData("QUOTE", quoteId)]); + if (!quote) { + response.status(404); + return response.send("Quote was not found."); + } + + const rows = quote.lines.map((line) => ` + + ${escapeHtml(line.itemSku)} +
${escapeHtml(line.itemName)}
${escapeHtml(line.description || "")}
+ ${line.quantity} + ${escapeHtml(line.unitOfMeasure)} + $${line.unitPrice.toFixed(2)} + $${line.lineTotal.toFixed(2)} + + `).join(""); + + const pdf = await renderCommercialDocumentPdf({ + company: profile, + title: "Sales Quote", + documentNumber: quote.documentNumber, + issueDate: quote.issueDate, + status: quote.status, + partyTitle: "Bill To", + partyLines: buildAddressLines(quote.customer), + partyMeta: [ + { label: "Email", value: quote.customer.email || "Not set" }, + { label: "Phone", value: quote.customer.phone || "Not set" }, + ], + documentMeta: [ + { label: "Expires", value: formatDate(quote.expiresAt) }, + ], + rows, + totalsRows: ` + Subtotal$${quote.subtotal.toFixed(2)} + Discount (${quote.discountPercent.toFixed(2)}%)-$${quote.discountAmount.toFixed(2)} + Tax (${quote.taxPercent.toFixed(2)}%)$${quote.taxAmount.toFixed(2)} + Freight$${quote.freightAmount.toFixed(2)} + Total$${quote.total.toFixed(2)} + `, + notes: quote.notes, + }); + + response.setHeader("Content-Type", "application/pdf"); + response.setHeader("Content-Disposition", `inline; filename=${quote.documentNumber.toLowerCase()}-quote.pdf`); + return response.send(pdf); + } +); + +documentsRouter.get( + "/sales/orders/:orderId/document.pdf", + requirePermissions([permissions.salesRead]), + async (request, response) => { + const orderId = typeof request.params.orderId === "string" ? request.params.orderId : null; + if (!orderId) { + response.status(400); + return response.send("Invalid sales order id."); + } + + const [profile, order] = await Promise.all([getActiveCompanyProfile(), getSalesDocumentPdfData("ORDER", orderId)]); + if (!order) { + response.status(404); + return response.send("Sales order was not found."); + } + + const rows = order.lines.map((line) => ` + + ${escapeHtml(line.itemSku)} +
${escapeHtml(line.itemName)}
${escapeHtml(line.description || "")}
+ ${line.quantity} + ${escapeHtml(line.unitOfMeasure)} + $${line.unitPrice.toFixed(2)} + $${line.lineTotal.toFixed(2)} + + `).join(""); + + const pdf = await renderCommercialDocumentPdf({ + company: profile, + title: "Sales Order", + documentNumber: order.documentNumber, + issueDate: order.issueDate, + status: order.status, + partyTitle: "Bill To", + partyLines: buildAddressLines(order.customer), + partyMeta: [ + { label: "Email", value: order.customer.email || "Not set" }, + { label: "Phone", value: order.customer.phone || "Not set" }, + ], + documentMeta: [], + rows, + totalsRows: ` + Subtotal$${order.subtotal.toFixed(2)} + Discount (${order.discountPercent.toFixed(2)}%)-$${order.discountAmount.toFixed(2)} + Tax (${order.taxPercent.toFixed(2)}%)$${order.taxAmount.toFixed(2)} + Freight$${order.freightAmount.toFixed(2)} + Total$${order.total.toFixed(2)} + `, + notes: order.notes, + }); + + response.setHeader("Content-Type", "application/pdf"); + response.setHeader("Content-Disposition", `inline; filename=${order.documentNumber.toLowerCase()}-sales-order.pdf`); + return response.send(pdf); + } +); + +documentsRouter.get( + "/purchasing/orders/:orderId/document.pdf", + requirePermissions([permissions.purchasingRead]), + async (request, response) => { + const orderId = typeof request.params.orderId === "string" ? request.params.orderId : null; + if (!orderId) { + response.status(400); + return response.send("Invalid purchase order id."); + } + + const [profile, order] = await Promise.all([getActiveCompanyProfile(), getPurchaseOrderPdfData(orderId)]); + if (!order) { + response.status(404); + return response.send("Purchase order was not found."); + } + + const rows = order.lines.map((line) => ` + + ${escapeHtml(line.itemSku)} +
${escapeHtml(line.itemName)}
${escapeHtml(line.description || "")}
+ ${line.quantity} + ${escapeHtml(line.unitOfMeasure)} + $${line.unitCost.toFixed(2)} + $${line.lineTotal.toFixed(2)} + + `).join(""); + + const pdf = await renderCommercialDocumentPdf({ + company: profile, + title: "Purchase Order", + documentNumber: order.documentNumber, + issueDate: order.issueDate, + status: order.status, + partyTitle: "Vendor", + partyLines: buildAddressLines(order.vendor), + partyMeta: [ + { label: "Email", value: order.vendor.email || "Not set" }, + { label: "Phone", value: order.vendor.phone || "Not set" }, + { label: "Terms", value: order.vendor.paymentTerms || "Not set" }, + { label: "Currency", value: order.vendor.currencyCode || "USD" }, + ], + documentMeta: [], + rows, + totalsRows: ` + Subtotal$${order.subtotal.toFixed(2)} + Tax (${order.taxPercent.toFixed(2)}%)$${order.taxAmount.toFixed(2)} + Freight$${order.freightAmount.toFixed(2)} + Total$${order.total.toFixed(2)} + `, + notes: order.notes, + }); + + response.setHeader("Content-Type", "application/pdf"); + response.setHeader("Content-Disposition", `inline; filename=${order.documentNumber.toLowerCase()}-purchase-order.pdf`); + return response.send(pdf); + } +); + documentsRouter.get( "/shipping/shipments/:shipmentId/packing-slip.pdf", requirePermissions([permissions.shippingRead]), diff --git a/server/src/modules/purchasing/service.ts b/server/src/modules/purchasing/service.ts index 8943452..14f3513 100644 --- a/server/src/modules/purchasing/service.ts +++ b/server/src/modules/purchasing/service.ts @@ -6,6 +6,42 @@ import { prisma } from "../../lib/prisma.js"; const purchaseOrderModel = prisma.purchaseOrder; +export interface PurchaseOrderPdfData { + documentNumber: string; + status: PurchaseOrderStatus; + issueDate: string; + vendor: { + name: string; + email: string; + phone: string; + addressLine1: string; + addressLine2: string; + city: string; + state: string; + postalCode: string; + country: string; + paymentTerms: string | null; + currencyCode: string | null; + }; + notes: string; + subtotal: number; + taxPercent: number; + taxAmount: number; + freightAmount: number; + total: number; + lines: Array<{ + itemSku: string; + itemName: string; + description: string; + quantity: number; + receivedQuantity: number; + remainingQuantity: number; + unitOfMeasure: string; + unitCost: number; + lineTotal: number; + }>; +} + type PurchaseLineRecord = { id: string; description: string; @@ -642,3 +678,80 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe const detail = await getPurchaseOrderById(orderId); return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." }; } + +export async function getPurchaseOrderPdfData(orderId: string): Promise { + const order = await prisma.purchaseOrder.findUnique({ + where: { id: orderId }, + include: { + vendor: { + select: { + name: true, + email: true, + phone: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, + country: true, + paymentTerms: true, + currencyCode: true, + }, + }, + lines: { + include: { + item: { + select: { + sku: true, + name: true, + }, + }, + receiptLines: { + select: { + quantity: true, + }, + }, + }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }, + }, + }); + + if (!order) { + return null; + } + + const lines = order.lines.map((line) => { + const receivedQuantity = line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0); + return { + itemSku: line.item.sku, + itemName: line.item.name, + description: line.description, + quantity: line.quantity, + receivedQuantity, + remainingQuantity: Math.max(0, line.quantity - receivedQuantity), + unitOfMeasure: line.unitOfMeasure, + unitCost: line.unitCost, + lineTotal: line.quantity * line.unitCost, + }; + }); + const totals = calculateTotals( + lines.reduce((sum, line) => sum + line.lineTotal, 0), + order.taxPercent, + order.freightAmount + ); + + return { + documentNumber: order.documentNumber, + status: order.status as PurchaseOrderStatus, + issueDate: order.issueDate.toISOString(), + vendor: order.vendor, + notes: order.notes, + subtotal: totals.subtotal, + taxPercent: totals.taxPercent, + taxAmount: totals.taxAmount, + freightAmount: totals.freightAmount, + total: totals.total, + lines, + }; +} diff --git a/server/src/modules/sales/service.ts b/server/src/modules/sales/service.ts index f956ee1..90fc53e 100644 --- a/server/src/modules/sales/service.ts +++ b/server/src/modules/sales/service.ts @@ -10,6 +10,42 @@ import type { import { prisma } from "../../lib/prisma.js"; +export interface SalesDocumentPdfData { + type: SalesDocumentType; + documentNumber: string; + status: SalesDocumentStatus; + issueDate: string; + expiresAt: string | null; + customer: { + name: string; + email: string; + phone: string; + addressLine1: string; + addressLine2: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + notes: string; + subtotal: number; + discountPercent: number; + discountAmount: number; + taxPercent: number; + taxAmount: number; + freightAmount: number; + total: number; + lines: Array<{ + itemSku: string; + itemName: string; + description: string; + quantity: number; + unitOfMeasure: string; + unitPrice: number; + lineTotal: number; + }>; +} + type SalesLineRecord = { id: string; description: string; @@ -475,3 +511,80 @@ export async function convertQuoteToSalesOrder(quoteId: string) { const order = await getSalesDocumentById("ORDER", created.id); return order ? { ok: true as const, document: order } : { ok: false as const, reason: "Unable to load converted sales order." }; } + +export async function getSalesDocumentPdfData(type: SalesDocumentType, documentId: string): Promise { + const include = { + customer: { + select: { + name: true, + email: true, + phone: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + postalCode: true, + country: true, + }, + }, + lines: { + include: { + item: { + select: { + sku: true, + name: true, + }, + }, + }, + orderBy: [{ position: "asc" as const }, { createdAt: "asc" as const }], + }, + }; + const record = + type === "QUOTE" + ? await prisma.salesQuote.findUnique({ + where: { id: documentId }, + include, + }) + : await prisma.salesOrder.findUnique({ + where: { id: documentId }, + include, + }); + + if (!record) { + return null; + } + + const lines = record.lines.map((line: { item: { sku: string; name: string }; description: string; quantity: number; unitOfMeasure: string; unitPrice: number }) => ({ + itemSku: line.item.sku, + itemName: line.item.name, + description: line.description, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure, + unitPrice: line.unitPrice, + lineTotal: line.quantity * line.unitPrice, + })); + const totals = calculateTotals( + lines.reduce((sum: number, line: SalesDocumentPdfData["lines"][number]) => sum + line.lineTotal, 0), + record.discountPercent, + record.taxPercent, + record.freightAmount + ); + + return { + type, + documentNumber: record.documentNumber, + status: record.status as SalesDocumentStatus, + issueDate: record.issueDate.toISOString(), + expiresAt: "expiresAt" in record && record.expiresAt ? record.expiresAt.toISOString() : null, + customer: record.customer, + notes: record.notes, + subtotal: totals.subtotal, + discountPercent: totals.discountPercent, + discountAmount: totals.discountAmount, + taxPercent: totals.taxPercent, + taxAmount: totals.taxAmount, + freightAmount: totals.freightAmount, + total: totals.total, + lines, + }; +}