sales documents

This commit is contained in:
2026-03-15 11:44:14 -05:00
parent e2254d020e
commit a9d31730f8
15 changed files with 628 additions and 115 deletions

View File

@@ -16,7 +16,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- filesystem-backed attachments - filesystem-backed attachments
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments - CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing - inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
- sales quotes, sales orders, and purchase orders - sales quotes, sales orders, approvals, revision history, and purchase orders
- purchase-order supporting documents and vendor-side purchasing visibility - purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments - shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
@@ -117,11 +117,10 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Sales approvals and document revision history 1. Planning and gantt scheduling with live project/manufacturing data
2. Planning and gantt scheduling with live project/manufacturing data 2. Inventory transfers, reservations, and deeper stock controls
3. Inventory transfers, reservations, and deeper stock controls 3. Broader audit-trail coverage and operational diagnostics
4. Broader audit-trail coverage and operational diagnostics 4. Code-splitting and bundle-size reduction
5. Code-splitting and bundle-size reduction
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Sales approval actions with approved-by/approved-at stamps on quotes and sales orders
- Automatic sales-document revision history with authored reasons and per-revision snapshots
- Projects domain foundation with customer, owner, due date, priority, notes, and attachment support - Projects domain foundation with customer, owner, due date, priority, notes, and attachment support
- Project linkage to sales quotes, sales orders, and shipments for cross-module delivery tracking - Project linkage to sales quotes, sales orders, and shipments for cross-module delivery tracking
- Project list/detail/create/edit workflows and app-shell navigation entry - Project list/detail/create/edit workflows and app-shell navigation entry
@@ -20,11 +22,12 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping - The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects - The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns - Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records - Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself - Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders - Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat sales approvals and document revision history as the next active priority after the vendor-document and stabilization pass - Roadmap and project docs now treat planning and gantt scheduling as the next active priority after the sales approval and revision-history slice
## 2026-03-15 ## 2026-03-15

View File

@@ -16,6 +16,7 @@ This repository implements the platform foundation milestone:
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata - CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments - inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
- sales quotes and sales orders with quick actions and quote conversion - sales quotes and sales orders with quick actions and quote conversion
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders
- purchase orders with quick actions and searchable vendor/SKU entry - purchase orders with quick actions and searchable vendor/SKU entry
- purchase orders restricted to inventory items flagged as purchasable - purchase orders restricted to inventory items flagged as purchasable
- purchase receiving foundation with inventory posting and receipt history - purchase receiving foundation with inventory posting and receipt history
@@ -59,7 +60,6 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- sales approvals and document revision history
- planning and gantt scheduling with live project/manufacturing data - planning and gantt scheduling with live project/manufacturing data
- inventory transfers, reservations, and deeper stock controls - inventory transfers, reservations, and deeper stock controls
- broader audit and operations maturity - broader audit and operations maturity

View File

@@ -16,6 +16,7 @@ Current foundation scope includes:
- CRM contact history, account contacts, and shared attachments - CRM contact history, account contacts, and shared attachments
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- sales quotes and sales orders with searchable customer and SKU entry - sales quotes and sales orders with searchable customer and SKU entry
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - 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 - 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 - branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
@@ -44,17 +45,15 @@ Planned cross-module execution areas:
Near-term priorities: Near-term priorities:
1. Sales approvals and revision history 1. Planning and gantt scheduling with live project/manufacturing data
2. Planning and gantt scheduling with live project/manufacturing data 2. Inventory transfers, reservations, and deeper stock controls
3. Inventory transfers, reservations, and deeper stock controls 3. Broader audit-trail coverage and operational diagnostics
4. Broader audit-trail coverage and operational diagnostics 4. Code-splitting and bundle-size reduction
5. Code-splitting and bundle-size reduction
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- frontend code-splitting and bundle-size reduction - frontend code-splitting and bundle-size reduction
- sales approvals and revision history
- inventory transfers, reservations, and deeper stock controls - inventory transfers, reservations, and deeper stock controls
- deeper audit-trail coverage - deeper audit-trail coverage
@@ -222,11 +221,13 @@ The current sales foundation supports:
- quote conversion into a sales order - quote conversion into a sales order
- line-level unit prices populated from the selected inventory item default price - line-level unit prices populated from the selected inventory item default price
- branded quote and sales-order PDFs through the shared document pipeline - branded quote and sales-order PDFs through the shared document pipeline
- approval stamps and revision history directly on quote and sales-order detail pages
- revision-reason capture when editing customer-facing sales documents
QOL direction: QOL direction:
- line duplication and faster keyboard-heavy line editing - line duplication and faster keyboard-heavy line editing
- stronger revision/approval flow - revision comparison view and restore-style workflows
- richer PDF output for quotes and sales orders - richer PDF output for quotes and sales orders
This module introduces `sales.read` and `sales.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. This module introduces `sales.read` and `sales.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.
@@ -311,6 +312,7 @@ As of March 14, 2026, the latest committed domain migrations include:
- purchase-order foundation - purchase-order foundation
- purchase receiving foundation - purchase receiving foundation
- branded sales and purchasing PDF templates - branded sales and purchasing PDF templates
- sales approvals and document revision history
- inventory default price support - inventory default price support
- sales totals and commercial fields - sales totals and commercial fields
- shipping foundation - shipping foundation

