doc compare
This commit is contained in:
@@ -17,7 +17,8 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
|||||||
- 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
|
||||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||||
- sales quotes, sales orders, approvals, revision history, and purchase orders
|
- sales quotes, sales orders, approvals, revision history/comparison, and purchase orders
|
||||||
|
- purchase-order revision history and revision comparison across document and receipt changes
|
||||||
- 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
|
||||||
@@ -33,7 +34,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
|||||||
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
|
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
|
||||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||||
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
|
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
|
||||||
- support-log viewing and support debugging helpers in the admin diagnostics workflow
|
- support-log viewing, filtering, retention cleanup, and support debugging helpers in the admin diagnostics workflow
|
||||||
- Puppeteer PDF foundation
|
- Puppeteer PDF foundation
|
||||||
- single-container Docker deployment
|
- single-container Docker deployment
|
||||||
|
|
||||||
@@ -131,8 +132,8 @@ If implementation changes invalidate those docs, update them in the same change
|
|||||||
|
|
||||||
Near-term priorities are:
|
Near-term priorities are:
|
||||||
|
|
||||||
1. Support-log filtering, retention controls, and broader support-package polish
|
1. Project milestones and project-side rollup visibility
|
||||||
2. Revision comparison UX for changed sales and purchasing documents
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs
|
||||||
|
- Purchase-order revision snapshots covering document edits, status changes, and receipt posting
|
||||||
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
|
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
|
||||||
- Session filters and text search for admin-side access review across user, email, IP, user agent, and review reasons
|
- Session filters and text search for admin-side access review across user, email, IP, user agent, and review reasons
|
||||||
|
- Support-log filtering by severity, source, search text, and retention window in admin diagnostics
|
||||||
|
- Support-log export and support-snapshot export now carry filter context, summary counts, available sources, and retention metadata
|
||||||
- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions
|
- Shared destructive-action confirmation dialog with impact and recovery guidance for high-risk operational actions
|
||||||
- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings
|
- Typed confirmation for sensitive admin actions such as account deactivation, current-session revocation, and terminal manufacturing/inventory postings
|
||||||
- Destructive-action confirmation and recovery coverage for sales approvals, quote conversion, purchase receiving, purchase status changes, and shipment status changes
|
- Destructive-action confirmation and recovery coverage for sales approvals, quote conversion, purchase receiving, purchase status changes, and shipment status changes
|
||||||
@@ -54,7 +58,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Sales and purchasing detail pages now expose revision comparison directly alongside chronological revision history
|
||||||
- `ROADMAP.md` now tracks remaining work only, and shipped phase history now lives in `SHIPPED.md`
|
- `ROADMAP.md` now tracks remaining work only, and shipped phase history now lives in `SHIPPED.md`
|
||||||
|
- Support logs now prune retained entries by age instead of only trimming by count, and admin diagnostics now reviews filtered support-log summaries instead of an unbounded flat dump
|
||||||
- Admin diagnostics now summarizes sessions that need review, and startup now prunes old expired or revoked auth-session records
|
- Admin diagnostics now summarizes sessions that need review, and startup now prunes old expired or revoked auth-session records
|
||||||
- Admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks
|
- Admin, sales, purchasing, shipping, inventory, manufacturing, project, warehouse, and attachment workflows now use explicit destructive-action confirmation and recovery messaging instead of immediate irreversible clicks
|
||||||
- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records
|
- Admin operations now combine user management with live session visibility so operators can inspect and revoke sign-ins without changing user records
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ This repository implements the platform foundation milestone:
|
|||||||
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
||||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
||||||
- 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
|
- sales approvals, approval stamps, automatic revision history, and revision comparison 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
|
||||||
|
- purchase-order revision history and revision comparison across document and receipt changes
|
||||||
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
|
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
|
||||||
- purchase-order supporting documents and vendor-side purchasing visibility
|
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||||
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
@@ -37,7 +38,7 @@ This repository implements the platform foundation milestone:
|
|||||||
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
|
||||||
- backup verification checklist and restore-drill runbook in diagnostics
|
- backup verification checklist and restore-drill runbook in diagnostics
|
||||||
- support-log viewing and support debugging helpers in diagnostics
|
- support-log viewing, filtering, retention cleanup, and support debugging helpers in diagnostics
|
||||||
- Dockerized single-container deployment
|
- Dockerized single-container deployment
|
||||||
- Puppeteer PDF pipeline foundation
|
- Puppeteer PDF pipeline foundation
|
||||||
|
|
||||||
@@ -73,5 +74,5 @@ This repository implements the platform foundation milestone:
|
|||||||
|
|
||||||
## Next roadmap candidates
|
## Next roadmap candidates
|
||||||
|
|
||||||
- support-log filtering, retention controls, and broader support-package polish
|
- project milestones and project-side rollup visibility
|
||||||
- revision comparison UX for changed sales and purchasing documents
|
- manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -17,8 +17,9 @@ Current foundation scope includes:
|
|||||||
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
||||||
- inventory transfers, reservations, and available-stock visibility
|
- inventory transfers, reservations, and available-stock visibility
|
||||||
- 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
|
- sales approvals, approval stamps, automatic revision history, and revision comparison 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-order revision history and revision comparison across commercial and receipt changes
|
||||||
- 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
|
||||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||||
@@ -36,7 +37,7 @@ Current foundation scope includes:
|
|||||||
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
|
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
|
||||||
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
|
||||||
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
|
||||||
- support-log viewing and support debugging helpers in admin diagnostics
|
- support-log viewing, filtering, retention cleanup, and richer support-debug export helpers in admin diagnostics
|
||||||
- route-level code-splitting and vendor chunking for lighter initial client loads
|
- route-level code-splitting and vendor chunking for lighter initial client loads
|
||||||
- file storage and PDF rendering
|
- file storage and PDF rendering
|
||||||
|
|
||||||
@@ -60,14 +61,13 @@ Current completed foundation areas:
|
|||||||
|
|
||||||
Near-term priorities:
|
Near-term priorities:
|
||||||
|
|
||||||
1. Support-log filtering, retention controls, and broader support-package polish
|
1. Project milestones and project-side rollup visibility
|
||||||
2. Revision comparison UX for changed sales and purchasing documents
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
|
|
||||||
Revisit / deferred items:
|
Revisit / deferred items:
|
||||||
|
|
||||||
- local Windows Prisma migration reliability
|
- local Windows Prisma migration reliability
|
||||||
- support-log filtering, retention controls, and broader support-package polish
|
- project milestones and project-side rollup visibility
|
||||||
- revision comparison UX for changed sales and purchasing documents
|
|
||||||
|
|
||||||
Dashboard direction:
|
Dashboard direction:
|
||||||
|
|
||||||
@@ -377,13 +377,14 @@ The current admin operations slice supports:
|
|||||||
- startup now prunes stale expired or revoked auth-session records before serving requests
|
- startup now prunes stale expired or revoked auth-session records before serving requests
|
||||||
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
|
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
|
||||||
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
|
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
|
||||||
|
- support logs now support admin-side filtering by severity, source, search text, and retention window, and exports include summary metadata
|
||||||
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
|
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
|
||||||
- operator-facing review of recent high-impact changes without direct database access
|
- operator-facing review of recent high-impact changes without direct database access
|
||||||
|
|
||||||
Current follow-up direction:
|
Current follow-up direction:
|
||||||
|
|
||||||
- support-log filtering, retention controls, and broader support-package polish
|
|
||||||
- revision comparison UX for changed sales and purchasing documents
|
- revision comparison UX for changed sales and purchasing documents
|
||||||
|
- project milestones and project-side rollup visibility
|
||||||
|
|
||||||
## UI Notes
|
## UI Notes
|
||||||
|
|
||||||
|
|||||||
11
ROADMAP.md
11
ROADMAP.md
@@ -14,11 +14,10 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
## Near-term priority order
|
## Near-term priority order
|
||||||
|
|
||||||
1. Support-log filtering, retention controls, and broader support-package polish
|
1. Project milestones, project rollups, and deeper project-side execution visibility
|
||||||
2. Revision comparison UX for changed sales and purchasing documents
|
2. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
||||||
3. Project milestones, project rollups, and deeper project-side execution visibility
|
3. Dashboard KPI, alert, recent-activity, and exception-widget expansion
|
||||||
4. Manufacturing routing/work-center depth, labor capture, and capacity-aware execution views
|
4. Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||||
5. Dashboard KPI, alert, recent-activity, and exception-widget expansion
|
|
||||||
|
|
||||||
## Active roadmap
|
## Active roadmap
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
- Vendor exception handling for acknowledgements, invoice matching, receipt discrepancies, and related inbound follow-up
|
- Vendor exception handling for acknowledgements, invoice matching, receipt discrepancies, and related inbound follow-up
|
||||||
- Deeper carrier/commercial defaults where they improve order-entry speed
|
- Deeper carrier/commercial defaults where they improve order-entry speed
|
||||||
- Revision comparison UX for changed customer-facing and purchasing documents
|
|
||||||
- Line duplication, drag ordering, and keyboard-first line editing
|
- Line duplication, drag ordering, and keyboard-first line editing
|
||||||
- Saved customer defaults for tax, freight, and commercial terms
|
- Saved customer defaults for tax, freight, and commercial terms
|
||||||
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
|
- Richer dashboard widgets for recent quotes, open orders, purchasing queues, and shipping exceptions
|
||||||
@@ -123,7 +121,6 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
### Security, audit, and operations maturity
|
### Security, audit, and operations maturity
|
||||||
|
|
||||||
- Support-log filtering, retention controls, and broader support-package polish
|
|
||||||
- Admin diagnostics depth for permissions, migrations, storage, and PDF health
|
- Admin diagnostics depth for permissions, migrations, storage, and PDF health
|
||||||
- Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
- Longer-term session history and audit depth beyond the current review filtering and retention cleanup
|
||||||
- More explicit environment validation on startup
|
- More explicit environment validation on startup
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
- Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling
|
||||||
- Vendor invoice/supporting-document attachments directly on purchase orders
|
- Vendor invoice/supporting-document attachments directly on purchase orders
|
||||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||||
|
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
|
||||||
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
|
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
|
||||||
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
|
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
|
||||||
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
|
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
|
||||||
@@ -120,3 +121,4 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Route-level lazy loading and vendor chunking for a lighter initial client payload
|
- Route-level lazy loading and vendor chunking for a lighter initial client payload
|
||||||
- Persisted auth-session review filtering and admin-side access review cues
|
- Persisted auth-session review filtering and admin-side access review cues
|
||||||
- Destructive-action confirmation coverage expanded into project customer/document unlinking and form-row removals in sales, purchasing, inventory, and warehouse editors
|
- Destructive-action confirmation coverage expanded into project customer/document unlinking and form-row removals in sales, purchasing, inventory, and warehouse editors
|
||||||
|
- Support-log filtering, retention cleanup, and richer filtered support-bundle exports in admin diagnostics
|
||||||
|
|||||||
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type RevisionOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonField = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonLine = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
quantity: string;
|
||||||
|
unitLabel: string;
|
||||||
|
amountLabel: string;
|
||||||
|
totalLabel?: string;
|
||||||
|
extraLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComparisonDocument = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
status: string;
|
||||||
|
metaFields: ComparisonField[];
|
||||||
|
totalFields: ComparisonField[];
|
||||||
|
notes: string;
|
||||||
|
lines: ComparisonLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffRow = {
|
||||||
|
key: string;
|
||||||
|
status: "ADDED" | "REMOVED" | "CHANGED";
|
||||||
|
left?: ComparisonLine;
|
||||||
|
right?: ComparisonLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildLineMap(lines: ComparisonLine[]) {
|
||||||
|
return new Map(lines.map((line) => [line.key, line]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineSignature(line?: ComparisonLine) {
|
||||||
|
if (!line) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
|
||||||
|
const leftLines = buildLineMap(left.lines);
|
||||||
|
const rightLines = buildLineMap(right.lines);
|
||||||
|
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
|
||||||
|
const rows: DiffRow[] = [];
|
||||||
|
|
||||||
|
for (const key of orderedKeys) {
|
||||||
|
const leftLine = leftLines.get(key);
|
||||||
|
const rightLine = rightLines.get(key);
|
||||||
|
|
||||||
|
if (leftLine && !rightLine) {
|
||||||
|
rows.push({ key, status: "REMOVED", left: leftLine });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leftLine && rightLine) {
|
||||||
|
rows.push({ key, status: "ADDED", right: rightLine });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
|
||||||
|
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
|
||||||
|
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
|
||||||
|
|
||||||
|
return left.flatMap((field) => {
|
||||||
|
const rightValue = rightByLabel.get(field.label);
|
||||||
|
if (rightValue == null || rightValue === field.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: field.label,
|
||||||
|
leftValue: field.value,
|
||||||
|
rightValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
|
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
|
||||||
|
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{document.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{document.metaFields.map((field) => (
|
||||||
|
<div key={`${label}-${field.label}`}>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-text">{field.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{document.totalFields.map((field) => (
|
||||||
|
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentRevisionComparison({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
currentLabel,
|
||||||
|
currentDocument,
|
||||||
|
revisions,
|
||||||
|
getRevisionDocument,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
currentLabel: string;
|
||||||
|
currentDocument: ComparisonDocument;
|
||||||
|
revisions: RevisionOption[];
|
||||||
|
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
|
||||||
|
}) {
|
||||||
|
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
|
||||||
|
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
|
||||||
|
}, [revisions]);
|
||||||
|
|
||||||
|
const leftDocument = getRevisionDocument(leftRevisionId);
|
||||||
|
const rightDocument = getRevisionDocument(rightRevisionId);
|
||||||
|
const diffRows = buildDiffRows(leftDocument, rightDocument);
|
||||||
|
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
|
||||||
|
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block min-w-[220px]">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
|
||||||
|
<select
|
||||||
|
value={leftRevisionId}
|
||||||
|
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<option key={revision.id} value={revision.id}>
|
||||||
|
{revision.label} | {revision.meta}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block min-w-[220px]">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
|
||||||
|
<select
|
||||||
|
value={rightRevisionId}
|
||||||
|
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||||
|
>
|
||||||
|
<option value="current">{currentLabel}</option>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<option key={revision.id} value={revision.id}>
|
||||||
|
{revision.label} | {revision.meta}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<ComparisonCard label="Baseline" document={leftDocument} />
|
||||||
|
<ComparisonCard label="Compare To" document={rightDocument} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
||||||
|
{metaChanges.length === 0 && totalChanges.length === 0 ? (
|
||||||
|
<div className="mt-4 text-sm text-muted">No header or total changes between the selected revisions.</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{[...metaChanges, ...totalChanges].map((change) => (
|
||||||
|
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
||||||
|
<div className="mt-2 text-sm text-text">
|
||||||
|
{change.leftValue} {"->"} {change.rightValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{diffRows.length === 0 ? (
|
||||||
|
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{diffRows.map((row) => (
|
||||||
|
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
|
||||||
|
<div className="mt-1 text-sm text-text">
|
||||||
|
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
|
||||||
|
</div>
|
||||||
|
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
|
||||||
|
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
|
||||||
|
<div className="mt-1 text-sm text-text">
|
||||||
|
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
|
||||||
|
</div>
|
||||||
|
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
|
||||||
|
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
AdminRoleDto,
|
AdminRoleDto,
|
||||||
AdminRoleInput,
|
AdminRoleInput,
|
||||||
SupportLogEntryDto,
|
SupportLogEntryDto,
|
||||||
|
SupportLogFiltersDto,
|
||||||
|
SupportLogListDto,
|
||||||
SupportSnapshotDto,
|
SupportSnapshotDto,
|
||||||
AdminUserDto,
|
AdminUserDto,
|
||||||
AdminUserInput,
|
AdminUserInput,
|
||||||
@@ -81,6 +83,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
PurchaseOrderDetailDto,
|
PurchaseOrderDetailDto,
|
||||||
PurchaseOrderInput,
|
PurchaseOrderInput,
|
||||||
|
PurchaseOrderRevisionDto,
|
||||||
PurchaseOrderStatus,
|
PurchaseOrderStatus,
|
||||||
PurchaseOrderSummaryDto,
|
PurchaseOrderSummaryDto,
|
||||||
PurchaseVendorOptionDto,
|
PurchaseVendorOptionDto,
|
||||||
@@ -152,8 +155,33 @@ export const api = {
|
|||||||
getSupportSnapshot(token: string) {
|
getSupportSnapshot(token: string) {
|
||||||
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
|
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
|
||||||
},
|
},
|
||||||
getSupportLogs(token: string) {
|
getSupportSnapshotWithFilters(token: string, filters?: SupportLogFiltersDto) {
|
||||||
return request<SupportLogEntryDto[]>("/api/v1/admin/support-logs", undefined, token);
|
return request<SupportSnapshotDto>(
|
||||||
|
`/api/v1/admin/support-snapshot${buildQueryString({
|
||||||
|
level: filters?.level,
|
||||||
|
source: filters?.source,
|
||||||
|
query: filters?.query,
|
||||||
|
start: filters?.start,
|
||||||
|
end: filters?.end,
|
||||||
|
limit: filters?.limit?.toString(),
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSupportLogs(token: string, filters?: SupportLogFiltersDto) {
|
||||||
|
return request<SupportLogListDto>(
|
||||||
|
`/api/v1/admin/support-logs${buildQueryString({
|
||||||
|
level: filters?.level,
|
||||||
|
source: filters?.source,
|
||||||
|
query: filters?.query,
|
||||||
|
start: filters?.start,
|
||||||
|
end: filters?.end,
|
||||||
|
limit: filters?.limit?.toString(),
|
||||||
|
})}`,
|
||||||
|
undefined,
|
||||||
|
token
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getAdminPermissions(token: string) {
|
getAdminPermissions(token: string) {
|
||||||
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
|
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
|
||||||
@@ -683,6 +711,9 @@ export const api = {
|
|||||||
getPurchaseOrder(token: string, orderId: string) {
|
getPurchaseOrder(token: string, orderId: string) {
|
||||||
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
|
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, undefined, token);
|
||||||
},
|
},
|
||||||
|
getPurchaseOrderRevisions(token: string, orderId: string) {
|
||||||
|
return request<PurchaseOrderRevisionDto[]>(`/api/v1/purchasing/orders/${orderId}/revisions`, undefined, token);
|
||||||
|
},
|
||||||
createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
|
createPurchaseOrder(token: string, payload: PurchaseOrderInput) {
|
||||||
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
return request<PurchaseOrderDetailDto>("/api/v1/purchasing/orders", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,11 +8,67 @@ import { Link, useParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
|
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
|
||||||
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPurchaseDocumentForComparison(
|
||||||
|
document: Pick<
|
||||||
|
PurchaseOrderDetailDto,
|
||||||
|
| "documentNumber"
|
||||||
|
| "vendorName"
|
||||||
|
| "status"
|
||||||
|
| "issueDate"
|
||||||
|
| "taxPercent"
|
||||||
|
| "taxAmount"
|
||||||
|
| "freightAmount"
|
||||||
|
| "subtotal"
|
||||||
|
| "total"
|
||||||
|
| "notes"
|
||||||
|
| "paymentTerms"
|
||||||
|
| "currencyCode"
|
||||||
|
| "lines"
|
||||||
|
| "receipts"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: document.documentNumber,
|
||||||
|
subtitle: document.vendorName,
|
||||||
|
status: document.status,
|
||||||
|
metaFields: [
|
||||||
|
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||||
|
{ label: "Payment Terms", value: document.paymentTerms || "N/A" },
|
||||||
|
{ label: "Currency", value: document.currencyCode || "USD" },
|
||||||
|
{ label: "Receipts", value: document.receipts.length.toString() },
|
||||||
|
],
|
||||||
|
totalFields: [
|
||||||
|
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||||
|
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||||
|
{ label: "Total", value: formatCurrency(document.total) },
|
||||||
|
],
|
||||||
|
notes: document.notes,
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
key: line.id || `${line.itemId}-${line.position}`,
|
||||||
|
title: `${line.itemSku} | ${line.itemName}`,
|
||||||
|
subtitle: line.description,
|
||||||
|
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||||
|
unitLabel: line.unitOfMeasure,
|
||||||
|
amountLabel: formatCurrency(line.unitCost),
|
||||||
|
totalLabel: formatCurrency(line.lineTotal),
|
||||||
|
extraLabel:
|
||||||
|
`${line.receivedQuantity} received | ${line.remainingQuantity} remaining` +
|
||||||
|
(line.salesOrderNumber ? ` | Demand ${line.salesOrderNumber}` : ""),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PurchaseDetailPage() {
|
export function PurchaseDetailPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { orderId } = useParams();
|
const { orderId } = useParams();
|
||||||
@@ -244,6 +300,9 @@ export function PurchaseDetailPage() {
|
|||||||
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<PurchaseStatusBadge status={activeDocument.status} />
|
<PurchaseStatusBadge 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.revisions[0]?.revisionNumber ?? 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -297,6 +356,68 @@ export function PurchaseDetailPage() {
|
|||||||
<article className="rounded-[18px] 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">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
|
<article className="rounded-[18px] 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">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
|
||||||
<article className="rounded-[18px] 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">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
<article className="rounded-[18px] 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">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="rounded-[20px] 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 purchase order changes or receipts are posted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeDocument.revisions.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-[18px] 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-[18px] 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>
|
||||||
|
{activeDocument.revisions.length > 0 ? (
|
||||||
|
<DocumentRevisionComparison
|
||||||
|
title="Revision Comparison"
|
||||||
|
description="Compare earlier purchase-order revisions against the current document or another revision to review commercial, receiving, and line-level changes."
|
||||||
|
currentLabel="Current document"
|
||||||
|
currentDocument={mapPurchaseDocumentForComparison(activeDocument)}
|
||||||
|
revisions={activeDocument.revisions.map((revision) => ({
|
||||||
|
id: revision.id,
|
||||||
|
label: `Rev ${revision.revisionNumber}`,
|
||||||
|
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||||
|
}))}
|
||||||
|
getRevisionDocument={(revisionId) => {
|
||||||
|
if (revisionId === "current") {
|
||||||
|
return mapPurchaseDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||||
|
if (!revision) {
|
||||||
|
return mapPurchaseDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPurchaseDocumentForComparison({
|
||||||
|
...revision.snapshot,
|
||||||
|
lines: revision.snapshot.lines.map((line) => ({
|
||||||
|
id: `${line.itemId}-${line.position}`,
|
||||||
|
...line,
|
||||||
|
})),
|
||||||
|
receipts: revision.snapshot.receipts,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<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-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] 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">Vendor</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor</p>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
taxPercent: document.taxPercent,
|
taxPercent: document.taxPercent,
|
||||||
freightAmount: document.freightAmount,
|
freightAmount: document.freightAmount,
|
||||||
notes: document.notes,
|
notes: document.notes,
|
||||||
|
revisionReason: "",
|
||||||
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
|
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
|
||||||
itemId: line.itemId,
|
itemId: line.itemId,
|
||||||
description: line.description,
|
description: line.description,
|
||||||
@@ -344,6 +345,17 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] 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-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
|||||||
taxPercent: 0,
|
taxPercent: 0,
|
||||||
freightAmount: 0,
|
freightAmount: 0,
|
||||||
notes: "",
|
notes: "",
|
||||||
|
revisionReason: "",
|
||||||
lines: [],
|
lines: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||||
|
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||||
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||||
import { SalesStatusBadge } from "./SalesStatusBadge";
|
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||||
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
|
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
|
||||||
@@ -46,6 +47,61 @@ function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSalesDocumentForComparison(
|
||||||
|
document: Pick<
|
||||||
|
SalesDocumentDetailDto,
|
||||||
|
| "documentNumber"
|
||||||
|
| "customerName"
|
||||||
|
| "status"
|
||||||
|
| "issueDate"
|
||||||
|
| "expiresAt"
|
||||||
|
| "approvedAt"
|
||||||
|
| "approvedByName"
|
||||||
|
| "discountAmount"
|
||||||
|
| "discountPercent"
|
||||||
|
| "taxAmount"
|
||||||
|
| "taxPercent"
|
||||||
|
| "freightAmount"
|
||||||
|
| "subtotal"
|
||||||
|
| "total"
|
||||||
|
| "notes"
|
||||||
|
| "lines"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: document.documentNumber,
|
||||||
|
subtitle: document.customerName,
|
||||||
|
status: document.status,
|
||||||
|
metaFields: [
|
||||||
|
{ label: "Issue Date", value: new Date(document.issueDate).toLocaleDateString() },
|
||||||
|
{ label: "Expires", value: document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A" },
|
||||||
|
{ label: "Approval", value: document.approvedAt ? new Date(document.approvedAt).toLocaleDateString() : "Pending" },
|
||||||
|
{ label: "Approver", value: document.approvedByName ?? "No approver recorded" },
|
||||||
|
],
|
||||||
|
totalFields: [
|
||||||
|
{ label: "Subtotal", value: formatCurrency(document.subtotal) },
|
||||||
|
{ label: "Discount", value: `${formatCurrency(document.discountAmount)} (${document.discountPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Tax", value: `${formatCurrency(document.taxAmount)} (${document.taxPercent.toFixed(2)}%)` },
|
||||||
|
{ label: "Freight", value: formatCurrency(document.freightAmount) },
|
||||||
|
{ label: "Total", value: formatCurrency(document.total) },
|
||||||
|
],
|
||||||
|
notes: document.notes,
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
key: line.id || `${line.itemId}-${line.position}`,
|
||||||
|
title: `${line.itemSku} | ${line.itemName}`,
|
||||||
|
subtitle: line.description,
|
||||||
|
quantity: `${line.quantity} ${line.unitOfMeasure}`,
|
||||||
|
unitLabel: line.unitOfMeasure,
|
||||||
|
amountLabel: formatCurrency(line.unitPrice),
|
||||||
|
totalLabel: formatCurrency(line.lineTotal),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -419,6 +475,37 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{activeDocument.revisions.length > 0 ? (
|
||||||
|
<DocumentRevisionComparison
|
||||||
|
title="Revision Comparison"
|
||||||
|
description="Compare a prior revision against the current document or another revision to see commercial and line-level changes."
|
||||||
|
currentLabel="Current document"
|
||||||
|
currentDocument={mapSalesDocumentForComparison(activeDocument)}
|
||||||
|
revisions={activeDocument.revisions.map((revision) => ({
|
||||||
|
id: revision.id,
|
||||||
|
label: `Rev ${revision.revisionNumber}`,
|
||||||
|
meta: `${new Date(revision.createdAt).toLocaleString()} | ${revision.createdByName ?? "System"}`,
|
||||||
|
}))}
|
||||||
|
getRevisionDocument={(revisionId) => {
|
||||||
|
if (revisionId === "current") {
|
||||||
|
return mapSalesDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = activeDocument.revisions.find((entry) => entry.id === revisionId);
|
||||||
|
if (!revision) {
|
||||||
|
return mapSalesDocumentForComparison(activeDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapSalesDocumentForComparison({
|
||||||
|
...revision.snapshot,
|
||||||
|
lines: revision.snapshot.lines.map((line) => ({
|
||||||
|
id: `${line.itemId}-${line.position}`,
|
||||||
|
...line,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<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-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] 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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto } from "@mrp/shared";
|
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto } from "@mrp/shared";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -21,8 +21,28 @@ export function AdminDiagnosticsPage() {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
|
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
|
||||||
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
|
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
|
||||||
const [supportLogs, setSupportLogs] = useState<SupportLogEntryDto[]>([]);
|
const [supportLogData, setSupportLogData] = useState<SupportLogListDto | null>(null);
|
||||||
const [status, setStatus] = useState("Loading diagnostics...");
|
const [status, setStatus] = useState("Loading diagnostics...");
|
||||||
|
const [supportLogLevel, setSupportLogLevel] = useState<"ALL" | SupportLogEntryDto["level"]>("ALL");
|
||||||
|
const [supportLogSource, setSupportLogSource] = useState("ALL");
|
||||||
|
const [supportLogQuery, setSupportLogQuery] = useState("");
|
||||||
|
const [supportLogWindowDays, setSupportLogWindowDays] = useState<"ALL" | "1" | "7" | "14">("ALL");
|
||||||
|
|
||||||
|
function buildSupportLogFilters(): SupportLogFiltersDto {
|
||||||
|
const now = new Date();
|
||||||
|
const start =
|
||||||
|
supportLogWindowDays === "ALL"
|
||||||
|
? undefined
|
||||||
|
: new Date(now.getTime() - Number.parseInt(supportLogWindowDays, 10) * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: supportLogLevel === "ALL" ? undefined : supportLogLevel,
|
||||||
|
source: supportLogSource === "ALL" ? undefined : supportLogSource,
|
||||||
|
query: supportLogQuery.trim() || undefined,
|
||||||
|
start,
|
||||||
|
limit: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -31,14 +51,14 @@ export function AdminDiagnosticsPage() {
|
|||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)])
|
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token, buildSupportLogFilters())])
|
||||||
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
|
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDiagnostics(nextDiagnostics);
|
setDiagnostics(nextDiagnostics);
|
||||||
setBackupGuidance(nextBackupGuidance);
|
setBackupGuidance(nextBackupGuidance);
|
||||||
setSupportLogs(nextSupportLogs);
|
setSupportLogData(nextSupportLogs);
|
||||||
setStatus("Diagnostics loaded.");
|
setStatus("Diagnostics loaded.");
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -51,7 +71,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||||
|
|
||||||
if (!diagnostics || !backupGuidance) {
|
if (!diagnostics || !backupGuidance) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||||
@@ -62,7 +82,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await api.getSupportSnapshot(token);
|
const snapshot = await api.getSupportSnapshotWithFilters(token, buildSupportLogFilters());
|
||||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -78,7 +98,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await api.getSupportLogs(token);
|
const logs = await api.getSupportLogs(token, buildSupportLogFilters());
|
||||||
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
|
||||||
const objectUrl = window.URL.createObjectURL(blob);
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -86,15 +106,20 @@ export function AdminDiagnosticsPage() {
|
|||||||
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||||
link.click();
|
link.click();
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
setSupportLogs(logs);
|
setSupportLogData(logs);
|
||||||
setStatus("Support logs exported.");
|
setStatus("Support logs exported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supportLogs = supportLogData?.entries ?? [];
|
||||||
|
const supportLogSummary = supportLogData?.summary;
|
||||||
|
const supportLogSources = supportLogData?.availableSources ?? [];
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
["Server time", formatDateTime(diagnostics.serverTime)],
|
["Server time", formatDateTime(diagnostics.serverTime)],
|
||||||
["Node runtime", diagnostics.nodeVersion],
|
["Node runtime", diagnostics.nodeVersion],
|
||||||
["Audit events", diagnostics.auditEventCount.toString()],
|
["Audit events", diagnostics.auditEventCount.toString()],
|
||||||
["Support logs", diagnostics.supportLogCount.toString()],
|
["Support logs", diagnostics.supportLogCount.toString()],
|
||||||
|
["Retention", `${supportLogSummary?.retentionDays ?? 0} days`],
|
||||||
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
||||||
["Sessions to review", diagnostics.reviewSessionCount.toString()],
|
["Sessions to review", diagnostics.reviewSessionCount.toString()],
|
||||||
["Sales docs", diagnostics.salesDocumentCount.toString()],
|
["Sales docs", diagnostics.salesDocumentCount.toString()],
|
||||||
@@ -290,7 +315,52 @@ export function AdminDiagnosticsPage() {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
|
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">{supportLogs.length} entries loaded</p>
|
<p className="text-sm text-muted">
|
||||||
|
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
||||||
|
<input
|
||||||
|
value={supportLogQuery}
|
||||||
|
onChange={(event) => setSupportLogQuery(event.target.value)}
|
||||||
|
placeholder="Message, source, context"
|
||||||
|
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Level</span>
|
||||||
|
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All levels</option>
|
||||||
|
<option value="ERROR">Error</option>
|
||||||
|
<option value="WARN">Warn</option>
|
||||||
|
<option value="INFO">Info</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Source</span>
|
||||||
|
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All sources</option>
|
||||||
|
{supportLogSources.map((source) => (
|
||||||
|
<option key={source} value={source}>{source}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-text">Window</span>
|
||||||
|
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
|
<option value="ALL">All retained</option>
|
||||||
|
<option value="1">Last 24 hours</option>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="14">Last 14 days</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||||
|
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
||||||
|
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
||||||
|
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-5 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
@@ -322,6 +392,13 @@ export function AdminDiagnosticsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{supportLogs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-sm text-muted">
|
||||||
|
No support logs matched the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PurchaseOrderRevision" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"purchaseOrderId" 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 "PurchaseOrderRevision_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "PurchaseOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PurchaseOrderRevision_purchaseOrderId_revisionNumber_key" ON "PurchaseOrderRevision"("purchaseOrderId", "revisionNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PurchaseOrderRevision_purchaseOrderId_createdAt_idx" ON "PurchaseOrderRevision"("purchaseOrderId", "createdAt");
|
||||||
@@ -30,6 +30,7 @@ model User {
|
|||||||
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
||||||
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
||||||
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
|
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
|
||||||
|
purchaseOrderRevisionsCreated PurchaseOrderRevision[]
|
||||||
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
|
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
|
||||||
auditEvents AuditEvent[]
|
auditEvents AuditEvent[]
|
||||||
}
|
}
|
||||||
@@ -681,6 +682,7 @@ model PurchaseOrder {
|
|||||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||||
lines PurchaseOrderLine[]
|
lines PurchaseOrderLine[]
|
||||||
receipts PurchaseReceipt[]
|
receipts PurchaseReceipt[]
|
||||||
|
revisions PurchaseOrderRevision[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model PurchaseOrderLine {
|
model PurchaseOrderLine {
|
||||||
@@ -743,6 +745,22 @@ model PurchaseReceiptLine {
|
|||||||
@@index([purchaseOrderLineId])
|
@@index([purchaseOrderLineId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PurchaseOrderRevision {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
purchaseOrderId String
|
||||||
|
revisionNumber Int
|
||||||
|
reason String
|
||||||
|
snapshot String
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([purchaseOrderId, revisionNumber])
|
||||||
|
@@index([purchaseOrderId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model AuditEvent {
|
model AuditEvent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
actorId String?
|
actorId String?
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { SupportLogEntryDto } from "@mrp/shared";
|
import type { SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto, SupportLogSummaryDto } from "@mrp/shared";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const SUPPORT_LOG_LIMIT = 200;
|
const SUPPORT_LOG_LIMIT = 500;
|
||||||
|
const SUPPORT_LOG_RETENTION_DAYS = 14;
|
||||||
|
|
||||||
const supportLogs: SupportLogEntryDto[] = [];
|
const supportLogs: SupportLogEntryDto[] = [];
|
||||||
|
|
||||||
@@ -17,12 +18,89 @@ function serializeContext(context?: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRetentionCutoff(now = new Date()) {
|
||||||
|
return new Date(now.getTime() - SUPPORT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSupportLogs(now = new Date()) {
|
||||||
|
const cutoff = getRetentionCutoff(now).getTime();
|
||||||
|
const retained = supportLogs.filter((entry) => new Date(entry.createdAt).getTime() >= cutoff);
|
||||||
|
supportLogs.length = 0;
|
||||||
|
supportLogs.push(...retained.slice(0, SUPPORT_LOG_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilters(filters?: SupportLogFiltersDto): SupportLogFiltersDto {
|
||||||
|
return {
|
||||||
|
level: filters?.level,
|
||||||
|
source: filters?.source?.trim() || undefined,
|
||||||
|
query: filters?.query?.trim() || undefined,
|
||||||
|
start: filters?.start,
|
||||||
|
end: filters?.end,
|
||||||
|
limit: filters?.limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSupportLogs(filters?: SupportLogFiltersDto) {
|
||||||
|
pruneSupportLogs();
|
||||||
|
|
||||||
|
const normalized = normalizeFilters(filters);
|
||||||
|
const startMs = normalized.start ? new Date(normalized.start).getTime() : null;
|
||||||
|
const endMs = normalized.end ? new Date(normalized.end).getTime() : null;
|
||||||
|
const query = normalized.query?.toLowerCase();
|
||||||
|
const limit = Math.max(0, Math.min(normalized.limit ?? 100, SUPPORT_LOG_LIMIT));
|
||||||
|
|
||||||
|
return supportLogs
|
||||||
|
.filter((entry) => {
|
||||||
|
if (normalized.level && entry.level !== normalized.level) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.source && entry.source !== normalized.source) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtMs = new Date(entry.createdAt).getTime();
|
||||||
|
if (startMs != null && createdAtMs < startMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endMs != null && createdAtMs > endMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [entry.source, entry.message, entry.contextJson].some((value) => value.toLowerCase().includes(query));
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSupportLogSummary(entries: SupportLogEntryDto[], totalCount: number, availableSources: string[]): SupportLogSummaryDto {
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
filteredCount: entries.length,
|
||||||
|
sourceCount: availableSources.length,
|
||||||
|
retentionDays: SUPPORT_LOG_RETENTION_DAYS,
|
||||||
|
oldestEntryAt: entries.length > 0 ? entries[entries.length - 1]?.createdAt ?? null : null,
|
||||||
|
newestEntryAt: entries.length > 0 ? entries[0]?.createdAt ?? null : null,
|
||||||
|
levelCounts: {
|
||||||
|
INFO: entries.filter((entry) => entry.level === "INFO").length,
|
||||||
|
WARN: entries.filter((entry) => entry.level === "WARN").length,
|
||||||
|
ERROR: entries.filter((entry) => entry.level === "ERROR").length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function recordSupportLog(entry: {
|
export function recordSupportLog(entry: {
|
||||||
level: SupportLogEntryDto["level"];
|
level: SupportLogEntryDto["level"];
|
||||||
source: string;
|
source: string;
|
||||||
message: string;
|
message: string;
|
||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
|
pruneSupportLogs();
|
||||||
|
|
||||||
supportLogs.unshift({
|
supportLogs.unshift({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
level: entry.level,
|
level: entry.level,
|
||||||
@@ -37,10 +115,25 @@ export function recordSupportLog(entry: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSupportLogs(limit = 50) {
|
export function listSupportLogs(filters?: SupportLogFiltersDto): SupportLogListDto {
|
||||||
return supportLogs.slice(0, Math.max(0, limit));
|
pruneSupportLogs();
|
||||||
|
const normalized = normalizeFilters(filters);
|
||||||
|
const availableSources = [...new Set(supportLogs.map((entry) => entry.source))].sort();
|
||||||
|
const entries = filterSupportLogs(normalized);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
summary: buildSupportLogSummary(entries, supportLogs.length, availableSources),
|
||||||
|
availableSources,
|
||||||
|
filters: normalized,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportLogCount() {
|
export function getSupportLogCount() {
|
||||||
|
pruneSupportLogs();
|
||||||
return supportLogs.length;
|
return supportLogs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSupportLogRetentionDays() {
|
||||||
|
return SUPPORT_LOG_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ const userSchema = z.object({
|
|||||||
password: z.string().min(8).nullable(),
|
password: z.string().min(8).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const supportLogQuerySchema = z.object({
|
||||||
|
level: z.enum(["INFO", "WARN", "ERROR"]).optional(),
|
||||||
|
source: z.string().trim().min(1).optional(),
|
||||||
|
query: z.string().trim().optional(),
|
||||||
|
start: z.string().datetime().optional(),
|
||||||
|
end: z.string().datetime().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function getRouteParam(value: unknown) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -50,11 +59,21 @@ adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]
|
|||||||
});
|
});
|
||||||
|
|
||||||
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||||
return ok(response, await getSupportSnapshot());
|
const parsed = supportLogQuerySchema.safeParse(_request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Support snapshot filters are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, await getSupportSnapshot(parsed.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (request, response) => {
|
||||||
return ok(response, getSupportLogs());
|
const parsed = supportLogQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Support log filters are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, getSupportLogs(parsed.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
SupportSnapshotDto,
|
SupportSnapshotDto,
|
||||||
AuditEventDto,
|
AuditEventDto,
|
||||||
SupportLogEntryDto,
|
SupportLogEntryDto,
|
||||||
|
SupportLogFiltersDto,
|
||||||
|
SupportLogListDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
|
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
@@ -18,7 +20,7 @@ import { logAuditEvent } from "../../lib/audit.js";
|
|||||||
import { hashPassword } from "../../lib/password.js";
|
import { hashPassword } from "../../lib/password.js";
|
||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
import { getLatestStartupReport } from "../../lib/startup-state.js";
|
import { getLatestStartupReport } from "../../lib/startup-state.js";
|
||||||
import { getSupportLogCount, listSupportLogs } from "../../lib/support-log.js";
|
import { getSupportLogCount, getSupportLogRetentionDays, listSupportLogs } from "../../lib/support-log.js";
|
||||||
|
|
||||||
function mapAuditEvent(record: {
|
function mapAuditEvent(record: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,13 +50,15 @@ function mapAuditEvent(record: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
|
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
|
||||||
|
return { ...record };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSupportLogList(record: SupportLogListDto): SupportLogListDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
entries: record.entries.map(mapSupportLogEntry),
|
||||||
level: record.level,
|
summary: record.summary,
|
||||||
source: record.source,
|
availableSources: record.availableSources,
|
||||||
message: record.message,
|
filters: record.filters,
|
||||||
contextJson: record.contextJson,
|
|
||||||
createdAt: record.createdAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,7 +661,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
|
|||||||
|
|
||||||
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||||
const startupReport = getLatestStartupReport();
|
const startupReport = getLatestStartupReport();
|
||||||
const recentSupportLogs = listSupportLogs(50);
|
const recentSupportLogs = listSupportLogs({ limit: 50 });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const reviewSessions = await listAdminAuthSessions();
|
const reviewSessions = await listAdminAuthSessions();
|
||||||
const [
|
const [
|
||||||
@@ -748,7 +752,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
|||||||
supportLogCount: getSupportLogCount(),
|
supportLogCount: getSupportLogCount(),
|
||||||
startup: startupReport,
|
startup: startupReport,
|
||||||
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
|
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
|
||||||
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
|
recentSupportLogs: recentSupportLogs.entries.map(mapSupportLogEntry),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,9 +867,10 @@ export function getBackupGuidance(): BackupGuidanceDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
|
export async function getSupportSnapshot(filters?: SupportLogFiltersDto): Promise<SupportSnapshotDto> {
|
||||||
const diagnostics = await getAdminDiagnostics();
|
const diagnostics = await getAdminDiagnostics();
|
||||||
const backupGuidance = getBackupGuidance();
|
const backupGuidance = getBackupGuidance();
|
||||||
|
const supportLogs = listSupportLogs({ limit: 200, ...filters });
|
||||||
const [users, roles] = await Promise.all([
|
const [users, roles] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
@@ -882,10 +887,16 @@ export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
|
|||||||
roleCount: roles,
|
roleCount: roles,
|
||||||
activeUserEmails: users.map((user) => user.email),
|
activeUserEmails: users.map((user) => user.email),
|
||||||
backupGuidance,
|
backupGuidance,
|
||||||
recentSupportLogs: diagnostics.recentSupportLogs,
|
supportLogs: mapSupportLogList(supportLogs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportLogs() {
|
export function getSupportLogs(filters?: SupportLogFiltersDto) {
|
||||||
return listSupportLogs(100).map(mapSupportLogEntry);
|
return mapSupportLogList(listSupportLogs(filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupportLogRetentionPolicy() {
|
||||||
|
return {
|
||||||
|
retentionDays: getSupportLogRetentionDays(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createPurchaseReceipt,
|
createPurchaseReceipt,
|
||||||
createPurchaseOrder,
|
createPurchaseOrder,
|
||||||
getPurchaseOrderById,
|
getPurchaseOrderById,
|
||||||
|
listPurchaseOrderRevisions,
|
||||||
listPurchaseOrders,
|
listPurchaseOrders,
|
||||||
listPurchaseVendorOptions,
|
listPurchaseVendorOptions,
|
||||||
updatePurchaseOrder,
|
updatePurchaseOrder,
|
||||||
@@ -33,6 +34,7 @@ const purchaseOrderSchema = z.object({
|
|||||||
taxPercent: z.number().min(0).max(100),
|
taxPercent: z.number().min(0).max(100),
|
||||||
freightAmount: z.number().nonnegative(),
|
freightAmount: z.number().nonnegative(),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
|
revisionReason: z.string().optional(),
|
||||||
lines: z.array(purchaseLineSchema),
|
lines: z.array(purchaseLineSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +94,20 @@ purchasingRouter.get("/orders/:orderId", requirePermissions(["purchasing.read"])
|
|||||||
return ok(response, order);
|
return ok(response, order);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
purchasingRouter.get("/orders/:orderId/revisions", requirePermissions(["purchasing.read"]), async (request, response) => {
|
||||||
|
const orderId = getRouteParam(request.params.orderId);
|
||||||
|
if (!orderId) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await getPurchaseOrderById(orderId);
|
||||||
|
if (!order) {
|
||||||
|
return fail(response, 404, "PURCHASE_ORDER_NOT_FOUND", "Purchase order was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, await listPurchaseOrderRevisions(orderId));
|
||||||
|
});
|
||||||
|
|
||||||
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
|
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
|
||||||
const parsed = purchaseOrderSchema.safeParse(request.body);
|
const parsed = purchaseOrderSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
|
import type {
|
||||||
|
PurchaseLineInput,
|
||||||
|
PurchaseOrderDetailDto,
|
||||||
|
PurchaseOrderInput,
|
||||||
|
PurchaseOrderRevisionDto,
|
||||||
|
PurchaseOrderRevisionSnapshotDto,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
PurchaseOrderSummaryDto,
|
||||||
|
PurchaseVendorOptionDto,
|
||||||
|
} from "@mrp/shared";
|
||||||
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||||
|
|
||||||
import { logAuditEvent } from "../../lib/audit.js";
|
import { logAuditEvent } from "../../lib/audit.js";
|
||||||
@@ -102,6 +111,18 @@ type PurchaseReceiptRecord = {
|
|||||||
lines: PurchaseReceiptLineRecord[];
|
lines: PurchaseReceiptLineRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PurchaseOrderRevisionRecord = {
|
||||||
|
id: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
reason: string;
|
||||||
|
snapshot: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdBy: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
type PurchaseOrderRecord = {
|
type PurchaseOrderRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
@@ -121,6 +142,7 @@ type PurchaseOrderRecord = {
|
|||||||
};
|
};
|
||||||
lines: PurchaseLineRecord[];
|
lines: PurchaseLineRecord[];
|
||||||
receipts: PurchaseReceiptRecord[];
|
receipts: PurchaseReceiptRecord[];
|
||||||
|
revisions: PurchaseOrderRevisionRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
function roundMoney(value: number) {
|
||||||
@@ -147,6 +169,10 @@ function getCreatedByName(createdBy: PurchaseReceiptRecord["createdBy"]) {
|
|||||||
return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System";
|
return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserDisplayName(user: { firstName: string; lastName: string } | null) {
|
||||||
|
return user ? `${user.firstName} ${user.lastName}`.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto {
|
function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto {
|
||||||
const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({
|
const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
@@ -177,6 +203,21 @@ function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRevisionSnapshot(snapshot: string): PurchaseOrderRevisionSnapshotDto {
|
||||||
|
return JSON.parse(snapshot) as PurchaseOrderRevisionSnapshotDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPurchaseOrderRevision(record: PurchaseOrderRevisionRecord): PurchaseOrderRevisionDto {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
revisionNumber: record.revisionNumber,
|
||||||
|
reason: record.reason,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
createdByName: getUserDisplayName(record.createdBy),
|
||||||
|
snapshot: parseRevisionSnapshot(record.snapshot),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLines(lines: PurchaseLineInput[]) {
|
function normalizeLines(lines: PurchaseLineInput[]) {
|
||||||
return lines
|
return lines
|
||||||
.map((line, index) => ({
|
.map((line, index) => ({
|
||||||
@@ -319,6 +360,10 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime())
|
.sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime())
|
||||||
.map((receipt) => mapPurchaseReceipt(receipt, record.id));
|
.map((receipt) => mapPurchaseReceipt(receipt, record.id));
|
||||||
|
const revisions = record.revisions
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.revisionNumber - left.revisionNumber)
|
||||||
|
.map(mapPurchaseOrderRevision);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -341,9 +386,87 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
|||||||
lineCount: lines.length,
|
lineCount: lines.length,
|
||||||
lines,
|
lines,
|
||||||
receipts,
|
receipts,
|
||||||
|
revisions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPurchaseOrderRevisionSnapshot(document: PurchaseOrderDetailDto) {
|
||||||
|
return JSON.stringify({
|
||||||
|
documentNumber: document.documentNumber,
|
||||||
|
vendorId: document.vendorId,
|
||||||
|
vendorName: document.vendorName,
|
||||||
|
status: document.status,
|
||||||
|
issueDate: document.issueDate,
|
||||||
|
taxPercent: document.taxPercent,
|
||||||
|
taxAmount: document.taxAmount,
|
||||||
|
freightAmount: document.freightAmount,
|
||||||
|
subtotal: document.subtotal,
|
||||||
|
total: document.total,
|
||||||
|
notes: document.notes,
|
||||||
|
paymentTerms: document.paymentTerms,
|
||||||
|
currencyCode: document.currencyCode,
|
||||||
|
lines: document.lines.map((line) => ({
|
||||||
|
itemId: line.itemId,
|
||||||
|
itemSku: line.itemSku,
|
||||||
|
itemName: line.itemName,
|
||||||
|
description: line.description,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitOfMeasure: line.unitOfMeasure,
|
||||||
|
unitCost: line.unitCost,
|
||||||
|
lineTotal: line.lineTotal,
|
||||||
|
receivedQuantity: line.receivedQuantity,
|
||||||
|
remainingQuantity: line.remainingQuantity,
|
||||||
|
salesOrderId: line.salesOrderId,
|
||||||
|
salesOrderLineId: line.salesOrderLineId,
|
||||||
|
salesOrderNumber: line.salesOrderNumber,
|
||||||
|
position: line.position,
|
||||||
|
})),
|
||||||
|
receipts: document.receipts.map((receipt) => ({
|
||||||
|
id: receipt.id,
|
||||||
|
receiptNumber: receipt.receiptNumber,
|
||||||
|
purchaseOrderId: receipt.purchaseOrderId,
|
||||||
|
receivedAt: receipt.receivedAt,
|
||||||
|
notes: receipt.notes,
|
||||||
|
createdAt: receipt.createdAt,
|
||||||
|
createdByName: receipt.createdByName,
|
||||||
|
warehouseId: receipt.warehouseId,
|
||||||
|
warehouseCode: receipt.warehouseCode,
|
||||||
|
warehouseName: receipt.warehouseName,
|
||||||
|
locationId: receipt.locationId,
|
||||||
|
locationCode: receipt.locationCode,
|
||||||
|
locationName: receipt.locationName,
|
||||||
|
totalQuantity: receipt.totalQuantity,
|
||||||
|
lineCount: receipt.lineCount,
|
||||||
|
lines: receipt.lines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
purchaseOrderLineId: line.purchaseOrderLineId,
|
||||||
|
itemId: line.itemId,
|
||||||
|
itemSku: line.itemSku,
|
||||||
|
itemName: line.itemName,
|
||||||
|
quantity: line.quantity,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPurchaseOrderRevision(documentId: string, detail: PurchaseOrderDetailDto, reason: string, actorId?: string | null) {
|
||||||
|
const aggregate = await prisma.purchaseOrderRevision.aggregate({
|
||||||
|
where: { purchaseOrderId: documentId },
|
||||||
|
_max: { revisionNumber: true },
|
||||||
|
});
|
||||||
|
const nextRevisionNumber = (aggregate._max.revisionNumber ?? 0) + 1;
|
||||||
|
|
||||||
|
await prisma.purchaseOrderRevision.create({
|
||||||
|
data: {
|
||||||
|
purchaseOrderId: documentId,
|
||||||
|
revisionNumber: nextRevisionNumber,
|
||||||
|
reason,
|
||||||
|
snapshot: buildPurchaseOrderRevisionSnapshot(detail),
|
||||||
|
createdById: actorId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function nextDocumentNumber() {
|
async function nextDocumentNumber() {
|
||||||
const next = (await purchaseOrderModel.count()) + 1;
|
const next = (await purchaseOrderModel.count()) + 1;
|
||||||
return `PO-${String(next).padStart(5, "0")}`;
|
return `PO-${String(next).padStart(5, "0")}`;
|
||||||
@@ -423,6 +546,17 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
|
|||||||
},
|
},
|
||||||
orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }],
|
orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }],
|
||||||
},
|
},
|
||||||
|
revisions: {
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ revisionNumber: "desc" }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) {
|
function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) {
|
||||||
@@ -605,6 +739,23 @@ export async function getPurchaseOrderById(documentId: string) {
|
|||||||
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
|
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPurchaseOrderRevisions(documentId: string) {
|
||||||
|
const revisions = await prisma.purchaseOrderRevision.findMany({
|
||||||
|
where: { purchaseOrderId: documentId },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ revisionNumber: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return revisions.map((revision) => mapPurchaseOrderRevision(revision as PurchaseOrderRevisionRecord));
|
||||||
|
}
|
||||||
|
|
||||||
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
|
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
|
||||||
const validatedLines = await validateLines(payload.lines);
|
const validatedLines = await validateLines(payload.lines);
|
||||||
if (!validatedLines.ok) {
|
if (!validatedLines.ok) {
|
||||||
@@ -640,6 +791,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
|
|||||||
|
|
||||||
const detail = await getPurchaseOrderById(created.id);
|
const detail = await getPurchaseOrderById(created.id);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
await createPurchaseOrderRevision(created.id, detail, payload.revisionReason?.trim() || "Initial issue", actorId);
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
actorId,
|
actorId,
|
||||||
entityType: "purchase-order",
|
entityType: "purchase-order",
|
||||||
@@ -700,6 +852,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
|||||||
|
|
||||||
const detail = await getPurchaseOrderById(documentId);
|
const detail = await getPurchaseOrderById(documentId);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
await createPurchaseOrderRevision(documentId, detail, payload.revisionReason?.trim() || "Document edited", actorId);
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
actorId,
|
actorId,
|
||||||
entityType: "purchase-order",
|
entityType: "purchase-order",
|
||||||
@@ -735,6 +888,7 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
|
|||||||
|
|
||||||
const detail = await getPurchaseOrderById(documentId);
|
const detail = await getPurchaseOrderById(documentId);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
await createPurchaseOrderRevision(documentId, detail, `Status changed to ${status}`, actorId);
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
actorId,
|
actorId,
|
||||||
entityType: "purchase-order",
|
entityType: "purchase-order",
|
||||||
@@ -796,6 +950,7 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
|
|||||||
|
|
||||||
const detail = await getPurchaseOrderById(orderId);
|
const detail = await getPurchaseOrderById(orderId);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
await createPurchaseOrderRevision(orderId, detail, `Receipt posted on ${payload.receivedAt}`, createdById);
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
actorId: createdById,
|
actorId: createdById,
|
||||||
entityType: "purchase-order",
|
entityType: "purchase-order",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SalesDocumentDetailDto,
|
SalesDocumentDetailDto,
|
||||||
SalesDocumentInput,
|
SalesDocumentInput,
|
||||||
SalesDocumentRevisionDto,
|
SalesDocumentRevisionDto,
|
||||||
|
SalesDocumentRevisionSnapshotDto,
|
||||||
SalesDocumentStatus,
|
SalesDocumentStatus,
|
||||||
SalesDocumentSummaryDto,
|
SalesDocumentSummaryDto,
|
||||||
SalesDocumentType,
|
SalesDocumentType,
|
||||||
@@ -66,6 +67,7 @@ type RevisionRecord = {
|
|||||||
id: string;
|
id: string;
|
||||||
revisionNumber: number;
|
revisionNumber: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
snapshot: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
createdBy: {
|
createdBy: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -172,6 +174,10 @@ function getUserDisplayName(user: { firstName: string; lastName: string } | null
|
|||||||
return `${user.firstName} ${user.lastName}`.trim();
|
return `${user.firstName} ${user.lastName}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRevisionSnapshot(snapshot: string): SalesDocumentRevisionSnapshotDto {
|
||||||
|
return JSON.parse(snapshot) as SalesDocumentRevisionSnapshotDto;
|
||||||
|
}
|
||||||
|
|
||||||
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
|
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -179,6 +185,7 @@ function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
|
|||||||
reason: record.reason,
|
reason: record.reason,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
createdByName: getUserDisplayName(record.createdBy),
|
createdByName: getUserDisplayName(record.createdBy),
|
||||||
|
snapshot: parseRevisionSnapshot(record.snapshot),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,32 @@ export interface SupportLogEntryDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SupportLogFiltersDto {
|
||||||
|
level?: SupportLogEntryDto["level"];
|
||||||
|
source?: string;
|
||||||
|
query?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportLogSummaryDto {
|
||||||
|
totalCount: number;
|
||||||
|
filteredCount: number;
|
||||||
|
sourceCount: number;
|
||||||
|
retentionDays: number;
|
||||||
|
oldestEntryAt: string | null;
|
||||||
|
newestEntryAt: string | null;
|
||||||
|
levelCounts: Record<SupportLogEntryDto["level"], number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportLogListDto {
|
||||||
|
entries: SupportLogEntryDto[];
|
||||||
|
summary: SupportLogSummaryDto;
|
||||||
|
availableSources: string[];
|
||||||
|
filters: SupportLogFiltersDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SupportSnapshotDto {
|
export interface SupportSnapshotDto {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
diagnostics: AdminDiagnosticsDto;
|
diagnostics: AdminDiagnosticsDto;
|
||||||
@@ -130,7 +156,7 @@ export interface SupportSnapshotDto {
|
|||||||
roleCount: number;
|
roleCount: number;
|
||||||
activeUserEmails: string[];
|
activeUserEmails: string[];
|
||||||
backupGuidance: BackupGuidanceDto;
|
backupGuidance: BackupGuidanceDto;
|
||||||
recentSupportLogs: SupportLogEntryDto[];
|
supportLogs: SupportLogListDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminDiagnosticsDto {
|
export interface AdminDiagnosticsDto {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
lines: PurchaseLineDto[];
|
lines: PurchaseLineDto[];
|
||||||
receipts: PurchaseReceiptDto[];
|
receipts: PurchaseReceiptDto[];
|
||||||
|
revisions: PurchaseOrderRevisionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseOrderInput {
|
export interface PurchaseOrderInput {
|
||||||
@@ -74,9 +75,82 @@ export interface PurchaseOrderInput {
|
|||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
freightAmount: number;
|
freightAmount: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
revisionReason?: string;
|
||||||
lines: PurchaseLineInput[];
|
lines: PurchaseLineInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderRevisionSnapshotLineDto {
|
||||||
|
itemId: string;
|
||||||
|
itemSku: string;
|
||||||
|
itemName: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitOfMeasure: InventoryUnitOfMeasure;
|
||||||
|
unitCost: number;
|
||||||
|
lineTotal: number;
|
||||||
|
receivedQuantity: number;
|
||||||
|
remainingQuantity: number;
|
||||||
|
salesOrderId: string | null;
|
||||||
|
salesOrderLineId: string | null;
|
||||||
|
salesOrderNumber: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderRevisionSnapshotReceiptLineDto {
|
||||||
|
id: string;
|
||||||
|
purchaseOrderLineId: string;
|
||||||
|
itemId: string;
|
||||||
|
itemSku: string;
|
||||||
|
itemName: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderRevisionSnapshotReceiptDto {
|
||||||
|
id: string;
|
||||||
|
receiptNumber: string;
|
||||||
|
purchaseOrderId: string;
|
||||||
|
receivedAt: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string;
|
||||||
|
warehouseId: string;
|
||||||
|
warehouseCode: string;
|
||||||
|
warehouseName: string;
|
||||||
|
locationId: string;
|
||||||
|
locationCode: string;
|
||||||
|
locationName: string;
|
||||||
|
totalQuantity: number;
|
||||||
|
lineCount: number;
|
||||||
|
lines: PurchaseOrderRevisionSnapshotReceiptLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderRevisionSnapshotDto {
|
||||||
|
documentNumber: string;
|
||||||
|
vendorId: string;
|
||||||
|
vendorName: string;
|
||||||
|
status: PurchaseOrderStatus;
|
||||||
|
issueDate: string;
|
||||||
|
taxPercent: number;
|
||||||
|
taxAmount: number;
|
||||||
|
freightAmount: number;
|
||||||
|
subtotal: number;
|
||||||
|
total: number;
|
||||||
|
notes: string;
|
||||||
|
paymentTerms: string | null;
|
||||||
|
currencyCode: string | null;
|
||||||
|
lines: PurchaseOrderRevisionSnapshotLineDto[];
|
||||||
|
receipts: PurchaseOrderRevisionSnapshotReceiptDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderRevisionDto {
|
||||||
|
id: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
reason: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdByName: string | null;
|
||||||
|
snapshot: PurchaseOrderRevisionSnapshotDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseReceiptLineDto {
|
export interface PurchaseReceiptLineDto {
|
||||||
id: string;
|
id: string;
|
||||||
purchaseOrderLineId: string;
|
purchaseOrderLineId: string;
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
|
|||||||
revisions: SalesDocumentRevisionDto[];
|
revisions: SalesDocumentRevisionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SalesDocumentRevisionSnapshotLineDto {
|
||||||
|
itemId: string;
|
||||||
|
itemSku: string;
|
||||||
|
itemName: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitOfMeasure: InventoryUnitOfMeasure;
|
||||||
|
unitPrice: number;
|
||||||
|
lineTotal: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesDocumentRevisionSnapshotDto {
|
||||||
|
documentNumber: string;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
status: SalesDocumentStatus;
|
||||||
|
approvedAt: string | null;
|
||||||
|
approvedByName: string | null;
|
||||||
|
issueDate: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
discountPercent: number;
|
||||||
|
discountAmount: number;
|
||||||
|
taxPercent: number;
|
||||||
|
taxAmount: number;
|
||||||
|
freightAmount: number;
|
||||||
|
subtotal: number;
|
||||||
|
total: number;
|
||||||
|
notes: string;
|
||||||
|
lines: SalesDocumentRevisionSnapshotLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SalesOrderPlanningNodeDto {
|
export interface SalesOrderPlanningNodeDto {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
itemSku: string;
|
itemSku: string;
|
||||||
@@ -193,4 +225,5 @@ export interface SalesDocumentRevisionDto {
|
|||||||
reason: string;
|
reason: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdByName: string | null;
|
createdByName: string | null;
|
||||||
|
snapshot: SalesDocumentRevisionSnapshotDto;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user