next slice

This commit is contained in:
2026-03-15 09:22:39 -05:00
parent 18e4044124
commit ce21ad4a4c
13 changed files with 708 additions and 24 deletions

View File

@@ -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

32
CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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 (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -174,6 +197,14 @@ export function PurchaseDetailPage() {
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to purchase orders
</Link>
<button
type="button"
onClick={handleOpenPdf}
disabled={isOpeningPdf}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
</button>
{canManage ? (
<Link to={`/purchasing/orders/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit purchase order

View File

@@ -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<ShipmentSummaryDto[]>([]);
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 (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -109,6 +135,14 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<Link to={config.routeBase} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to {config.collectionLabel.toLowerCase()}
</Link>
<button
type="button"
onClick={handleOpenPdf}
disabled={isOpeningPdf}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
</button>
{canManage ? (
<>
<Link to={`${config.routeBase}/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">

View File

@@ -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("'", "&#39;");
}
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<ReturnType<typeof getActiveCompanyProfile>>;
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(`
<html>
<head>
<style>
@page { margin: 16mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${company.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; line-height: 1.45; }
.document-meta { min-width: 280px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 16px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.number { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.summary { margin-left: auto; width: 320px; border: 1px solid #d7deeb; border-radius: 14px; overflow: hidden; }
.summary table tbody td { padding: 10px 12px; }
.summary table tbody tr:last-child td { font-size: 14px; font-weight: 700; background: #f4f7fb; }
.notes { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(company.companyName)}</h1>
<p>${escapeHtml(company.addressLine1)}${company.addressLine2 ? `<br/>${escapeHtml(company.addressLine2)}` : ""}<br/>${escapeHtml(company.city)}, ${escapeHtml(company.state)} ${escapeHtml(company.postalCode)}<br/>${escapeHtml(company.country)}</p>
</div>
<div class="document-meta">
<div><div class="label">Document</div><div class="value">${escapeHtml(options.title)}</div></div>
<div><div class="label">Number</div><div class="value">${escapeHtml(options.documentNumber)}</div></div>
<div><div class="label">Issue Date</div><div class="value">${escapeHtml(formatDate(options.issueDate))}</div></div>
<div><div class="label">Status</div><div class="value">${escapeHtml(options.status)}</div></div>
${options.documentMeta.map((entry) => `<div><div class="label">${escapeHtml(entry.label)}</div><div class="value">${escapeHtml(entry.value)}</div></div>`).join("")}
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">${escapeHtml(options.partyTitle)}</div>
<div class="stack">${options.partyLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Contact</div>
<div class="stack">${options.partyMeta.map((entry) => `<div><strong>${escapeHtml(entry.label)}:</strong> ${escapeHtml(entry.value)}</div>`).join("")}</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 16%;">SKU</th>
<th>Description</th>
<th style="width: 9%;" class="number">Qty</th>
<th style="width: 9%;" class="number">UOM</th>
<th style="width: 13%;" class="number">Unit</th>
<th style="width: 14%;" class="number">Line Total</th>
</tr>
</thead>
<tbody>${options.rows}</tbody>
</table>
<div class="summary">
<table>
<tbody>${options.totalsRows}</tbody>
</table>
</div>
<div class="notes"><div class="card-title">Notes</div>${escapeHtml(options.notes || "No notes recorded for this document.")}</div>
</div>
</body>
</html>
`);
}
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) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitPrice.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).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: `
<tr><td>Subtotal</td><td class="number">$${quote.subtotal.toFixed(2)}</td></tr>
<tr><td>Discount (${quote.discountPercent.toFixed(2)}%)</td><td class="number">-$${quote.discountAmount.toFixed(2)}</td></tr>
<tr><td>Tax (${quote.taxPercent.toFixed(2)}%)</td><td class="number">$${quote.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${quote.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${quote.total.toFixed(2)}</td></tr>
`,
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) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitPrice.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).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: `
<tr><td>Subtotal</td><td class="number">$${order.subtotal.toFixed(2)}</td></tr>
<tr><td>Discount (${order.discountPercent.toFixed(2)}%)</td><td class="number">-$${order.discountAmount.toFixed(2)}</td></tr>
<tr><td>Tax (${order.taxPercent.toFixed(2)}%)</td><td class="number">$${order.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${order.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${order.total.toFixed(2)}</td></tr>
`,
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) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitCost.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).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: `
<tr><td>Subtotal</td><td class="number">$${order.subtotal.toFixed(2)}</td></tr>
<tr><td>Tax (${order.taxPercent.toFixed(2)}%)</td><td class="number">$${order.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${order.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${order.total.toFixed(2)}</td></tr>
`,
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]),

View File

@@ -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<PurchaseOrderPdfData | null> {
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,
};
}

View File

@@ -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<SalesDocumentPdfData | null> {
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,
};
}