View File

@@ -58,7 +58,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 - 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 - 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 - 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, vendor exception handling, or deeper carrier integration - The current sales/purchasing/shipping foundation now includes sales approvals and revision history, but still needs vendor exception handling, deeper carrier integration, and richer document comparison tooling
- 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 - 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
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins - The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
- The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins - The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
@@ -119,6 +119,7 @@ QOL subfeatures:
Foundation slice shipped: Foundation slice shipped:
- Sales approval stamps and automatic revision history on quotes and sales orders
- Purchase-order supporting documents through the shared attachment pipeline - Purchase-order supporting documents through the shared attachment pipeline
- Vendor-detail purchasing visibility for recent purchase-order activity - Vendor-detail purchasing visibility for recent purchase-order activity
@@ -257,7 +258,6 @@ QOL subfeatures:
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper - 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 - Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- Sales approvals and document revision history were planned but not yet built
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up - 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 - CRM document rollups and broader account-role depth were deferred until more downstream modules exist
- Audit-trail depth is still thin outside the current record/update flows - Audit-trail depth is still thin outside the current record/update flows
@@ -275,8 +275,7 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Sales approvals and document revision history 1. Planning and scheduling with live project/manufacturing data
2. Planning and scheduling with live project/manufacturing data 2. Inventory transfers, reservations, and deeper stock controls
3. Inventory transfers, reservations, and deeper stock controls 3. Broader audit-trail coverage and operational diagnostics
4. Broader audit-trail coverage and operational diagnostics 4. Code-splitting and bundle-size reduction
5. Code-splitting and bundle-size reduction

View File

