This commit is contained in:
jason
2026-03-16 14:38:00 -05:00
commit 3d05e3929d
193 changed files with 40238 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import type { AuthUser } from "@mrp/shared";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { api } from "../lib/api";
interface AuthContextValue {
token: string | null;
user: AuthUser | null;
isReady: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const tokenKey = "mrp.auth.token";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(() => window.localStorage.getItem(tokenKey));
const [user, setUser] = useState<AuthUser | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!token) {
setUser(null);
setIsReady(true);
return;
}
api.me(token)
.then((nextUser) => {
setUser(nextUser);
})
.catch(() => {
window.localStorage.removeItem(tokenKey);
setToken(null);
})
.finally(() => setIsReady(true));
}, [token]);
const value = useMemo<AuthContextValue>(
() => ({
token,
user,
isReady,
async login(email, password) {
const result = await api.login({ email, password });
setToken(result.token);
setUser(result.user);
window.localStorage.setItem(tokenKey, result.token);
},
async logout() {
if (token) {
try {
await api.logout(token);
} catch {
// Clearing local auth state still signs the user out on the client.
}
}
window.localStorage.removeItem(tokenKey);
setToken(null);
setUser(null);
},
}),
[token, user, isReady]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,257 @@
import { NavLink, Outlet } from "react-router-dom";
import type { ReactNode } from "react";
import { useAuth } from "../auth/AuthProvider";
import { ThemeToggle } from "./ThemeToggle";
const links = [
{ to: "/", label: "Dashboard", icon: <DashboardIcon /> },
{ to: "/settings/company", label: "Company Settings", icon: <CompanyIcon /> },
{ to: "/crm/customers", label: "Customers", icon: <CustomersIcon /> },
{ to: "/crm/vendors", label: "Vendors", icon: <VendorsIcon /> },
{ to: "/inventory/items", label: "Inventory", icon: <InventoryIcon /> },
{ to: "/inventory/warehouses", label: "Warehouses", icon: <WarehouseIcon /> },
{ to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> },
{ to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> },
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
{ to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> },
{ to: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> },
];
function NavIcon({ children }: { children: ReactNode }) {
return (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
{children}
</svg>
</span>
);
}
function DashboardIcon() {
return (
<NavIcon>
<path d="M4 4h7v7H4z" />
<path d="M13 4h7v4h-7z" />
<path d="M13 10h7v10h-7z" />
<path d="M4 13h7v7H4z" />
</NavIcon>
);
}
function CompanyIcon() {
return (
<NavIcon>
<path d="M4 20h16" />
<path d="M6 20V8l6-4 6 4v12" />
<path d="M9 12h.01" />
<path d="M15 12h.01" />
<path d="M12 20v-4" />
</NavIcon>
);
}
function CustomersIcon() {
return (
<NavIcon>
<path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
<path d="M16 13a2.5 2.5 0 1 0 0-5" />
<path d="M3.5 19a4.5 4.5 0 0 1 9 0" />
<path d="M14 18a3.5 3.5 0 0 1 6 0" />
</NavIcon>
);
}
function VendorsIcon() {
return (
<NavIcon>
<path d="M4 20h16" />
<path d="M6 20V7h12v13" />
<path d="M9 10h.01" />
<path d="M12 10h.01" />
<path d="M15 10h.01" />
<path d="M9 14h.01" />
<path d="M12 14h.01" />
<path d="M15 14h.01" />
</NavIcon>
);
}
function InventoryIcon() {
return (
<NavIcon>
<path d="M4 8l8-4 8 4-8 4-8-4Z" />
<path d="M4 8v8l8 4 8-4V8" />
<path d="M12 12v8" />
</NavIcon>
);
}
function WarehouseIcon() {
return (
<NavIcon>
<path d="M3 10 12 4l9 6" />
<path d="M5 10v10h14V10" />
<path d="M9 20v-5h6v5" />
</NavIcon>
);
}
function QuoteIcon() {
return (
<NavIcon>
<path d="M7 4h8l4 4v12H7z" />
<path d="M15 4v4h4" />
<path d="M10 12h6" />
<path d="M10 16h4" />
<path d="M5 8H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8" />
</NavIcon>
);
}
function SalesOrderIcon() {
return (
<NavIcon>
<path d="M7 4h10l3 3v13H7z" />
<path d="M17 4v3h3" />
<path d="M10 11h7" />
<path d="M10 15h7" />
<path d="M10 19h5" />
</NavIcon>
);
}
function PurchaseOrderIcon() {
return (
<NavIcon>
<path d="M7 4h10l3 3v13H7z" />
<path d="M17 4v3h3" />
<path d="M10 11h7" />
<path d="M10 15h4" />
<path d="M15.5 17.5 18 20l3-4" />
</NavIcon>
);
}
function ShipmentIcon() {
return (
<NavIcon>
<path d="M3 8h11v8H3z" />
<path d="M14 11h3l3 3v2h-6" />
<path d="M7 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M17 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
</NavIcon>
);
}
function GanttIcon() {
return (
<NavIcon>
<path d="M4 6h5" />
<path d="M4 12h16" />
<path d="M4 18h10" />
<rect x="10" y="4" width="7" height="4" rx="1.5" />
<rect x="7" y="16" width="9" height="4" rx="1.5" />
</NavIcon>
);
}
function ProjectsIcon() {
return (
<NavIcon>
<path d="M5 6h6" />
<path d="M5 12h14" />
<path d="M5 18h8" />
<rect x="12" y="4" width="7" height="4" rx="1.5" />
<rect x="9" y="16" width="9" height="4" rx="1.5" />
<path d="M12 8v8" />
</NavIcon>
);
}
function ManufacturingIcon() {
return (
<NavIcon>
<circle cx="8" cy="16" r="2" />
<circle cx="16" cy="16" r="2" />
<path d="M8 14V8l4-2 4 2v6" />
<path d="M12 10h6" />
<path d="M18 8v4" />
</NavIcon>
);
}
export function AppShell() {
const { user, logout } = useAuth();
return (
<div className="min-h-screen px-4 py-5 xl:px-6 2xl:px-8">
<div className="mx-auto flex w-full max-w-[1760px] gap-3 2xl:gap-4">
<aside className="hidden w-72 shrink-0 flex-col rounded-[22px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur md:flex 2xl:w-80">
<div>
<h1 className="text-xl font-extrabold uppercase tracking-[0.24em] text-text">CODEXIUM</h1>
</div>
<nav className="mt-6 space-y-2">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`flex items-center gap-2 rounded-2xl px-2 py-2 text-sm font-semibold transition ${
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
}`
}
>
{link.icon}
{link.label}
</NavLink>
))}
</nav>
<div className="mt-auto space-y-3">
<div className="rounded-[18px] border border-line/70 bg-page/70 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">Theme</p>
<ThemeToggle />
</div>
<div className="rounded-[18px] border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
<p className="text-xs text-muted">{user?.email}</p>
<button
type="button"
onClick={() => {
void logout();
}}
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
>
Sign out
</button>
</div>
</div>
</aside>
<main className="min-w-0 flex-1">
<nav className="mb-4 flex gap-3 overflow-x-auto rounded-[20px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`inline-flex whitespace-nowrap items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition ${
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
}`
}
>
{link.icon}
{link.label}
</NavLink>
))}
</nav>
<div className="mb-4 md:hidden">
<ThemeToggle />
</div>
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
interface ConfirmActionDialogProps {
open: boolean;
title: string;
description: string;
impact?: string;
recovery?: string;
confirmLabel?: string;
cancelLabel?: string;
intent?: "danger" | "primary";
confirmationLabel?: string;
confirmationValue?: string;
isConfirming?: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
export function ConfirmActionDialog({
open,
title,
description,
impact,
recovery,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
intent = "danger",
confirmationLabel,
confirmationValue,
isConfirming = false,
onConfirm,
onClose,
}: ConfirmActionDialogProps) {
const [typedValue, setTypedValue] = useState("");
useEffect(() => {
if (open) {
setTypedValue("");
}
}, [open]);
if (!open) {
return null;
}
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
const confirmButtonClass =
intent === "danger"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-brand text-white hover:brightness-110";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
<div className="w-full max-w-xl rounded-[20px] border border-line/70 bg-surface p-5 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
{impact ? (
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
<span className="mt-1 block">{impact}</span>
</div>
) : null}
{recovery ? (
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
<span className="mt-1 block">{recovery}</span>
</div>
) : null}
{requiresTypedConfirmation ? (
<label className="mt-4 block">
<span className="mb-2 block text-sm font-semibold text-text">
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
</span>
<input
value={typedValue}
onChange={(event) => setTypedValue(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
autoFocus
/>
</label>
) : null}
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={isConfirming}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => {
void onConfirm();
}}
disabled={isConfirmDisabled}
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
>
{isConfirming ? "Working..." : confirmLabel}
</button>
</div>
</div>
</div>
);
}

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

@@ -0,0 +1,221 @@
import type { FileAttachmentDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../auth/AuthProvider";
import { api, ApiError } from "../lib/api";
import { ConfirmActionDialog } from "./ConfirmActionDialog";
interface FileAttachmentsPanelProps {
ownerType: string;
ownerId: string;
eyebrow: string;
title: string;
description: string;
emptyMessage: string;
onAttachmentCountChange?: (count: number) => void;
}
function formatFileSize(sizeBytes: number) {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`;
}
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileAttachmentsPanel({
ownerType,
ownerId,
eyebrow,
title,
description,
emptyMessage,
onAttachmentCountChange,
}: FileAttachmentsPanelProps) {
const { token, user } = useAuth();
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
useEffect(() => {
if (!token || !canReadFiles) {
return;
}
api
.getAttachments(token, ownerType, ownerId)
.then((nextAttachments) => {
setAttachments(nextAttachments);
onAttachmentCountChange?.(nextAttachments.length);
setStatus(nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
setStatus(message);
});
}, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !token || !canWriteFiles) {
return;
}
setIsUploading(true);
setStatus("Uploading attachment...");
try {
const attachment = await api.uploadFile(token, file, ownerType, ownerId);
setAttachments((current) => {
const nextAttachments = [attachment, ...current];
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment uploaded.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
setStatus(message);
} finally {
setIsUploading(false);
event.target.value = "";
}
}
async function handleOpen(attachment: FileAttachmentDto) {
if (!token) {
return;
}
try {
const blob = await api.getFileContentBlob(token, attachment.id);
const objectUrl = window.URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to open attachment.";
setStatus(message);
}
}
async function handleDelete(attachment: FileAttachmentDto) {
if (!token || !canWriteFiles) {
return;
}
setDeletingAttachmentId(attachment.id);
setStatus(`Deleting ${attachment.originalName}...`);
try {
await api.deleteAttachment(token, attachment.id);
setAttachments((current) => {
const nextAttachments = current.filter((item) => item.id !== attachment.id);
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message);
} finally {
setDeletingAttachmentId(null);
setAttachmentPendingDelete(null);
}
}
return (
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4>
<p className="mt-2 text-sm text-muted">{description}</p>
</div>
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
{isUploading ? "Uploading..." : "Upload file"}
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
</label>
) : null}
</div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
{!canReadFiles ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
You do not have permission to view file attachments.
</div>
) : attachments.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
{emptyMessage}
</div>
) : (
<div className="mt-5 space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex flex-col gap-2 rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
<p className="mt-1 text-xs text-muted">
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
</p>
</div>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => handleOpen(attachment)}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
>
Open
</button>
{canWriteFiles ? (
<button
type="button"
onClick={() => setAttachmentPendingDelete(attachment)}
disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
</button>
) : null}
</div>
</div>
))}
</div>
)}
<ConfirmActionDialog
open={attachmentPendingDelete != null}
title="Delete attachment"
description={
attachmentPendingDelete
? `Delete ${attachmentPendingDelete.originalName} from this record.`
: "Delete this attachment."
}
impact="The file link will be removed from this record immediately."
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
confirmLabel="Delete file"
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
onClose={() => {
if (!deletingAttachmentId) {
setAttachmentPendingDelete(null);
}
}}
onConfirm={async () => {
if (attachmentPendingDelete) {
await handleDelete(attachmentPendingDelete);
}
}}
/>
</article>
);
}

View File

@@ -0,0 +1,22 @@
import type { PermissionKey } from "@mrp/shared";
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthProvider";
export function ProtectedRoute({ requiredPermissions = [] }: { requiredPermissions?: PermissionKey[] }) {
const { isReady, token, user } = useAuth();
if (!isReady) {
return <div className="p-10 text-center text-muted">Loading workspace...</div>;
}
if (!token || !user) {
return <Navigate to="/login" replace />;
}
const permissionSet = new Set(user.permissions);
const allowed = requiredPermissions.every((permission) => permissionSet.has(permission));
return allowed ? <Outlet /> : <Navigate to="/" replace />;
}

View File

@@ -0,0 +1,15 @@
import { useTheme } from "../theme/ThemeProvider";
export function ThemeToggle() {
const { mode, toggleMode } = useTheme();
return (
<button
type="button"
onClick={toggleMode}
className="w-full rounded-xl border border-line/70 bg-page/70 px-3 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
>
{mode === "light" ? "Dark mode" : "Light mode"}
</button>
);
}

102
client/src/index.css Normal file
View File

@@ -0,0 +1,102 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
--font-family: "Manrope";
--color-brand: 24 90 219;
--color-accent: 0 166 166;
--color-surface-brand: 244 247 251;
--color-surface: var(--color-surface-brand);
--color-page: 248 250 252;
--color-text: 15 23 42;
--color-muted: 90 106 133;
--color-line: 215 222 235;
}
.dark {
color-scheme: dark;
--color-brand: 63 140 255;
--color-accent: 34 211 238;
--color-surface: 30 41 59;
--color-page: 2 6 23;
--color-text: 226 232 240;
--color-muted: 148 163 184;
--color-line: 51 65 85;
}
html,
body,
#root {
min-height: 100%;
}
body {
background:
radial-gradient(circle at top left, rgb(var(--color-brand) / 0.18), transparent 32%),
radial-gradient(circle at top right, rgb(var(--color-accent) / 0.16), transparent 25%),
rgb(var(--color-page));
color: rgb(var(--color-text));
font-family: var(--font-family), sans-serif;
}
input,
textarea,
select,
button {
font: inherit;
}
input:not([type="color"]),
textarea,
select {
color: rgb(var(--color-text));
}
input::placeholder,
textarea::placeholder {
color: rgb(var(--color-muted));
}
.gantt-theme .wx-bar,
.gantt-theme .wx-task {
fill: rgb(var(--color-brand));
}
.gantt-theme {
--wx-font-family: var(--font-family), sans-serif;
--wx-background: rgb(var(--color-page));
--wx-background-alt: rgb(var(--color-surface));
--wx-color-font: rgb(var(--color-text));
--wx-color-secondary-font: rgb(var(--color-muted));
--wx-color-font-disabled: rgb(var(--color-muted));
--wx-color-link: rgb(var(--color-brand));
--wx-color-primary: rgb(var(--color-brand));
--wx-icon-color: rgb(var(--color-muted));
--wx-border: 1px solid rgb(var(--color-line));
--wx-box-shadow: 0 24px 60px rgba(15, 23, 42, 0.14);
}
.gantt-theme .wx-layout,
.gantt-theme .wx-scale,
.gantt-theme .wx-gantt,
.gantt-theme .wx-table-container {
background-color: rgb(var(--color-page));
color: rgb(var(--color-text));
}
.gantt-theme .wx-grid,
.gantt-theme .wx-cell,
.gantt-theme .wx-row,
.gantt-theme .wx-text,
.gantt-theme .wx-task-name {
color: rgb(var(--color-text));
}
.gantt-theme .wx-cell,
.gantt-theme .wx-row {
border-color: rgb(var(--color-line));
}

