doc compare

This commit is contained in:
2026-03-15 21:07:28 -05:00
parent f3e421e9e3
commit a43374fe77
24 changed files with 1142 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

@@ -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);
}, },

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ export const emptyPurchaseOrderInput: PurchaseOrderInput = {
taxPercent: 0, taxPercent: 0,
freightAmount: 0, freightAmount: 0,
notes: "", notes: "",
revisionReason: "",
lines: [], lines: [],
}; };

View File

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

View File

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

View File

@@ -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");

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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(),
};
} }

View File

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

View File

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

View File

@@ -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),
}; };
} }

View File

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

View File

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

View File

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