@@ -58,6 +58,7 @@ import type {
SalesCustomerOptionDto, SalesCustomerOptionDto,
SalesDocumentDetailDto, SalesDocumentDetailDto,
SalesDocumentInput, SalesDocumentInput,
SalesDocumentRevisionDto,
SalesDocumentStatus, SalesDocumentStatus,
SalesDocumentSummaryDto, SalesDocumentSummaryDto,
} from "@mrp/shared/dist/sales/types.js"; } from "@mrp/shared/dist/sales/types.js";
@@ -537,6 +538,12 @@ export const api = {
token token
); );
}, },
approveQuote(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/approve`, { method: "POST" }, token);
},
getQuoteRevisions(token: string, quoteId: string) {
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/quotes/${quoteId}/revisions`, undefined, token);
},
convertQuoteToSalesOrder(token: string, quoteId: string) { convertQuoteToSalesOrder(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token); return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token);
}, },
@@ -566,6 +573,12 @@ export const api = {
token token
); );
}, },
approveSalesOrder(token: string, orderId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}/approve`, { method: "POST" }, token);
},
getSalesOrderRevisions(token: string, orderId: string) {
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/orders/${orderId}/revisions`, undefined, token);
},
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) { getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
return request<PurchaseOrderSummaryDto[]>( return request<PurchaseOrderSummaryDto[]>(
`/api/v1/purchasing/orders${buildQueryString({ `/api/v1/purchasing/orders${buildQueryString({

View File

@@ -21,6 +21,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isConverting, setIsConverting] = useState(false); const [isConverting, setIsConverting] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]); const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
@@ -119,6 +120,27 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
} }
async function handleApprove() {
if (!token) {
return;
}
setIsApproving(true);
setStatus(`Approving ${config.singularLabel.toLowerCase()}...`);
try {
const nextDocument =
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
setDocument(nextDocument);
setStatus(`${config.singularLabel} approved.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
} finally {
setIsApproving(false);
}
}
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -129,6 +151,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p> <p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<SalesStatusBadge status={activeDocument.status} /> <SalesStatusBadge status={activeDocument.status} />
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
Rev {activeDocument.currentRevisionNumber}
</span>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -148,6 +173,16 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<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"> <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">
Edit {config.singularLabel.toLowerCase()} Edit {config.singularLabel.toLowerCase()}
</Link> </Link>
{activeDocument.status !== "APPROVED" ? (
<button
type="button"
onClick={handleApprove}
disabled={isApproving}
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
>
{isApproving ? "Approving..." : "Approve"}
</button>
) : null}
{entity === "quote" ? ( {entity === "quote" ? (
<button <button
type="button" type="button"
@@ -205,8 +240,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div> <div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p>
<div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div> <div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
</article> </article>
</section> </section>
<section className="grid gap-3 xl:grid-cols-4"> <section className="grid gap-3 xl:grid-cols-4">
@@ -229,6 +265,36 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div> <div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div>
</article> </article>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the document changes status, content, or approval state.</p>
</div>
</div>
{activeDocument.revisions.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No revisions have been recorded yet.
</div>
) : (
<div className="mt-6 space-y-3">
{activeDocument.revisions.map((revision) => (
<article key={revision.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
<div className="mt-1 text-sm text-text">{revision.reason}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{new Date(revision.createdAt).toLocaleString()}</div>
<div className="mt-1">{revision.createdByName ?? "System"}</div>
</div>
</div>
</article>
))}
</div>
)}
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p>

View File

@@ -56,6 +56,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
taxPercent: document.taxPercent, taxPercent: document.taxPercent,
freightAmount: document.freightAmount, freightAmount: document.freightAmount,
notes: document.notes, notes: document.notes,
revisionReason: "",
lines: document.lines.map((line) => ({ lines: document.lines.map((line) => ({
itemId: line.itemId, itemId: line.itemId,
description: line.description, description: line.description,
@@ -290,6 +291,17 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/> />
</label> </label>
{mode === "edit" ? (
<label className="block xl:max-w-xl">
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
<input
value={form.revisionReason ?? ""}
onChange={(event) => updateField("revisionReason", event.target.value)}
placeholder="What changed in this revision?"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
) : null}
<div className="grid gap-3 xl:grid-cols-3"> <div className="grid gap-3 xl:grid-cols-3">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Discount %</span> <span className="mb-2 block text-sm font-semibold text-text">Discount %</span>

View File

@@ -93,6 +93,7 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
<th className="px-2 py-2">Document</th> <th className="px-2 py-2">Document</th>
<th className="px-2 py-2">Customer</th> <th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Status</th> <th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Revision</th>
<th className="px-2 py-2">Issue Date</th> <th className="px-2 py-2">Issue Date</th>
<th className="px-2 py-2">Value</th> <th className="px-2 py-2">Value</th>
<th className="px-2 py-2">Lines</th> <th className="px-2 py-2">Lines</th>
@@ -110,6 +111,10 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
<td className="px-2 py-2"> <td className="px-2 py-2">
<SalesStatusBadge status={document.status} /> <SalesStatusBadge status={document.status} />
</td> </td>
<td className="px-2 py-2 text-muted">
Rev {document.currentRevisionNumber}
{document.approvedAt ? <div className="mt-1 text-xs text-muted">Approved</div> : null}
</td>
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td> <td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td> <td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">{document.lineCount}</td> <td className="px-2 py-2 text-muted">{document.lineCount}</td>

View File

@@ -59,4 +59,5 @@ export const emptySalesDocumentInput: SalesDocumentInput = {
freightAmount: 0, freightAmount: 0,
notes: "", notes: "",
lines: [], lines: [],
revisionReason: "",
}; };

View File

@@ -0,0 +1,53 @@
-- AlterTable
ALTER TABLE "SalesQuote" ADD COLUMN "approvedAt" DATETIME;
ALTER TABLE "SalesQuote" ADD COLUMN "approvedById" TEXT;
-- AlterTable
ALTER TABLE "SalesOrder" ADD COLUMN "approvedAt" DATETIME;
ALTER TABLE "SalesOrder" ADD COLUMN "approvedById" TEXT;
-- CreateTable
CREATE TABLE "SalesQuoteRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"quoteId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuoteRevision_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesQuoteRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrderRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrderRevision_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "SalesQuote_approvedById_idx" ON "SalesQuote"("approvedById");
-- CreateIndex
CREATE INDEX "SalesOrder_approvedById_idx" ON "SalesOrder"("approvedById");
-- CreateIndex
CREATE UNIQUE INDEX "SalesQuoteRevision_quoteId_revisionNumber_key" ON "SalesQuoteRevision"("quoteId", "revisionNumber");
-- CreateIndex
CREATE INDEX "SalesQuoteRevision_quoteId_createdAt_idx" ON "SalesQuoteRevision"("quoteId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "SalesOrderRevision_orderId_revisionNumber_key" ON "SalesOrderRevision"("orderId", "revisionNumber");
-- CreateIndex
CREATE INDEX "SalesOrderRevision_orderId_createdAt_idx" ON "SalesOrderRevision"("orderId", "createdAt");

View File

@@ -24,6 +24,10 @@ model User {
ownedProjects Project[] @relation("ProjectOwner") ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[] workOrderCompletions WorkOrderCompletion[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
} }
model Role { model Role {
@@ -305,6 +309,8 @@ model SalesQuote {
status String status String
issueDate DateTime issueDate DateTime
expiresAt DateTime? expiresAt DateTime?
approvedAt DateTime?
approvedById String?
discountPercent Float @default(0) discountPercent Float @default(0)
taxPercent Float @default(0) taxPercent Float @default(0)
freightAmount Float @default(0) freightAmount Float @default(0)
@@ -312,8 +318,10 @@ model SalesQuote {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
approvedBy User? @relation("SalesQuoteApprovedBy", fields: [approvedById], references: [id], onDelete: SetNull)
lines SalesQuoteLine[] lines SalesQuoteLine[]
projects Project[] projects Project[]
revisions SalesQuoteRevision[]
} }
model SalesQuoteLine { model SalesQuoteLine {
@@ -339,6 +347,8 @@ model SalesOrder {
customerId String customerId String
status String status String
issueDate DateTime issueDate DateTime
approvedAt DateTime?
approvedById String?
discountPercent Float @default(0) discountPercent Float @default(0)
taxPercent Float @default(0) taxPercent Float @default(0)
freightAmount Float @default(0) freightAmount Float @default(0)
@@ -346,9 +356,11 @@ model SalesOrder {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
approvedBy User? @relation("SalesOrderApprovedBy", fields: [approvedById], references: [id], onDelete: SetNull)
lines SalesOrderLine[] lines SalesOrderLine[]
shipments Shipment[] shipments Shipment[]
projects Project[] projects Project[]
revisions SalesOrderRevision[]
} }
model SalesOrderLine { model SalesOrderLine {
@@ -368,6 +380,38 @@ model SalesOrderLine {
@@index([orderId, position]) @@index([orderId, position])
} }
model SalesQuoteRevision {
id String @id @default(cuid())
quoteId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote SalesQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
createdBy User? @relation("SalesQuoteRevisionCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@unique([quoteId, revisionNumber])
@@index([quoteId, createdAt])
}
model SalesOrderRevision {
id String @id @default(cuid())
orderId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
createdBy User? @relation("SalesOrderRevisionCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@unique([orderId, revisionNumber])
@@index([orderId, createdAt])
}
model Shipment { model Shipment {
id String @id @default(cuid()) id String @id @default(cuid())
shipmentNumber String @unique shipmentNumber String @unique

View File

@@ -7,9 +7,11 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js"; import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { import {
approveSalesDocument,
convertQuoteToSalesOrder, convertQuoteToSalesOrder,
createSalesDocument, createSalesDocument,
getSalesDocumentById, getSalesDocumentById,
listSalesDocumentRevisions,
listSalesCustomerOptions, listSalesCustomerOptions,
listSalesDocuments, listSalesDocuments,
listSalesOrderOptions, listSalesOrderOptions,
@@ -36,6 +38,7 @@ const quoteSchema = z.object({
freightAmount: z.number().nonnegative(), freightAmount: z.number().nonnegative(),
notes: z.string(), notes: z.string(),
lines: z.array(salesLineSchema), lines: z.array(salesLineSchema),
revisionReason: z.string().optional(),
}); });
const orderSchema = z.object({ const orderSchema = z.object({
@@ -47,6 +50,7 @@ const orderSchema = z.object({
freightAmount: z.number().nonnegative(), freightAmount: z.number().nonnegative(),
notes: z.string(), notes: z.string(),
lines: z.array(salesLineSchema), lines: z.array(salesLineSchema),
revisionReason: z.string().optional(),
}); });
const salesListQuerySchema = z.object({ const salesListQuerySchema = z.object({
@@ -101,7 +105,7 @@ salesRouter.post("/quotes", requirePermissions([permissions.salesWrite]), async
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
} }
const result = await createSalesDocument("QUOTE", parsed.data); const result = await createSalesDocument("QUOTE", parsed.data, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -120,7 +124,7 @@ salesRouter.put("/quotes/:quoteId", requirePermissions([permissions.salesWrite])
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
} }
const result = await updateSalesDocument("QUOTE", quoteId, parsed.data); const result = await updateSalesDocument("QUOTE", quoteId, parsed.data, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -139,7 +143,7 @@ salesRouter.patch("/quotes/:quoteId/status", requirePermissions([permissions.sal
return fail(response, 400, "INVALID_INPUT", "Quote status payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Quote status payload is invalid.");
} }
const result = await updateSalesDocumentStatus("QUOTE", quoteId, parsed.data.status); const result = await updateSalesDocumentStatus("QUOTE", quoteId, parsed.data.status, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -147,13 +151,41 @@ salesRouter.patch("/quotes/:quoteId/status", requirePermissions([permissions.sal
return ok(response, result.document); return ok(response, result.document);
}); });
salesRouter.post("/quotes/:quoteId/approve", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const result = await approveSalesDocument("QUOTE", quoteId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/quotes/:quoteId/revisions", requirePermissions([permissions.salesRead]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const quote = await getSalesDocumentById("QUOTE", quoteId);
if (!quote) {
return fail(response, 404, "QUOTE_NOT_FOUND", "Quote was not found.");
}
return ok(response, await listSalesDocumentRevisions("QUOTE", quoteId));
});
salesRouter.post("/quotes/:quoteId/convert", requirePermissions([permissions.salesWrite]), async (request, response) => { salesRouter.post("/quotes/:quoteId/convert", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId); const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) { if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid."); return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
} }
const result = await convertQuoteToSalesOrder(quoteId); const result = await convertQuoteToSalesOrder(quoteId, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -193,7 +225,7 @@ salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async
const result = await createSalesDocument("ORDER", { const result = await createSalesDocument("ORDER", {
...parsed.data, ...parsed.data,
expiresAt: null, expiresAt: null,
}); }, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -215,7 +247,7 @@ salesRouter.put("/orders/:orderId", requirePermissions([permissions.salesWrite])
const result = await updateSalesDocument("ORDER", orderId, { const result = await updateSalesDocument("ORDER", orderId, {
...parsed.data, ...parsed.data,
expiresAt: null, expiresAt: null,
}); }, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -234,10 +266,38 @@ salesRouter.patch("/orders/:orderId/status", requirePermissions([permissions.sal
return fail(response, 400, "INVALID_INPUT", "Sales order status payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Sales order status payload is invalid.");
} }
const result = await updateSalesDocumentStatus("ORDER", orderId, parsed.data.status); const result = await updateSalesDocumentStatus("ORDER", orderId, parsed.data.status, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
return ok(response, result.document); return ok(response, result.document);
}); });
salesRouter.post("/orders/:orderId/approve", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const result = await approveSalesDocument("ORDER", orderId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/orders/:orderId/revisions", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const order = await getSalesDocumentById("ORDER", orderId);
if (!order) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, await listSalesDocumentRevisions("ORDER", orderId));
});

View File

@@ -2,6 +2,7 @@ import type {
SalesCustomerOptionDto, SalesCustomerOptionDto,
SalesDocumentDetailDto, SalesDocumentDetailDto,
SalesDocumentInput, SalesDocumentInput,
SalesDocumentRevisionDto,
SalesDocumentStatus, SalesDocumentStatus,
SalesDocumentSummaryDto, SalesDocumentSummaryDto,
SalesDocumentType, SalesDocumentType,
@@ -60,12 +61,24 @@ type SalesLineRecord = {
}; };
}; };
type RevisionRecord = {
id: string;
revisionNumber: number;
reason: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type SalesDocumentRecord = { type SalesDocumentRecord = {
id: string; id: string;
documentNumber: string; documentNumber: string;
status: string; status: string;
issueDate: Date; issueDate: Date;
expiresAt?: Date | null; expiresAt?: Date | null;
approvedAt: Date | null;
discountPercent: number; discountPercent: number;
taxPercent: number; taxPercent: number;
freightAmount: number; freightAmount: number;
@@ -77,10 +90,28 @@ type SalesDocumentRecord = {
name: string; name: string;
email: string; email: string;
}; };
approvedBy: {
firstName: string;
lastName: string;
} | null;
revisions: RevisionRecord[];
lines: SalesLineRecord[]; lines: SalesLineRecord[];
}; };
const documentConfig = { type DocumentConfig = {
prefix: string;
findMany: typeof prisma.salesQuote.findMany;
findUnique: typeof prisma.salesQuote.findUnique;
create: typeof prisma.salesQuote.create;
update: typeof prisma.salesQuote.update;
count: typeof prisma.salesQuote.count;
revisionFindMany: typeof prisma.salesQuoteRevision.findMany;
revisionAggregate: typeof prisma.salesQuoteRevision.aggregate;
revisionCreate: typeof prisma.salesQuoteRevision.create;
revisionDocumentField: "quoteId" | "orderId";
};
const documentConfig: Record<SalesDocumentType, DocumentConfig> = {
QUOTE: { QUOTE: {
prefix: "Q", prefix: "Q",
findMany: (prisma as any).salesQuote.findMany.bind((prisma as any).salesQuote), findMany: (prisma as any).salesQuote.findMany.bind((prisma as any).salesQuote),
@@ -88,6 +119,10 @@ const documentConfig = {
create: (prisma as any).salesQuote.create.bind((prisma as any).salesQuote), create: (prisma as any).salesQuote.create.bind((prisma as any).salesQuote),
update: (prisma as any).salesQuote.update.bind((prisma as any).salesQuote), update: (prisma as any).salesQuote.update.bind((prisma as any).salesQuote),
count: (prisma as any).salesQuote.count.bind((prisma as any).salesQuote), count: (prisma as any).salesQuote.count.bind((prisma as any).salesQuote),
revisionFindMany: (prisma as any).salesQuoteRevision.findMany.bind((prisma as any).salesQuoteRevision),
revisionAggregate: (prisma as any).salesQuoteRevision.aggregate.bind((prisma as any).salesQuoteRevision),
revisionCreate: (prisma as any).salesQuoteRevision.create.bind((prisma as any).salesQuoteRevision),
revisionDocumentField: "quoteId",
}, },
ORDER: { ORDER: {
prefix: "SO", prefix: "SO",
@@ -96,8 +131,12 @@ const documentConfig = {
create: (prisma as any).salesOrder.create.bind((prisma as any).salesOrder), create: (prisma as any).salesOrder.create.bind((prisma as any).salesOrder),
update: (prisma as any).salesOrder.update.bind((prisma as any).salesOrder), update: (prisma as any).salesOrder.update.bind((prisma as any).salesOrder),
count: (prisma as any).salesOrder.count.bind((prisma as any).salesOrder), count: (prisma as any).salesOrder.count.bind((prisma as any).salesOrder),
revisionFindMany: (prisma as any).salesOrderRevision.findMany.bind((prisma as any).salesOrderRevision),
revisionAggregate: (prisma as any).salesOrderRevision.aggregate.bind((prisma as any).salesOrderRevision),
revisionCreate: (prisma as any).salesOrderRevision.create.bind((prisma as any).salesOrderRevision),
revisionDocumentField: "orderId",
}, },
} as const; };
function roundMoney(value: number) { function roundMoney(value: number) {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
@@ -124,6 +163,24 @@ function calculateTotals(subtotal: number, discountPercent: number, taxPercent:
}; };
} }
function getUserDisplayName(user: { firstName: string; lastName: string } | null) {
if (!user) {
return null;
}
return `${user.firstName} ${user.lastName}`.trim();
}
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
return {
id: record.id,
revisionNumber: record.revisionNumber,
reason: record.reason,
createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy),
};
}
function normalizeLines(lines: SalesLineInput[]) { function normalizeLines(lines: SalesLineInput[]) {
return lines return lines
.map((line, index) => ({ .map((line, index) => ({
@@ -187,6 +244,10 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
record.taxPercent, record.taxPercent,
record.freightAmount record.freightAmount
); );
const revisions = record.revisions
.slice()
.sort((left, right) => right.revisionNumber - left.revisionNumber)
.map(mapRevision);
return { return {
id: record.id, id: record.id,
@@ -195,6 +256,9 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
customerName: record.customer.name, customerName: record.customer.name,
customerEmail: record.customer.email, customerEmail: record.customer.email,
status: record.status as SalesDocumentStatus, status: record.status as SalesDocumentStatus,
approvedAt: record.approvedAt ? record.approvedAt.toISOString() : null,
approvedByName: getUserDisplayName(record.approvedBy),
currentRevisionNumber: revisions[0]?.revisionNumber ?? 0,
subtotal: totals.subtotal, subtotal: totals.subtotal,
discountPercent: totals.discountPercent, discountPercent: totals.discountPercent,
discountAmount: totals.discountAmount, discountAmount: totals.discountAmount,
@@ -209,16 +273,95 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
updatedAt: record.updatedAt.toISOString(), updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length, lineCount: lines.length,
lines, lines,
revisions,
}; };
} }
function buildRevisionSnapshot(document: SalesDocumentDetailDto) {
return JSON.stringify({
documentNumber: document.documentNumber,
customerId: document.customerId,
customerName: document.customerName,
status: document.status,
approvedAt: document.approvedAt,
approvedByName: document.approvedByName,
issueDate: document.issueDate,
expiresAt: document.expiresAt,
discountPercent: document.discountPercent,
discountAmount: document.discountAmount,
taxPercent: document.taxPercent,
taxAmount: document.taxAmount,
freightAmount: document.freightAmount,
subtotal: document.subtotal,
total: document.total,
notes: document.notes,
lines: document.lines.map((line) => ({
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
lineTotal: line.lineTotal,
position: line.position,
})),
});
}
async function createRevision(
type: SalesDocumentType,
documentId: string,
detail: SalesDocumentDetailDto,
reason: string,
userId?: string
) {
const aggregate = await documentConfig[type].revisionAggregate({
where: { [documentConfig[type].revisionDocumentField]: documentId },
_max: { revisionNumber: true },
});
const nextRevisionNumber = (aggregate._max.revisionNumber ?? 0) + 1;
if (type === "QUOTE") {
await prisma.salesQuoteRevision.create({
data: {
quoteId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildRevisionSnapshot(detail),
createdById: userId ?? null,
},
});
return;
}
await prisma.salesOrderRevision.create({
data: {
orderId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildRevisionSnapshot(detail),
createdById: userId ?? null,
},
});
}
async function getDocumentDetailOrNull(type: SalesDocumentType, documentId: string) {
const record = await documentConfig[type].findUnique({
where: { id: documentId },
include: buildInclude(),
});
return record ? mapDocument(record as SalesDocumentRecord) : null;
}
async function nextDocumentNumber(type: SalesDocumentType) { async function nextDocumentNumber(type: SalesDocumentType) {
const next = (await documentConfig[type].count()) + 1; const next = (await documentConfig[type].count()) + 1;
return `${documentConfig[type].prefix}-${String(next).padStart(5, "0")}`; return `${documentConfig[type].prefix}-${String(next).padStart(5, "0")}`;
} }
function buildInclude(type: SalesDocumentType) { function buildInclude() {
const base = { return {
customer: { customer: {
select: { select: {
id: true, id: true,
@@ -226,28 +369,23 @@ function buildInclude(type: SalesDocumentType) {
email: true, email: true,
}, },
}, },
}; approvedBy: {
select: {
if (type === "QUOTE") { firstName: true,
return { lastName: true,
...base, },
lines: { },
include: { revisions: {
item: { include: {
select: { createdBy: {
id: true, select: {
sku: true, firstName: true,
name: true, lastName: true,
},
}, },
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },
}; orderBy: [{ revisionNumber: "desc" as const }],
} },
return {
...base,
lines: { lines: {
include: { include: {
item: { item: {
@@ -258,7 +396,7 @@ function buildInclude(type: SalesDocumentType) {
}, },
}, },
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" as const }, { createdAt: "asc" as const }],
}, },
}; };
} }
@@ -328,7 +466,7 @@ export async function listSalesDocuments(type: SalesDocumentType, filters: { q?:
} }
: {}), : {}),
}, },
include: buildInclude(type), include: buildInclude(),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }], orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
}); });
@@ -340,6 +478,9 @@ export async function listSalesDocuments(type: SalesDocumentType, filters: { q?:
customerId: detail.customerId, customerId: detail.customerId,
customerName: detail.customerName, customerName: detail.customerName,
status: detail.status, status: detail.status,
approvedAt: detail.approvedAt,
approvedByName: detail.approvedByName,
currentRevisionNumber: detail.currentRevisionNumber,
subtotal: detail.subtotal, subtotal: detail.subtotal,
discountPercent: detail.discountPercent, discountPercent: detail.discountPercent,
discountAmount: detail.discountAmount, discountAmount: detail.discountAmount,
@@ -357,15 +498,27 @@ export async function listSalesDocuments(type: SalesDocumentType, filters: { q?:
} }
export async function getSalesDocumentById(type: SalesDocumentType, documentId: string) { export async function getSalesDocumentById(type: SalesDocumentType, documentId: string) {
const record = await documentConfig[type].findUnique({ return getDocumentDetailOrNull(type, documentId);
where: { id: documentId },
include: buildInclude(type),
});
return record ? mapDocument(record as SalesDocumentRecord) : null;
} }
export async function createSalesDocument(type: SalesDocumentType, payload: SalesDocumentInput) { export async function listSalesDocumentRevisions(type: SalesDocumentType, documentId: string) {
const revisions = await documentConfig[type].revisionFindMany({
where: { [documentConfig[type].revisionDocumentField]: documentId },
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
});
return revisions.map((revision: RevisionRecord) => mapRevision(revision));
}
export async function createSalesDocument(type: SalesDocumentType, payload: SalesDocumentInput, userId?: string) {
const validatedLines = await validateLines(payload.lines); const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) { if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason }; return { ok: false as const, reason: validatedLines.reason };
@@ -382,32 +535,60 @@ export async function createSalesDocument(type: SalesDocumentType, payload: Sale
const documentNumber = await nextDocumentNumber(type); const documentNumber = await nextDocumentNumber(type);
const created = await documentConfig[type].create({ const createdId = await prisma.$transaction(async (tx) => {
data: { const created =
documentNumber, type === "QUOTE"
customerId: payload.customerId, ? await tx.salesQuote.create({
status: payload.status, data: {
issueDate: new Date(payload.issueDate), documentNumber,
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}), customerId: payload.customerId,
discountPercent: payload.discountPercent, status: payload.status,
taxPercent: payload.taxPercent, issueDate: new Date(payload.issueDate),
freightAmount: payload.freightAmount, expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
notes: payload.notes, discountPercent: payload.discountPercent,
lines: { taxPercent: payload.taxPercent,
create: validatedLines.lines, freightAmount: payload.freightAmount,
}, notes: payload.notes,
}, lines: {
select: { id: true }, create: validatedLines.lines,
},
},
select: { id: true },
})
: await tx.salesOrder.create({
data: {
documentNumber,
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
discountPercent: payload.discountPercent,
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
return created.id;
}); });
const detail = await getSalesDocumentById(type, created.id); const detail = await getDocumentDetailOrNull(type, createdId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." }; if (!detail) {
return { ok: false as const, reason: "Unable to load saved document." };
}
await createRevision(type, createdId, detail, payload.revisionReason?.trim() || "Initial issue", userId);
const refreshed = await getDocumentDetailOrNull(type, createdId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
} }
export async function updateSalesDocument(type: SalesDocumentType, documentId: string, payload: SalesDocumentInput) { export async function updateSalesDocument(type: SalesDocumentType, documentId: string, payload: SalesDocumentInput, userId?: string) {
const existing = await documentConfig[type].findUnique({ const existing = await documentConfig[type].findUnique({
where: { id: documentId }, where: { id: documentId },
select: { id: true }, select: { id: true, approvedAt: true },
}); });
if (!existing) { if (!existing) {
@@ -439,6 +620,8 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s
taxPercent: payload.taxPercent, taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount, freightAmount: payload.freightAmount,
notes: payload.notes, notes: payload.notes,
approvedAt: payload.status === "APPROVED" ? existing.approvedAt ?? new Date() : null,
approvedById: payload.status === "APPROVED" ? userId ?? null : null,
lines: { lines: {
deleteMany: {}, deleteMany: {},
create: validatedLines.lines, create: validatedLines.lines,
@@ -447,14 +630,20 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s
select: { id: true }, select: { id: true },
}); });
const detail = await getSalesDocumentById(type, documentId); const detail = await getDocumentDetailOrNull(type, documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." }; if (!detail) {
return { ok: false as const, reason: "Unable to load saved document." };
}
await createRevision(type, documentId, detail, payload.revisionReason?.trim() || "Document edited", userId);
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
} }
export async function updateSalesDocumentStatus(type: SalesDocumentType, documentId: string, status: SalesDocumentStatus) { export async function updateSalesDocumentStatus(type: SalesDocumentType, documentId: string, status: SalesDocumentStatus, userId?: string) {
const existing = await documentConfig[type].findUnique({ const existing = await documentConfig[type].findUnique({
where: { id: documentId }, where: { id: documentId },
select: { id: true }, select: { id: true, status: true, approvedAt: true },
}); });
if (!existing) { if (!existing) {
@@ -463,18 +652,62 @@ export async function updateSalesDocumentStatus(type: SalesDocumentType, documen
await documentConfig[type].update({ await documentConfig[type].update({
where: { id: documentId }, where: { id: documentId },
data: { status }, data: {
status,
approvedAt: status === "APPROVED" ? existing.approvedAt ?? new Date() : null,
approvedById: status === "APPROVED" ? userId ?? null : null,
},
select: { id: true }, select: { id: true },
}); });
const detail = await getSalesDocumentById(type, documentId); const detail = await getDocumentDetailOrNull(type, documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated document." }; if (!detail) {
return { ok: false as const, reason: "Unable to load updated document." };
}
await createRevision(type, documentId, detail, `Status changed to ${status}`, userId);
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load updated document." };
} }
export async function convertQuoteToSalesOrder(quoteId: string) { export async function approveSalesDocument(type: SalesDocumentType, documentId: string, userId?: string) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true, status: true, approvedAt: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
if (existing.status === "CLOSED") {
return { ok: false as const, reason: "Closed sales documents cannot be approved." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
status: "APPROVED",
approvedAt: existing.approvedAt ?? new Date(),
approvedById: userId ?? null,
},
select: { id: true },
});
const detail = await getDocumentDetailOrNull(type, documentId);
if (!detail) {
return { ok: false as const, reason: "Unable to load approved document." };
}
await createRevision(type, documentId, detail, "Document approved", userId);
const refreshed = await getDocumentDetailOrNull(type, documentId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load approved document." };
}
export async function convertQuoteToSalesOrder(quoteId: string, userId?: string) {
const quote = await documentConfig.QUOTE.findUnique({ const quote = await documentConfig.QUOTE.findUnique({
where: { id: quoteId }, where: { id: quoteId },
include: buildInclude("QUOTE"), include: buildInclude(),
}); });
if (!quote) { if (!quote) {
@@ -484,32 +717,42 @@ export async function convertQuoteToSalesOrder(quoteId: string) {
const mappedQuote = mapDocument(quote as SalesDocumentRecord); const mappedQuote = mapDocument(quote as SalesDocumentRecord);
const nextOrderNumber = await nextDocumentNumber("ORDER"); const nextOrderNumber = await nextDocumentNumber("ORDER");
const created = await documentConfig.ORDER.create({ const createdId = await prisma.$transaction(async (tx) => {
data: { const created = await tx.salesOrder.create({
documentNumber: nextOrderNumber, data: {
customerId: mappedQuote.customerId, documentNumber: nextOrderNumber,
status: "DRAFT", customerId: mappedQuote.customerId,
issueDate: new Date(), status: "DRAFT",
discountPercent: mappedQuote.discountPercent, issueDate: new Date(),
taxPercent: mappedQuote.taxPercent, discountPercent: mappedQuote.discountPercent,
freightAmount: mappedQuote.freightAmount, taxPercent: mappedQuote.taxPercent,
notes: mappedQuote.notes ? `Converted from ${mappedQuote.documentNumber}\n\n${mappedQuote.notes}` : `Converted from ${mappedQuote.documentNumber}`, freightAmount: mappedQuote.freightAmount,
lines: { notes: mappedQuote.notes ? `Converted from ${mappedQuote.documentNumber}\n\n${mappedQuote.notes}` : `Converted from ${mappedQuote.documentNumber}`,
create: mappedQuote.lines.map((line) => ({ lines: {
itemId: line.itemId, create: mappedQuote.lines.map((line) => ({
description: line.description, itemId: line.itemId,
quantity: line.quantity, description: line.description,
unitOfMeasure: line.unitOfMeasure, quantity: line.quantity,
unitPrice: line.unitPrice, unitOfMeasure: line.unitOfMeasure,
position: line.position, unitPrice: line.unitPrice,
})), position: line.position,
})),
},
}, },
}, select: { id: true },
select: { id: true }, });
return created.id;
}); });
const order = await getSalesDocumentById("ORDER", created.id); const order = await getDocumentDetailOrNull("ORDER", createdId);
return order ? { ok: true as const, document: order } : { ok: false as const, reason: "Unable to load converted sales order." }; if (!order) {
return { ok: false as const, reason: "Unable to load converted sales order." };
}
await createRevision("ORDER", createdId, order, `Converted from quote ${mappedQuote.documentNumber}`, userId);
const refreshed = await getDocumentDetailOrNull("ORDER", createdId);
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load converted sales order." };
} }
export async function getSalesDocumentPdfData(type: SalesDocumentType, documentId: string): Promise<SalesDocumentPdfData | null> { export async function getSalesDocumentPdfData(type: SalesDocumentType, documentId: string): Promise<SalesDocumentPdfData | null> {

View File

@@ -40,6 +40,9 @@ export interface SalesDocumentSummaryDto {
customerId: string; customerId: string;
customerName: string; customerName: string;
status: SalesDocumentStatus; status: SalesDocumentStatus;
approvedAt: string | null;
approvedByName: string | null;
currentRevisionNumber: number;
subtotal: number; subtotal: number;
discountPercent: number; discountPercent: number;
discountAmount: number; discountAmount: number;
@@ -58,6 +61,7 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
lines: SalesLineDto[]; lines: SalesLineDto[];
revisions: SalesDocumentRevisionDto[];
} }
export interface SalesDocumentInput { export interface SalesDocumentInput {
@@ -70,4 +74,13 @@ export interface SalesDocumentInput {
freightAmount: number; freightAmount: number;
notes: string; notes: string;
lines: SalesLineInput[]; lines: SalesLineInput[];
revisionReason?: string;
}
export interface SalesDocumentRevisionDto {
id: string;
revisionNumber: number;
reason: string;
createdAt: string;
createdByName: string | null;
} }