896
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,896 @@
import type {
AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
SupportLogEntryDto,
SupportLogFiltersDto,
SupportLogListDto,
SupportSnapshotDto,
AdminUserDto,
AdminUserInput,
ApiResponse,
CompanyProfileDto,
CompanyProfileInput,
FileAttachmentDto,
PlanningTimelineDto,
LoginRequest,
LoginResponse,
LogoutResponse,
} from "@mrp/shared";
import type {
CrmContactDto,
CrmContactInput,
CrmContactEntryDto,
CrmContactEntryInput,
CrmCustomerHierarchyOptionDto,
CrmRecordDetailDto,
CrmRecordInput,
CrmLifecycleStage,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOptionDto,
InventorySkuBuilderPreviewDto,
InventorySkuCatalogTreeDto,
InventorySkuFamilyDto,
InventorySkuFamilyInput,
InventorySkuNodeDto,
InventorySkuNodeInput,
InventoryReservationInput,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransferInput,
InventoryTransactionInput,
InventoryItemType,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
ManufacturingStationDto,
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
} from "@mrp/shared";
import type {
ProjectCustomerOptionDto,
ProjectDetailDto,
ProjectDocumentOptionDto,
ProjectInput,
ProjectOwnerOptionDto,
ProjectPriority,
ProjectShipmentOptionDto,
ProjectStatus,
ProjectSummaryDto,
} from "@mrp/shared/dist/projects/types.js";
import type {
SalesCustomerOptionDto,
DemandPlanningRollupDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesOrderPlanningDto,
SalesDocumentRevisionDto,
SalesDocumentStatus,
SalesDocumentSummaryDto,
} from "@mrp/shared/dist/sales/types.js";
import type {
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderRevisionDto,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import type {
ShipmentDetailDto,
ShipmentInput,
ShipmentOrderOptionDto,
ShipmentStatus,
ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js";
export class ApiError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
}
}
async function request<T>(input: string, init?: RequestInit, token?: string): Promise<T> {
const response = await fetch(input, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
});
const json = (await response.json()) as ApiResponse<T>;
if (!json.ok) {
throw new ApiError(json.error.message, json.error.code);
}
return json.data;
}
function buildQueryString(params: Record<string, string | undefined>) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) {
searchParams.set(key, value);
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
}
export const api = {
login(payload: LoginRequest) {
return request<LoginResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
});
},
me(token: string) {
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
},
logout(token: string) {
return request<LogoutResponse>("/api/v1/auth/logout", { method: "POST" }, token);
},
getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
},
getBackupGuidance(token: string) {
return request<BackupGuidanceDto>("/api/v1/admin/backup-guidance", undefined, token);
},
getSupportSnapshot(token: string) {
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", 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);
},
getAdminRoles(token: string) {
return request<AdminRoleDto[]>("/api/v1/admin/roles", undefined, token);
},
createAdminRole(token: string, payload: AdminRoleInput) {
return request<AdminRoleDto>("/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminRole(token: string, roleId: string, payload: AdminRoleInput) {
return request<AdminRoleDto>(`/api/v1/admin/roles/${roleId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getAdminUsers(token: string) {
return request<AdminUserDto[]>("/api/v1/admin/users", undefined, token);
},
getAdminSessions(token: string) {
return request<AdminAuthSessionDto[]>("/api/v1/admin/sessions", undefined, token);
},
revokeAdminSession(token: string, sessionId: string) {
return request<LogoutResponse>(`/api/v1/admin/sessions/${sessionId}/revoke`, { method: "POST" }, token);
},
createAdminUser(token: string, payload: AdminUserInput) {
return request<AdminUserDto>("/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateAdminUser(token: string, userId: string, payload: AdminUserInput) {
return request<AdminUserDto>(`/api/v1/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getCompanyProfile(token: string) {
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
},
updateCompanyProfile(token: string, payload: CompanyProfileInput) {
return request<CompanyProfileDto>(
"/api/v1/company-profile",
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
async uploadFile(token: string, file: File, ownerType: string, ownerId: string) {
const formData = new FormData();
formData.append("file", file);
formData.append("ownerType", ownerType);
formData.append("ownerId", ownerId);
const response = await fetch("/api/v1/files/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const json = (await response.json()) as ApiResponse<FileAttachmentDto>;
if (!json.ok) {
throw new ApiError(json.error.message, json.error.code);
}
return json.data;
},
async getFileContentBlob(token: string, fileId: string) {
const response = await fetch(`/api/v1/files/${fileId}/content`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to load file content.", "FILE_CONTENT_FAILED");
}
return response.blob();
},
getAttachments(token: string, ownerType: string, ownerId: string) {
return request<FileAttachmentDto[]>(
`/api/v1/files${buildQueryString({
ownerType,
ownerId,
})}`,
undefined,
token
);
},
deleteAttachment(token: string, fileId: string) {
return request<FileAttachmentDto>(
`/api/v1/files/${fileId}`,
{
method: "DELETE",
},
token
);
},
getCustomers(
token: string,
filters?: {
q?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
) {
return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/customers${buildQueryString({
q: filters?.q,
status: filters?.status,
lifecycleStage: filters?.lifecycleStage,
state: filters?.state,
flag: filters?.flag,
})}`,
undefined,
token
);
},
getCustomer(token: string, customerId: string) {
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
},
getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) {
return request<CrmCustomerHierarchyOptionDto[]>(
`/api/v1/crm/customers/hierarchy-options${buildQueryString({
excludeCustomerId,
})}`,
undefined,
token
);
},
createCustomer(token: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
"/api/v1/crm/customers",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateCustomer(token: string, customerId: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
`/api/v1/crm/customers/${customerId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
createCustomerContactEntry(token: string, customerId: string, payload: CrmContactEntryInput) {
return request<CrmContactEntryDto>(
`/api/v1/crm/customers/${customerId}/contact-history`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createCustomerContact(token: string, customerId: string, payload: CrmContactInput) {
return request<CrmContactDto>(
`/api/v1/crm/customers/${customerId}/contacts`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getVendors(
token: string,
filters?: {
q?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
) {
return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/vendors${buildQueryString({
q: filters?.q,
status: filters?.status,
lifecycleStage: filters?.lifecycleStage,
state: filters?.state,
flag: filters?.flag,
})}`,
undefined,
token
);
},
getVendor(token: string, vendorId: string) {
return request<CrmRecordDetailDto>(`/api/v1/crm/vendors/${vendorId}`, undefined, token);
},
createVendor(token: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
"/api/v1/crm/vendors",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateVendor(token: string, vendorId: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
`/api/v1/crm/vendors/${vendorId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
createVendorContactEntry(token: string, vendorId: string, payload: CrmContactEntryInput) {
return request<CrmContactEntryDto>(
`/api/v1/crm/vendors/${vendorId}/contact-history`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createVendorContact(token: string, vendorId: string, payload: CrmContactInput) {
return request<CrmContactDto>(
`/api/v1/crm/vendors/${vendorId}/contacts`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getInventoryItems(token: string, filters?: { q?: string; status?: InventoryItemStatus; type?: InventoryItemType }) {
return request<InventoryItemSummaryDto[]>(
`/api/v1/inventory/items${buildQueryString({
q: filters?.q,
status: filters?.status,
type: filters?.type,
})}`,
undefined,
token
);
},
getInventoryItem(token: string, itemId: string) {
return request<InventoryItemDetailDto>(`/api/v1/inventory/items/${itemId}`, undefined, token);
},
getInventoryItemOptions(token: string) {
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
},
getInventorySkuFamilies(token: string) {
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
},
getInventorySkuCatalog(token: string) {
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
},
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
return request<InventorySkuNodeDto[]>(
`/api/v1/inventory/sku/nodes${buildQueryString({
familyId,
parentNodeId: parentNodeId ?? undefined,
})}`,
undefined,
token
);
},
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
return request<InventorySkuBuilderPreviewDto>(
`/api/v1/inventory/sku/preview${buildQueryString({
familyId,
nodeId: nodeId ?? undefined,
})}`,
undefined,
token
);
},
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
},
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWarehouseLocationOptions(token: string) {
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
},
createInventoryItem(token: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
"/api/v1/inventory/items",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateInventoryItem(token: string, itemId: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
createInventoryTransaction(token: string, itemId: string, payload: InventoryTransactionInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transactions`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createInventoryTransfer(token: string, itemId: string, payload: InventoryTransferInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transfers`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createInventoryReservation(token: string, itemId: string, payload: InventoryReservationInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/reservations`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getWarehouses(token: string) {
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
},
getWarehouse(token: string, warehouseId: string) {
return request<WarehouseDetailDto>(`/api/v1/inventory/warehouses/${warehouseId}`, undefined, token);
},
createWarehouse(token: string, payload: WarehouseInput) {
return request<WarehouseDetailDto>(
"/api/v1/inventory/warehouses",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateWarehouse(token: string, warehouseId: string, payload: WarehouseInput) {
return request<WarehouseDetailDto>(
`/api/v1/inventory/warehouses/${warehouseId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
getProjects(
token: string,
filters?: { q?: string; status?: ProjectStatus; priority?: ProjectPriority; customerId?: string; ownerId?: string }
) {
return request<ProjectSummaryDto[]>(
`/api/v1/projects${buildQueryString({
q: filters?.q,
status: filters?.status,
priority: filters?.priority,
customerId: filters?.customerId,
ownerId: filters?.ownerId,
})}`,
undefined,
token
);
},
getProject(token: string, projectId: string) {
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, undefined, token);
},
createProject(token: string, payload: ProjectInput) {
return request<ProjectDetailDto>("/api/v1/projects", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateProject(token: string, projectId: string, payload: ProjectInput) {
return request<ProjectDetailDto>(`/api/v1/projects/${projectId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getProjectCustomerOptions(token: string) {
return request<ProjectCustomerOptionDto[]>("/api/v1/projects/customers/options", undefined, token);
},
getProjectOwnerOptions(token: string) {
return request<ProjectOwnerOptionDto[]>("/api/v1/projects/owners/options", undefined, token);
},
getProjectQuoteOptions(token: string, customerId?: string) {
return request<ProjectDocumentOptionDto[]>(
`/api/v1/projects/quotes/options${buildQueryString({ customerId })}`,
undefined,
token
);
},
getProjectOrderOptions(token: string, customerId?: string) {
return request<ProjectDocumentOptionDto[]>(
`/api/v1/projects/orders/options${buildQueryString({ customerId })}`,
undefined,
token
);
},
getProjectShipmentOptions(token: string, customerId?: string) {
return request<ProjectShipmentOptionDto[]>(
`/api/v1/projects/shipments/options${buildQueryString({ customerId })}`,
undefined,
token
);
},
getManufacturingItemOptions(token: string) {
return request<ManufacturingItemOptionDto[]>("/api/v1/manufacturing/items/options", undefined, token);
},
getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
},
getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
},
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({
q: filters?.q,
status: filters?.status,
projectId: filters?.projectId,
itemId: filters?.itemId,
})}`,
undefined,
token
);
},
getWorkOrder(token: string, workOrderId: string) {
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, undefined, token);
},
createWorkOrder(token: string, payload: WorkOrderInput) {
return request<WorkOrderDetailDto>("/api/v1/manufacturing/work-orders", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
recordWorkOrderCompletion(token: string, workOrderId: string, payload: WorkOrderCompletionInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/completions`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
getPlanningTimeline(token: string) {
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
},
getSalesCustomers(token: string) {
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
},
getPurchaseVendors(token: string) {
return request<PurchaseVendorOptionDto[]>("/api/v1/purchasing/vendors/options", undefined, token);
},
getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
return request<SalesDocumentSummaryDto[]>(
`/api/v1/sales/quotes${buildQueryString({
q: filters?.q,
status: filters?.status,
})}`,
undefined,
token
);
},
getQuote(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, undefined, token);
},
createQuote(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/quotes", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateQuoteStatus(token: string, quoteId: string, status: SalesDocumentStatus) {
return request<SalesDocumentDetailDto>(
`/api/v1/sales/quotes/${quoteId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
approveQuote(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/approve`, { method: "POST" }, token);
},
getQuoteRevisions(token: string, quoteId: string) {
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/quotes/${quoteId}/revisions`, undefined, token);
},
convertQuoteToSalesOrder(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token);
},
getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
return request<SalesDocumentSummaryDto[]>(
`/api/v1/sales/orders${buildQueryString({
q: filters?.q,
status: filters?.status,
})}`,
undefined,
token
);
},
getSalesOrder(token: string, orderId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
},
getSalesOrderPlanning(token: string, orderId: string) {
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
},
getDemandPlanningRollup(token: string) {
return request<DemandPlanningRollupDto>("/api/v1/sales/planning-rollup", undefined, token);
},
createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateSalesOrderStatus(token: string, orderId: string, status: SalesDocumentStatus) {
return request<SalesDocumentDetailDto>(
`/api/v1/sales/orders/${orderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
approveSalesOrder(token: string, orderId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}/approve`, { method: "POST" }, token);
},
getSalesOrderRevisions(token: string, orderId: string) {
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/orders/${orderId}/revisions`, undefined, token);
},
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
return request<PurchaseOrderSummaryDto[]>(
`/api/v1/purchasing/orders${buildQueryString({
q: filters?.q,
status: filters?.status,
vendorId: filters?.vendorId,
})}`,
undefined,
token
);
},
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);
},
updatePurchaseOrder(token: string, orderId: string, payload: PurchaseOrderInput) {
return request<PurchaseOrderDetailDto>(`/api/v1/purchasing/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updatePurchaseOrderStatus(token: string, orderId: string, status: PurchaseOrderStatus) {
return request<PurchaseOrderDetailDto>(
`/api/v1/purchasing/orders/${orderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
createPurchaseReceipt(token: string, orderId: string, payload: PurchaseReceiptInput) {
return request<PurchaseOrderDetailDto>(
`/api/v1/purchasing/orders/${orderId}/receipts`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
getShipmentOrderOptions(token: string) {
return request<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
},
getShipments(token: string, filters?: { q?: string; status?: ShipmentStatus; salesOrderId?: string }) {
return request<ShipmentSummaryDto[]>(
`/api/v1/shipping/shipments${buildQueryString({
q: filters?.q,
status: filters?.status,
salesOrderId: filters?.salesOrderId,
})}`,
undefined,
token
);
},
getShipment(token: string, shipmentId: string) {
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, undefined, token);
},
createShipment(token: string, payload: ShipmentInput) {
return request<ShipmentDetailDto>("/api/v1/shipping/shipments", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateShipment(token: string, shipmentId: string, payload: ShipmentInput) {
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateShipmentStatus(token: string, shipmentId: string, status: ShipmentStatus) {
return request<ShipmentDetailDto>(
`/api/v1/shipping/shipments/${shipmentId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
async getShipmentPackingSlipPdf(token: string, shipmentId: string) {
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render packing slip PDF.", "PACKING_SLIP_FAILED");
}
return response.blob();
},
async getShipmentLabelPdf(token: string, shipmentId: string) {
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/shipping-label.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render shipping label PDF.", "SHIPPING_LABEL_FAILED");
}
return response.blob();
},
async getShipmentBillOfLadingPdf(token: string, shipmentId: string) {
const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/bill-of-lading.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render bill of lading PDF.", "BILL_OF_LADING_FAILED");
}
return response.blob();
},
async getQuotePdf(token: string, quoteId: string) {
const response = await fetch(`/api/v1/documents/sales/quotes/${quoteId}/document.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render quote PDF.", "QUOTE_PDF_FAILED");
}
return response.blob();
},
async getSalesOrderPdf(token: string, orderId: string) {
const response = await fetch(`/api/v1/documents/sales/orders/${orderId}/document.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render sales order PDF.", "SALES_ORDER_PDF_FAILED");
}
return response.blob();
},
async getPurchaseOrderPdf(token: string, orderId: string) {
const response = await fetch(`/api/v1/documents/purchasing/orders/${orderId}/document.pdf`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render purchase order PDF.", "PURCHASE_ORDER_PDF_FAILED");
}
return response.blob();
},
async getCompanyProfilePreviewPdf(token: string) {
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render company profile preview PDF.", "PDF_PREVIEW_FAILED");
}
return response.blob();
},
};

273
client/src/main.tsx Normal file
View File

@@ -0,0 +1,273 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
import { permissions } from "@mrp/shared";
import { AppShell } from "./components/AppShell";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage";
import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css";
const queryClient = new QueryClient();
const CompanySettingsPage = React.lazy(() =>
import("./modules/settings/CompanySettingsPage").then((module) => ({ default: module.CompanySettingsPage }))
);
const AdminDiagnosticsPage = React.lazy(() =>
import("./modules/settings/AdminDiagnosticsPage").then((module) => ({ default: module.AdminDiagnosticsPage }))
);
const UserManagementPage = React.lazy(() =>
import("./modules/settings/UserManagementPage").then((module) => ({ default: module.UserManagementPage }))
);
const CustomersPage = React.lazy(() =>
import("./modules/crm/CustomersPage").then((module) => ({ default: module.CustomersPage }))
);
const VendorsPage = React.lazy(() =>
import("./modules/crm/VendorsPage").then((module) => ({ default: module.VendorsPage }))
);
const CrmDetailPage = React.lazy(() =>
import("./modules/crm/CrmDetailPage").then((module) => ({ default: module.CrmDetailPage }))
);
const CrmFormPage = React.lazy(() =>
import("./modules/crm/CrmFormPage").then((module) => ({ default: module.CrmFormPage }))
);
const InventoryItemsPage = React.lazy(() =>
import("./modules/inventory/InventoryItemsPage").then((module) => ({ default: module.InventoryItemsPage }))
);
const InventoryDetailPage = React.lazy(() =>
import("./modules/inventory/InventoryDetailPage").then((module) => ({ default: module.InventoryDetailPage }))
);
const InventoryFormPage = React.lazy(() =>
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
);
const InventorySkuMasterPage = React.lazy(() =>
import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
);
const WarehousesPage = React.lazy(() =>
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
);
const WarehouseDetailPage = React.lazy(() =>
import("./modules/inventory/WarehouseDetailPage").then((module) => ({ default: module.WarehouseDetailPage }))
);
const WarehouseFormPage = React.lazy(() =>
import("./modules/inventory/WarehouseFormPage").then((module) => ({ default: module.WarehouseFormPage }))
);
const ProjectsPage = React.lazy(() =>
import("./modules/projects/ProjectsPage").then((module) => ({ default: module.ProjectsPage }))
);
const ProjectDetailPage = React.lazy(() =>
import("./modules/projects/ProjectDetailPage").then((module) => ({ default: module.ProjectDetailPage }))
);
const ProjectFormPage = React.lazy(() =>
import("./modules/projects/ProjectFormPage").then((module) => ({ default: module.ProjectFormPage }))
);
const ManufacturingPage = React.lazy(() =>
import("./modules/manufacturing/ManufacturingPage").then((module) => ({ default: module.ManufacturingPage }))
);
const WorkOrderDetailPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderDetailPage").then((module) => ({ default: module.WorkOrderDetailPage }))
);
const WorkOrderFormPage = React.lazy(() =>
import("./modules/manufacturing/WorkOrderFormPage").then((module) => ({ default: module.WorkOrderFormPage }))
);
const PurchaseListPage = React.lazy(() =>
import("./modules/purchasing/PurchaseListPage").then((module) => ({ default: module.PurchaseListPage }))
);
const PurchaseDetailPage = React.lazy(() =>
import("./modules/purchasing/PurchaseDetailPage").then((module) => ({ default: module.PurchaseDetailPage }))
);
const PurchaseFormPage = React.lazy(() =>
import("./modules/purchasing/PurchaseFormPage").then((module) => ({ default: module.PurchaseFormPage }))
);
const SalesListPage = React.lazy(() =>
import("./modules/sales/SalesListPage").then((module) => ({ default: module.SalesListPage }))
);
const SalesDetailPage = React.lazy(() =>
import("./modules/sales/SalesDetailPage").then((module) => ({ default: module.SalesDetailPage }))
);
const SalesFormPage = React.lazy(() =>
import("./modules/sales/SalesFormPage").then((module) => ({ default: module.SalesFormPage }))
);
const ShipmentListPage = React.lazy(() =>
import("./modules/shipping/ShipmentListPage").then((module) => ({ default: module.ShipmentListPage }))
);
const ShipmentDetailPage = React.lazy(() =>
import("./modules/shipping/ShipmentDetailPage").then((module) => ({ default: module.ShipmentDetailPage }))
);
const ShipmentFormPage = React.lazy(() =>
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
);
const GanttPage = React.lazy(() =>
import("./modules/gantt/GanttPage").then((module) => ({ default: module.GanttPage }))
);
function RouteFallback() {
return (
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
Loading module...
</div>
);
}
function lazyElement(element: React.ReactNode) {
return <React.Suspense fallback={<RouteFallback />}>{element}</React.Suspense>;
}
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{
element: <ProtectedRoute />,
children: [
{
element: <AppShell />,
children: [
{ path: "/", element: <DashboardPage /> },
{
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
children: [{ path: "/settings/company", element: lazyElement(<CompanySettingsPage />) }],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
children: [
{ path: "/settings/admin-diagnostics", element: lazyElement(<AdminDiagnosticsPage />) },
{ path: "/settings/users", element: lazyElement(<UserManagementPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [
{ path: "/crm/customers", element: lazyElement(<CustomersPage />) },
{ path: "/crm/customers/:customerId", element: lazyElement(<CrmDetailPage entity="customer" />) },
{ path: "/crm/vendors", element: lazyElement(<VendorsPage />) },
{ path: "/crm/vendors/:vendorId", element: lazyElement(<CrmDetailPage entity="vendor" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
children: [
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
children: [
{ path: "/projects", element: lazyElement(<ProjectsPage />) },
{ path: "/projects/:projectId", element: lazyElement(<ProjectDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
children: [
{ path: "/manufacturing/work-orders", element: lazyElement(<ManufacturingPage />) },
{ path: "/manufacturing/work-orders/:workOrderId", element: lazyElement(<WorkOrderDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [
{ path: "/purchasing/orders", element: lazyElement(<PurchaseListPage />) },
{ path: "/purchasing/orders/:orderId", element: lazyElement(<PurchaseDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
children: [
{ path: "/sales/quotes", element: lazyElement(<SalesListPage entity="quote" />) },
{ path: "/sales/quotes/:quoteId", element: lazyElement(<SalesDetailPage entity="quote" />) },
{ path: "/sales/orders", element: lazyElement(<SalesListPage entity="order" />) },
{ path: "/sales/orders/:orderId", element: lazyElement(<SalesDetailPage entity="order" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
children: [
{ path: "/shipping/shipments", element: lazyElement(<ShipmentListPage />) },
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
{ path: "/crm/customers/new", element: lazyElement(<CrmFormPage entity="customer" mode="create" />) },
{ path: "/crm/customers/:customerId/edit", element: lazyElement(<CrmFormPage entity="customer" mode="edit" />) },
{ path: "/crm/vendors/new", element: lazyElement(<CrmFormPage entity="vendor" mode="create" />) },
{ path: "/crm/vendors/:vendorId/edit", element: lazyElement(<CrmFormPage entity="vendor" mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
children: [
{ path: "/projects/new", element: lazyElement(<ProjectFormPage mode="create" />) },
{ path: "/projects/:projectId/edit", element: lazyElement(<ProjectFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
children: [
{ path: "/manufacturing/work-orders/new", element: lazyElement(<WorkOrderFormPage mode="create" />) },
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: lazyElement(<WorkOrderFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [
{ path: "/purchasing/orders/new", element: lazyElement(<PurchaseFormPage mode="create" />) },
{ path: "/purchasing/orders/:orderId/edit", element: lazyElement(<PurchaseFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
children: [
{ path: "/sales/quotes/new", element: lazyElement(<SalesFormPage entity="quote" mode="create" />) },
{ path: "/sales/quotes/:quoteId/edit", element: lazyElement(<SalesFormPage entity="quote" mode="edit" />) },
{ path: "/sales/orders/new", element: lazyElement(<SalesFormPage entity="order" mode="create" />) },
{ path: "/sales/orders/:orderId/edit", element: lazyElement(<SalesFormPage entity="order" mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
children: [
{ path: "/shipping/shipments/new", element: lazyElement(<ShipmentFormPage mode="create" />) },
{ path: "/shipping/shipments/:shipmentId/edit", element: lazyElement(<ShipmentFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [
{ path: "/inventory/items/new", element: lazyElement(<InventoryFormPage mode="create" />) },
{ path: "/inventory/items/:itemId/edit", element: lazyElement(<InventoryFormPage mode="edit" />) },
{ path: "/inventory/warehouses/new", element: lazyElement(<WarehouseFormPage mode="create" />) },
{ path: "/inventory/warehouses/:warehouseId/edit", element: lazyElement(<WarehouseFormPage mode="edit" />) },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: lazyElement(<GanttPage />) }],
},
],
},
],
},
{ path: "*", element: <Navigate to="/" replace /> },
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,21 @@
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
interface CrmAttachmentsPanelProps {
ownerType: string;
ownerId: string;
onAttachmentCountChange?: (count: number) => void;
}
export function CrmAttachmentsPanel({ ownerType, ownerId, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
return (
<FileAttachmentsPanel
ownerType={ownerType}
ownerId={ownerId}
eyebrow="Attachments"
title="Shared files"
description="Drawings, customer markups, vendor documents, and other reference files linked to this record."
emptyMessage="No attachments have been added to this record yet."
onAttachmentCountChange={onAttachmentCountChange}
/>
);
}

View File

@@ -0,0 +1,72 @@
import type { CrmContactEntryInput } from "@mrp/shared/dist/crm/types.js";
import { crmContactTypeOptions } from "./config";
interface CrmContactEntryFormProps {
form: CrmContactEntryInput;
isSaving: boolean;
status: string;
onChange: <Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
}
export function CrmContactEntryForm({ form, isSaving, status, onChange, onSubmit }: CrmContactEntryFormProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(220px,1.1fr)]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Interaction type</span>
<select
value={form.type}
onChange={(event) => onChange("type", event.target.value as CrmContactEntryInput["type"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmContactTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Contact timestamp</span>
<input
type="datetime-local"
value={form.contactAt.slice(0, 16)}
onChange={(event) => onChange("contactAt", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Summary</span>
<input
value={form.summary}
onChange={(event) => onChange("summary", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
placeholder="Short headline for the interaction"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Details</span>
<textarea
value={form.body}
onChange={(event) => onChange("body", event.target.value)}
rows={5}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
placeholder="Capture what happened, follow-ups, and commitments."
/>
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button
type="submit"
disabled={isSaving}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "Saving..." : "Add entry"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,13 @@
import type { CrmContactEntryType } from "@mrp/shared/dist/crm/types.js";
import { crmContactTypeOptions, crmContactTypePalette } from "./config";
export function CrmContactTypeBadge({ type }: { type: CrmContactEntryType }) {
const label = crmContactTypeOptions.find((option) => option.value === type)?.label ?? type;
return (
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmContactTypePalette[type]}`}>
{label}
</span>
);
}

View File

@@ -0,0 +1,154 @@
import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js";
import { permissions } from "@mrp/shared";
import { useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config";
interface CrmContactsPanelProps {
entity: CrmEntity;
ownerId: string;
contacts: CrmContactDto[];
onContactsChange: (contacts: CrmContactDto[]) => void;
}
export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) {
const { token, user } = useAuth();
const [form, setForm] = useState<CrmContactInput>(emptyCrmContactInput);
const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering.");
const [isSaving, setIsSaving] = useState(false);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
function updateField<Key extends keyof CrmContactInput>(key: Key, value: CrmContactInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving contact...");
try {
const nextContact =
entity === "customer"
? await api.createCustomerContact(token, ownerId, form)
: await api.createVendorContact(token, ownerId, form);
onContactsChange(
[nextContact, ...contacts]
.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName))
);
setForm({
...emptyCrmContactInput,
isPrimary: false,
});
setStatus("Contact added.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save CRM contact.";
setStatus(message);
} finally {
setIsSaving(false);
}
}
return (
<article className="min-w-0 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">Contacts</p>
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
<div className="mt-5 space-y-3">
{contacts.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No contacts have been added yet.
</div>
) : (
contacts.map((contact) => (
<div key={contact.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">
{contact.fullName} {contact.isPrimary ? <span className="text-brand"> Primary</span> : null}
</div>
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
</div>
<div className="text-sm text-muted lg:text-right">
<div>{contact.email}</div>
<div>{contact.phone}</div>
</div>
</div>
</div>
))
)}
</div>
{canManage ? (
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
<input
value={form.fullName}
onChange={(event) => updateField("fullName", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
<select
value={form.role}
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmContactRoleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
type="email"
value={form.email}
onChange={(event) => updateField("email", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
<input
value={form.phone}
onChange={(event) => updateField("phone", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.isPrimary}
onChange={(event) => updateField("isPrimary", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Primary contact</span>
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted">{status}</span>
<button
type="submit"
disabled={isSaving}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "Saving..." : "Add contact"}
</button>
</div>
</form>
) : null}
</article>
);
}

View File

@@ -0,0 +1,392 @@
import { permissions } from "@mrp/shared";
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
import { CrmContactsPanel } from "./CrmContactsPanel";
import { CrmContactEntryForm } from "./CrmContactEntryForm";
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
import { CrmStatusBadge } from "./CrmStatusBadge";
import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config";
interface CrmDetailPageProps {
entity: CrmEntity;
}
export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const { token, user } = useAuth();
const { customerId, vendorId } = useParams();
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
const [isSavingContactEntry, setIsSavingContactEntry] = useState(false);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
useEffect(() => {
if (!token || !recordId) {
return;
}
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
loadRecord
.then((nextRecord) => {
setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`);
setContactEntryStatus("Add a timeline entry for this account.");
if (entity === "vendor") {
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
}
return [];
})
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, entity, recordId, token]);
if (!record) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) {
setContactEntryForm((current) => ({ ...current, [key]: value }));
}
async function handleContactEntrySubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !recordId) {
return;
}
setIsSavingContactEntry(true);
setContactEntryStatus("Saving timeline entry...");
try {
const nextEntry =
entity === "customer"
? await api.createCustomerContactEntry(token, recordId, contactEntryForm)
: await api.createVendorContactEntry(token, recordId, contactEntryForm);
setRecord((current) =>
current
? {
...current,
contactHistory: [nextEntry, ...current.contactHistory].sort(
(left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime()
),
rollups: {
lastContactAt: nextEntry.contactAt,
contactHistoryCount: (current.rollups?.contactHistoryCount ?? current.contactHistory.length) + 1,
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
attachmentCount: current.rollups?.attachmentCount ?? 0,
childCustomerCount: current.rollups?.childCustomerCount,
},
}
: current
);
setContactEntryForm({
...emptyCrmContactEntryInput,
contactAt: new Date().toISOString(),
});
setContactEntryStatus("Timeline entry added.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save timeline entry.";
setContactEntryStatus(message);
} finally {
setIsSavingContactEntry(false);
}
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
<h3 className="mt-2 text-2xl font-bold text-text">{record.name}</h3>
<div className="mt-4">
<div className="flex flex-wrap gap-3">
<CrmStatusBadge status={record.status} />
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
</div>
</div>
<p className="mt-2 text-sm text-muted">
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
to={config.routeBase}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Back to {config.collectionLabel.toLowerCase()}
</Link>
{canManage ? (
<Link
to={`${config.routeBase}/${record.id}/edit`}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white"
>
Edit {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<article className="min-w-0 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">Contact</p>
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
<dd className="mt-1 text-sm text-text">{record.email}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt>
<dd className="mt-1 text-sm text-text">{record.phone}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Address</dt>
<dd className="mt-1 whitespace-pre-line text-sm text-text">
{[record.addressLine1, record.addressLine2, `${record.city}, ${record.state} ${record.postalCode}`, record.country]
.filter(Boolean)
.join("\n")}
</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial terms</dt>
<dd className="mt-2 grid gap-3 text-sm text-text md:grid-cols-2">
<div>Payment terms: {record.paymentTerms ?? "Not set"}</div>
<div>Currency: {record.currencyCode ?? "USD"}</div>
<div>Tax exempt: {record.taxExempt ? "Yes" : "No"}</div>
<div>Credit hold: {record.creditHold ? "Yes" : "No"}</div>
</dd>
</div>
</dl>
</article>
<article className="min-w-0 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">Internal Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">
{record.notes || "No internal notes recorded for this account yet."}
</p>
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
Created {new Date(record.createdAt).toLocaleDateString()}
</div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operational Flags</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em]">
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Preferred</span> : null}
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-3 py-1 text-text">Strategic</span> : null}
{record.requiresApproval ? <span className="rounded-full border border-amber-400/40 px-3 py-1 text-amber-700 dark:text-amber-300">Requires Approval</span> : null}
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-3 py-1 text-rose-700 dark:text-rose-300">Blocked</span> : null}
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
<span className="rounded-full border border-line/70 px-3 py-1 text-muted">Standard</span>
) : null}
</div>
</div>
{entity === "customer" ? (
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
<div className="mt-3 grid gap-3 text-sm text-text">
<div>
<span className="font-semibold">Account type:</span>{" "}
{record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"}
</div>
<div>
<span className="font-semibold">Discount:</span> {(record.resellerDiscountPercent ?? 0).toFixed(2)}%
</div>
<div>
<span className="font-semibold">Parent reseller:</span> {record.parentCustomerName ?? "None"}
</div>
<div>
<span className="font-semibold">Child accounts:</span> {record.childCustomers?.length ?? 0}
</div>
</div>
</div>
) : null}
</article>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<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">Last Contact</p>
<div className="mt-2 text-base font-bold text-text">
{record.rollups?.lastContactAt ? new Date(record.rollups.lastContactAt).toLocaleDateString() : "None"}
</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">Timeline Entries</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactHistoryCount ?? record.contactHistory.length}</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">Account Contacts</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.contactCount ?? record.contacts?.length ?? 0}</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">Attachments</p>
<div className="mt-2 text-base font-bold text-text">{record.rollups?.attachmentCount ?? 0}</div>
</article>
</section>
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
<section 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">Hierarchy</p>
<h4 className="mt-2 text-lg font-bold text-text">End customers under this reseller</h4>
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
{record.childCustomers?.map((child) => (
<Link
key={child.id}
to={`/crm/customers/${child.id}`}
className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 transition hover:border-brand/50 hover:bg-page/80"
>
<div className="text-sm font-semibold text-text">{child.name}</div>
<div className="mt-2">
<CrmStatusBadge status={child.status} />
</div>
</Link>
))}
</div>
</section>
) : null}
{entity === "vendor" ? (
<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">Purchasing Activity</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
</div>
<div className="flex flex-wrap gap-2">
{canManage ? (
<Link to={`/purchasing/orders/new?vendorId=${record.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New purchase order
</Link>
) : null}
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open purchasing
</Link>
</div>
</div>
{relatedPurchaseOrders.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 purchase orders exist for this vendor yet.</div>
) : (
<div className="mt-6 space-y-3">
{relatedPurchaseOrders.slice(0, 8).map((order) => (
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
</div>
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
</div>
</Link>
))}
</div>
)}
</section>
) : null}
<CrmContactsPanel
entity={entity}
ownerId={record.id}
contacts={record.contacts ?? []}
onContactsChange={(contacts: CrmContactDto[]) =>
setRecord((current) =>
current
? {
...current,
contacts,
rollups: {
lastContactAt: current.rollups?.lastContactAt ?? null,
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
contactCount: contacts.length,
attachmentCount: current.rollups?.attachmentCount ?? 0,
childCustomerCount: current.rollups?.childCustomerCount,
},
}
: current
)
}
/>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
{canManage ? (
<article className="min-w-0 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">Contact History</p>
<h4 className="mt-2 text-lg font-bold text-text">Add timeline entry</h4>
<p className="mt-2 text-sm text-muted">
Record calls, emails, meetings, and follow-up notes directly against this account.
</p>
<div className="mt-6">
<CrmContactEntryForm
form={contactEntryForm}
isSaving={isSavingContactEntry}
status={contactEntryStatus}
onChange={updateContactEntryField}
onSubmit={handleContactEntrySubmit}
/>
</div>
</article>
) : null}
<article className="min-w-0 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">Timeline</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent interactions</h4>
{record.contactHistory.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 contact history has been recorded for this account yet.
</div>
) : (
<div className="mt-6 space-y-3">
{record.contactHistory.map((entry) => (
<article key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<CrmContactTypeBadge type={entry.type} />
<h5 className="text-sm font-semibold text-text">{entry.summary}</h5>
</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{entry.body}</p>
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(entry.contactAt).toLocaleString()}</div>
<div className="mt-1">{entry.createdBy.name}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
<CrmAttachmentsPanel
ownerType={config.fileOwnerType}
ownerId={record.id}
onAttachmentCountChange={(attachmentCount) =>
setRecord((current) =>
current
? {
...current,
rollups: {
lastContactAt: current.rollups?.lastContactAt ?? null,
contactHistoryCount: current.rollups?.contactHistoryCount ?? current.contactHistory.length,
contactCount: current.rollups?.contactCount ?? current.contacts?.length ?? 0,
attachmentCount,
childCustomerCount: current.rollups?.childCustomerCount,
},
}
: current
)
}
/>
</section>
);
}

View File

@@ -0,0 +1,149 @@
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmRecordForm } from "./CrmRecordForm";
import { type CrmEntity, crmConfigs, emptyCrmRecordInput } from "./config";
interface CrmFormPageProps {
entity: CrmEntity;
mode: "create" | "edit";
}
export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
const navigate = useNavigate();
const { token } = useAuth();
const { customerId, vendorId } = useParams();
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
const [hierarchyOptions, setHierarchyOptions] = useState<CrmCustomerHierarchyOptionDto[]>([]);
const [status, setStatus] = useState(
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (entity !== "customer" || !token) {
return;
}
api
.getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined)
.then(setHierarchyOptions)
.catch(() => setHierarchyOptions([]));
}, [entity, mode, recordId, token]);
useEffect(() => {
if (mode !== "edit" || !token || !recordId) {
return;
}
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
loadRecord
.then((record) => {
setForm({
name: record.name,
email: record.email,
phone: record.phone,
addressLine1: record.addressLine1,
addressLine2: record.addressLine2,
city: record.city,
state: record.state,
postalCode: record.postalCode,
country: record.country,
status: record.status,
isReseller: record.isReseller ?? false,
resellerDiscountPercent: record.resellerDiscountPercent ?? 0,
parentCustomerId: record.parentCustomerId ?? null,
paymentTerms: record.paymentTerms ?? "Net 30",
currencyCode: record.currencyCode ?? "USD",
taxExempt: record.taxExempt ?? false,
creditHold: record.creditHold ?? false,
lifecycleStage: record.lifecycleStage ?? "ACTIVE",
preferredAccount: record.preferredAccount ?? false,
strategicAccount: record.strategicAccount ?? false,
requiresApproval: record.requiresApproval ?? false,
blockedAccount: record.blockedAccount ?? false,
notes: record.notes,
});
setStatus(`${config.singularLabel} record loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, entity, mode, recordId, token]);
function updateField<Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) {
setForm((current: CrmRecordInput) => ({ ...current, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
try {
const savedRecord =
entity === "customer"
? mode === "create"
? await api.createCustomer(token, form)
: await api.updateCustomer(token, recordId ?? "", form)
: mode === "create"
? await api.createVendor(token, form)
: await api.updateVendor(token, recordId ?? "", form);
navigate(`${config.routeBase}/${savedRecord.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows.
</p>
</div>
<Link
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button
type="submit"
disabled={isSaving}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel}` : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,22 @@
import type { CrmLifecycleStage } from "@mrp/shared/dist/crm/types.js";
import { crmLifecyclePalette } from "./config";
interface CrmLifecycleBadgeProps {
stage: CrmLifecycleStage;
}
const labels: Record<CrmLifecycleStage, string> = {
PROSPECT: "Prospect",
ACTIVE: "Active",
DORMANT: "Dormant",
CHURNED: "Churned",
};
export function CrmLifecycleBadge({ stage }: CrmLifecycleBadgeProps) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${crmLifecyclePalette[stage]}`}>
{labels[stage]}
</span>
);
}

View File

@@ -0,0 +1,212 @@
import { permissions } from "@mrp/shared";
import type { CrmLifecycleStage, CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmLifecycleBadge } from "./CrmLifecycleBadge";
import { CrmStatusBadge } from "./CrmStatusBadge";
import { crmLifecycleFilters, crmOperationalFilters, crmStatusFilters, type CrmEntity, crmConfigs } from "./config";
interface CrmListPageProps {
entity: CrmEntity;
}
export function CrmListPage({ entity }: CrmListPageProps) {
const { token, user } = useAuth();
const config = crmConfigs[entity];
const [records, setRecords] = useState<CrmRecordSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [stateFilter, setStateFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL");
const [lifecycleFilter, setLifecycleFilter] = useState<"ALL" | CrmLifecycleStage>("ALL");
const [operationalFilter, setOperationalFilter] = useState<"ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED">(
"ALL"
);
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
const filters = {
q: searchTerm.trim() || undefined,
state: stateFilter.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
lifecycleStage: lifecycleFilter === "ALL" ? undefined : lifecycleFilter,
flag: operationalFilter === "ALL" ? undefined : operationalFilter,
};
const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters);
loadRecords
.then((nextRecords) => {
setRecords(nextRecords);
setStatus(`${nextRecords.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.collectionLabel, entity, lifecycleFilter, operationalFilter, searchTerm, stateFilter, statusFilter, token]);
return (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}.
</p>
</div>
{canManage ? (
<Link
to={`${config.routeBase}/new`}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white"
>
New {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr_0.8fr_0.9fr_0.9fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={`Search ${config.collectionLabel.toLowerCase()} by company, email, phone, or location`}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | CrmRecordStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Lifecycle</span>
<select
value={lifecycleFilter}
onChange={(event) => setLifecycleFilter(event.target.value as "ALL" | CrmLifecycleStage)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmLifecycleFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">State / Province</span>
<input
value={stateFilter}
onChange={(event) => setStateFilter(event.target.value)}
placeholder="Filter by region"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Operational</span>
<select
value={operationalFilter}
onChange={(event) =>
setOperationalFilter(event.target.value as "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED")
}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmOperationalFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{records.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">
{config.emptyMessage}
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Name</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Lifecycle</th>
<th className="px-2 py-2">Operational</th>
<th className="px-2 py-2">Activity</th>
<th className="px-2 py-2">Contact</th>
<th className="px-2 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{records.map((record) => (
<tr key={record.id} className="transition hover:bg-page/70">
<td className="px-2 py-2 font-semibold text-text">
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
{record.name}
</Link>
{entity === "customer" && (record.isReseller || record.parentCustomerName) ? (
<div className="mt-1 flex flex-wrap gap-2 text-xs font-medium text-muted">
{record.isReseller ? <span>Reseller</span> : null}
{record.parentCustomerName ? <span>Child of {record.parentCustomerName}</span> : null}
</div>
) : null}
</td>
<td className="px-2 py-2">
<CrmStatusBadge status={record.status} />
</td>
<td className="px-2 py-2 text-muted">
{record.lifecycleStage ? <CrmLifecycleBadge stage={record.lifecycleStage} /> : null}
</td>
<td className="px-2 py-2 text-xs text-muted">
<div className="flex flex-wrap gap-2">
{record.preferredAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Preferred</span> : null}
{record.strategicAccount ? <span className="rounded-full border border-line/70 px-2 py-1">Strategic</span> : null}
{record.requiresApproval ? <span className="rounded-full border border-line/70 px-2 py-1">Approval</span> : null}
{record.blockedAccount ? <span className="rounded-full border border-rose-400/40 px-2 py-1 text-rose-600 dark:text-rose-300">Blocked</span> : null}
{!record.preferredAccount && !record.strategicAccount && !record.requiresApproval && !record.blockedAccount ? (
<span>Standard</span>
) : null}
</div>
</td>
<td className="px-2 py-2 text-xs text-muted">
<div>{record.rollups?.contactHistoryCount ?? 0} timeline entries</div>
<div>{record.rollups?.attachmentCount ?? 0} attachments</div>
{entity === "customer" ? <div>{record.rollups?.childCustomerCount ?? 0} child accounts</div> : null}
</td>
<td className="px-2 py-2 text-muted">
<div>{record.email}</div>
<div className="mt-1 text-xs">
{record.city}, {record.state}, {record.country}
</div>
<div className="mt-1 text-xs">{record.phone}</div>
</td>
<td className="px-2 py-2 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,199 @@
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { crmLifecycleOptions, crmStatusOptions } from "./config";
import type { CrmEntity } from "./config";
const fields: Array<{
key: "name" | "email" | "phone" | "addressLine1" | "addressLine2" | "city" | "state" | "postalCode" | "country";
label: string;
type?: string;
}> = [
{ key: "name", label: "Company name" },
{ key: "email", label: "Email", type: "email" },
{ key: "phone", label: "Phone" },
{ key: "addressLine1", label: "Address line 1" },
{ key: "addressLine2", label: "Address line 2" },
{ key: "city", label: "City" },
{ key: "state", label: "State / Province" },
{ key: "postalCode", label: "Postal code" },
{ key: "country", label: "Country" },
];
interface CrmRecordFormProps {
entity: CrmEntity;
form: CrmRecordInput;
hierarchyOptions?: CrmCustomerHierarchyOptionDto[];
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
}
export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: CrmRecordFormProps) {
return (
<>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={form.status}
onChange={(event) => onChange("status", event.target.value as CrmRecordInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Lifecycle stage</span>
<select
value={form.lifecycleStage ?? "ACTIVE"}
onChange={(event) => onChange("lifecycleStage", event.target.value as CrmRecordInput["lifecycleStage"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{crmLifecycleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{entity === "customer" ? (
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reseller account</span>
<select
value={form.isReseller ? "yes" : "no"}
onChange={(event) => onChange("isReseller", event.target.value === "yes")}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="no">Standard customer</option>
<option value="yes">Reseller</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reseller discount %</span>
<input
type="number"
min={0}
max={100}
step={0.01}
value={form.resellerDiscountPercent ?? 0}
disabled={!form.isReseller}
onChange={(event) =>
onChange("resellerDiscountPercent", event.target.value === "" ? 0 : Number(event.target.value))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Parent reseller</span>
<select
value={form.parentCustomerId ?? ""}
onChange={(event) => onChange("parentCustomerId", event.target.value || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">No parent reseller</option>
{hierarchyOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name} {option.isReseller ? "(Reseller)" : ""}
</option>
))}
</select>
</label>
</div>
) : null}
<div className="grid gap-3 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Payment terms</span>
<input
value={form.paymentTerms ?? ""}
onChange={(event) => onChange("paymentTerms", event.target.value || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
placeholder="Net 30"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Currency</span>
<input
value={form.currencyCode ?? "USD"}
onChange={(event) => onChange("currencyCode", event.target.value.toUpperCase() || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
maxLength={8}
/>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.taxExempt ?? false}
onChange={(event) => onChange("taxExempt", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Tax exempt</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.creditHold ?? false}
onChange={(event) => onChange("creditHold", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Credit hold</span>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.preferredAccount ?? false}
onChange={(event) => onChange("preferredAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Preferred account</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.strategicAccount ?? false}
onChange={(event) => onChange("strategicAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Strategic account</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.requiresApproval ?? false}
onChange={(event) => onChange("requiresApproval", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Requires approval</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input
type="checkbox"
checked={form.blockedAccount ?? false}
onChange={(event) => onChange("blockedAccount", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Blocked account</span>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
{fields.map((field) => (
<label key={String(field.key)} className="block">
<span className="mb-2 block text-sm font-semibold text-text">{field.label}</span>
<input
type={field.type ?? "text"}
value={form[field.key]}
onChange={(event) => onChange(field.key, event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
))}
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Internal notes</span>
<textarea
value={form.notes}
onChange={(event) => onChange("notes", event.target.value)}
rows={5}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</>
);
}

View File

@@ -0,0 +1,13 @@
import type { CrmRecordStatus } from "@mrp/shared/dist/crm/types.js";
import { crmStatusOptions, crmStatusPalette } from "./config";
export function CrmStatusBadge({ status }: { status: CrmRecordStatus }) {
const label = crmStatusOptions.find((option) => option.value === status)?.label ?? status;
return (
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmStatusPalette[status]}`}>
{label}
</span>
);
}

View File

@@ -0,0 +1,5 @@
import { CrmListPage } from "./CrmListPage";
export function CustomersPage() {
return <CrmListPage entity="customer" />;
}

View File

@@ -0,0 +1,5 @@
import { CrmListPage } from "./CrmListPage";
export function VendorsPage() {
return <CrmListPage entity="vendor" />;
}

View File

@@ -0,0 +1,159 @@
import {
crmContactRoles,
crmContactEntryTypes,
crmLifecycleStages,
crmRecordStatuses,
type CrmContactInput,
type CrmContactRole,
type CrmContactEntryInput,
type CrmContactEntryType,
type CrmLifecycleStage,
type CrmRecordInput,
type CrmRecordStatus,
} from "@mrp/shared/dist/crm/types.js";
export type CrmEntity = "customer" | "vendor";
interface CrmModuleConfig {
entity: CrmEntity;
collectionLabel: string;
singularLabel: string;
routeBase: string;
fileOwnerType: string;
emptyMessage: string;
}
export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
customer: {
entity: "customer",
collectionLabel: "Customers",
singularLabel: "Customer",
routeBase: "/crm/customers",
fileOwnerType: "crm-customer",
emptyMessage: "No customer accounts have been added yet.",
},
vendor: {
entity: "vendor",
collectionLabel: "Vendors",
singularLabel: "Vendor",
routeBase: "/crm/vendors",
fileOwnerType: "crm-vendor",
emptyMessage: "No vendor records have been added yet.",
},
};
export const emptyCrmRecordInput: CrmRecordInput = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "USA",
status: "ACTIVE",
notes: "",
isReseller: false,
resellerDiscountPercent: 0,
parentCustomerId: null,
paymentTerms: "Net 30",
currencyCode: "USD",
taxExempt: false,
creditHold: false,
lifecycleStage: "ACTIVE",
preferredAccount: false,
strategicAccount: false,
requiresApproval: false,
blockedAccount: false,
};
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
{ value: "LEAD", label: "Lead" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "INACTIVE", label: "Inactive" },
];
export const crmStatusFilters: Array<{ value: "ALL" | CrmRecordStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...crmStatusOptions,
];
export const crmLifecycleOptions: Array<{ value: CrmLifecycleStage; label: string }> = [
{ value: "PROSPECT", label: "Prospect" },
{ value: "ACTIVE", label: "Active" },
{ value: "DORMANT", label: "Dormant" },
{ value: "CHURNED", label: "Churned" },
];
export const crmLifecycleFilters: Array<{ value: "ALL" | CrmLifecycleStage; label: string }> = [
{ value: "ALL", label: "All lifecycle stages" },
...crmLifecycleOptions,
];
export const crmOperationalFilters: Array<{
value: "ALL" | "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
label: string;
}> = [
{ value: "ALL", label: "All accounts" },
{ value: "PREFERRED", label: "Preferred only" },
{ value: "STRATEGIC", label: "Strategic only" },
{ value: "REQUIRES_APPROVAL", label: "Requires approval" },
{ value: "BLOCKED", label: "Blocked only" },
];
export const crmStatusPalette: Record<CrmRecordStatus, string> = {
LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const crmLifecyclePalette: Record<CrmLifecycleStage, string> = {
PROSPECT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
DORMANT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
CHURNED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const emptyCrmContactEntryInput: CrmContactEntryInput = {
type: "NOTE",
summary: "",
body: "",
contactAt: new Date().toISOString(),
};
export const emptyCrmContactInput: CrmContactInput = {
fullName: "",
role: "PRIMARY",
email: "",
phone: "",
isPrimary: true,
};
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
{ value: "NOTE", label: "Note" },
{ value: "CALL", label: "Call" },
{ value: "EMAIL", label: "Email" },
{ value: "MEETING", label: "Meeting" },
];
export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
NOTE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
CALL: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
EMAIL: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string }> = [
{ value: "PRIMARY", label: "Primary" },
{ value: "PURCHASING", label: "Purchasing" },
{ value: "AP", label: "Accounts Payable" },
{ value: "SHIPPING", label: "Shipping" },
{ value: "ENGINEERING", label: "Engineering" },
{ value: "SALES", label: "Sales" },
{ value: "OTHER", label: "Other" },
];
export { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses };

View File

@@ -0,0 +1,593 @@
import { permissions } from "@mrp/shared";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api";
interface DashboardSnapshot {
customers: Awaited<ReturnType<typeof api.getCustomers>> | null;
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
projects: Awaited<ReturnType<typeof api.getProjects>> | null;
planningRollup: DemandPlanningRollupDto | null;
refreshedAt: string;
}
function hasPermission(userPermissions: string[] | undefined, permission: string) {
return Boolean(userPermissions?.includes(permission));
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
}
function sumNumber(values: number[]) {
return values.reduce((total, value) => total + value, 0);
}
function formatPercent(value: number, total: number) {
if (total <= 0) {
return "0%";
}
return `${Math.round((value / total) * 100)}%`;
}
function ProgressBar({
value,
total,
tone,
}: {
value: number;
total: number;
tone: string;
}) {
const width = total > 0 ? Math.max(6, Math.round((value / total) * 100)) : 0;
return (
<div className="h-2 overflow-hidden rounded-full bg-page/80">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.min(width, 100)}%` }} />
</div>
);
}
function StackedBar({
segments,
}: {
segments: Array<{ value: number; tone: string }>;
}) {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
return (
<div className="flex h-3 overflow-hidden rounded-full bg-page/80">
{segments.map((segment, index) => {
const width = total > 0 ? (segment.value / total) * 100 : 0;
return <div key={`${segment.tone}-${index}`} className={segment.tone} style={{ width: `${width}%` }} />;
})}
</div>
);
}
function DashboardCard({
eyebrow,
title,
children,
className = "",
}: {
eyebrow: string;
title: string;
children: ReactNode;
className?: string;
}) {
return (
<article className={`rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${className}`.trim()}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
{children}
</article>
);
}
export function DashboardPage() {
const { token, user } = useAuth();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token || !user) {
setSnapshot(null);
setIsLoading(false);
return;
}
const authToken = token;
let isMounted = true;
setIsLoading(true);
setError(null);
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
const canReadProjects = hasPermission(user.permissions, permissions.projectsRead);
async function loadSnapshot() {
const results = await Promise.allSettled([
canReadCrm ? api.getCustomers(authToken) : Promise.resolve(null),
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
]);
if (!isMounted) {
return;
}
const firstRejected = results.find((result) => result.status === "rejected");
if (firstRejected?.status === "rejected") {
const reason = firstRejected.reason;
setError(reason instanceof ApiError ? reason.message : "Unable to load dashboard data.");
}
setSnapshot({
customers: results[0].status === "fulfilled" ? results[0].value : null,
vendors: results[1].status === "fulfilled" ? results[1].value : null,
items: results[2].status === "fulfilled" ? results[2].value : null,
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
workOrders: results[5].status === "fulfilled" ? results[5].value : null,
quotes: results[6].status === "fulfilled" ? results[6].value : null,
orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null,
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
}
loadSnapshot().catch((loadError) => {
if (!isMounted) {
return;
}
setError(loadError instanceof ApiError ? loadError.message : "Unable to load dashboard data.");
setSnapshot(null);
setIsLoading(false);
});
return () => {
isMounted = false;
};
}, [token, user]);
const customers = snapshot?.customers ?? [];
const vendors = snapshot?.vendors ?? [];
const items = snapshot?.items ?? [];
const warehouses = snapshot?.warehouses ?? [];
const purchaseOrders = snapshot?.purchaseOrders ?? [];
const workOrders = snapshot?.workOrders ?? [];
const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? [];
const projects = snapshot?.projects ?? [];
const planningRollup = snapshot?.planningRollup;
const customerCount = customers.length;
const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length;
const resellerCount = customers.filter((customer) => customer.isReseller).length;
const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length;
const vendorCount = vendors.length;
const itemCount = items.length;
const activeItemCount = items.filter((item) => item.status === "ACTIVE").length;
const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length;
const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length;
const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
const purchaseOrderCount = purchaseOrders.length;
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const closedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "CLOSED").length;
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
const workOrderCount = workOrders.length;
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
const inProgressWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "IN_PROGRESS").length;
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
const quoteCount = quotes.length;
const orderCount = orders.length;
const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length;
const approvedQuoteCount = quotes.filter((quote) => quote.status === "APPROVED").length;
const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const quoteValue = sumNumber(quotes.map((quote) => quote.total));
const orderValue = sumNumber(orders.map((order) => order.total));
const shipmentCount = shipments.length;
const activeShipmentCount = shipments.filter((shipment) => shipment.status !== "DELIVERED").length;
const inTransitCount = shipments.filter((shipment) => shipment.status === "SHIPPED").length;
const deliveredCount = shipments.filter((shipment) => shipment.status === "DELIVERED").length;
const projectCount = projects.length;
const activeProjectCount = projects.filter((project) => project.status === "ACTIVE").length;
const atRiskProjectCount = projects.filter((project) => project.status === "AT_RISK").length;
const overdueProjectCount = projects.filter((project) => {
if (!project.dueDate || project.status === "COMPLETE") {
return false;
}
return new Date(project.dueDate).getTime() < Date.now();
}).length;
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
const planningItemCount = planningRollup?.summary.itemCount ?? 0;
const metricCards = [
{
label: "Accounts",
value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access",
secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "",
tone: "bg-emerald-500",
},
{
label: "Inventory",
value: snapshot?.items !== null ? `${itemCount}` : "No access",
secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "",
tone: "bg-sky-500",
},
{
label: "Open Supply",
value: snapshot?.purchaseOrders !== null || snapshot?.workOrders !== null ? `${openPurchaseOrderCount + activeWorkOrderCount}` : "No access",
secondary: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount} PO | ${activeWorkOrderCount} WO` : "",
tone: "bg-teal-500",
},
{
label: "Commercial",
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "",
tone: "bg-amber-500",
},
{
label: "Projects",
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "",
tone: "bg-violet-500",
},
{
label: "Readiness",
value: planningRollup ? `${shortageItemCount}` : "No access",
secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "",
tone: "bg-rose-500",
},
];
return (
<div className="space-y-4">
{error ? <div className="rounded-[18px] border border-amber-400/30 bg-amber-500/12 px-3 py-3 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
<section className="grid gap-3 xl:grid-cols-6">
{metricCards.map((card) => (
<article key={card.label} 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">{card.label}</p>
<div className="mt-2 text-xl font-extrabold text-text">{isLoading ? "Loading..." : card.value}</div>
<div className="mt-2 flex items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-page/80">
<div className={`h-full rounded-full ${card.tone}`} style={{ width: isLoading ? "35%" : "100%" }} />
</div>
<span className="text-xs text-muted">Live</span>
</div>
{card.secondary ? <div className="mt-2 text-xs text-muted">{card.secondary}</div> : null}
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-[1.2fr_0.8fr]">
<DashboardCard eyebrow="Commercial Surface" title="Revenue and document mix">
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quotes</div>
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}</div>
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs text-muted">
<span>Draft</span>
<span>{draftQuoteCount}</span>
</div>
<ProgressBar value={draftQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-amber-500" />
<div className="flex items-center justify-between text-xs text-muted">
<span>Approved</span>
<span>{approvedQuoteCount}</span>
</div>
<ProgressBar value={approvedQuoteCount} total={Math.max(quoteCount, 1)} tone="bg-emerald-500" />
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Orders</div>
<div className="mt-2 text-2xl font-bold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<div className="text-xs text-muted">Issued / approved</div>
<div className="mt-1 text-lg font-semibold text-text">{issuedOrderCount}</div>
</div>
<div>
<div className="text-xs text-muted">Total orders</div>
<div className="mt-1 text-lg font-semibold text-text">{orderCount}</div>
</div>
</div>
<div className="mt-3">
<ProgressBar value={issuedOrderCount} total={Math.max(orderCount, 1)} tone="bg-brand" />
</div>
</div>
</div>
</DashboardCard>
<DashboardCard eyebrow="CRM Footprint" title="Customer and vendor balance">
<div className="mt-4 space-y-4">
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted">Active customers</span>
<span className="font-semibold text-text">{snapshot?.customers !== null ? formatPercent(activeCustomerCount, Math.max(customerCount, 1)) : "No access"}</span>
</div>
<div className="mt-2">
<ProgressBar value={activeCustomerCount} total={Math.max(customerCount, 1)} tone="bg-emerald-500" />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Customers</div>
<div className="mt-1 text-lg font-bold text-text">{customerCount}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Resellers</div>
<div className="mt-1 text-lg font-bold text-text">{resellerCount}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Vendors</div>
<div className="mt-1 text-lg font-bold text-text">{vendorCount}</div>
</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted">Strategic accounts</span>
<span className="font-semibold text-text">{strategicCustomerCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={strategicCustomerCount} total={Math.max(customerCount, 1)} tone="bg-violet-500" />
</div>
</div>
</div>
</DashboardCard>
</section>
<section className="grid gap-3 xl:grid-cols-3">
<DashboardCard eyebrow="Inventory and Supply" title="Stock posture">
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Item mix</div>
<div className="mt-3 space-y-3">
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Active items</span>
<span>{activeItemCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={activeItemCount} total={Math.max(itemCount, 1)} tone="bg-sky-500" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Buildable items</span>
<span>{assemblyCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={assemblyCount} total={Math.max(itemCount, 1)} tone="bg-indigo-500" />
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs text-muted">
<span>Obsolete items</span>
<span>{obsoleteItemCount}</span>
</div>
<div className="mt-2">
<ProgressBar value={obsoleteItemCount} total={Math.max(itemCount, 1)} tone="bg-slate-500" />
</div>
</div>
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Storage surface</div>
<div className="mt-3 grid gap-3">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Warehouses</div>
<div className="mt-1 text-lg font-bold text-text">{warehouseCount}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Locations</div>
<div className="mt-1 text-lg font-bold text-text">{locationCount}</div>
</div>
</div>
</div>
</div>
</DashboardCard>
<DashboardCard eyebrow="Supply Execution" title="Purchasing and manufacturing flow">
<div className="mt-4 rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Open workload split</div>
<div className="mt-3">
<StackedBar
segments={[
{ value: openPurchaseOrderCount, tone: "bg-teal-500" },
{ value: releasedWorkOrderCount, tone: "bg-indigo-500" },
{ value: inProgressWorkOrderCount, tone: "bg-amber-500" },
{ value: overdueWorkOrderCount, tone: "bg-rose-500" },
]}
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Open PO queue</div>
<div className="mt-1 text-lg font-bold text-text">{openPurchaseOrderCount}</div>
<div className="mt-1 text-xs text-muted">{formatCurrency(purchaseOrderValue)} committed</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Active work orders</div>
<div className="mt-1 text-lg font-bold text-text">{activeWorkOrderCount}</div>
<div className="mt-1 text-xs text-muted">{overdueWorkOrderCount} overdue</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Issued / approved POs</div>
<div className="mt-1 text-lg font-bold text-text">{issuedPurchaseOrderCount}</div>
</div>
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
<div className="text-xs text-muted">Released WOs</div>
<div className="mt-1 text-lg font-bold text-text">{releasedWorkOrderCount}</div>
</div>
</div>
</div>
</DashboardCard>
<DashboardCard eyebrow="Readiness" title="Planning pressure">
<div className="mt-4 space-y-3">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted">Shortage items</span>
<span className="font-semibold text-text">{planningRollup ? shortageItemCount : "No access"}</span>
</div>
<div className="mt-2">
<ProgressBar value={shortageItemCount} total={Math.max(planningItemCount, 1)} tone="bg-rose-500" />
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Build vs buy</div>
<div className="mt-3">
<StackedBar
segments={[
{ value: buildRecommendationCount, tone: "bg-indigo-500" },
{ value: buyRecommendationCount, tone: "bg-teal-500" },
]}
/>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<div className="text-xs text-muted">Build recommendations</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buildRecommendationCount : "No access"}</div>
</div>
<div>
<div className="text-xs text-muted">Buy recommendations</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? buyRecommendationCount : "No access"}</div>
</div>
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs text-muted">Uncovered quantity</div>
<div className="mt-1 text-lg font-bold text-text">{planningRollup ? totalUncoveredQuantity : "No access"}</div>
</div>
</div>
</DashboardCard>
</section>
<section className="grid gap-3 xl:grid-cols-[0.95fr_1.05fr]">
<DashboardCard eyebrow="Programs" title="Project and shipment execution">
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Projects</div>
<div className="mt-3">
<StackedBar
segments={[
{ value: activeProjectCount, tone: "bg-violet-500" },
{ value: atRiskProjectCount, tone: "bg-amber-500" },
{ value: overdueProjectCount, tone: "bg-rose-500" },
]}
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-muted">Active</div>
<div className="mt-1 font-semibold text-text">{activeProjectCount}</div>
</div>
<div>
<div className="text-xs text-muted">At risk</div>
<div className="mt-1 font-semibold text-text">{atRiskProjectCount}</div>
</div>
<div>
<div className="text-xs text-muted">Overdue</div>
<div className="mt-1 font-semibold text-text">{overdueProjectCount}</div>
</div>
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipping</div>
<div className="mt-3">
<StackedBar
segments={[
{ value: activeShipmentCount, tone: "bg-brand" },
{ value: inTransitCount, tone: "bg-sky-500" },
{ value: deliveredCount, tone: "bg-emerald-500" },
]}
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-muted">Open</div>
<div className="mt-1 font-semibold text-text">{activeShipmentCount}</div>
</div>
<div>
<div className="text-xs text-muted">In transit</div>
<div className="mt-1 font-semibold text-text">{inTransitCount}</div>
</div>
<div>
<div className="text-xs text-muted">Delivered</div>
<div className="mt-1 font-semibold text-text">{deliveredCount}</div>
</div>
</div>
</div>
</div>
</DashboardCard>
<DashboardCard eyebrow="Operations Mix" title="Cross-module volume">
<div className="mt-4 space-y-3">
{[
{ label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" },
{ label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" },
{ label: "Sales orders", value: orderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-amber-500" },
{ label: "Purchase orders", value: purchaseOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-teal-500" },
{ label: "Work orders", value: workOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-indigo-500" },
{ label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" },
{ label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" },
].map((row) => (
<div key={row.label} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted">{row.label}</span>
<span className="font-semibold text-text">{row.value}</span>
</div>
<div className="mt-2">
<ProgressBar value={row.value} total={row.total} tone={row.tone} />
</div>
</div>
))}
</div>
{snapshot ? <div className="mt-4 text-xs text-muted">Refreshed {new Date(snapshot.refreshedAt).toLocaleString()}</div> : null}
</DashboardCard>
</section>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from "react";
import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css";
import { Link } from "react-router-dom";
import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
function formatDate(value: string | null) {
if (!value) {
return "Unscheduled";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(new Date(value));
}
export function GanttPage() {
const { token } = useAuth();
const { mode } = useTheme();
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => {
if (!token) {
return;
}
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.then(([data, rollup]) => {
setTimeline(data);
setPlanningRollup(rollup);
setStatus("Planning timeline loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
setStatus(message);
});
}, [token]);
const tasks = timeline?.tasks ?? [];
const links = timeline?.links ?? [];
const summary = timeline?.summary;
const exceptions = timeline?.exceptions ?? [];
const ganttCellHeight = 44;
const ganttScaleHeight = 56;
const ganttHeight = Math.max(420, tasks.length * ganttCellHeight + ganttScaleHeight);
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place.
</p>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
<div className="mt-2 font-semibold text-text">{status}</div>
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-6">
<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">Active Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</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">At Risk</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</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">Overdue Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</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">Active Work Orders</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</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">Overdue Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</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">Unscheduled Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</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">Shortage Items</p>
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</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">Build / Buy</p>
<div className="mt-2 text-xl font-extrabold text-text">
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div
className={`gantt-theme overflow-x-auto overflow-y-visible rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
<p className="mt-2 text-sm text-muted">
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
{tasks.length} schedule rows
</div>
</div>
<div style={{ height: `${ganttHeight}px`, minWidth: "100%" }}>
<Gantt
tasks={tasks.map((task: GanttTaskDto) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
parent: task.parentId ?? undefined,
}))}
links={links}
cellHeight={ganttCellHeight}
scaleHeight={ganttScaleHeight}
/>
</div>
</div>
<aside className="space-y-3">
<section 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">Planning Exceptions</p>
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
{exceptions.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No planning exceptions are active.
</div>
) : (
<div className="mt-5 space-y-3">
{exceptions.map((exception: PlanningExceptionDto) => (
<Link key={exception.id} to={exception.detailHref} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
<div className="mt-1 font-semibold text-text">{exception.title}</div>
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
</div>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
{exception.status.replaceAll("_", " ")}
</span>
</div>
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
</Link>
))}
</div>
)}
</section>
<section 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">Planner Actions</p>
<div className="mt-4 space-y-2 rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Uncovered quantity</span>
<span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Projects with linked demand</span>
<span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open projects
</Link>
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open work orders
</Link>
<Link to="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to dashboard
</Link>
</div>
</section>
</aside>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { inventoryFileOwnerType } from "./config";
export function InventoryAttachmentsPanel({
itemId,
onAttachmentCountChange,
}: {
itemId: string;
onAttachmentCountChange?: (count: number) => void;
}) {
return (
<FileAttachmentsPanel
ownerType={inventoryFileOwnerType}
ownerId={itemId}
eyebrow="Attachments"
title="Drawings and support docs"
description="Store drawings, cut sheets, work instructions, and other manufacturing support files on the item record."
emptyMessage="No drawings or support documents have been added to this item yet."
onAttachmentCountChange={onAttachmentCountChange}
/>
);
}

View File

@@ -0,0 +1,722 @@
import type {
InventoryItemDetailDto,
InventoryReservationInput,
InventoryTransactionInput,
InventoryTransferInput,
WarehouseLocationOptionDto,
} from "@mrp/shared/dist/inventory/types.js";
import type { FileAttachmentDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyInventoryTransactionInput, inventoryThumbnailOwnerType, inventoryTransactionOptions } from "./config";
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
const emptyTransferInput: InventoryTransferInput = {
quantity: 1,
fromWarehouseId: "",
fromLocationId: "",
toWarehouseId: "",
toLocationId: "",
notes: "",
};
const emptyReservationInput: InventoryReservationInput = {
quantity: 1,
warehouseId: null,
locationId: null,
notes: "",
};
export function InventoryDetailPage() {
const { token, user } = useAuth();
const { itemId } = useParams();
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
const [isSavingReservation, setIsSavingReservation] = useState(false);
const [status, setStatus] = useState("Loading inventory item...");
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "transaction" | "transfer" | "reservation";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
function replaceThumbnailPreview(nextUrl: string | null) {
setThumbnailPreviewUrl((current) => {
if (current) {
window.URL.revokeObjectURL(current);
}
return nextUrl;
});
}
useEffect(() => {
if (!token || !itemId) {
return;
}
api
.getInventoryItem(token, itemId)
.then((nextItem) => {
setItem(nextItem);
setStatus("Inventory item loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
api
.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
const firstOption = options[0];
if (!firstOption) {
return;
}
setTransactionForm((current) => ({
...current,
warehouseId: current.warehouseId || firstOption.warehouseId,
locationId: current.locationId || firstOption.locationId,
}));
setTransferForm((current) => ({
...current,
fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
fromLocationId: current.fromLocationId || firstOption.locationId,
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
toLocationId: current.toLocationId || firstOption.locationId,
}));
})
.catch(() => setLocationOptions([]));
}, [itemId, token]);
useEffect(() => {
return () => {
if (thumbnailPreviewUrl) {
window.URL.revokeObjectURL(thumbnailPreviewUrl);
}
};
}, [thumbnailPreviewUrl]);
useEffect(() => {
if (!token || !itemId || !canReadFiles) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
return;
}
let cancelled = false;
const activeToken: string = token;
const activeItemId: string = itemId;
async function loadThumbnail() {
const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId);
const latestAttachment = attachments[0] ?? null;
if (!latestAttachment) {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
return;
}
const blob = await api.getFileContentBlob(activeToken, latestAttachment.id);
if (!cancelled) {
setThumbnailAttachment(latestAttachment);
replaceThumbnailPreview(window.URL.createObjectURL(blob));
}
}
void loadThumbnail().catch(() => {
if (!cancelled) {
setThumbnailAttachment(null);
replaceThumbnailPreview(null);
}
});
return () => {
cancelled = true;
};
}, [canReadFiles, itemId, token]);
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
setTransactionForm((current) => ({ ...current, [key]: value }));
}
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
setTransferForm((current) => ({ ...current, [key]: value }));
}
async function submitTransaction() {
if (!token || !itemId) {
return;
}
setIsSavingTransaction(true);
setTransactionStatus("Saving stock transaction...");
try {
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
setItem(nextItem);
setTransactionStatus("Stock transaction recorded. If this was posted in error, create an offsetting stock entry and verify the result in Recent Movements.");
setTransactionForm((current) => ({
...emptyInventoryTransactionInput,
transactionType: current.transactionType,
warehouseId: current.warehouseId,
locationId: current.locationId,
}));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
setTransactionStatus(message);
} finally {
setIsSavingTransaction(false);
}
}
async function submitTransfer() {
if (!token || !itemId) {
return;
}
setIsSavingTransfer(true);
setTransferStatus("Saving transfer...");
try {
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
setItem(nextItem);
setTransferStatus("Transfer recorded. Review stock balances on both locations and post a return transfer if this movement was entered incorrectly.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
setTransferStatus(message);
} finally {
setIsSavingTransfer(false);
}
}
async function submitReservation() {
if (!token || !itemId) {
return;
}
setIsSavingReservation(true);
setReservationStatus("Saving reservation...");
try {
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
setItem(nextItem);
setReservationStatus("Reservation recorded. Verify available stock and add a compensating reservation change if this demand hold was entered incorrectly.");
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
setReservationStatus(message);
} finally {
setIsSavingReservation(false);
}
}
function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
const transactionLabel = inventoryTransactionOptions.find((option) => option.value === transactionForm.transactionType)?.label ?? "transaction";
setPendingConfirmation({
kind: "transaction",
title: `Post ${transactionLabel.toLowerCase()}`,
description: `Post a ${transactionLabel.toLowerCase()} of ${transactionForm.quantity} units for ${item.sku} at the selected stock location.`,
impact:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "This reduces available inventory immediately and affects downstream shortage and readiness calculations."
: "This updates the stock ledger immediately and becomes part of the item transaction history.",
recovery: "If this is incorrect, post an explicit offsetting transaction instead of editing history.",
confirmLabel: `Post ${transactionLabel.toLowerCase()}`,
confirmationLabel:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? "Type item SKU to confirm:"
: undefined,
confirmationValue:
transactionForm.transactionType === "ISSUE" || transactionForm.transactionType === "ADJUSTMENT_OUT"
? item.sku
: undefined,
});
}
function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "transfer",
title: "Post inventory transfer",
description: `Move ${transferForm.quantity} units of ${item.sku} between the selected source and destination locations.`,
impact: "This creates paired stock movement entries and changes both source and destination availability immediately.",
recovery: "If the move was entered incorrectly, post a reversing transfer back to the original location.",
confirmLabel: "Post transfer",
});
}
function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!item) {
return;
}
setPendingConfirmation({
kind: "reservation",
title: "Create manual reservation",
description: `Reserve ${reservationForm.quantity} units of ${item.sku}${reservationForm.locationId ? " at the selected location" : ""}.`,
impact: "This reduces available quantity used by planning, purchasing, manufacturing, and readiness views.",
recovery: "Add the correcting reservation entry if this hold should be reduced or removed.",
confirmLabel: "Create reservation",
});
}
if (!item) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3>
<p className="mt-1 text-sm text-text">{item.name}</p>
<div className="mt-4 flex flex-wrap gap-3">
<InventoryTypeBadge type={item.type} />
<InventoryStatusBadge status={item.status} />
</div>
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to items
</Link>
{canManage ? (
<Link to={`/inventory/items/${item.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
Edit item
</Link>
) : null}
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-7">
<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">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</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">Reserved</p>
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</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">Available</p>
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</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">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</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">Transactions</p>
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</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">Transfers</p>
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</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">Reservations</p>
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,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">Item Definition</p>
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
<dd className="mt-1 text-sm leading-6 text-text">{item.description || "No description provided."}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unit of measure</dt>
<dd className="mt-2 text-sm text-text">{item.unitOfMeasure}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default cost</dt>
<dd className="mt-2 text-sm text-text">{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<dd className="mt-2 text-sm text-text">
{item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"}
</dd>
</div>
</dl>
</article>
<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">Thumbnail</p>
<div className="mt-4 overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt={`${item.sku} thumbnail`} className="aspect-square w-full object-cover" />
) : (
<div className="flex aspect-square items-center justify-center px-4 text-center text-sm text-muted">
No thumbnail image has been attached to this item.
</div>
)}
</div>
<div className="mt-3 text-xs text-muted">
{thumbnailAttachment ? `Current file: ${thumbnailAttachment.originalName}` : "Add or replace the thumbnail from the item edit page."}
</div>
</article>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,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">Stock By Location</p>
{item.stockBalances.length === 0 ? (
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
) : (
<div className="mt-4 space-y-2">
{item.stockBalances.map((balance) => (
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="min-w-0">
<div className="font-semibold text-text">
{balance.warehouseCode} / {balance.locationCode}
</div>
<div className="text-xs text-muted">
{balance.warehouseName} / {balance.locationName}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
<div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
</div>
</div>
))}
</div>
)}
</article>
</div>
<section className="grid gap-3 xl:grid-cols-2">
{canManage ? (
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
<div className="mt-5 grid gap-3">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
<select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{inventoryTransactionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", Number.parseInt(event.target.value, 10) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select value={transactionForm.locationId} onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateTransactionField("locationId", event.target.value);
if (nextLocation) {
updateTransactionField("warehouseId", nextLocation.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reference</span>
<input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("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>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{transactionStatus}</span>
<button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingTransaction ? "Posting..." : "Post transaction"}
</button>
</div>
</div>
</form>
) : null}
<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">Recent Movements</p>
{item.recentTransactions.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 stock transactions have been recorded for this item yet.
</div>
) : (
<div className="mt-6 space-y-3">
{item.recentTransactions.map((transaction) => (
<article key={transaction.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<InventoryTransactionTypeBadge type={transaction.transactionType} />
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
{transaction.signedQuantity >= 0 ? "+" : ""}
{transaction.signedQuantity}
</span>
</div>
<div className="mt-2 text-sm font-semibold text-text">
{transaction.warehouseCode} / {transaction.locationCode}
</div>
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
<div className="mt-1">{transaction.createdByName}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">From</span>
<select value={transferForm.fromLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("fromLocationId", event.target.value);
if (option) {
updateTransferField("fromWarehouseId", option.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={`from-${option.locationId}`} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">To</span>
<select value={transferForm.toLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("toLocationId", event.target.value);
if (option) {
updateTransferField("toWarehouseId", option.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={`to-${option.locationId}`} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("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>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{transferStatus}</span>
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
</button>
</div>
</div>
</form>
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
setReservationForm((current) => ({
...current,
locationId: event.target.value || null,
warehouseId: option ? option.warehouseId : null,
}));
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Global / not location-specific</option>
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, 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>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{reservationStatus}</span>
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
</button>
</div>
</div>
</form>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-2">
<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">Reservations</p>
{item.reservations.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 reservations have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.reservations.map((reservation) => (
<article key={reservation.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
</div>
<div className="text-xs text-muted">{reservation.status}</div>
</div>
<div className="mt-2 text-xs text-muted">
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
</div>
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
</article>
))}
</div>
)}
</article>
<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">Transfers</p>
{item.transfers.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 transfers have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.transfers.map((transfer) => (
<article key={transfer.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-text">{transfer.quantity} moved</div>
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
</div>
<div className="mt-2 text-xs text-muted">
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
</div>
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
</article>
))}
</div>
)}
</article>
</section>
<InventoryAttachmentsPanel itemId={item.id} />
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm inventory action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "transaction" && isSavingTransaction) ||
(pendingConfirmation?.kind === "transfer" && isSavingTransfer) ||
(pendingConfirmation?.kind === "reservation" && isSavingReservation)
}
onClose={() => {
if (!isSavingTransaction && !isSavingTransfer && !isSavingReservation) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "transaction") {
await submitTransaction();
} else if (pendingConfirmation.kind === "transfer") {
await submitTransfer();
} else {
await submitReservation();
}
setPendingConfirmation(null);
}}
/>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import { InventoryListPage } from "./InventoryListPage";
export function InventoryItemsPage() {
return <InventoryListPage />;
}

View File

@@ -0,0 +1,153 @@
import { permissions } from "@mrp/shared";
import type { InventoryItemStatus, InventoryItemSummaryDto, InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryStatusFilters, inventoryTypeFilters } from "./config";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
export function InventoryListPage() {
const { token, user } = useAuth();
const [items, setItems] = useState<InventoryItemSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | InventoryItemStatus>("ALL");
const [typeFilter, setTypeFilter] = useState<"ALL" | InventoryItemType>("ALL");
const [status, setStatus] = useState("Loading inventory items...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getInventoryItems(token, {
q: searchTerm.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
type: typeFilter === "ALL" ? undefined : typeFilter,
})
.then((nextItems) => {
setItems(nextItems);
setStatus(`${nextItems.length} item(s) matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory items.";
setStatus(message);
});
}, [searchTerm, statusFilter, token, typeFilter]);
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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
<h3 className="mt-2 text-lg font-bold text-text">Item Master</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Core item and BOM definitions for purchased parts, manufactured items, assemblies, and service SKUs.
</p>
</div>
{canManage ? (
<div className="flex flex-wrap gap-2">
<Link to="/inventory/sku-master" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
SKU master
</Link>
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New item
</Link>
</div>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by SKU, item name, or description"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | InventoryItemStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{inventoryStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Type</span>
<select
value={typeFilter}
onChange={(event) => setTypeFilter(event.target.value as "ALL" | InventoryItemType)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{inventoryTypeFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{items.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 inventory items have been added yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Type</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Qty</th>
<th className="px-2 py-2">BOM</th>
<th className="px-2 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{items.map((item) => (
<tr key={item.id} className="transition hover:bg-page/70">
<td className="px-2 py-2">
<Link to={`/inventory/items/${item.id}`} className="font-semibold text-text hover:text-brand">
{item.sku}
</Link>
<div className="mt-1 text-xs text-muted">{item.name}</div>
</td>
<td className="px-2 py-2">
<InventoryTypeBadge type={item.type} />
</td>
<td className="px-2 py-2">
<InventoryStatusBadge status={item.status} />
</td>
<td className="px-2 py-2 text-muted">{item.unitOfMeasure}</td>
<td className="px-2 py-2 text-xs text-muted">
<div>Total {item.onHandQuantity}</div>
<div>Available {item.availableQuantity}</div>
</td>
<td className="px-2 py-2 text-muted">{item.bomLineCount} lines</td>
<td className="px-2 py-2 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,298 @@
import { permissions } from "@mrp/shared";
import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeDto, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
const emptyFamilyForm: InventorySkuFamilyInput = {
code: "",
sequenceCode: "",
name: "",
description: "",
isActive: true,
};
const emptyNodeForm: InventorySkuNodeInput = {
familyId: "",
parentNodeId: null,
code: "",
label: "",
description: "",
sortOrder: 10,
isActive: true,
};
export function InventorySkuMasterPage() {
const { token, user } = useAuth();
const [catalog, setCatalog] = useState<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(emptyNodeForm);
const [selectedFamilyId, setSelectedFamilyId] = useState("");
const [status, setStatus] = useState("Loading SKU master...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getInventorySkuCatalog(token)
.then((nextCatalog) => {
setCatalog(nextCatalog);
const firstFamilyId = nextCatalog.families[0]?.id ?? "";
setSelectedFamilyId((current) => current || firstFamilyId);
setNodeForm((current) => ({
...current,
familyId: current.familyId || firstFamilyId,
}));
setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
})
.catch((error: unknown) => {
setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
});
}, [token]);
const familyNodes = useMemo(
() =>
catalog.nodes
.filter((node) => node.familyId === selectedFamilyId)
.sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
[catalog.nodes, selectedFamilyId]
);
const parentOptions = useMemo(
() => familyNodes.filter((node) => node.level < 6),
[familyNodes]
);
function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
if (!branchNodes.length) {
return null;
}
return branchNodes.map((node) => (
<div key={node.id} className="space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3" style={{ marginLeft: `${depth * 16}px` }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-text">{node.code} <span className="text-muted">- {node.label}</span></div>
<div className="mt-1 text-xs text-muted">Level {node.level} {node.childCount} child branch(es)</div>
</div>
<button
type="button"
onClick={() =>
setNodeForm((current) => ({
...current,
familyId: selectedFamilyId,
parentNodeId: node.id,
}))
}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
>
Add child
</button>
</div>
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
</div>
{renderNodes(node.id, depth + 1)}
</div>
));
}
async function reloadCatalog() {
if (!token) {
return;
}
const nextCatalog = await api.getInventorySkuCatalog(token);
setCatalog(nextCatalog);
if (!selectedFamilyId && nextCatalog.families[0]) {
setSelectedFamilyId(nextCatalog.families[0].id);
}
}
async function handleCreateFamily(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !canManage) {
return;
}
try {
const created = await api.createInventorySkuFamily(token, familyForm);
setFamilyForm(emptyFamilyForm);
setSelectedFamilyId(created.id);
setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
await reloadCatalog();
setStatus(`Created SKU family ${created.code}.`);
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
}
}
async function handleCreateNode(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !canManage || !nodeForm.familyId) {
return;
}
try {
const created = await api.createInventorySkuNode(token, nodeForm);
setNodeForm((current) => ({
...emptyNodeForm,
familyId: current.familyId,
parentNodeId: created.parentNodeId,
}));
await reloadCatalog();
setStatus(`Created SKU branch ${created.code}.`);
} catch (error: unknown) {
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
}
}
return (
<section className="space-y-6">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Master Data</p>
<h3 className="mt-2 text-xl font-bold text-text">SKU Master Builder</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.</p>
</div>
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to items
</Link>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]">
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Families</div>
<div className="mt-4 space-y-2">
{catalog.families.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">No SKU families defined yet.</div>
) : (
catalog.families.map((family) => (
<button
key={family.id}
type="button"
onClick={() => {
setSelectedFamilyId(family.id);
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
}}
className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
}`}
>
<div className="text-sm font-semibold text-text">{family.code} <span className="text-muted">({family.sequenceCode})</span></div>
<div className="mt-1 text-xs text-muted">{family.name}</div>
<div className="mt-2 text-xs text-muted">{family.childNodeCount} branch nodes next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}</div>
</button>
))
)}
</div>
</section>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Add family</div>
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: 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>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
</form>
</section>
) : null}
</div>
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">Branch tree</div>
<div className="mt-1 text-xs text-muted">{status}</div>
</div>
{selectedFamilyId ? (
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div>
) : null}
</div>
<div className="mt-4 space-y-3">
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
</div>
</section>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Add branch node</div>
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select family</option>
{catalog.families.map((family) => (
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: event.target.value || null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Family root</option>
{parentOptions.map((node) => (
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: 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 className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: Number(event.target.value) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
</form>
</section>
) : null}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,17 @@
import type { InventoryItemStatus } from "@mrp/shared/dist/inventory/types.js";
import { inventoryStatusPalette } from "./config";
const labels: Record<InventoryItemStatus, string> = {
DRAFT: "Draft",
ACTIVE: "Active",
OBSOLETE: "Obsolete",
};
export function InventoryStatusBadge({ status }: { status: InventoryItemStatus }) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryStatusPalette[status]}`}>
{labels[status]}
</span>
);
}

View File

@@ -0,0 +1,13 @@
import type { InventoryTransactionType } from "@mrp/shared/dist/inventory/types.js";
import { inventoryTransactionOptions, inventoryTransactionPalette } from "./config";
export function InventoryTransactionTypeBadge({ type }: { type: InventoryTransactionType }) {
const label = inventoryTransactionOptions.find((option) => option.value === type)?.label ?? type;
return (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${inventoryTransactionPalette[type]}`}>
{label}
</span>
);
}

View File

@@ -0,0 +1,18 @@
import type { InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
import { inventoryTypePalette } from "./config";
const labels: Record<InventoryItemType, string> = {
PURCHASED: "Purchased",
MANUFACTURED: "Manufactured",
ASSEMBLY: "Assembly",
SERVICE: "Service",
};
export function InventoryTypeBadge({ type }: { type: InventoryItemType }) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryTypePalette[type]}`}>
{labels[type]}
</span>
);
}

View File

@@ -0,0 +1,91 @@
import type { WarehouseDetailDto, WarehouseLocationDto } from "@mrp/shared/dist/inventory/types.js";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
export function WarehouseDetailPage() {
const { token, user } = useAuth();
const { warehouseId } = useParams();
const [warehouse, setWarehouse] = useState<WarehouseDetailDto | null>(null);
const [status, setStatus] = useState("Loading warehouse...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token || !warehouseId) {
return;
}
api
.getWarehouse(token, warehouseId)
.then((nextWarehouse) => {
setWarehouse(nextWarehouse);
setStatus("Warehouse loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
setStatus(message);
});
}, [token, warehouseId]);
if (!warehouse) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Detail</p>
<h3 className="mt-2 text-2xl font-bold text-text">{warehouse.code}</h3>
<p className="mt-1 text-sm text-text">{warehouse.name}</p>
<p className="mt-3 text-sm text-muted">Last updated {new Date(warehouse.updatedAt).toLocaleString()}.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/inventory/warehouses" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to warehouses
</Link>
{canManage ? (
<Link to={`/inventory/warehouses/${warehouse.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit warehouse
</Link>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<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">Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{warehouse.notes || "No warehouse notes recorded."}</p>
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
Created {new Date(warehouse.createdAt).toLocaleDateString()}
</div>
</article>
<section 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">Locations</p>
<h4 className="mt-2 text-lg font-bold text-text">Stock locations</h4>
{warehouse.locations.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 stock locations have been defined for this warehouse yet.
</div>
) : (
<div className="mt-6 grid gap-3 xl:grid-cols-2">
{warehouse.locations.map((location: WarehouseLocationDto) => (
<article key={location.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="text-sm font-semibold text-text">{location.code}</div>
<div className="mt-1 text-sm text-text">{location.name}</div>
<div className="mt-2 text-xs leading-6 text-muted">{location.notes || "No notes."}</div>
</article>
))}
</div>
)}
</section>
</div>
</section>
);
}

View File

@@ -0,0 +1,192 @@
import type { WarehouseInput, WarehouseLocationInput } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config";
export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) {
const navigate = useNavigate();
const { warehouseId } = useParams();
const { token } = useAuth();
const [form, setForm] = useState<WarehouseInput>(emptyWarehouseInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse...");
const [isSaving, setIsSaving] = useState(false);
const [pendingLocationRemovalIndex, setPendingLocationRemovalIndex] = useState<number | null>(null);
useEffect(() => {
if (mode !== "edit" || !token || !warehouseId) {
return;
}
api
.getWarehouse(token, warehouseId)
.then((warehouse) => {
setForm({
code: warehouse.code,
name: warehouse.name,
notes: warehouse.notes,
locations: warehouse.locations.map((location: WarehouseLocationInput) => ({
code: location.code,
name: location.name,
notes: location.notes,
})),
});
setStatus("Warehouse loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load warehouse.";
setStatus(message);
});
}, [mode, token, warehouseId]);
function updateField<Key extends keyof WarehouseInput>(key: Key, value: WarehouseInput[Key]) {
setForm((current: WarehouseInput) => ({ ...current, [key]: value }));
}
function updateLocation(index: number, nextLocation: WarehouseLocationInput) {
setForm((current: WarehouseInput) => ({
...current,
locations: current.locations.map((location: WarehouseLocationInput, locationIndex: number) =>
locationIndex === index ? nextLocation : location
),
}));
}
function addLocation() {
setForm((current: WarehouseInput) => ({
...current,
locations: [...current.locations, emptyWarehouseLocationInput],
}));
}
function removeLocation(index: number) {
setForm((current: WarehouseInput) => ({
...current,
locations: current.locations.filter((_location: WarehouseLocationInput, locationIndex: number) => locationIndex !== index),
}));
}
const pendingLocationRemoval = pendingLocationRemovalIndex != null ? form.locations[pendingLocationRemovalIndex] : null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving warehouse...");
try {
const saved =
mode === "create" ? await api.createWarehouse(token, form) : await api.updateWarehouse(token, warehouseId ?? "", form);
navigate(`/inventory/warehouses/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save warehouse.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Warehouse Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Warehouse" : "Edit Warehouse"}</h3>
</div>
<Link
to={mode === "create" ? "/inventory/warehouses" : `/inventory/warehouses/${warehouseId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse code</span>
<input value={form.code} onChange={(event) => updateField("code", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse name</span>
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<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={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</section>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Locations</p>
<h4 className="mt-2 text-lg font-bold text-text">Internal stock locations</h4>
</div>
<button type="button" onClick={addLocation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add location
</button>
</div>
{form.locations.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No locations added yet.
</div>
) : (
<div className="mt-5 space-y-4">
{form.locations.map((location: WarehouseLocationInput, index: number) => (
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[0.7fr_1fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
<input value={location.code} onChange={(event) => updateLocation(index, { ...location, code: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
<input value={location.name} onChange={(event) => updateLocation(index, { ...location, name: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => setPendingLocationRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input value={location.notes} onChange={(event) => updateLocation(index, { ...location, notes: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
))}
</div>
)}
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create warehouse" : "Save changes"}
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLocationRemoval != null}
title="Remove warehouse location"
description={pendingLocationRemoval ? `Remove location ${pendingLocationRemoval.code || pendingLocationRemoval.name || "from this warehouse draft"}.` : "Remove this location."}
impact="The location will be removed from the warehouse edit form immediately."
recovery="Add the location back before saving if it should remain part of this warehouse."
confirmLabel="Remove location"
onClose={() => setPendingLocationRemovalIndex(null)}
onConfirm={() => {
if (pendingLocationRemovalIndex != null) {
removeLocation(pendingLocationRemovalIndex);
}
setPendingLocationRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -0,0 +1,83 @@
import { permissions } from "@mrp/shared";
import type { WarehouseSummaryDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
export function WarehousesPage() {
const { token, user } = useAuth();
const [warehouses, setWarehouses] = useState<WarehouseSummaryDto[]>([]);
const [status, setStatus] = useState("Loading warehouses...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getWarehouses(token)
.then((nextWarehouses) => {
setWarehouses(nextWarehouses);
setStatus(`${nextWarehouses.length} warehouse(s) available.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load warehouses.";
setStatus(message);
});
}, [token]);
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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
<h3 className="mt-2 text-lg font-bold text-text">Warehouses</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Physical warehouse records and their internal stock locations.</p>
</div>
{canManage ? (
<Link to="/inventory/warehouses/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New warehouse
</Link>
) : null}
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{warehouses.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 warehouses have been added yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Code</th>
<th className="px-2 py-2">Name</th>
<th className="px-2 py-2">Locations</th>
<th className="px-2 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{warehouses.map((warehouse) => (
<tr key={warehouse.id} className="transition hover:bg-page/70">
<td className="px-2 py-2 font-semibold text-text">
<Link to={`/inventory/warehouses/${warehouse.id}`} className="hover:text-brand">
{warehouse.code}
</Link>
</td>
<td className="px-2 py-2 text-muted">{warehouse.name}</td>
<td className="px-2 py-2 text-muted">{warehouse.locationCount}</td>
<td className="px-2 py-2 text-muted">{new Date(warehouse.updatedAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,133 @@
import {
inventoryItemStatuses,
inventoryItemTypes,
inventoryTransactionTypes,
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
type InventoryItemOperationInput,
type WarehouseInput,
type WarehouseLocationInput,
type InventoryItemStatus,
type InventoryItemType,
type InventoryTransactionInput,
type InventoryTransactionType,
type InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
export const emptyInventoryBomLineInput: InventoryBomLineInput = {
componentItemId: "",
quantity: 1,
unitOfMeasure: "EA",
notes: "",
position: 10,
};
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
stationId: "",
setupMinutes: 0,
runMinutesPerUnit: 0,
moveMinutes: 0,
position: 10,
notes: "",
};
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
skuBuilder: null,
name: "",
description: "",
type: "PURCHASED",
status: "ACTIVE",
unitOfMeasure: "EA",
isSellable: true,
isPurchasable: true,
preferredVendorId: null,
defaultCost: null,
defaultPrice: null,
notes: "",
bomLines: [],
operations: [],
};
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
transactionType: "RECEIPT",
quantity: 1,
warehouseId: "",
locationId: "",
reference: "",
notes: "",
};
export const emptyWarehouseLocationInput: WarehouseLocationInput = {
code: "",
name: "",
notes: "",
};
export const emptyWarehouseInput: WarehouseInput = {
code: "",
name: "",
notes: "",
locations: [],
};
export const inventoryTypeOptions: Array<{ value: InventoryItemType; label: string }> = [
{ value: "PURCHASED", label: "Purchased" },
{ value: "MANUFACTURED", label: "Manufactured" },
{ value: "ASSEMBLY", label: "Assembly" },
{ value: "SERVICE", label: "Service" },
];
export const inventoryStatusOptions: Array<{ value: InventoryItemStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "OBSOLETE", label: "Obsolete" },
];
export const inventoryStatusFilters: Array<{ value: "ALL" | InventoryItemStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...inventoryStatusOptions,
];
export const inventoryTypeFilters: Array<{ value: "ALL" | InventoryItemType; label: string }> = [
{ value: "ALL", label: "All item types" },
...inventoryTypeOptions,
];
export const inventoryUnitOptions: Array<{ value: InventoryUnitOfMeasure; label: string }> = inventoryUnitsOfMeasure.map((unit) => ({
value: unit,
label: unit,
}));
export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
OBSOLETE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const inventoryFileOwnerType = "inventory-item";
export const inventoryThumbnailOwnerType = "inventory-item-thumbnail";
export const inventoryTypePalette: Record<InventoryItemType, string> = {
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
MANUFACTURED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
ASSEMBLY: "border border-brand/30 bg-brand/10 text-brand",
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
};
export const inventoryTransactionOptions: Array<{ value: InventoryTransactionType; label: string }> = [
{ value: "RECEIPT", label: "Receipt" },
{ value: "ISSUE", label: "Issue" },
{ value: "ADJUSTMENT_IN", label: "Adjustment In" },
{ value: "ADJUSTMENT_OUT", label: "Adjustment Out" },
];
export const inventoryTransactionPalette: Record<InventoryTransactionType, string> = {
RECEIPT: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ISSUE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
ADJUSTMENT_IN: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ADJUSTMENT_OUT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure };

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
export function LoginPage() {
const { login, token } = useAuth();
const [email, setEmail] = useState("admin@mrp.local");
const [password, setPassword] = useState("ChangeMe123!");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (token) {
return <Navigate to="/" replace />;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(email, password);
} catch (submissionError) {
setError(submissionError instanceof Error ? submissionError.message : "Unable to sign in.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="grid w-full max-w-5xl overflow-hidden rounded-[32px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur lg:grid-cols-[1.2fr_0.8fr]">
<section className="bg-brand px-6 py-10 text-white md:px-10">
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-white/75">MRP Codex</p>
<h1 className="mt-6 text-4xl font-extrabold">A streamlined manufacturing operating system.</h1>
<p className="mt-4 max-w-xl text-sm leading-6 text-white/82">
This foundation release establishes authentication, company settings, brand theming, file persistence, and planning scaffolding.
</p>
</section>
<section className="px-6 py-10 md:px-10">
<h2 className="text-lg font-bold text-text">Sign in</h2>
<p className="mt-2 text-sm text-muted">Use the seeded admin account to access the initial platform shell.</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-2 py-2 text-sm text-red-200 dark:text-red-200">{error}</div> : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-text px-2 py-2 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Signing in..." : "Enter workspace"}
</button>
</form>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { permissions, type ManufacturingStationInput, type ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { WorkOrderListPage } from "./WorkOrderListPage";
const emptyStationInput: ManufacturingStationInput = {
code: "",
name: "",
description: "",
queueDays: 0,
isActive: true,
};
export function ManufacturingPage() {
const { token, user } = useAuth();
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput);
const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically.");
const [isSaving, setIsSaving] = useState(false);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving station...");
try {
const station = await api.createManufacturingStation(token, form);
setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code)));
setForm(emptyStationInput);
setStatus("Station saved.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save station.";
setStatus(message);
} finally {
setIsSaving(false);
}
}
return (
<div className="space-y-4">
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
<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">Manufacturing Stations</p>
<h3 className="mt-2 text-xl font-bold text-text">Scheduling anchors</h3>
<p className="mt-2 text-sm text-muted">Stations define where operation time belongs. Buildable items reference them in their routing template, and work orders inherit those steps automatically into planning.</p>
{stations.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No stations defined yet.
</div>
) : (
<div className="mt-5 space-y-3">
{stations.map((station) => (
<article key={station.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{station.code} - {station.name}</div>
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{station.queueDays} expected wait day(s)</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
{canManage ? (
<form onSubmit={handleSubmit} 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">New Station</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Code</span>
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Name</span>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: 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 className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} />
<span className="text-sm font-semibold text-text">Active station</span>
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : "Create station"}
</button>
</div>
</div>
</form>
) : null}
</section>
<WorkOrderListPage />
</div>
);
}

View File

@@ -0,0 +1,487 @@
import { permissions } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
export function WorkOrderDetailPage() {
const { token, user } = useAuth();
const { workOrderId } = useParams();
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
const [status, setStatus] = useState("Loading work order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "issue" | "completion";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: WorkOrderStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
useEffect(() => {
if (!token || !workOrderId) {
return;
}
api.getWorkOrder(token, workOrderId)
.then((nextWorkOrder) => {
setWorkOrder(nextWorkOrder);
setIssueForm({
...emptyMaterialIssueInput,
warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId,
});
setCompletionForm({
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
setStatus(message);
});
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token, workOrderId]);
const filteredLocationOptions = useMemo(
() => locationOptions.filter((option) => option.warehouseId === issueForm.warehouseId),
[issueForm.warehouseId, locationOptions]
);
async function applyStatusChange(nextStatus: WorkOrderStatus) {
if (!token || !workOrder) {
return;
}
setIsUpdatingStatus(true);
setStatus("Updating work-order status...");
try {
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
setWorkOrder(nextWorkOrder);
setStatus("Work-order status updated. Review downstream planning and shipment readiness if this change affects execution timing.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
setStatus(message);
} finally {
setIsUpdatingStatus(false);
}
}
async function submitIssue() {
if (!token || !workOrder) {
return;
}
setIsPostingIssue(true);
setStatus("Posting material issue...");
try {
const nextWorkOrder = await api.issueWorkOrderMaterial(token, workOrder.id, issueForm);
setWorkOrder(nextWorkOrder);
setIssueForm({
...emptyMaterialIssueInput,
warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId,
});
setStatus("Material issue posted. This consumed inventory immediately; post a correcting stock movement if the issue quantity was wrong.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
setStatus(message);
} finally {
setIsPostingIssue(false);
}
}
async function submitCompletion() {
if (!token || !workOrder) {
return;
}
setIsPostingCompletion(true);
setStatus("Posting completion...");
try {
const nextWorkOrder = await api.recordWorkOrderCompletion(token, workOrder.id, completionForm);
setWorkOrder(nextWorkOrder);
setCompletionForm({
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setStatus("Completion posted. Finished-goods stock has been received; verify the remaining quantity and post a correcting transaction if this completion was overstated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
setStatus(message);
} finally {
setIsPostingCompletion(false);
}
}
function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) {
return;
}
const option = workOrderStatusOptions.find((entry) => entry.value === nextStatus);
setPendingConfirmation({
kind: "status",
title: `Change status to ${option?.label ?? nextStatus}`,
description: `Update work order ${workOrder.workOrderNumber} from ${workOrder.status} to ${nextStatus}.`,
impact:
nextStatus === "CANCELLED"
? "Cancelling a work order can invalidate planning assumptions, reservations, and operator expectations."
: nextStatus === "COMPLETE"
? "Completing the work order signals execution closure and can change readiness views across the system."
: "This changes the execution state used by planning, dashboards, and downstream operational review.",
recovery: "If this status was selected in error, set the work order back to the correct state immediately after review.",
confirmLabel: `Set ${option?.label ?? nextStatus}`,
confirmationLabel: nextStatus === "CANCELLED" ? "Type work-order number to confirm:" : undefined,
confirmationValue: nextStatus === "CANCELLED" ? workOrder.workOrderNumber : undefined,
nextStatus,
});
}
function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
const component = workOrder.materialRequirements.find((requirement) => requirement.componentItemId === issueForm.componentItemId);
setPendingConfirmation({
kind: "issue",
title: "Post material issue",
description: `Issue ${issueForm.quantity} units of ${component?.componentSku ?? "the selected component"} to work order ${workOrder.workOrderNumber}.`,
impact: "This consumes component inventory immediately and updates work-order material history.",
recovery: "If the wrong quantity was issued, post a correcting stock transaction and note the reason on the work order.",
confirmLabel: "Post issue",
confirmationLabel: "Type work-order number to confirm:",
confirmationValue: workOrder.workOrderNumber,
});
}
function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!workOrder) {
return;
}
setPendingConfirmation({
kind: "completion",
title: "Post production completion",
description: `Receive ${completionForm.quantity} finished units into ${workOrder.warehouseCode} / ${workOrder.locationCode}.`,
impact: "This increases finished-goods inventory immediately and advances the execution history for this work order.",
recovery: "If the completion quantity is wrong, post the correcting inventory movement and verify the work-order remaining quantity.",
confirmLabel: "Post completion",
confirmationLabel: completionForm.quantity >= workOrder.dueQuantity ? "Type work-order number to confirm:" : undefined,
confirmationValue: completionForm.quantity >= workOrder.dueQuantity ? workOrder.workOrderNumber : undefined,
});
}
if (!workOrder) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p>
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
{workOrder.salesOrderId ? <Link to={`/sales/orders/${workOrder.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link> : null}
<Link to={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link>
{canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null}
</div>
</div>
</div>
{canManage ? (
<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 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
</div>
<div className="flex flex-wrap gap-2">
{workOrderStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || workOrder.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-6">
<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">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</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">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</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">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</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">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</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">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<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">Execution Context</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "Not linked"}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
</dl>
</article>
<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">Work Instructions</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
</article>
</div>
<section 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">Operation Plan</p>
{workOrder.operations.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">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
) : (
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Seq</th>
<th className="px-3 py-3">Station</th>
<th className="px-3 py-3">Start</th>
<th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrder.operations.map((operation) => (
<tr key={operation.id} className="bg-surface/70">
<td className="px-3 py-3 text-text">{operation.sequence}</td>
<td className="px-3 py-3">
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form onSubmit={handleIssueSubmit} 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">Material Issue</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Component</span>
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select component</option>
{workOrder.materialRequirements.map((requirement) => (
<option key={requirement.componentItemId} value={requirement.componentItemId}>{requirement.componentSku} - {requirement.componentName}</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select location</option>
{filteredLocationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, 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>
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isPostingIssue ? "Posting issue..." : "Post material issue"}
</button>
</div>
</form>
<form onSubmit={handleCompletionSubmit} 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">Production Completion</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, 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>
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
<button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isPostingCompletion ? "Posting completion..." : "Post completion"}
</button>
</div>
</form>
</section>
) : null}
<section 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">Material Requirements</p>
{workOrder.materialRequirements.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">This build item does not currently have BOM material requirements.</div>
) : (
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Component</th>
<th className="px-3 py-3">Per</th>
<th className="px-3 py-3">Required</th>
<th className="px-3 py-3">Issued</th>
<th className="px-3 py-3">Remaining</th>
<th className="px-3 py-3">Available</th>
<th className="px-3 py-3">Shortage</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrder.materialRequirements.map((requirement) => (
<tr key={requirement.componentItemId} className="bg-surface/70">
<td className="px-3 py-3"><div className="font-semibold text-text">{requirement.componentSku}</div><div className="mt-1 text-xs text-muted">{requirement.componentName}</div></td>
<td className="px-3 py-3 text-text">{requirement.quantityPer} {requirement.unitOfMeasure}</td>
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.availableQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.shortageQuantity}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="grid gap-3 xl:grid-cols-2">
<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">Issue History</p>
{workOrder.materialIssues.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 material issues have been posted yet.</div>
) : (
<div className="mt-5 space-y-3">
{workOrder.materialIssues.map((issue) => (
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
<div className="mt-1 text-xs text-muted">{issue.warehouseCode} / {issue.locationCode} · {issue.createdByName}</div>
</div>
<div className="text-sm font-semibold text-text">{issue.quantity}</div>
</div>
<div className="mt-2 text-xs text-muted">{new Date(issue.createdAt).toLocaleString()}</div>
<div className="mt-2 text-sm text-text">{issue.notes || "No notes recorded."}</div>
</div>
))}
</div>
)}
</article>
<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">Completion History</p>
{workOrder.completions.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 production completions have been posted yet.</div>
) : (
<div className="mt-5 space-y-3">
{workOrder.completions.map((completion) => (
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="font-semibold text-text">{completion.quantity} completed</div>
<div className="text-xs text-muted">{completion.createdByName}</div>
</div>
<div className="mt-2 text-xs text-muted">{new Date(completion.createdAt).toLocaleString()}</div>
<div className="mt-2 text-sm text-text">{completion.notes || "No notes recorded."}</div>
</div>
))}
</div>
)}
</article>
</section>
<FileAttachmentsPanel
ownerType="WORK_ORDER"
ownerId={workOrder.id}
eyebrow="Manufacturing Documents"
title="Work-order files"
description="Store travelers, build instructions, inspection records, and support documents directly on the work order."
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm manufacturing action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "issue" && isPostingIssue) ||
(pendingConfirmation?.kind === "completion" && isPostingCompletion)
}
onClose={() => {
if (!isUpdatingStatus && !isPostingIssue && !isPostingCompletion) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
} else if (pendingConfirmation.kind === "issue") {
await submitIssue();
} else if (pendingConfirmation.kind === "completion") {
await submitCompletion();
}
setPendingConfirmation(null);
}}
/>
</section>
);
}

View File

@@ -0,0 +1,307 @@
import type {
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderInput,
} from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyWorkOrderInput, workOrderStatusOptions } from "./config";
export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { workOrderId } = useParams();
const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId");
const seededItemId = searchParams.get("itemId");
const seededSalesOrderId = searchParams.get("salesOrderId");
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
const seededQuantity = searchParams.get("quantity");
const seededStatus = searchParams.get("status");
const seededDueDate = searchParams.get("dueDate");
const seededNotes = searchParams.get("notes");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [itemSearchTerm, setItemSearchTerm] = useState("");
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [itemPickerOpen, setItemPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new work order." : "Loading work order...");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingItemOptions(token).then((options) => {
setItemOptions(options);
if (mode === "create" && seededItemId) {
const seededItem = options.find((option) => option.id === seededItemId);
if (seededItem) {
setForm((current) => ({
...current,
itemId: seededItem.id,
salesOrderId: seededSalesOrderId || current.salesOrderId,
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
dueDate: seededDueDate || current.dueDate,
notes: seededNotes || current.notes,
}));
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
}
}
}).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options);
if (mode === "create" && seededProjectId) {
const seededProject = options.find((option) => option.id === seededProjectId);
if (seededProject) {
setForm((current) => ({ ...current, projectId: seededProject.id }));
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
}
}
}).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) {
return;
}
api.getWorkOrder(token, workOrderId)
.then((workOrder) => {
setForm({
itemId: workOrder.itemId,
projectId: workOrder.projectId,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
status: workOrder.status,
quantity: workOrder.quantity,
warehouseId: workOrder.warehouseId,
locationId: workOrder.locationId,
dueDate: workOrder.dueDate,
notes: workOrder.notes,
});
setItemSearchTerm(`${workOrder.itemSku} - ${workOrder.itemName}`);
setProjectSearchTerm(workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "");
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
setStatus(message);
});
}, [mode, token, workOrderId]);
const warehouseOptions = useMemo(
() => [...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()],
[locationOptions]
);
const filteredLocationOptions = useMemo(
() => locationOptions.filter((option) => option.warehouseId === form.warehouseId),
[form.warehouseId, locationOptions]
);
function updateField<Key extends keyof WorkOrderInput>(key: Key, value: WorkOrderInput[Key]) {
setForm((current) => ({
...current,
[key]: value,
...(key === "warehouseId" ? { locationId: "" } : {}),
}));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving work order...");
try {
const saved = mode === "create" ? await api.createWorkOrder(token, form) : await api.updateWorkOrder(token, workOrderId ?? "", form);
navigate(`/manufacturing/work-orders/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save work order.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Work Order" : "Edit Work Order"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Create a build record for a manufactured item, assign it to a project when needed, and define where completed output should post.</p>
</div>
<Link to={mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
<div className="relative">
<input
value={itemSearchTerm}
onChange={(event) => {
setItemSearchTerm(event.target.value);
updateField("itemId", "");
setItemPickerOpen(true);
}}
onFocus={() => setItemPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setItemPickerOpen(false);
const selected = itemOptions.find((option) => option.id === form.itemId);
if (selected) {
setItemSearchTerm(`${selected.sku} - ${selected.name}`);
}
}, 120);
}}
placeholder="Search manufactured item"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{itemPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => {
const query = itemSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.sku.toLowerCase().includes(query) || option.name.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button key={option.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("itemId", option.id);
setItemSearchTerm(`${option.sku} - ${option.name}`);
setItemPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Project</span>
<div className="relative">
<input
value={projectSearchTerm}
onChange={(event) => {
setProjectSearchTerm(event.target.value);
updateField("projectId", null);
setProjectPickerOpen(true);
}}
onFocus={() => setProjectPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setProjectPickerOpen(false);
const selected = projectOptions.find((option) => option.id === form.projectId);
if (selected) {
setProjectSearchTerm(`${selected.projectNumber} - ${selected.name}`);
}
}, 120);
}}
placeholder="Search linked project (optional)"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{projectPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("projectId", null);
setProjectSearchTerm("");
setProjectPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked project</div>
</button>
{projectOptions
.filter((option) => {
const query = projectSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.projectNumber.toLowerCase().includes(query) || option.name.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button key={option.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("projectId", option.id);
setProjectSearchTerm(`${option.projectNumber} - ${option.name}`);
setProjectPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.projectNumber}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.customerName}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as WorkOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{workOrderStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={form.quantity} onChange={(event) => updateField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
<select value={form.warehouseId} onChange={(event) => updateField("warehouseId", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select warehouse</option>
{warehouseOptions.map((option) => <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
<select value={form.locationId} onChange={(event) => updateField("locationId", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select location</option>
{filteredLocationOptions.map((option) => <option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>)}
</select>
</label>
</div>
<label className="block max-w-sm">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,110 @@
import { permissions } from "@mrp/shared";
import type { WorkOrderStatus, WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { workOrderStatusFilters } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
export function WorkOrderListPage() {
const { token, user } = useAuth();
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | WorkOrderStatus>("ALL");
const [status, setStatus] = useState("Loading work orders...");
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
setStatus("Loading work orders...");
api.getWorkOrders(token, { q: query || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
.then((nextWorkOrders) => {
setWorkOrders(nextWorkOrders);
setStatus(nextWorkOrders.length === 0 ? "No work orders matched the current filters." : `${nextWorkOrders.length} work order(s) loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work orders.";
setStatus(message);
});
}, [query, statusFilter, token]);
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p>
<h3 className="mt-2 text-xl font-bold text-text">Work Orders</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Release and execute build work against manufactured or assembly inventory items, with project linkage and real inventory posting.</p>
</div>
{canManage ? (
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
New work order
</Link>
) : null}
</div>
</div>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | WorkOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{workOrderStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
</section>
{workOrders.length === 0 ? (
<div className="rounded-[20px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
) : (
<div className="overflow-hidden rounded-[20px] border border-line/70 bg-surface/90 shadow-panel">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Work Order</th>
<th className="px-3 py-3">Item</th>
<th className="px-3 py-3">Project</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Location</th>
<th className="px-3 py-3">Ops</th>
<th className="px-3 py-3">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrders.map((workOrder) => (
<tr key={workOrder.id} className="bg-surface/70 transition hover:bg-page/60">
<td className="px-3 py-3 align-top">
<Link to={`/manufacturing/work-orders/${workOrder.id}`} className="font-semibold text-text hover:text-brand">{workOrder.workOrderNumber}</Link>
</td>
<td className="px-3 py-3 align-top">
<div className="font-semibold text-text">{workOrder.itemSku}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemName}</div>
</td>
<td className="px-3 py-3 align-top text-text">{workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "Unlinked"}</td>
<td className="px-3 py-3 align-top"><WorkOrderStatusBadge status={workOrder.status} /></td>
<td className="px-3 py-3 align-top text-text">{workOrder.completedQuantity} / {workOrder.quantity}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.operationCount} / {Math.round(workOrder.totalPlannedMinutes / 60)}h</td>
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,7 @@
import type { WorkOrderStatus } from "@mrp/shared";
import { workOrderStatusPalette } from "./config";
export function WorkOrderStatusBadge({ status }: { status: WorkOrderStatus }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${workOrderStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
}

View File

@@ -0,0 +1,50 @@
import type { WorkOrderCompletionInput, WorkOrderInput, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
export const workOrderStatusOptions: Array<{ value: WorkOrderStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "RELEASED", label: "Released" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETE", label: "Complete" },
{ value: "CANCELLED", label: "Cancelled" },
];
export const workOrderStatusFilters: Array<{ value: "ALL" | WorkOrderStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...workOrderStatusOptions,
];
export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
DRAFT: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
RELEASED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
COMPLETE: "border border-brand/30 bg-brand/10 text-brand",
CANCELLED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const emptyWorkOrderInput: WorkOrderInput = {
itemId: "",
projectId: null,
salesOrderId: null,
salesOrderLineId: null,
status: "DRAFT",
quantity: 1,
warehouseId: "",
locationId: "",
dueDate: null,
notes: "",
};
export const emptyMaterialIssueInput: WorkOrderMaterialIssueInput = {
componentItemId: "",
warehouseId: "",
locationId: "",
quantity: 1,
notes: "",
};
export const emptyCompletionInput: WorkOrderCompletionInput = {
quantity: 1,
notes: "",
};

View File

@@ -0,0 +1,190 @@
import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
import { ProjectStatusBadge } from "./ProjectStatusBadge";
export function ProjectDetailPage() {
const { token, user } = useAuth();
const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
useEffect(() => {
if (!token || !projectId) {
return;
}
api.getProject(token, projectId)
.then((nextProject) => {
setProject(nextProject);
setStatus("Project loaded.");
if (nextProject.salesOrderId) {
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null));
} else {
setPlanning(null);
}
return api.getWorkOrders(token, { projectId: nextProject.id });
})
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message);
});
}, [projectId, token]);
if (!project) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project</p>
<h3 className="mt-2 text-xl font-bold text-text">{project.projectNumber}</h3>
<p className="mt-1 text-sm text-text">{project.name}</p>
<div className="mt-3 flex flex-wrap gap-2">
<ProjectStatusBadge status={project.status} />
<ProjectPriorityBadge priority={project.priority} />
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/projects" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to projects</Link>
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit project</Link> : null}
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<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">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</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">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</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">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</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">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
</section>
<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 Linkage</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{project.customerEmail}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
</dl>
</article>
<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">Program Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
</article>
</div>
<section 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">Commercial + Delivery Links</p>
<div className="mt-5 grid gap-3 xl:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div>
<div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div>
<div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div>
<div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div>
</div>
</div>
</section>
{planning ? (
<section 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">Material Readiness</p>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div>
</article>
</div>
<div className="mt-5 space-y-3">
{planning.items
.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)
.slice(0, 8)
.map((item) => (
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
</section>
) : null}
<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">Manufacturing Links</p>
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
</div>
{canManage ? (
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New work order
</Link>
) : null}
</div>
{workOrders.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 work orders are linked to this project yet.</div>
) : (
<div className="mt-6 space-y-3">
{workOrders.map((workOrder) => (
<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{workOrder.workOrderNumber}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div>
</div>
<div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
</div>
</Link>
))}
</div>
)}
</section>
<FileAttachmentsPanel
ownerType="PROJECT"
ownerId={project.id}
eyebrow="Project Documents"
title="Program file hub"
description="Store drawings, revision references, correspondence, and support files directly on the project record."
emptyMessage="No project files have been uploaded yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);
}

View File

@@ -0,0 +1,544 @@
import type {
ProjectCustomerOptionDto,
ProjectDocumentOptionDto,
ProjectInput,
ProjectOwnerOptionDto,
ProjectShipmentOptionDto,
} from "@mrp/shared/dist/projects/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
type ProjectPendingConfirmation =
| { kind: "change-customer"; customerId: string; customerName: string }
| { kind: "unlink-quote" }
| { kind: "unlink-order" }
| { kind: "unlink-shipment" };
export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const { token, user } = useAuth();
const navigate = useNavigate();
const { projectId } = useParams();
const [form, setForm] = useState<ProjectInput>(() => ({ ...emptyProjectInput, ownerId: user?.id ?? null }));
const [customerOptions, setCustomerOptions] = useState<ProjectCustomerOptionDto[]>([]);
const [ownerOptions, setOwnerOptions] = useState<ProjectOwnerOptionDto[]>([]);
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
const [orderSearchTerm, setOrderSearchTerm] = useState("");
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<ProjectPendingConfirmation | null>(null);
useEffect(() => {
if (!token) {
return;
}
api.getProjectCustomerOptions(token).then(setCustomerOptions).catch(() => setCustomerOptions([]));
api.getProjectOwnerOptions(token).then(setOwnerOptions).catch(() => setOwnerOptions([]));
}, [token]);
useEffect(() => {
if (!token || !form.customerId) {
setQuoteOptions([]);
setOrderOptions([]);
setShipmentOptions([]);
return;
}
api.getProjectQuoteOptions(token, form.customerId).then(setQuoteOptions).catch(() => setQuoteOptions([]));
api.getProjectOrderOptions(token, form.customerId).then(setOrderOptions).catch(() => setOrderOptions([]));
api.getProjectShipmentOptions(token, form.customerId).then(setShipmentOptions).catch(() => setShipmentOptions([]));
}, [form.customerId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !projectId) {
return;
}
api.getProject(token, projectId)
.then((project) => {
setForm({
name: project.name,
status: project.status,
priority: project.priority,
customerId: project.customerId,
salesQuoteId: project.salesQuoteId,
salesOrderId: project.salesOrderId,
shipmentId: project.shipmentId,
ownerId: project.ownerId,
dueDate: project.dueDate,
notes: project.notes,
});
setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? "");
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
setOrderSearchTerm(project.salesOrderNumber ?? "");
setShipmentSearchTerm(project.shipmentNumber ?? "");
setStatus("Project loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message);
});
}, [mode, projectId, token]);
function updateField<Key extends keyof ProjectInput>(key: Key, value: ProjectInput[Key]) {
setForm((current: ProjectInput) => ({
...current,
[key]: value,
...(key === "customerId"
? {
salesQuoteId: null,
salesOrderId: null,
shipmentId: null,
}
: {}),
}));
}
function hasLinkedCommercialRecords() {
return Boolean(form.salesQuoteId || form.salesOrderId || form.shipmentId);
}
function applyCustomerSelection(customerId: string, customerName: string) {
updateField("customerId", customerId);
setCustomerSearchTerm(customerName);
setCustomerPickerOpen(false);
}
function requestCustomerSelection(customerId: string, customerName: string) {
if (form.customerId && form.customerId !== customerId && hasLinkedCommercialRecords()) {
setPendingConfirmation({ kind: "change-customer", customerId, customerName });
return;
}
applyCustomerSelection(customerId, customerName);
}
function unlinkQuote() {
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}
function unlinkOrder() {
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}
function unlinkShipment() {
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
setCustomerSearchTerm(selectedCustomer?.name ?? "");
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving project...");
try {
const saved = mode === "create" ? await api.createProject(token, form) : await api.updateProject(token, projectId ?? "", form);
navigate(`/projects/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save project.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Project" : "Edit Project"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Create a customer-linked program record that can anchor commercial documents, delivery work, and project files.</p>
</div>
<Link to={mode === "create" ? "/projects" : `/projects/${projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Project name</span>
<input value={form.name} onChange={(event) => updateField("name", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{customerOptions
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
requestCustomerSelection(customer.id, customer.name);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ProjectInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{projectStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
<select value={form.priority} onChange={(event) => updateField("priority", event.target.value as ProjectInput["priority"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{projectPriorityOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Owner</span>
<div className="relative">
<input
value={ownerSearchTerm}
onChange={(event) => {
setOwnerSearchTerm(event.target.value);
updateField("ownerId", null);
setOwnerPickerOpen(true);
}}
onFocus={() => setOwnerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOwnerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search owner"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{ownerPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", null);
setOwnerSearchTerm("");
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">Unassigned</div>
</button>
{ownerOptions
.filter((owner) => {
const query = ownerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((owner) => (
<button key={owner.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", owner.id);
setOwnerSearchTerm(owner.fullName);
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{owner.fullName}</div>
<div className="mt-1 text-xs text-muted">{owner.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
<div className="relative">
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search quote"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{quotePickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
if (form.salesQuoteId) {
setPendingConfirmation({ kind: "unlink-quote" });
} else {
unlinkQuote();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
{quoteOptions
.filter((quote) => {
const query = quoteSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((quote) => (
<button key={quote.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", quote.id);
setQuoteSearchTerm(quote.documentNumber);
setQuotePickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{quote.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search sales order"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{orderPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
if (form.salesOrderId) {
setPendingConfirmation({ kind: "unlink-order" });
} else {
unlinkOrder();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
{orderOptions
.filter((order) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((order) => (
<button key={order.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", order.id);
setOrderSearchTerm(order.documentNumber);
setOrderPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<div className="relative">
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search shipment"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{shipmentPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
if (form.shipmentId) {
setPendingConfirmation({ kind: "unlink-shipment" });
} else {
unlinkShipment();
}
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
{shipmentOptions
.filter((shipment) => {
const query = shipmentSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((shipment) => (
<button key={shipment.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", shipment.id);
setShipmentSearchTerm(shipment.shipmentNumber);
setShipmentPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<label className="block">
<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={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create project" : "Save changes"}
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={
pendingConfirmation?.kind === "change-customer"
? "Change project customer"
: pendingConfirmation?.kind === "unlink-quote"
? "Remove linked quote"
: pendingConfirmation?.kind === "unlink-order"
? "Remove linked sales order"
: "Remove linked shipment"
}
description={
pendingConfirmation?.kind === "change-customer"
? `Switch this project to ${pendingConfirmation.customerName}. Existing quote, sales order, and shipment links will be cleared.`
: pendingConfirmation?.kind === "unlink-quote"
? "Remove the currently linked quote from this project draft."
: pendingConfirmation?.kind === "unlink-order"
? "Remove the currently linked sales order from this project draft."
: "Remove the currently linked shipment from this project draft."
}
impact={
pendingConfirmation?.kind === "change-customer"
? "Commercial and delivery linkage tied to the previous customer will be cleared immediately from the draft."
: "The project will no longer point to that related record after you save this edit."
}
recovery={
pendingConfirmation?.kind === "change-customer"
? "Re-link the correct quote, order, and shipment before saving if the customer change was accidental."
: "Pick the related record again before saving if this unlink was a mistake."
}
confirmLabel={
pendingConfirmation?.kind === "change-customer"
? "Change customer"
: "Remove link"
}
onClose={() => setPendingConfirmation(null)}
onConfirm={() => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "change-customer") {
applyCustomerSelection(pendingConfirmation.customerId, pendingConfirmation.customerName);
} else if (pendingConfirmation.kind === "unlink-quote") {
unlinkQuote();
} else if (pendingConfirmation.kind === "unlink-order") {
unlinkOrder();
} else if (pendingConfirmation.kind === "unlink-shipment") {
unlinkShipment();
}
setPendingConfirmation(null);
}}
/>
</form>
);
}

View File

@@ -0,0 +1,117 @@
import { permissions } from "@mrp/shared";
import type { ProjectPriority, ProjectStatus, ProjectSummaryDto } from "@mrp/shared/dist/projects/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { projectPriorityFilters, projectStatusFilters } from "./config";
import { ProjectPriorityBadge } from "./ProjectPriorityBadge";
import { ProjectStatusBadge } from "./ProjectStatusBadge";
export function ProjectListPage() {
const { token, user } = useAuth();
const [projects, setProjects] = useState<ProjectSummaryDto[]>([]);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | ProjectStatus>("ALL");
const [priorityFilter, setPriorityFilter] = useState<"ALL" | ProjectPriority>("ALL");
const [status, setStatus] = useState("Load projects, linked customer work, and program ownership.");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
setStatus("Loading projects...");
api
.getProjects(token, {
q: query || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
priority: priorityFilter === "ALL" ? undefined : priorityFilter,
})
.then((nextProjects) => {
setProjects(nextProjects);
setStatus(nextProjects.length === 0 ? "No projects matched the current filters." : `${nextProjects.length} project(s) loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load projects.";
setStatus(message);
});
}, [priorityFilter, query, statusFilter, token]);
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Projects</p>
<h3 className="mt-2 text-xl font-bold text-text">Program records</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Track long-running customer programs across commercial commitments, shipment deliverables, ownership, and due dates.</p>
</div>
{canManage ? (
<Link to="/projects/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
New project
</Link>
) : null}
</div>
</div>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_0.45fr_0.45fr]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Project number, name, customer" className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | ProjectStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{projectStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Priority</span>
<select value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | ProjectPriority)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{projectPriorityFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
{projects.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No projects are available for the current filters.</div>
) : (
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Project</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Owner</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Priority</th>
<th className="px-2 py-2">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{projects.map((project) => (
<tr key={project.id}>
<td className="px-2 py-2">
<Link to={`/projects/${project.id}`} className="font-semibold text-text hover:text-brand">{project.projectNumber}</Link>
<div className="mt-1 text-xs text-muted">{project.name}</div>
</td>
<td className="px-2 py-2 text-muted">{project.customerName}</td>
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
<td className="px-2 py-2 text-muted">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "No due date"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
);
}

View File

@@ -0,0 +1,7 @@
import type { ProjectPriority } from "@mrp/shared/dist/projects/types.js";
import { projectPriorityPalette } from "./config";
export function ProjectPriorityBadge({ priority }: { priority: ProjectPriority }) {
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectPriorityPalette[priority]}`}>{priority}</span>;
}

View File

@@ -0,0 +1,7 @@
import type { ProjectStatus } from "@mrp/shared/dist/projects/types.js";
import { projectStatusPalette } from "./config";
export function ProjectStatusBadge({ status }: { status: ProjectStatus }) {
return <span className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${projectStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
}

View File

@@ -0,0 +1,5 @@
import { ProjectListPage } from "./ProjectListPage";
export function ProjectsPage() {
return <ProjectListPage />;
}

View File

@@ -0,0 +1,54 @@
import type { ProjectInput, ProjectPriority, ProjectStatus } from "@mrp/shared/dist/projects/types.js";
export const projectStatusOptions: Array<{ value: ProjectStatus; label: string }> = [
{ value: "PLANNED", label: "Planned" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "AT_RISK", label: "At Risk" },
{ value: "COMPLETE", label: "Complete" },
];
export const projectPriorityOptions: Array<{ value: ProjectPriority; label: string }> = [
{ value: "LOW", label: "Low" },
{ value: "MEDIUM", label: "Medium" },
{ value: "HIGH", label: "High" },
{ value: "CRITICAL", label: "Critical" },
];
export const projectStatusFilters: Array<{ value: "ALL" | ProjectStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...projectStatusOptions,
];
export const projectPriorityFilters: Array<{ value: "ALL" | ProjectPriority; label: string }> = [
{ value: "ALL", label: "All priorities" },
...projectPriorityOptions,
];
export const projectStatusPalette: Record<ProjectStatus, string> = {
PLANNED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
AT_RISK: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
COMPLETE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const projectPriorityPalette: Record<ProjectPriority, string> = {
LOW: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
MEDIUM: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
HIGH: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
CRITICAL: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const emptyProjectInput: ProjectInput = {
name: "",
status: "PLANNED",
priority: "MEDIUM",
customerId: "",
salesQuoteId: null,
salesOrderId: null,
shipmentId: null,
ownerId: null,
dueDate: null,
notes: "",
};

View File

@@ -0,0 +1,673 @@
import { permissions } from "@mrp/shared";
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
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();
const [document, setDocument] = useState<PurchaseOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [receiptForm, setReceiptForm] = useState<PurchaseReceiptInput>(emptyPurchaseReceiptInput);
const [receiptQuantities, setReceiptQuantities] = useState<Record<string, number>>({});
const [receiptStatus, setReceiptStatus] = useState("Receive ordered material into inventory against this purchase order.");
const [isSavingReceipt, setIsSavingReceipt] = useState(false);
const [status, setStatus] = useState("Loading purchase order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "receipt";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: PurchaseOrderStatus;
}
| null
>(null);
const canManage = user?.permissions.includes("purchasing.write") ?? false;
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
useEffect(() => {
if (!token || !orderId) {
return;
}
api.getPurchaseOrder(token, orderId)
.then((nextDocument) => {
setDocument(nextDocument);
setStatus("Purchase order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
setStatus(message);
});
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
if (!canReceive) {
return;
}
api.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
setReceiptForm((current: PurchaseReceiptInput) => {
if (current.locationId) {
return current;
}
const firstOption = options[0];
return firstOption
? {
...current,
warehouseId: firstOption.warehouseId,
locationId: firstOption.locationId,
}
: current;
});
})
.catch(() => setLocationOptions([]));
}, [canReceive, orderId, token]);
useEffect(() => {
if (!document) {
return;
}
setReceiptQuantities((current) => {
const next: Record<string, number> = {};
for (const line of document.lines) {
if (line.remainingQuantity > 0) {
next[line.id] = current[line.id] ?? 0;
}
}
return next;
});
}, [document]);
if (!document) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
const activeDocument = document;
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
const demandContextItems =
planningRollup?.items.filter((item) => activeDocument.lines.some((line) => line.itemId === item.itemId) && (item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)) ?? [];
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
}
function updateReceiptQuantity(lineId: string, quantity: number) {
setReceiptQuantities((current: Record<string, number>) => ({
...current,
[lineId]: quantity,
}));
}
async function applyStatusChange(nextStatus: PurchaseOrderStatus) {
if (!token) {
return;
}
setIsUpdatingStatus(true);
setStatus("Updating purchase order status...");
try {
const nextDocument = await api.updatePurchaseOrderStatus(token, activeDocument.id, nextStatus);
setDocument(nextDocument);
setStatus("Purchase order status updated. Confirm vendor communication and receiving expectations if this moved the order into a terminal state.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update purchase order status.";
setStatus(message);
} finally {
setIsUpdatingStatus(false);
}
}
async function applyReceipt() {
if (!token || !canReceive) {
return;
}
setIsSavingReceipt(true);
setReceiptStatus("Posting purchase receipt...");
try {
const payload: PurchaseReceiptInput = {
...receiptForm,
lines: openLines
.map((line) => ({
purchaseOrderLineId: line.id,
quantity: Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)),
}))
.filter((line) => line.quantity > 0),
};
const nextDocument = await api.createPurchaseReceipt(token, activeDocument.id, payload);
setDocument(nextDocument);
setReceiptQuantities({});
setReceiptForm((current: PurchaseReceiptInput) => ({
...current,
receivedAt: new Date().toISOString(),
notes: "",
}));
setReceiptStatus("Purchase receipt recorded. Inventory has been increased; verify stock balances and post a correcting movement if quantities were overstated.");
setStatus("Purchase order updated after receipt.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt.";
setReceiptStatus(message);
} finally {
setIsSavingReceipt(false);
}
}
function handleStatusChange(nextStatus: PurchaseOrderStatus) {
const label = purchaseStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
kind: "status",
title: `Set purchase order to ${label}`,
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
impact:
nextStatus === "CLOSED"
? "This closes the order operationally and can change inbound supply expectations, shortage coverage, and vendor follow-up."
: "This changes the purchasing state used by receiving, planning, and audit review.",
recovery: "If the status is wrong, set the order back to the correct state and verify any downstream receiving or planning assumptions.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "CLOSED" ? "Type purchase order number to confirm:" : undefined,
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
nextStatus,
});
}
function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const totalReceiptQuantity = openLines.reduce((sum, line) => sum + Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)), 0);
setPendingConfirmation({
kind: "receipt",
title: "Post purchase receipt",
description: `Receive ${totalReceiptQuantity} total units into ${receiptForm.warehouseId && receiptForm.locationId ? "the selected stock location" : "inventory"} for ${activeDocument.documentNumber}.`,
impact: "This increases inventory immediately and becomes part of the PO receipt history.",
recovery: "If quantities are wrong, post the correcting inventory movement and review the remaining quantities on the purchase order.",
confirmLabel: "Post receipt",
confirmationLabel: totalReceiptQuantity > 0 ? "Type purchase order number to confirm:" : undefined,
confirmationValue: totalReceiptQuantity > 0 ? activeDocument.documentNumber : undefined,
});
}
async function handleOpenPdf() {
if (!token) {
return;
}
setIsOpeningPdf(true);
setStatus("Rendering purchase order PDF...");
try {
const blob = await api.getPurchaseOrderPdf(token, activeDocument.id);
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
setStatus("Purchase order PDF ready.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to render purchase order PDF.";
setStatus(message);
} finally {
setIsOpeningPdf(false);
}
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Order</p>
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
<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">
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to purchase orders
</Link>
<button
type="button"
onClick={handleOpenPdf}
disabled={isOpeningPdf}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
</button>
{canManage ? (
<Link to={`/purchasing/orders/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit purchase order
</Link>
) : null}
</div>
</div>
</div>
{canManage ? (
<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 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update purchase-order status without opening the full editor.</p>
</div>
<div className="flex flex-wrap gap-2">
{purchaseStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || activeDocument.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<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">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</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">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</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">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</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">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<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">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</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">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</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">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</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">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</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>
</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>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
</dl>
</article>
<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">Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article>
</div>
<section 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">Demand Context</p>
{demandContextItems.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 active shared shortage or buy-signal records currently point at items on this purchase order.
</div>
) : (
<div className="mt-5 space-y-3">
{demandContextItems.map((item) => (
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
)}
</section>
<section 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">Line Items</p>
{activeDocument.lines.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 line items have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
<tr key={line.id}>
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
<td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">
{line.salesOrderId && line.salesOrderNumber ? <Link to={`/sales/orders/${line.salesOrderId}`} className="hover:text-brand">{line.salesOrderNumber}</Link> : "Unlinked"}
</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">${line.unitCost.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canReceive ? (
<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">Purchase Receiving</p>
<h4 className="mt-2 text-lg font-bold text-text">Receive material</h4>
<p className="mt-2 text-sm text-muted">Post received quantities to inventory and retain a receipt record against this order.</p>
{openLines.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
All ordered quantities have been received for this purchase order.
</div>
) : (
<form className="mt-5 space-y-4" onSubmit={handleReceiptSubmit}>
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
<input
type="date"
value={receiptForm.receivedAt.slice(0, 10)}
onChange={(event) => updateReceiptField("receivedAt", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select
value={receiptForm.locationId}
onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateReceiptField("locationId", event.target.value);
if (nextLocation) {
updateReceiptField("warehouseId", nextLocation.warehouseId);
}
}}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea
value={receiptForm.notes}
onChange={(event) => updateReceiptField("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>
<div className="space-y-3">
{openLines.map((line) => (
<div key={line.id} className="grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
<div>
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
<div className="mt-2 text-xs text-muted">{line.description}</div>
</div>
<div className="text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Remaining</div>
<div className="mt-2 font-semibold text-text">{line.remainingQuantity}</div>
</div>
<div className="text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Received</div>
<div className="mt-2 font-semibold text-text">{line.receivedQuantity}</div>
</div>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Receive Now</span>
<input
type="number"
min={0}
max={line.remainingQuantity}
step={1}
value={receiptQuantities[line.id] ?? 0}
onChange={(event) => updateReceiptQuantity(line.id, Number.parseInt(event.target.value, 10) || 0)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
))}
</div>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{receiptStatus}</span>
<button
type="submit"
disabled={isSavingReceipt || locationOptions.length === 0}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSavingReceipt ? "Posting..." : "Post receipt"}
</button>
</div>
</form>
)}
</article>
) : null}
<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">Receipt History</p>
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
{activeDocument.receipts.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 purchase receipts have been recorded for this order yet.
</div>
) : (
<div className="mt-6 space-y-3">
{activeDocument.receipts.map((receipt) => (
<article key={receipt.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div>
<div className="mt-1 text-xs text-muted">
{receipt.warehouseCode} / {receipt.locationCode} · {receipt.totalQuantity} units across {receipt.lineCount} line{receipt.lineCount === 1 ? "" : "s"}
</div>
<div className="mt-2 text-xs text-muted">
{receipt.warehouseName} · {receipt.locationName}
</div>
<div className="mt-3 space-y-1">
{receipt.lines.map((line) => (
<div key={line.id} className="text-sm text-text">
<span className="font-semibold">{line.itemSku}</span> · {line.quantity}
</div>
))}
</div>
{receipt.notes ? <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{receipt.notes}</p> : null}
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(receipt.receivedAt).toLocaleDateString()}</div>
<div className="mt-1">{receipt.createdByName}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
<FileAttachmentsPanel
ownerType="PURCHASE_ORDER"
ownerId={activeDocument.id}
eyebrow="Supporting Documents"
title="Vendor invoices and backup"
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm purchasing action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "receipt" && isSavingReceipt)
}
onClose={() => {
if (!isUpdatingStatus && !isSavingReceipt) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "receipt") {
await applyReceipt();
setPendingConfirmation(null);
}
}}
/>
</section>
);
}

View File

@@ -0,0 +1,492 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config";
import { emptyPurchaseOrderInput, purchaseStatusOptions } from "./config";
export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { orderId } = useParams();
const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId");
const planningOrderId = searchParams.get("planningOrderId");
const selectedPlanningItemId = searchParams.get("itemId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
for (const child of node.children) {
nodes.push(...collectRecommendedPurchaseNodes(child));
}
return nodes;
}
const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0);
const taxAmount = subtotal * (form.taxPercent / 100);
const total = subtotal + taxAmount + form.freightAmount;
useEffect(() => {
if (!token) {
return;
}
api.getPurchaseVendors(token).then((nextVendors) => {
setVendors(nextVendors);
if (mode === "create" && seededVendorId) {
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
if (seededVendor) {
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
setVendorSearchTerm(seededVendor.name);
}
}
}).catch(() => setVendors([]));
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [mode, seededVendorId, token]);
useEffect(() => {
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
return;
}
api.getSalesOrderPlanning(token, planningOrderId)
.then((planning: SalesOrderPlanningDto) => {
const recommendedNodes = planning.lines.flatMap((line) =>
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
salesOrderLineId: node.itemId === line.itemId ? line.lineId : null,
...node,
}))
);
const filteredNodes = selectedPlanningItemId
? recommendedNodes.filter((node) => node.itemId === selectedPlanningItemId)
: recommendedNodes;
const recommendedLines = filteredNodes.map((node, index) => {
const inventoryItem = itemOptions.find((option) => option.id === node.itemId);
return {
itemId: node.itemId,
description: node.itemName,
quantity: node.recommendedPurchaseQuantity,
unitOfMeasure: node.unitOfMeasure,
unitCost: inventoryItem?.defaultCost ?? 0,
salesOrderId: planning.orderId,
salesOrderLineId: node.salesOrderLineId,
position: (index + 1) * 10,
} satisfies PurchaseLineInput;
});
if (recommendedLines.length === 0) {
return;
}
const preferredVendorIds = [
...new Set(
recommendedLines
.map((line) => itemOptions.find((option) => option.id === line.itemId)?.preferredVendorId)
.filter((vendorId): vendorId is string => Boolean(vendorId))
),
];
const autoVendorId = seededVendorId || (preferredVendorIds.length === 1 ? preferredVendorIds[0] : null);
setForm((current) => ({
...current,
vendorId: current.vendorId || autoVendorId || "",
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
lines: current.lines.length > 0 ? current.lines : recommendedLines,
}));
if (autoVendorId) {
const autoVendor = vendors.find((vendor) => vendor.id === autoVendorId);
if (autoVendor) {
setVendorSearchTerm(autoVendor.name);
}
}
setLineSearchTerms((current) =>
current.length > 0 ? current : recommendedLines.map((line) => itemOptions.find((option) => option.id === line.itemId)?.sku ?? "")
);
setStatus(
preferredVendorIds.length > 1 && !seededVendorId
? `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}. Multiple preferred vendors exist, so confirm the vendor before saving.`
: `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}.`
);
})
.catch(() => {
setStatus("Unable to load demand-planning recommendations.");
});
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
useEffect(() => {
if (!token || mode !== "edit" || !orderId) {
return;
}
api.getPurchaseOrder(token, orderId)
.then((document) => {
setForm({
vendorId: document.vendorId,
status: document.status,
issueDate: document.issueDate,
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,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
position: line.position,
})),
});
setVendorSearchTerm(document.vendorName);
setLineSearchTerms(document.lines.map((line: { itemSku: string }) => line.itemSku));
setStatus("Purchase order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
setStatus(message);
});
}, [mode, orderId, token]);
function updateField<Key extends keyof PurchaseOrderInput>(key: Key, value: PurchaseOrderInput[Key]) {
setForm((current: PurchaseOrderInput) => ({ ...current, [key]: value }));
}
function getSelectedVendorName(vendorId: string) {
return vendors.find((vendor) => vendor.id === vendorId)?.name ?? "";
}
function getSelectedVendor(vendorId: string) {
return vendors.find((vendor) => vendor.id === vendorId) ?? null;
}
function updateLine(index: number, nextLine: PurchaseLineInput) {
setForm((current: PurchaseOrderInput) => ({
...current,
lines: current.lines.map((line: PurchaseLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
}));
}
function updateLineSearchTerm(index: number, value: string) {
setLineSearchTerms((current) => {
const next = [...current];
next[index] = value;
return next;
});
}
function addLine() {
setForm((current: PurchaseOrderInput) => ({
...current,
lines: [
...current.lines,
{
itemId: "",
description: "",
quantity: 1,
unitOfMeasure: "EA",
unitCost: 0,
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: PurchaseLineInput) => line.position)) + 10,
},
],
}));
setLineSearchTerms((current) => [...current, ""]);
}
function removeLine(index: number) {
setForm((current: PurchaseOrderInput) => ({
...current,
lines: current.lines.filter((_line: PurchaseLineInput, lineIndex: number) => lineIndex !== index),
}));
setLineSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
}
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving purchase order...");
try {
const saved = mode === "create" ? await api.createPurchaseOrder(token, form) : await api.updatePurchaseOrder(token, orderId ?? "", form);
navigate(`/purchasing/orders/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save purchase order.";
setStatus(message);
setIsSaving(false);
}
}
const filteredVendorCount = vendors.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
}).length;
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Purchase Order" : "Edit Purchase Order"}</h3>
</div>
<Link to={mode === "create" ? "/purchasing/orders" : `/purchasing/orders/${orderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Vendor</span>
<div className="relative">
<input
value={vendorSearchTerm}
onChange={(event) => {
setVendorSearchTerm(event.target.value);
updateField("vendorId", "");
setVendorPickerOpen(true);
}}
onFocus={() => setVendorPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setVendorPickerOpen(false);
if (form.vendorId) {
setVendorSearchTerm(getSelectedVendorName(form.vendorId));
}
}, 120);
}}
placeholder="Search vendor"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{vendorPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{vendors
.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((vendor) => (
<button
key={vendor.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("vendorId", vendor.id);
setVendorSearchTerm(vendor.name);
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{vendor.name}</div>
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
</button>
))}
{filteredVendorCount === 0 ? <div className="px-2 py-2 text-sm text-muted">No matching vendors found.</div> : null}
</div>
) : null}
</div>
<div className="mt-2 min-h-5 text-xs text-muted">{form.vendorId ? getSelectedVendorName(form.vendorId) : "No vendor selected"}</div>
{form.vendorId ? (
<div className="mt-1 text-xs text-muted">
Terms: {getSelectedVendor(form.vendorId)?.paymentTerms || "N/A"} | Currency: {getSelectedVendor(form.vendorId)?.currencyCode || "USD"}
</div>
) : null}
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as PurchaseOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{purchaseStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<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>
<input type="number" min={0} max={100} step={0.01} value={form.taxPercent} onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
<input type="number" min={0} step={0.01} value={form.freightAmount} onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
</section>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
<h4 className="mt-2 text-lg font-bold text-text">Procurement lines</h4>
</div>
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add line</button>
</div>
{form.lines.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No line items added yet.</div>
) : (
<div className="mt-5 space-y-4">
{form.lines.map((line: PurchaseLineInput, index: number) => (
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
<div className="relative">
<input
value={lineSearchTerms[index] ?? ""}
onChange={(event) => {
updateLineSearchTerm(index, event.target.value);
updateLine(index, { ...line, itemId: "" });
setActiveLinePicker(index);
}}
onFocus={() => setActiveLinePicker(index)}
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
placeholder="Search by SKU"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{activeLinePicker === index ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateLine(index, {
...line,
itemId: option.id,
description: line.description || option.name,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
});
updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
>
{option.sku}
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as PurchaseLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Cost</span>
<input type="number" min={0} step={0.01} value={line.unitCost} onChange={(event) => updateLine(index, { ...line, unitCost: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end"><div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">${(line.quantity * line.unitCost).toFixed(2)}</div></div>
<div className="flex items-end"><button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">Remove</button></div>
</div>
</div>
))}
</div>
)}
<div className="mt-5 grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div><div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax</div><div className="mt-1 font-semibold text-text">${taxAmount.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Freight</div><div className="mt-1 font-semibold text-text">${form.freightAmount.toFixed(2)}</div></div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div><div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div></div>
</div>
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create purchase order" : "Save changes"}
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title="Remove purchase line"
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the purchase order draft.`
: "Remove this purchase line."
}
impact="The line will be removed from the draft immediately and purchasing totals will recalculate."
recovery="Re-add the line before saving if the removal was accidental."
confirmLabel="Remove line"
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -0,0 +1,100 @@
import { permissions } from "@mrp/shared";
import type { PurchaseOrderStatus, PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { purchaseStatusFilters } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
export function PurchaseListPage() {
const { token, user } = useAuth();
const [documents, setDocuments] = useState<PurchaseOrderSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | PurchaseOrderStatus>("ALL");
const [status, setStatus] = useState("Loading purchase orders...");
const canManage = user?.permissions.includes("purchasing.write") ?? false;
useEffect(() => {
if (!token) {
return;
}
api.getPurchaseOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
.then((nextDocuments) => {
setDocuments(nextDocuments);
setStatus(`${nextDocuments.length} purchase orders matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load purchase orders.";
setStatus(message);
});
}, [searchTerm, statusFilter, token]);
return (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing</p>
<h3 className="mt-2 text-lg font-bold text-text">Purchase Orders</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Vendor-facing procurement documents for material replenishment and bought-in components.</p>
</div>
{canManage ? (
<Link to="/purchasing/orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New purchase order
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input value={searchTerm} onChange={(event) => setSearchTerm(event.target.value)} placeholder="Search purchase orders by document number or vendor" className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | PurchaseOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
{purchaseStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{documents.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 purchase orders have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Document</th>
<th className="px-2 py-2">Vendor</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Issue Date</th>
<th className="px-2 py-2">Value</th>
<th className="px-2 py-2">Lines</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{documents.map((document) => (
<tr key={document.id} className="transition hover:bg-page/70">
<td className="px-2 py-2"><Link to={`/purchasing/orders/${document.id}`} className="font-semibold text-text hover:text-brand">{document.documentNumber}</Link></td>
<td className="px-2 py-2 text-muted">{document.vendorName}</td>
<td className="px-2 py-2"><PurchaseStatusBadge status={document.status} /></td>
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,7 @@
import type { PurchaseOrderStatus } from "@mrp/shared";
import { purchaseStatusPalette } from "./config";
export function PurchaseStatusBadge({ status }: { status: PurchaseOrderStatus }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${purchaseStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
}

View File

@@ -0,0 +1,40 @@
import type { PurchaseOrderInput, PurchaseOrderStatus } from "@mrp/shared";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
export const purchaseStatusOptions: Array<{ value: PurchaseOrderStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ISSUED", label: "Issued" },
{ value: "APPROVED", label: "Approved" },
{ value: "CLOSED", label: "Closed" },
];
export const purchaseStatusFilters: Array<{ value: "ALL" | PurchaseOrderStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...purchaseStatusOptions,
];
export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
vendorId: "",
status: "DRAFT",
issueDate: new Date().toISOString(),
taxPercent: 0,
freightAmount: 0,
notes: "",
revisionReason: "",
lines: [],
};
export const emptyPurchaseReceiptInput: PurchaseReceiptInput = {
receivedAt: new Date().toISOString(),
warehouseId: "",
locationId: "",
notes: "",
lines: [],
};

View File

@@ -0,0 +1,764 @@
import { permissions } from "@mrp/shared";
import type { SalesDocumentDetailDto, SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react";
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";
function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
return (
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">
{node.itemSku} <span className="text-muted">{node.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType}
{node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""}
</div>
</div>
<div className="text-right text-xs text-muted">
<div>Linked WO {node.linkedWorkOrderSupply}</div>
<div>Linked PO {node.linkedPurchaseSupply}</div>
<div>Stock {node.supplyFromStock}</div>
<div>Open WO {node.supplyFromOpenWorkOrders}</div>
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
<div>Build {node.recommendedBuildQuantity}</div>
<div>Buy {node.recommendedPurchaseQuantity}</div>
{node.uncoveredQuantity > 0 ? <div>Uncovered {node.uncoveredQuantity}</div> : null}
</div>
</div>
{node.children.length > 0 ? (
<div className="mt-3 space-y-3">
{node.children.map((child) => (
<PlanningNodeCard key={`${child.itemId}-${child.level}-${child.itemSku}-${child.grossDemand}`} node={child} />
))}
</div>
) : null}
</div>
);
}
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();
const { quoteId, orderId } = useParams();
const config = salesConfigs[entity];
const documentId = entity === "quote" ? quoteId : orderId;
const [document, setDocument] = useState<SalesDocumentDetailDto | null>(null);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "approve" | "convert";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus?: SalesDocumentStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false;
const canManageManufacturing = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
const canManagePurchasing = user?.permissions.includes(permissions.purchasingWrite) ?? false;
useEffect(() => {
if (!token || !documentId) {
return;
}
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
Promise.all([loader, planningLoader])
.then(([nextDocument, nextPlanning]) => {
setDocument(nextDocument);
setPlanning(nextPlanning);
setStatus(`${config.singularLabel} loaded.`);
if (entity === "order" && canReadShipping) {
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
}
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [canReadShipping, config.singularLabel, documentId, entity, token]);
if (!document) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
const activeDocument = document;
function buildWorkOrderRecommendationLink(itemId: string, quantity: number) {
const params = new URLSearchParams({
itemId,
salesOrderId: activeDocument.id,
quantity: quantity.toString(),
status: "DRAFT",
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
});
return `/manufacturing/work-orders/new?${params.toString()}`;
}
function buildPurchaseRecommendationLink(itemId?: string, vendorId?: string | null) {
const params = new URLSearchParams();
params.set("planningOrderId", activeDocument.id);
if (itemId) {
params.set("itemId", itemId);
}
if (vendorId) {
params.set("vendorId", vendorId);
}
return `/purchasing/orders/new?${params.toString()}`;
}
async function applyStatusChange(nextStatus: SalesDocumentStatus) {
if (!token) {
return;
}
setIsUpdatingStatus(true);
setStatus(`Updating ${config.singularLabel.toLowerCase()} status...`);
try {
const nextDocument =
entity === "quote"
? await api.updateQuoteStatus(token, activeDocument.id, nextStatus)
: await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus);
setDocument(nextDocument);
setStatus(`${config.singularLabel} status updated. Review revisions and downstream workflows if the document moved into a terminal or customer-visible state.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`;
setStatus(message);
} finally {
setIsUpdatingStatus(false);
}
}
async function applyConvert() {
if (!token || entity !== "quote") {
return;
}
setIsConverting(true);
setStatus("Converting quote to sales order...");
try {
const order = await api.convertQuoteToSalesOrder(token, activeDocument.id);
navigate(`/sales/orders/${order.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to convert quote to sales order.";
setStatus(message);
setIsConverting(false);
}
}
async function handleOpenPdf() {
if (!token) {
return;
}
setIsOpeningPdf(true);
setStatus(`Rendering ${config.singularLabel.toLowerCase()} PDF...`);
try {
const blob =
entity === "quote"
? await api.getQuotePdf(token, activeDocument.id)
: await api.getSalesOrderPdf(token, activeDocument.id);
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
setStatus(`${config.singularLabel} PDF ready.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to render ${config.singularLabel.toLowerCase()} PDF.`;
setStatus(message);
} finally {
setIsOpeningPdf(false);
}
}
async function applyApprove() {
if (!token) {
return;
}
setIsApproving(true);
setStatus(`Approving ${config.singularLabel.toLowerCase()}...`);
try {
const nextDocument =
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
setDocument(nextDocument);
setStatus(`${config.singularLabel} approved. The approval stamp is now part of the document history and downstream teams can act on it immediately.`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
} finally {
setIsApproving(false);
}
}
function handleStatusChange(nextStatus: SalesDocumentStatus) {
const label = salesStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
kind: "status",
title: `Set ${config.singularLabel.toLowerCase()} to ${label}`,
description: `Update ${activeDocument.documentNumber} from ${activeDocument.status} to ${nextStatus}.`,
impact:
nextStatus === "CLOSED"
? "This closes the document operationally and can change customer-facing execution assumptions and downstream follow-up expectations."
: nextStatus === "APPROVED"
? "This marks the document ready for downstream action and becomes part of the approval history."
: "This changes the operational state used by downstream workflows and audit/revision history.",
recovery: "If this status is set in error, return the document to the correct state and verify the latest revision history.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "CLOSED" ? "Type document number to confirm:" : undefined,
confirmationValue: nextStatus === "CLOSED" ? activeDocument.documentNumber : undefined,
nextStatus,
});
}
function handleApprove() {
setPendingConfirmation({
kind: "approve",
title: `Approve ${config.singularLabel.toLowerCase()}`,
description: `Approve ${activeDocument.documentNumber} for ${activeDocument.customerName}.`,
impact: "Approval records the approver and timestamp and signals that downstream execution can proceed.",
recovery: "If approval was granted by mistake, change the document status and review the revision trail for follow-up.",
confirmLabel: "Approve document",
});
}
function handleConvert() {
setPendingConfirmation({
kind: "convert",
title: "Convert quote to sales order",
description: `Create a sales order from quote ${activeDocument.documentNumber}.`,
impact: "This creates a new sales order record and can trigger planning, purchasing, manufacturing, and shipping follow-up work.",
recovery: "Review the new order immediately after creation. If conversion was premature, move the resulting order to the correct status and coordinate with downstream teams.",
confirmLabel: "Convert quote",
confirmationLabel: "Type quote number to confirm:",
confirmationValue: activeDocument.documentNumber,
});
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p>
<h3 className="mt-2 text-xl font-bold text-text">{activeDocument.documentNumber}</h3>
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
<div className="mt-3 flex flex-wrap gap-2">
<SalesStatusBadge status={activeDocument.status} />
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
Rev {activeDocument.currentRevisionNumber}
</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link to={config.routeBase} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to {config.collectionLabel.toLowerCase()}
</Link>
<button
type="button"
onClick={handleOpenPdf}
disabled={isOpeningPdf}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{isOpeningPdf ? "Rendering PDF..." : "Open PDF"}
</button>
{canManage ? (
<>
<Link to={`${config.routeBase}/${activeDocument.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit {config.singularLabel.toLowerCase()}
</Link>
{activeDocument.status !== "APPROVED" ? (
<button
type="button"
onClick={handleApprove}
disabled={isApproving}
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
>
{isApproving ? "Approving..." : "Approve"}
</button>
) : null}
{entity === "quote" ? (
<button
type="button"
onClick={handleConvert}
disabled={isConverting}
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
>
{isConverting ? "Converting..." : "Convert to sales order"}
</button>
) : null}
{entity === "order" && canManageShipping ? (
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New shipment
</Link>
) : null}
</>
) : null}
</div>
</div>
</div>
{canManage ? (
<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 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update document status without opening the full editor.</p>
</div>
<div className="flex flex-wrap gap-2">
{salesStatusOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleStatusChange(option.value)}
disabled={isUpdatingStatus || activeDocument.status === option.value}
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<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">Issue Date</p>
<div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</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">Expires</p>
<div className="mt-2 text-base font-bold text-text">{activeDocument.expiresAt ? new Date(activeDocument.expiresAt).toLocaleDateString() : "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">Lines</p>
<div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</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">Approval</p>
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
</article>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<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">Discount</p>
<div className="mt-2 text-base font-bold text-text">-${activeDocument.discountAmount.toFixed(2)}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.discountPercent.toFixed(2)}%</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">Tax</p>
<div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div>
<div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</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">Freight</p>
<div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</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">Total</p>
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</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 document changes status, content, or approval state.</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 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>
<dl className="mt-5 grid gap-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
<dd className="mt-1 text-sm text-text">{activeDocument.customerName}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
<dd className="mt-1 text-sm text-text">{activeDocument.customerEmail}</dd>
</div>
</dl>
</article>
<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">Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article>
</div>
<section 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">Line Items</p>
{activeDocument.lines.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 line items have been added yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Description</th>
<th className="px-2 py-2">Qty</th>
<th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Unit Price</th>
<th className="px-2 py-2">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: SalesDocumentDetailDto["lines"][number]) => (
<tr key={line.id}>
<td className="px-2 py-2">
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
</td>
<td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">${line.unitPrice.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{entity === "order" && planning ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Planning</p>
<h3 className="mt-2 text-lg font-bold text-text">Net build and buy requirements</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Sales-order demand is netted against available stock, active reservations, open work orders, and open purchase orders before new build or buy quantities are recommended.
</p>
</div>
<div className="text-right text-xs text-muted">
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
<div>Status {planning.status}</div>
</div>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
</article>
</div>
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Gross</th>
<th className="px-2 py-2">Linked WO</th>
<th className="px-2 py-2">Linked PO</th>
<th className="px-2 py-2">Available</th>
<th className="px-2 py-2">Open WO</th>
<th className="px-2 py-2">Open PO</th>
<th className="px-2 py-2">Build</th>
<th className="px-2 py-2">Buy</th>
<th className="px-2 py-2">Uncovered</th>
<th className="px-2 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{planning.items.map((item) => (
<tr key={item.itemId}>
<td className="px-2 py-2">
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</td>
<td className="px-2 py-2 text-muted">{item.grossDemand}</td>
<td className="px-2 py-2 text-muted">{item.linkedWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.linkedPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
<td className="px-2 py-2">
<div className="flex flex-wrap gap-2">
{canManageManufacturing && item.recommendedBuildQuantity > 0 ? (
<Link
to={buildWorkOrderRecommendationLink(item.itemId, item.recommendedBuildQuantity)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft WO
</Link>
) : null}
{canManagePurchasing && item.recommendedPurchaseQuantity > 0 ? (
<Link
to={buildPurchaseRecommendationLink(item.itemId)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft PO
</Link>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{canManagePurchasing && planning.summary.purchaseRecommendationCount > 0 ? (
<div className="mt-4 flex justify-end">
<Link to={buildPurchaseRecommendationLink()} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Draft purchase order from recommendations
</Link>
</div>
) : null}
<div className="mt-5 space-y-3">
{planning.lines.map((line) => (
<div key={line.lineId} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="mb-3">
<div className="font-semibold text-text">
{line.itemSku} <span className="text-muted">{line.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Sales-order line demand: {line.quantity} {line.unitOfMeasure}
</div>
</div>
<PlanningNodeCard node={line.rootNode} />
</div>
))}
</div>
</section>
) : null}
{entity === "order" && canReadShipping ? (
<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">Shipping</p>
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
</div>
{canManageShipping ? (
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Create shipment
</Link>
) : null}
</div>
{shipments.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 shipments have been created for this sales order yet.
</div>
) : (
<div className="mt-6 space-y-3">
{shipments.map((shipment) => (
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.carrier || "Carrier not set"} · {shipment.trackingNumber || "No tracking"}</div>
</div>
<ShipmentStatusBadge status={shipment.status} />
</div>
</Link>
))}
</div>
)}
</section>
) : null}
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm sales action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={
(pendingConfirmation?.kind === "status" && isUpdatingStatus) ||
(pendingConfirmation?.kind === "approve" && isApproving) ||
(pendingConfirmation?.kind === "convert" && isConverting)
}
onClose={() => {
if (!isUpdatingStatus && !isApproving && !isConverting) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
if (pendingConfirmation.kind === "status" && pendingConfirmation.nextStatus) {
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "approve") {
await applyApprove();
setPendingConfirmation(null);
return;
}
if (pendingConfirmation.kind === "convert") {
await applyConvert();
setPendingConfirmation(null);
}
}}
/>
</section>
);
}

View File

@@ -0,0 +1,502 @@
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryUnitOptions } from "../inventory/config";
import { emptySalesDocumentInput, salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { quoteId, orderId } = useParams();
const documentId = entity === "quote" ? quoteId : orderId;
const config = salesConfigs[entity];
const [form, setForm] = useState<SalesDocumentInput>(emptySalesDocumentInput);
const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`);
const [customers, setCustomers] = useState<SalesCustomerOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [pendingLineRemovalIndex, setPendingLineRemovalIndex] = useState<number | null>(null);
const subtotal = form.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const discountAmount = subtotal * (form.discountPercent / 100);
const taxableSubtotal = subtotal - discountAmount;
const taxAmount = taxableSubtotal * (form.taxPercent / 100);
const total = taxableSubtotal + taxAmount + form.freightAmount;
useEffect(() => {
if (!token) {
return;
}
api.getSalesCustomers(token).then(setCustomers).catch(() => setCustomers([]));
api.getInventoryItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
}, [token]);
useEffect(() => {
if (!token || mode !== "edit" || !documentId) {
return;
}
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
loader
.then((document) => {
setForm({
customerId: document.customerId,
status: document.status,
issueDate: document.issueDate,
expiresAt: entity === "quote" ? document.expiresAt : null,
discountPercent: document.discountPercent,
taxPercent: document.taxPercent,
freightAmount: document.freightAmount,
notes: document.notes,
revisionReason: "",
lines: document.lines.map((line) => ({
itemId: line.itemId,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
position: line.position,
})),
});
setCustomerSearchTerm(document.customerName);
setLineSearchTerms(document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => line.itemSku));
setStatus(`${config.singularLabel} loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, documentId, entity, mode, token]);
function updateField<Key extends keyof SalesDocumentInput>(key: Key, value: SalesDocumentInput[Key]) {
setForm((current: SalesDocumentInput) => ({ ...current, [key]: value }));
}
function getSelectedCustomerName(customerId: string) {
return customers.find((customer) => customer.id === customerId)?.name ?? "";
}
function getSelectedCustomer(customerId: string) {
return customers.find((customer) => customer.id === customerId) ?? null;
}
function updateLine(index: number, nextLine: SalesLineInput) {
setForm((current: SalesDocumentInput) => ({
...current,
lines: current.lines.map((line: SalesLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
}));
}
function updateLineSearchTerm(index: number, value: string) {
setLineSearchTerms((current: string[]) => {
const next = [...current];
next[index] = value;
return next;
});
}
function addLine() {
setForm((current: SalesDocumentInput) => ({
...current,
lines: [
...current.lines,
{
itemId: "",
description: "",
quantity: 1,
unitOfMeasure: "EA",
unitPrice: 0,
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: SalesLineInput) => line.position)) + 10,
},
],
}));
setLineSearchTerms((current: string[]) => [...current, ""]);
}
function removeLine(index: number) {
setForm((current: SalesDocumentInput) => ({
...current,
lines: current.lines.filter((_line: SalesLineInput, lineIndex: number) => lineIndex !== index),
}));
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
}
const pendingLineRemoval =
pendingLineRemovalIndex != null
? {
index: pendingLineRemovalIndex,
line: form.lines[pendingLineRemovalIndex],
sku: lineSearchTerms[pendingLineRemovalIndex] ?? "",
}
: null;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
try {
const saved =
entity === "quote"
? mode === "create"
? await api.createQuote(token, form)
: await api.updateQuote(token, documentId ?? "", form)
: mode === "create"
? await api.createSalesOrder(token, { ...form, expiresAt: null })
: await api.updateSalesOrder(token, documentId ?? "", { ...form, expiresAt: null });
navigate(`${config.routeBase}/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
</div>
<Link to={mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setCustomerPickerOpen(false);
if (form.customerId) {
setCustomerSearchTerm(getSelectedCustomerName(form.customerId));
}
}, 120);
}}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{customers
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return (
customer.name.toLowerCase().includes(query) ||
customer.email.toLowerCase().includes(query)
);
})
.slice(0, 12)
.map((customer) => (
<button
key={customer.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
updateField("discountPercent", customer.resellerDiscountPercent);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
{customers.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
}).length === 0 ? (
<div className="px-2 py-2 text-sm text-muted">No matching customers found.</div>
) : null}
</div>
) : null}
</div>
<div className="mt-2 min-h-5 text-xs text-muted">
{form.customerId ? getSelectedCustomerName(form.customerId) : "No customer selected"}
</div>
{form.customerId ? (
<div className="mt-1 text-xs text-muted">
Default reseller discount: {getSelectedCustomer(form.customerId)?.resellerDiscountPercent.toFixed(2) ?? "0.00"}%
</div>
) : null}
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={form.status}
onChange={(event) => updateField("status", event.target.value as SalesDocumentInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{salesStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Issue date</span>
<input
type="date"
value={form.issueDate.slice(0, 10)}
onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
{entity === "quote" ? (
<label className="block xl:max-w-sm">
<span className="mb-2 block text-sm font-semibold text-text">Expiration date</span>
<input
type="date"
value={form.expiresAt ? form.expiresAt.slice(0, 10) : ""}
onChange={(event) => updateField("expiresAt", event.target.value ? new Date(event.target.value).toISOString() : null)}
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}
<label className="block">
<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-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Discount %</span>
<input
type="number"
min={0}
max={100}
step={0.01}
value={form.discountPercent}
onChange={(event) => updateField("discountPercent", Number(event.target.value) || 0)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Tax %</span>
<input
type="number"
min={0}
max={100}
step={0.01}
value={form.taxPercent}
onChange={(event) => updateField("taxPercent", Number(event.target.value) || 0)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Freight</span>
<input
type="number"
min={0}
step={0.01}
value={form.freightAmount}
onChange={(event) => updateField("freightAmount", Number(event.target.value) || 0)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
</section>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4>
</div>
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add line
</button>
</div>
{form.lines.length === 0 ? (
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No line items added yet.
</div>
) : (
<div className="mt-5 space-y-4">
{form.lines.map((line: SalesLineInput, index: number) => (
<div key={index} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.15fr_1.25fr_0.5fr_0.55fr_0.7fr_0.75fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
<div className="relative">
<input
value={lineSearchTerms[index] ?? ""}
onChange={(event) => {
updateLineSearchTerm(index, event.target.value);
updateLine(index, { ...line, itemId: "" });
setActiveLinePicker(index);
}}
onFocus={() => setActiveLinePicker(index)}
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
placeholder="Search by SKU"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{activeLinePicker === index ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateLine(index, {
...line,
itemId: option.id,
description: line.description || option.name,
unitPrice: line.unitPrice > 0 ? line.unitPrice : (option.defaultPrice ?? 0),
});
updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null);
}}
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
>
{option.sku}
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
<select value={line.unitOfMeasure} onChange={(event) => updateLine(index, { ...line, unitOfMeasure: event.target.value as SalesLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand">
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Unit Price</span>
<input type="number" min={0} step={0.01} value={line.unitPrice} onChange={(event) => updateLine(index, { ...line, unitPrice: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<div className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text">
${(line.quantity * line.unitPrice).toFixed(2)}
</div>
</div>
<div className="flex items-end">
<button type="button" onClick={() => setPendingLineRemovalIndex(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Subtotal</div>
<div className="mt-1 font-semibold text-text">${subtotal.toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Discount</div>
<div className="mt-1 font-semibold text-text">-${discountAmount.toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Tax + Freight</div>
<div className="mt-1 font-semibold text-text">${(taxAmount + form.freightAmount).toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Total</div>
<div className="mt-1 font-semibold text-text">${total.toFixed(2)}</div>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
</button>
</div>
</section>
<ConfirmActionDialog
open={pendingLineRemoval != null}
title={`Remove ${config.singularLabel.toLowerCase()} line`}
description={
pendingLineRemoval
? `Remove ${pendingLineRemoval.sku || pendingLineRemoval.line?.description || "this line"} from the ${config.singularLabel.toLowerCase()}.`
: "Remove this line."
}
impact="The line will be dropped from the document draft immediately and totals will recalculate."
recovery="Add the line back manually before saving if this removal was a mistake."
confirmLabel="Remove line"
isConfirming={false}
onClose={() => setPendingLineRemovalIndex(null)}
onConfirm={() => {
if (pendingLineRemoval) {
removeLine(pendingLineRemoval.index);
}
setPendingLineRemovalIndex(null);
}}
/>
</form>
);
}

View File

@@ -0,0 +1,130 @@
import { permissions } from "@mrp/shared";
import type { SalesDocumentStatus, SalesDocumentSummaryDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { salesConfigs, salesStatusFilters, type SalesDocumentEntity } from "./config";
import { SalesStatusBadge } from "./SalesStatusBadge";
export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
const { token, user } = useAuth();
const config = salesConfigs[entity];
const [documents, setDocuments] = useState<SalesDocumentSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | SalesDocumentStatus>("ALL");
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
const loader =
entity === "quote"
? api.getQuotes(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
: api.getSalesOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter });
loader
.then((nextDocuments) => {
setDocuments(nextDocuments);
setStatus(`${nextDocuments.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
return (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p>
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Customer-facing commercial documents for pricing, commitment, and downstream fulfillment planning.
</p>
</div>
{canManage ? (
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={`Search ${config.collectionLabel.toLowerCase()} by document number or customer`}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | SalesDocumentStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{salesStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{documents.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 {config.collectionLabel.toLowerCase()} have been added yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Document</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Revision</th>
<th className="px-2 py-2">Issue Date</th>
<th className="px-2 py-2">Value</th>
<th className="px-2 py-2">Lines</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{documents.map((document) => (
<tr key={document.id} className="transition hover:bg-page/70">
<td className="px-2 py-2">
<Link to={`${config.routeBase}/${document.id}`} className="font-semibold text-text hover:text-brand">
{document.documentNumber}
</Link>
</td>
<td className="px-2 py-2 text-muted">{document.customerName}</td>
<td className="px-2 py-2">
<SalesStatusBadge status={document.status} />
</td>
<td className="px-2 py-2 text-muted">
Rev {document.currentRevisionNumber}
{document.approvedAt ? <div className="mt-1 text-xs text-muted">Approved</div> : null}
</td>
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import type { SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
import { salesStatusOptions, salesStatusPalette } from "./config";
export function SalesStatusBadge({ status }: { status: SalesDocumentStatus }) {
const label = salesStatusOptions.find((option) => option.value === status)?.label ?? status;
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${salesStatusPalette[status]}`}>{label}</span>;
}

View File

@@ -0,0 +1,63 @@
import type { SalesDocumentInput, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
export type SalesDocumentEntity = "quote" | "order";
interface SalesModuleConfig {
entity: SalesDocumentEntity;
singularLabel: string;
collectionLabel: string;
routeBase: string;
detailEyebrow: string;
listEyebrow: string;
}
export const salesConfigs: Record<SalesDocumentEntity, SalesModuleConfig> = {
quote: {
entity: "quote",
singularLabel: "Quote",
collectionLabel: "Quotes",
routeBase: "/sales/quotes",
detailEyebrow: "Sales Quote",
listEyebrow: "Sales",
},
order: {
entity: "order",
singularLabel: "Sales Order",
collectionLabel: "Sales Orders",
routeBase: "/sales/orders",
detailEyebrow: "Sales Order",
listEyebrow: "Sales",
},
};
export const salesStatusOptions: Array<{ value: SalesDocumentStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ISSUED", label: "Issued" },
{ value: "APPROVED", label: "Approved" },
{ value: "CLOSED", label: "Closed" },
];
export const salesStatusFilters: Array<{ value: "ALL" | SalesDocumentStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...salesStatusOptions,
];
export const salesStatusPalette: Record<SalesDocumentStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptySalesDocumentInput: SalesDocumentInput = {
customerId: "",
status: "DRAFT",
issueDate: new Date().toISOString(),
expiresAt: null,
discountPercent: 0,
taxPercent: 0,
freightAmount: 0,
notes: "",
lines: [],
revisionReason: "",
};

View File

@@ -0,0 +1,457 @@
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
function formatDateTime(value: string) {
return new Date(value).toLocaleString();
}
function parseMetadata(metadataJson: string) {
try {
return JSON.parse(metadataJson) as Record<string, unknown>;
} catch {
return {};
}
}
export function AdminDiagnosticsPage() {
const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
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) {
return;
}
let active = true;
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token, buildSupportLogFilters())])
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
if (!active) {
return;
}
setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance);
setSupportLogData(nextSupportLogs);
setStatus("Diagnostics loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load diagnostics.");
});
return () => {
active = false;
};
}, [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>;
}
async function handleExportSupportSnapshot() {
if (!token) {
return;
}
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");
link.href = objectUrl;
link.download = `mrp-codex-support-snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
setStatus("Support snapshot exported.");
}
async function handleExportSupportLogs() {
if (!token) {
return;
}
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");
link.href = objectUrl;
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
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()],
["Work orders", diagnostics.workOrderCount.toString()],
["Projects", diagnostics.projectCount.toString()],
["Attachments", diagnostics.attachmentCount.toString()],
];
const footprintCards = [
["Database URL", diagnostics.databaseUrl],
["Data directory", diagnostics.dataDir],
["Uploads directory", diagnostics.uploadsDir],
["Client origin", diagnostics.clientOrigin],
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
["Active sessions", diagnostics.activeSessionCount.toString()],
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
["Purchase orders", diagnostics.purchaseOrderCount.toString()],
["Shipments", diagnostics.shipmentCount.toString()],
];
const startupStatusTone =
diagnostics.startup.status === "PASS"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
: diagnostics.startup.status === "WARN"
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
const startupSummaryCards = [
["Generated", formatDateTime(diagnostics.startup.generatedAt)],
["Duration", `${diagnostics.startup.durationMs} ms`],
["Pass / Warn / Fail", `${diagnostics.startup.passCount} / ${diagnostics.startup.warnCount} / ${diagnostics.startup.failCount}`],
];
return (
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleExportSupportSnapshot}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support bundle
</button>
<button
type="button"
onClick={handleExportSupportLogs}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support logs
</button>
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(([label, value]) => (
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
<p className="mt-3 text-lg font-bold text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Backup And Restore</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational backup workflow</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Use these paths and steps as the support baseline for manual backup and restore procedures.
</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
<div>Data: {backupGuidance.dataPath}</div>
<div>DB: {backupGuidance.databasePath}</div>
<div>Uploads: {backupGuidance.uploadsPath}</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Backup checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.backupSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
<p className="mt-1 text-sm text-muted">{step.detail}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Restore checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.restoreSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
<p className="mt-1 text-sm text-muted">{step.detail}</p>
</div>
))}
</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.verificationChecklist.map((item) => (
<div key={item.id}>
<p className="text-sm font-semibold text-text">{item.label}</p>
<p className="mt-1 text-sm text-muted">{item.detail}</p>
<p className="mt-1 text-xs text-muted">Evidence: {item.evidence}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
<div className="mt-3 space-y-3">
{backupGuidance.restoreDrillSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
<p className="mt-1 text-sm text-muted">{step.detail}</p>
<p className="mt-1 text-xs text-muted">Expected outcome: {step.expectedOutcome}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p>
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
</div>
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
{diagnostics.startup.status}
</span>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{diagnostics.startup.checks.map((check) => (
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-text">{check.label}</p>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{check.status}</span>
</div>
<p className="mt-2 text-sm text-muted">{check.message}</p>
</div>
))}
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{startupSummaryCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<p className="mt-2 text-sm text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{footprintCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<p className="mt-2 break-all text-sm text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 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">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">
{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">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">When</th>
<th className="px-3 py-3">Level</th>
<th className="px-3 py-3">Source</th>
<th className="px-3 py-3">Message</th>
<th className="px-3 py-3">Context</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{supportLogs.map((entry) => {
const context = parseMetadata(entry.contextJson);
return (
<tr key={entry.id} className="align-top">
<td className="px-3 py-3 text-muted">{formatDateTime(entry.createdAt)}</td>
<td className="px-3 py-3">
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
{entry.level}
</span>
</td>
<td className="px-3 py-3 text-text">{entry.source}</td>
<td className="px-3 py-3 text-text">{entry.message}</td>
<td className="px-3 py-3 text-xs text-muted">
{Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"}
</td>
</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>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 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">Recent Audit Trail</p>
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
</div>
<p className="text-sm text-muted">{status}</p>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">When</th>
<th className="px-3 py-3">Actor</th>
<th className="px-3 py-3">Entity</th>
<th className="px-3 py-3">Action</th>
<th className="px-3 py-3">Summary</th>
<th className="px-3 py-3">Metadata</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{diagnostics.recentAuditEvents.map((event) => {
const metadata = parseMetadata(event.metadataJson);
return (
<tr key={event.id} className="align-top">
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
<td className="px-3 py-3 text-text">
<div>{event.entityType}</div>
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
</td>
<td className="px-3 py-3">
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
{event.action}
</span>
</td>
<td className="px-3 py-3 text-text">{event.summary}</td>
<td className="px-3 py-3 text-xs text-muted">
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import type { CompanyProfileInput } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
export function CompanySettingsPage() {
const { token, user } = useAuth();
const { applyBrandProfile } = useTheme();
const [form, setForm] = useState<CompanyProfileInput | null>(null);
const [companyId, setCompanyId] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [status, setStatus] = useState<string>("Loading company profile...");
async function loadLogoPreview(nextToken: string, logoFileId: string | null) {
if (!logoFileId) {
setLogoUrl((current) => {
if (current?.startsWith("blob:")) {
window.URL.revokeObjectURL(current);
}
return null;
});
return;
}
const blob = await api.getFileContentBlob(nextToken, logoFileId);
const objectUrl = window.URL.createObjectURL(blob);
setLogoUrl((current) => {
if (current?.startsWith("blob:")) {
window.URL.revokeObjectURL(current);
}
return objectUrl;
});
}
useEffect(() => {
if (!token) {
return;
}
let active = true;
api.getCompanyProfile(token).then((profile) => {
setCompanyId(profile.id);
setForm({
companyName: profile.companyName,
legalName: profile.legalName,
email: profile.email,
phone: profile.phone,
website: profile.website,
taxId: profile.taxId,
addressLine1: profile.addressLine1,
addressLine2: profile.addressLine2,
city: profile.city,
state: profile.state,
postalCode: profile.postalCode,
country: profile.country,
theme: profile.theme,
});
applyBrandProfile(profile);
setStatus("Company profile loaded.");
if (profile.theme.logoFileId) {
loadLogoPreview(token, profile.theme.logoFileId)
.then(() => {
if (!active) {
return;
}
})
.catch(() => {
if (active) {
setLogoUrl(null);
}
});
} else {
setLogoUrl(null);
}
});
return () => {
active = false;
};
}, [applyBrandProfile, token]);
useEffect(() => {
return () => {
if (logoUrl?.startsWith("blob:")) {
window.URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (!form || !token) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !form) {
return;
}
const profile = await api.updateCompanyProfile(token, form);
applyBrandProfile(profile);
await loadLogoPreview(token, profile.theme.logoFileId);
setStatus("Company settings saved.");
}
async function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !companyId || !token) {
return;
}
const attachment = await api.uploadFile(token, file, "company-profile", companyId);
setForm((current) =>
current
? {
...current,
theme: {
...current.theme,
logoFileId: attachment.id,
},
}
: current
);
await loadLogoPreview(token, attachment.id);
setStatus("Logo uploaded. Save to persist it on the profile.");
}
async function handlePdfPreview() {
if (!token) {
return;
}
const blob = await api.getCompanyProfilePreviewPdf(token);
const objectUrl = window.URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
}
function updateField<Key extends keyof CompanyProfileInput>(key: Key, value: CompanyProfileInput[Key]) {
setForm((current) => (current ? { ...current, [key]: value } : current));
}
return (
<form className="space-y-6" onSubmit={handleSave}>
{user?.permissions.includes("admin.manage") ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Open diagnostics
</Link>
</div>
</div>
</section>
) : null}
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
<h3 className="mt-2 text-lg font-bold text-text">Branding and legal identity</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
</div>
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 p-4">
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
Upload logo
<input className="hidden" type="file" accept="image/*" onChange={handleLogoUpload} />
</label>
</div>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{[
["companyName", "Company name"],
["legalName", "Legal name"],
["email", "Email"],
["phone", "Phone"],
["website", "Website"],
["taxId", "Tax ID"],
["addressLine1", "Address line 1"],
["addressLine2", "Address line 2"],
["city", "City"],
["state", "State"],
["postalCode", "Postal code"],
["country", "Country"],
].map(([key, label]) => (
<label key={key} className="block">
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
<input
value={String(form[key as keyof CompanyProfileInput])}
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
))}
</div>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handlePdfPreview}
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Preview PDF
</button>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
Save changes
</button>
</div>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,677 @@
import type {
AdminAuthSessionDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
} from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
const emptyUserForm: AdminUserInput = {
email: "",
firstName: "",
lastName: "",
isActive: true,
roleIds: [],
password: "",
};
const emptyRoleForm: AdminRoleInput = {
name: "",
description: "",
permissionKeys: [],
};
export function UserManagementPage() {
const { token, user: authUser, logout } = useAuth();
const [users, setUsers] = useState<AdminUserDto[]>([]);
const [roles, setRoles] = useState<AdminRoleDto[]>([]);
const [permissions, setPermissions] = useState<AdminPermissionOptionDto[]>([]);
const [sessions, setSessions] = useState<AdminAuthSessionDto[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>("new");
const [selectedRoleId, setSelectedRoleId] = useState<string>("new");
const [sessionUserFilter, setSessionUserFilter] = useState<string>("all");
const [sessionStatusFilter, setSessionStatusFilter] = useState<"ALL" | AdminAuthSessionDto["status"]>("ALL");
const [sessionReviewFilter, setSessionReviewFilter] = useState<"ALL" | AdminAuthSessionDto["reviewState"]>("ALL");
const [sessionQuery, setSessionQuery] = useState("");
const [userForm, setUserForm] = useState<AdminUserInput>(emptyUserForm);
const [roleForm, setRoleForm] = useState<AdminRoleInput>(emptyRoleForm);
const [status, setStatus] = useState("Loading admin access controls...");
const [isConfirmingAction, setIsConfirmingAction] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "deactivate-user" | "revoke-session";
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
userId?: string;
sessionId?: string;
}
| null
>(null);
useEffect(() => {
if (!token) {
return;
}
let active = true;
Promise.all([api.getAdminUsers(token), api.getAdminRoles(token), api.getAdminPermissions(token), api.getAdminSessions(token)])
.then(([nextUsers, nextRoles, nextPermissions, nextSessions]) => {
if (!active) {
return;
}
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus("User management loaded.");
})
.catch((error: Error) => {
if (!active) {
return;
}
setStatus(error.message || "Unable to load admin access controls.");
});
return () => {
active = false;
};
}, [token]);
useEffect(() => {
if (selectedUserId === "new") {
setUserForm(emptyUserForm);
return;
}
const selectedUser = users.find((user) => user.id === selectedUserId);
if (!selectedUser) {
setUserForm(emptyUserForm);
return;
}
setUserForm({
email: selectedUser.email,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
isActive: selectedUser.isActive,
roleIds: selectedUser.roleIds,
password: "",
});
}, [selectedUserId, users]);
useEffect(() => {
if (selectedRoleId === "new") {
setRoleForm(emptyRoleForm);
return;
}
const selectedRole = roles.find((role) => role.id === selectedRoleId);
if (!selectedRole) {
setRoleForm(emptyRoleForm);
return;
}
setRoleForm({
name: selectedRole.name,
description: selectedRole.description,
permissionKeys: selectedRole.permissionKeys,
});
}, [roles, selectedRoleId]);
if (!token) {
return null;
}
const authToken = token;
async function refreshData(nextStatus: string) {
const [nextUsers, nextRoles, nextPermissions, nextSessions] = await Promise.all([
api.getAdminUsers(authToken),
api.getAdminRoles(authToken),
api.getAdminPermissions(authToken),
api.getAdminSessions(authToken),
]);
setUsers(nextUsers);
setRoles(nextRoles);
setPermissions(nextPermissions);
setSessions(nextSessions);
setStatus(nextStatus);
}
async function handleUserSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const selectedUser = users.find((entry) => entry.id === selectedUserId);
if (selectedUser && selectedUser.isActive && !userForm.isActive) {
setPendingConfirmation({
kind: "deactivate-user",
title: `Deactivate ${selectedUser.firstName} ${selectedUser.lastName}`,
description: `Disable sign-in for ${selectedUser.email}. Existing active sessions will remain revoked only if you separately revoke them below.`,
impact: "The user will be blocked from new sign-ins as soon as this save completes.",
recovery: "Re-enable the account later if the change was made in error, and revoke live sessions separately if immediate cut-off is required.",
confirmLabel: "Deactivate user",
confirmationLabel: "Type user email to confirm:",
confirmationValue: selectedUser.email,
userId: selectedUser.id,
});
return;
}
try {
await saveUser();
} catch (error: unknown) {
setStatus(error instanceof Error ? error.message : "Unable to save user.");
}
}
async function saveUser() {
const normalizedUserForm: AdminUserInput = {
...userForm,
password: userForm.password && userForm.password.trim().length > 0 ? userForm.password : null,
};
if (selectedUserId === "new") {
const createdUser = await api.createAdminUser(authToken, normalizedUserForm);
await refreshData(`Created user ${createdUser.email}.`);
setSelectedUserId(createdUser.id);
return;
}
const updatedUser = await api.updateAdminUser(authToken, selectedUserId, normalizedUserForm);
await refreshData(`Updated user ${updatedUser.email}.`);
setSelectedUserId(updatedUser.id);
}
async function handleRoleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
if (selectedRoleId === "new") {
const createdRole = await api.createAdminRole(authToken, roleForm);
await refreshData(`Created role ${createdRole.name}.`);
setSelectedRoleId(createdRole.id);
return;
}
const updatedRole = await api.updateAdminRole(authToken, selectedRoleId, roleForm);
await refreshData(`Updated role ${updatedRole.name}.`);
setSelectedRoleId(updatedRole.id);
} catch (error: unknown) {
setStatus(error instanceof Error ? error.message : "Unable to save role.");
}
}
function toggleUserRole(roleId: string) {
setUserForm((current) => ({
...current,
roleIds: current.roleIds.includes(roleId)
? current.roleIds.filter((currentRoleId) => currentRoleId !== roleId)
: [...current.roleIds, roleId],
}));
}
function toggleRolePermission(permissionKey: string) {
setRoleForm((current) => ({
...current,
permissionKeys: current.permissionKeys.includes(permissionKey)
? current.permissionKeys.filter((currentPermissionKey) => currentPermissionKey !== permissionKey)
: [...current.permissionKeys, permissionKey],
}));
}
async function handleSessionRevoke(sessionId: string, isCurrentSession: boolean) {
await api.revokeAdminSession(authToken, sessionId);
if (isCurrentSession) {
await logout();
return;
}
await refreshData("Revoked session. The user must sign in again to restore access unless their account is inactive.");
}
const normalizedSessionQuery = sessionQuery.trim().toLowerCase();
const filteredSessions = sessions.filter((session) => {
if (sessionUserFilter !== "all" && session.userId !== sessionUserFilter) {
return false;
}
if (sessionStatusFilter !== "ALL" && session.status !== sessionStatusFilter) {
return false;
}
if (sessionReviewFilter !== "ALL" && session.reviewState !== sessionReviewFilter) {
return false;
}
if (!normalizedSessionQuery) {
return true;
}
return (
session.userName.toLowerCase().includes(normalizedSessionQuery) ||
session.userEmail.toLowerCase().includes(normalizedSessionQuery) ||
(session.ipAddress ?? "").toLowerCase().includes(normalizedSessionQuery) ||
(session.userAgent ?? "").toLowerCase().includes(normalizedSessionQuery) ||
session.reviewReasons.some((reason) => reason.toLowerCase().includes(normalizedSessionQuery))
);
});
const activeSessionCount = sessions.filter((session) => session.status === "ACTIVE").length;
const revokedSessionCount = sessions.filter((session) => session.status === "REVOKED").length;
const expiredSessionCount = sessions.filter((session) => session.status === "EXPIRED").length;
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
return (
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">User Management</p>
<h3 className="mt-2 text-lg font-bold text-text">Accounts, roles, and permission assignment</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Manage user accounts and the role-permission model from one admin surface so onboarding and access control stay tied together.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Company settings
</Link>
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Diagnostics
</Link>
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleUserSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Users</p>
<h3 className="mt-2 text-lg font-bold text-text">Account generation and role assignment</h3>
</div>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New user</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName} ({user.email})
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={userForm.password ?? ""}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
placeholder={selectedUserId === "new" ? "Required for new user" : "Leave blank to keep current password"}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
<input
value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
<input
value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.isActive}
onChange={(event) => setUserForm((current) => ({ ...current, isActive: event.target.checked }))}
/>
User can sign in
</label>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Assigned roles</p>
<div className="mt-3 grid gap-3">
{roles.map((role) => (
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={userForm.roleIds.includes(role.id)}
onChange={() => toggleUserRole(role.id)}
/>
<span>
<span className="block font-semibold">{role.name}</span>
<span className="block text-xs text-muted">{role.description || "No description"}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"}
</button>
</div>
</form>
<form className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5" onSubmit={handleRoleSave}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roles</p>
<h3 className="mt-2 text-lg font-bold text-text">Permission assignment administration</h3>
</div>
<select
value={selectedRoleId}
onChange={(event) => setSelectedRoleId(event.target.value)}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
<option value="new">New role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div className="mt-5 grid gap-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
<input
value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
rows={3}
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Role permissions</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{permissions.map((permission) => (
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
<input
type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)}
onChange={() => toggleRolePermission(permission.key)}
/>
<span>
<span className="block font-semibold">{permission.key}</span>
<span className="block text-xs text-muted">{permission.description}</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{roles.map((role) => (
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-sm font-semibold text-text">{role.name}</p>
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
</div>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<span className="text-sm text-muted">{status}</span>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"}
</button>
</div>
</form>
</section>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sessions</p>
<h3 className="mt-2 text-lg font-bold text-text">Active sign-ins and revocation control</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Review recent authenticated sessions, see their current state, and revoke stale or risky access without changing the user record.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input
value={sessionQuery}
onChange={(event) => setSessionQuery(event.target.value)}
placeholder="User, email, IP, agent, review reason"
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">User</span>
<select
value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(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 users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={sessionStatusFilter}
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
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 statuses</option>
<option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="REVOKED">Revoked</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Review</span>
<select
value={sessionReviewFilter}
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
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 sessions</option>
<option value="REVIEW">Needs review</option>
<option value="NORMAL">Normal</option>
</select>
</label>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Active</p>
<p className="mt-2 text-2xl font-bold text-text">{activeSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Revoked</p>
<p className="mt-2 text-2xl font-bold text-text">{revokedSessionCount}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Expired</p>
<p className="mt-2 text-2xl font-bold text-text">{expiredSessionCount}</p>
</div>
<div className="rounded-2xl border border-amber-300/60 bg-amber-50 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Needs Review</p>
<p className="mt-2 text-2xl font-bold text-amber-900">{reviewSessionCount}</p>
</div>
</div>
<div className="mt-5 grid gap-3">
{filteredSessions.map((session) => (
<div key={session.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-text">{session.userName}</p>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
{session.status}
</span>
{session.reviewState === "REVIEW" ? (
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">
Review
</span>
) : null}
{session.isCurrent ? (
<span className="rounded-full bg-brand px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
Current
</span>
) : null}
</div>
<p className="mt-1 text-sm text-muted">{session.userEmail}</p>
<div className="mt-3 grid gap-2 text-xs text-muted md:grid-cols-2 xl:grid-cols-4">
<p>Started: {new Date(session.createdAt).toLocaleString()}</p>
<p>Last seen: {new Date(session.lastSeenAt).toLocaleString()}</p>
<p>Expires: {new Date(session.expiresAt).toLocaleString()}</p>
<p>IP: {session.ipAddress || "Unknown"}</p>
</div>
<p className="mt-2 text-xs text-muted">Agent: {session.userAgent || "Unknown"}</p>
{session.reviewReasons.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{session.reviewReasons.map((reason) => (
<span key={reason} className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800">
{reason}
</span>
))}
</div>
) : null}
{session.revokedAt ? (
<p className="mt-2 text-xs text-muted">
Revoked {new Date(session.revokedAt).toLocaleString()}
{session.revokedByName ? ` by ${session.revokedByName}` : ""}.
{session.revokedReason ? ` ${session.revokedReason}` : ""}
</p>
) : null}
</div>
{session.status === "ACTIVE" ? (
<button
type="button"
onClick={() =>
setPendingConfirmation({
kind: "revoke-session",
title: session.isCurrent ? "Revoke current session" : `Revoke session for ${session.userName}`,
description: session.isCurrent
? "Revoke the session you are using right now. Your current browser session will lose access immediately."
: `Revoke the selected active session for ${session.userEmail}.`,
impact: "The selected token becomes unusable immediately.",
recovery: "The user can sign in again unless the account itself is inactive. Review the remaining session list after revocation.",
confirmLabel: "Revoke session",
confirmationLabel: session.isCurrent ? "Type REVOKE to confirm:" : undefined,
confirmationValue: session.isCurrent ? "REVOKE" : undefined,
sessionId: session.id,
})
}
className="rounded-2xl border border-red-300 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
>
Revoke session
</button>
) : null}
</div>
</div>
))}
{filteredSessions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line/70 bg-page/40 px-3 py-6 text-sm text-muted">
No sessions match the current filter.
</div>
) : null}
</div>
</section>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm admin action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={isConfirmingAction}
onClose={() => {
if (!isConfirmingAction) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
setIsConfirmingAction(true);
try {
if (pendingConfirmation.kind === "deactivate-user" && pendingConfirmation.userId) {
await saveUser();
}
if (pendingConfirmation.kind === "revoke-session" && pendingConfirmation.sessionId) {
const isCurrentSession = sessions.find((session) => session.id === pendingConfirmation.sessionId)?.isCurrent ?? false;
await handleSessionRevoke(pendingConfirmation.sessionId, isCurrentSession);
}
if (
pendingConfirmation.kind === "deactivate-user" &&
pendingConfirmation.userId &&
pendingConfirmation.userId === authUser?.id
) {
setStatus("Your own account was deactivated. Sign-in will fail after this session ends unless another admin re-enables the account.");
}
setPendingConfirmation(null);
} finally {
setIsConfirmingAction(false);
}
}}
/>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { permissions } from "@mrp/shared";
import type { ShipmentDetailDto, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { shipmentStatusOptions } from "./config";
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
export function ShipmentDetailPage() {
const { token, user } = useAuth();
const { shipmentId } = useParams();
const [shipment, setShipment] = useState<ShipmentDetailDto | null>(null);
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
const [status, setStatus] = useState("Loading shipment...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
title: string;
description: string;
impact: string;
recovery: string;
confirmLabel: string;
confirmationLabel?: string;
confirmationValue?: string;
nextStatus: ShipmentStatus;
}
| null
>(null);
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
useEffect(() => {
if (!token || !shipmentId) {
return;
}
api.getShipment(token, shipmentId)
.then((nextShipment) => {
setShipment(nextShipment);
setStatus("Shipment loaded.");
return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId });
})
.then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId)))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
setStatus(message);
});
}, [shipmentId, token]);
async function applyStatusChange(nextStatus: ShipmentStatus) {
if (!token || !shipment) {
return;
}
setIsUpdatingStatus(true);
setStatus("Updating shipment status...");
try {
const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus);
setShipment(nextShipment);
setStatus("Shipment status updated. Verify carrier paperwork and sales-order expectations if the shipment moved into a terminal state.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update shipment status.";
setStatus(message);
} finally {
setIsUpdatingStatus(false);
}
}
function handleStatusChange(nextStatus: ShipmentStatus) {
if (!shipment) {
return;
}
const label = shipmentStatusOptions.find((option) => option.value === nextStatus)?.label ?? nextStatus;
setPendingConfirmation({
title: `Set shipment to ${label}`,
description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`,
impact:
nextStatus === "DELIVERED"
? "This marks delivery complete and can affect customer communication and project/shipping readiness views."
: nextStatus === "SHIPPED"
? "This marks the shipment as outbound and can trigger customer-facing tracking and downstream delivery expectations."
: "This changes the logistics state used by related shipping and sales workflows.",
recovery: "If the status is wrong, return the shipment to the correct state and confirm the linked sales order still reflects reality.",
confirmLabel: `Set ${label}`,
confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined,
confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined,
nextStatus,
});
}
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
if (!token || !shipment) {
return;
}
setActiveDocumentAction(kind);
setStatus(
kind === "packing-slip"
? "Rendering packing slip PDF..."
: kind === "label"
? "Rendering shipping label PDF..."
: "Rendering bill of lading PDF..."
);
try {
const blob =
kind === "packing-slip"
? await api.getShipmentPackingSlipPdf(token, shipment.id)
: kind === "label"
? await api.getShipmentLabelPdf(token, shipment.id)
: await api.getShipmentBillOfLadingPdf(token, shipment.id);
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
setStatus(
kind === "packing-slip"
? "Packing slip PDF rendered."
: kind === "label"
? "Shipping label PDF rendered."
: "Bill of lading PDF rendered."
);
} catch (error: unknown) {
const message =
error instanceof ApiError
? error.message
: kind === "packing-slip"
? "Unable to render packing slip PDF."
: kind === "label"
? "Unable to render shipping label PDF."
: "Unable to render bill of lading PDF.";
setStatus(message);
} finally {
setActiveDocumentAction(null);
}
}
if (!shipment) {
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p>
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/shipping/shipments" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to shipments</Link>
<Link to={`/sales/orders/${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link>
<button type="button" onClick={() => handleOpenDocument("packing-slip")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{activeDocumentAction === "packing-slip" ? "Rendering PDF..." : "Open packing slip"}
</button>
<button type="button" onClick={() => handleOpenDocument("label")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{activeDocumentAction === "label" ? "Rendering PDF..." : "Open shipping label"}
</button>
<button type="button" onClick={() => handleOpenDocument("bol")} disabled={activeDocumentAction !== null} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{activeDocumentAction === "bol" ? "Rendering PDF..." : "Open bill of lading"}
</button>
{canManage ? (
<Link to={`/shipping/shipments/${shipment.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit shipment</Link>
) : null}
</div>
</div>
</div>
{canManage ? (
<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 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
</div>
<div className="flex flex-wrap gap-2">
{shipmentStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || shipment.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<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">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</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">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</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">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</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">Packages</p><div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<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">Shipment Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
</article>
<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">Timing</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt><dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.createdAt).toLocaleString()}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.updatedAt).toLocaleString()}</dd></div>
</dl>
</article>
</div>
<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">Related Shipments</p>
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
</div>
{canManage ? (
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
) : null}
</div>
{relatedShipments.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 additional shipments exist for this sales order.</div>
) : (
<div className="mt-6 space-y-3">
{relatedShipments.map((related) => (
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{related.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}</div>
</div>
<ShipmentStatusBadge status={related.status} />
</div>
</Link>
))}
</div>
)}
</section>
<FileAttachmentsPanel
ownerType="SHIPMENT"
ownerId={shipment.id}
eyebrow="Logistics Attachments"
title="Shipment files"
description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record."
emptyMessage="No logistics attachments have been uploaded for this shipment yet."
/>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm shipment action"}
description={pendingConfirmation?.description ?? ""}
impact={pendingConfirmation?.impact}
recovery={pendingConfirmation?.recovery}
confirmLabel={pendingConfirmation?.confirmLabel ?? "Confirm"}
confirmationLabel={pendingConfirmation?.confirmationLabel}
confirmationValue={pendingConfirmation?.confirmationValue}
isConfirming={isUpdatingStatus}
onClose={() => {
if (!isUpdatingStatus) {
setPendingConfirmation(null);
}
}}
onConfirm={async () => {
if (!pendingConfirmation) {
return;
}
await applyStatusChange(pendingConfirmation.nextStatus);
setPendingConfirmation(null);
}}
/>
</section>
);
}

View File

@@ -0,0 +1,202 @@
import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyShipmentInput, shipmentStatusOptions } from "./config";
export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { shipmentId } = useParams();
const [searchParams] = useSearchParams();
const seededOrderId = searchParams.get("orderId") ?? "";
const [form, setForm] = useState<ShipmentInput>({ ...emptyShipmentInput, salesOrderId: seededOrderId });
const [orderOptions, setOrderOptions] = useState<ShipmentOrderOptionDto[]>([]);
const [orderSearchTerm, setOrderSearchTerm] = useState("");
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new shipment." : "Loading shipment...");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!token) {
return;
}
api.getShipmentOrderOptions(token).then((options) => {
setOrderOptions(options);
const seeded = options.find((option) => option.id === seededOrderId);
if (seeded && mode === "create") {
setOrderSearchTerm(`${seeded.documentNumber} - ${seeded.customerName}`);
}
}).catch(() => setOrderOptions([]));
}, [mode, seededOrderId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !shipmentId) {
return;
}
api.getShipment(token, shipmentId)
.then((shipment) => {
setForm({
salesOrderId: shipment.salesOrderId,
status: shipment.status,
shipDate: shipment.shipDate,
carrier: shipment.carrier,
serviceLevel: shipment.serviceLevel,
trackingNumber: shipment.trackingNumber,
packageCount: shipment.packageCount,
notes: shipment.notes,
});
setOrderSearchTerm(`${shipment.salesOrderNumber} - ${shipment.customerName}`);
setStatus("Shipment loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
setStatus(message);
});
}, [mode, shipmentId, token]);
function updateField<Key extends keyof ShipmentInput>(key: Key, value: ShipmentInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function getSelectedOrder(orderId: string) {
return orderOptions.find((option) => option.id === orderId) ?? null;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving shipment...");
try {
const saved = mode === "create" ? await api.createShipment(token, form) : await api.updateShipment(token, shipmentId ?? "", form);
navigate(`/shipping/shipments/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save shipment.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
</div>
<Link to={mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", "");
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setOrderPickerOpen(false);
const selected = getSelectedOrder(form.salesOrderId);
if (selected) {
setOrderSearchTerm(`${selected.documentNumber} - ${selected.customerName}`);
}
}, 120);
}}
placeholder="Search sales order"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{orderPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{orderOptions
.filter((option) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.documentNumber.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", option.id);
setOrderSearchTerm(`${option.documentNumber} - ${option.customerName}`);
setOrderPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{option.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{option.customerName}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ShipmentInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{shipmentStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Ship Date</span>
<input type="date" value={form.shipDate ? form.shipDate.slice(0, 10) : ""} onChange={(event) => updateField("shipDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Packages</span>
<input type="number" min={1} step={1} value={form.packageCount} onChange={(event) => updateField("packageCount", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Carrier</span>
<input value={form.carrier} onChange={(event) => updateField("carrier", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Service Level</span>
<input value={form.serviceLevel} onChange={(event) => updateField("serviceLevel", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Tracking Number</span>
<input value={form.trackingNumber} onChange={(event) => updateField("trackingNumber", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<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={4} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create shipment" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,117 @@
import { permissions } from "@mrp/shared";
import type { ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { shipmentStatusFilters } from "./config";
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
export function ShipmentListPage() {
const { token, user } = useAuth();
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | ShipmentStatus>("ALL");
const [status, setStatus] = useState("Loading shipments...");
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getShipments(token, {
q: searchTerm.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
})
.then((nextShipments) => {
setShipments(nextShipments);
setStatus(`${nextShipments.length} shipments matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipments.";
setStatus(message);
});
}, [searchTerm, statusFilter, token]);
return (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
<h3 className="mt-2 text-lg font-bold text-text">Shipments</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Outbound shipment records tied to sales orders, carriers, and tracking details.</p>
</div>
{canManage ? (
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New shipment
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by shipment, order, customer, carrier, or tracking"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | ShipmentStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{shipmentStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{shipments.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 shipments have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Shipment</th>
<th className="px-2 py-2">Sales Order</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Carrier</th>
<th className="px-2 py-2">Ship Date</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{shipments.map((shipment) => (
<tr key={shipment.id} className="transition hover:bg-page/70">
<td className="px-2 py-2">
<Link to={`/shipping/shipments/${shipment.id}`} className="font-semibold text-text hover:text-brand">
{shipment.shipmentNumber}
</Link>
</td>
<td className="px-2 py-2 text-muted">{shipment.salesOrderNumber}</td>
<td className="px-2 py-2 text-muted">{shipment.customerName}</td>
<td className="px-2 py-2"><ShipmentStatusBadge status={shipment.status} /></td>
<td className="px-2 py-2 text-muted">{shipment.carrier || "Not set"}</td>
<td className="px-2 py-2 text-muted">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,8 @@
import type { ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
import { shipmentStatusOptions, shipmentStatusPalette } from "./config";
export function ShipmentStatusBadge({ status }: { status: ShipmentStatus }) {
const label = shipmentStatusOptions.find((option) => option.value === status)?.label ?? status;
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${shipmentStatusPalette[status]}`}>{label}</span>;
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export function ShipmentsPage() {
return <Outlet />;
}

View File

@@ -0,0 +1,33 @@
import type { ShipmentInput, ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
export const shipmentStatusOptions: Array<{ value: ShipmentStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PICKING", label: "Picking" },
{ value: "PACKED", label: "Packed" },
{ value: "SHIPPED", label: "Shipped" },
{ value: "DELIVERED", label: "Delivered" },
];
export const shipmentStatusFilters: Array<{ value: "ALL" | ShipmentStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...shipmentStatusOptions,
];
export const shipmentStatusPalette: Record<ShipmentStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
PICKING: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
PACKED: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
SHIPPED: "border border-brand/30 bg-brand/10 text-brand",
DELIVERED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
};
export const emptyShipmentInput: ShipmentInput = {
salesOrderId: "",
status: "DRAFT",
shipDate: null,
carrier: "",
serviceLevel: "",
trackingNumber: "",
packageCount: 1,
notes: "",
};

View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -0,0 +1,52 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "../theme/ThemeProvider";
import { ThemeToggle } from "../components/ThemeToggle";
describe("ThemeToggle", () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute("style");
document.documentElement.classList.remove("dark");
});
it("toggles the html dark class", () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
fireEvent.click(screen.getByRole("button"));
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("hydrates persisted brand theme values on startup", async () => {
window.localStorage.setItem(
"mrp.theme.brand-profile",
JSON.stringify({
theme: {
primaryColor: "#112233",
accentColor: "#445566",
surfaceColor: "#778899",
fontFamily: "Manrope",
},
})
);
render(
<ThemeProvider>
<div>Theme</div>
</ThemeProvider>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--color-brand")).toBe("17 34 51");
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("68 85 102");
expect(document.documentElement.style.getPropertyValue("--color-surface-brand")).toBe("119 136 153");
expect(document.documentElement.style.getPropertyValue("--font-family")).toBe("Manrope");
});
});
});

View File

@@ -0,0 +1,78 @@
import type { CompanyProfileDto } from "@mrp/shared";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { hexToRgbTriplet } from "./utils";
type ThemeMode = "light" | "dark";
interface ThemeContextValue {
mode: ThemeMode;
toggleMode: () => void;
applyBrandProfile: (profile: CompanyProfileDto | null) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const storageKey = "mrp.theme.mode";
const brandProfileKey = "mrp.theme.brand-profile";
function applyThemeVariables(profile: Pick<CompanyProfileDto, "theme">) {
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => {
const stored = window.localStorage.getItem(storageKey);
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
const storedBrandProfile = window.localStorage.getItem(brandProfileKey);
if (!storedBrandProfile) {
return;
}
try {
const parsed = JSON.parse(storedBrandProfile) as Pick<CompanyProfileDto, "theme">;
applyThemeVariables(parsed);
} catch {
window.localStorage.removeItem(brandProfileKey);
}
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", mode === "dark");
document.documentElement.style.colorScheme = mode;
window.localStorage.setItem(storageKey, mode);
}, [mode]);
const applyBrandProfile = (profile: CompanyProfileDto | null) => {
if (!profile) {
return;
}
applyThemeVariables(profile);
window.localStorage.setItem(brandProfileKey, JSON.stringify({ theme: profile.theme }));
};
const value = useMemo(
() => ({
mode,
toggleMode: () => setMode((current) => (current === "light" ? "dark" : "light")),
applyBrandProfile,
}),
[mode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,9 @@
export function hexToRgbTriplet(hex: string) {
const normalized = hex.replace("#", "");
const numeric = Number.parseInt(normalized, 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `${r} ${g} ${b}`;
}