doc compare
This commit is contained in:
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,
|
||||
AdminRoleInput,
|
||||
SupportLogEntryDto,
|
||||
SupportLogFiltersDto,
|
||||
SupportLogListDto,
|
||||
SupportSnapshotDto,
|
||||
AdminUserDto,
|
||||
AdminUserInput,
|
||||
@@ -81,6 +83,7 @@ import type {
|
||||
import type {
|
||||
PurchaseOrderDetailDto,
|
||||
PurchaseOrderInput,
|
||||
PurchaseOrderRevisionDto,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderSummaryDto,
|
||||
PurchaseVendorOptionDto,
|
||||
@@ -152,8 +155,33 @@ export const api = {
|
||||
getSupportSnapshot(token: string) {
|
||||
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
|
||||
},
|
||||
getSupportLogs(token: string) {
|
||||
return request<SupportLogEntryDto[]>("/api/v1/admin/support-logs", undefined, token);
|
||||
getSupportSnapshotWithFilters(token: string, filters?: SupportLogFiltersDto) {
|
||||
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) {
|
||||
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
|
||||
@@ -683,6 +711,9 @@ export const api = {
|
||||
getPurchaseOrder(token: string, orderId: string) {
|
||||
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) {
|
||||
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 { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
|
||||
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() {
|
||||
const { token, user } = useAuth();
|
||||
const { orderId } = useParams();
|
||||
@@ -244,6 +300,9 @@ export function PurchaseDetailPage() {
|
||||
<p className="mt-1 text-sm text-text">{activeDocument.vendorName}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<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 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">Currency</p><div className="mt-2 text-base font-bold text-text">{activeDocument.currencyCode || "USD"}</div></article>
|
||||
</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)]">
|
||||
<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>
|
||||
|
||||
@@ -140,6 +140,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
||||
taxPercent: document.taxPercent,
|
||||
freightAmount: document.freightAmount,
|
||||
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 }) => ({
|
||||
itemId: line.itemId,
|
||||
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>
|
||||
<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>
|
||||
{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">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||
taxPercent: 0,
|
||||
freightAmount: 0,
|
||||
notes: "",
|
||||
revisionReason: "",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
|
||||
import { DocumentRevisionComparison } from "../../components/DocumentRevisionComparison";
|
||||
import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
|
||||
import { SalesStatusBadge } from "./SalesStatusBadge";
|
||||
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 }) {
|
||||
const { token, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -419,6 +475,37 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
</div>
|
||||
)}
|
||||
</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)]">
|
||||
<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>
|
||||
|
||||
@@ -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 { useEffect, useState } from "react";
|
||||
|
||||
@@ -21,8 +21,28 @@ export function AdminDiagnosticsPage() {
|
||||
const { token } = useAuth();
|
||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | 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 [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(() => {
|
||||
if (!token) {
|
||||
@@ -31,14 +51,14 @@ export function AdminDiagnosticsPage() {
|
||||
|
||||
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]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setDiagnostics(nextDiagnostics);
|
||||
setBackupGuidance(nextBackupGuidance);
|
||||
setSupportLogs(nextSupportLogs);
|
||||
setSupportLogData(nextSupportLogs);
|
||||
setStatus("Diagnostics loaded.");
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
@@ -51,7 +71,7 @@ export function AdminDiagnosticsPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||
|
||||
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>;
|
||||
@@ -62,7 +82,7 @@ export function AdminDiagnosticsPage() {
|
||||
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 objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -78,7 +98,7 @@ export function AdminDiagnosticsPage() {
|
||||
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 objectUrl = window.URL.createObjectURL(blob);
|
||||
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.click();
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setSupportLogs(logs);
|
||||
setSupportLogData(logs);
|
||||
setStatus("Support logs exported.");
|
||||
}
|
||||
|
||||
const supportLogs = supportLogData?.entries ?? [];
|
||||
const supportLogSummary = supportLogData?.summary;
|
||||
const supportLogSources = supportLogData?.availableSources ?? [];
|
||||
|
||||
const summaryCards = [
|
||||
["Server time", formatDateTime(diagnostics.serverTime)],
|
||||
["Node runtime", diagnostics.nodeVersion],
|
||||
["Audit events", diagnostics.auditEventCount.toString()],
|
||||
["Support logs", diagnostics.supportLogCount.toString()],
|
||||
["Retention", `${supportLogSummary?.retentionDays ?? 0} days`],
|
||||
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
||||
["Sessions to review", diagnostics.reviewSessionCount.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>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
|
||||
</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 className="mt-5 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
@@ -322,6 +392,13 @@ export function AdminDiagnosticsPage() {
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user