init
This commit is contained in:
76
client/src/auth/AuthProvider.tsx
Normal file
76
client/src/auth/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
257
client/src/components/AppShell.tsx
Normal file
257
client/src/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
client/src/components/ConfirmActionDialog.tsx
Normal file
108
client/src/components/ConfirmActionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
279
client/src/components/DocumentRevisionComparison.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type RevisionOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
type ComparisonField = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ComparisonLine = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
quantity: string;
|
||||
unitLabel: string;
|
||||
amountLabel: string;
|
||||
totalLabel?: string;
|
||||
extraLabel?: string;
|
||||
};
|
||||
|
||||
type ComparisonDocument = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
metaFields: ComparisonField[];
|
||||
totalFields: ComparisonField[];
|
||||
notes: string;
|
||||
lines: ComparisonLine[];
|
||||
};
|
||||
|
||||
type DiffRow = {
|
||||
key: string;
|
||||
status: "ADDED" | "REMOVED" | "CHANGED";
|
||||
left?: ComparisonLine;
|
||||
right?: ComparisonLine;
|
||||
};
|
||||
|
||||
function buildLineMap(lines: ComparisonLine[]) {
|
||||
return new Map(lines.map((line) => [line.key, line]));
|
||||
}
|
||||
|
||||
function lineSignature(line?: ComparisonLine) {
|
||||
if (!line) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [line.title, line.subtitle ?? "", line.quantity, line.unitLabel, line.amountLabel, line.totalLabel ?? "", line.extraLabel ?? ""].join("|");
|
||||
}
|
||||
|
||||
function buildDiffRows(left: ComparisonDocument, right: ComparisonDocument): DiffRow[] {
|
||||
const leftLines = buildLineMap(left.lines);
|
||||
const rightLines = buildLineMap(right.lines);
|
||||
const orderedKeys = [...new Set([...left.lines.map((line) => line.key), ...right.lines.map((line) => line.key)])];
|
||||
const rows: DiffRow[] = [];
|
||||
|
||||
for (const key of orderedKeys) {
|
||||
const leftLine = leftLines.get(key);
|
||||
const rightLine = rightLines.get(key);
|
||||
|
||||
if (leftLine && !rightLine) {
|
||||
rows.push({ key, status: "REMOVED", left: leftLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!leftLine && rightLine) {
|
||||
rows.push({ key, status: "ADDED", right: rightLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineSignature(leftLine) !== lineSignature(rightLine)) {
|
||||
rows.push({ key, status: "CHANGED", left: leftLine, right: rightLine });
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildFieldChanges(left: ComparisonField[], right: ComparisonField[]): Array<{ label: string; leftValue: string; rightValue: string }> {
|
||||
const rightByLabel = new Map(right.map((field) => [field.label, field.value]));
|
||||
|
||||
return left.flatMap((field) => {
|
||||
const rightValue = rightByLabel.get(field.label);
|
||||
if (rightValue == null || rightValue === field.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: field.label,
|
||||
leftValue: field.value,
|
||||
rightValue,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function ComparisonCard({ label, document }: { label: string; document: ComparisonDocument }) {
|
||||
return (
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||
<h4 className="mt-2 text-base font-bold text-text">{document.title}</h4>
|
||||
<p className="mt-1 text-sm text-muted">{document.subtitle}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
{document.status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{document.metaFields.map((field) => (
|
||||
<div key={`${label}-${field.label}`}>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</dt>
|
||||
<dd className="mt-1 text-sm text-text">{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{document.totalFields.map((field) => (
|
||||
<div key={`${label}-total-${field.label}`} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{field.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text">{field.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</div>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded."}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRevisionComparison({
|
||||
title,
|
||||
description,
|
||||
currentLabel,
|
||||
currentDocument,
|
||||
revisions,
|
||||
getRevisionDocument,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
currentLabel: string;
|
||||
currentDocument: ComparisonDocument;
|
||||
revisions: RevisionOption[];
|
||||
getRevisionDocument: (revisionId: string | "current") => ComparisonDocument;
|
||||
}) {
|
||||
const [leftRevisionId, setLeftRevisionId] = useState<string | "current">(revisions[0]?.id ?? "current");
|
||||
const [rightRevisionId, setRightRevisionId] = useState<string | "current">("current");
|
||||
|
||||
useEffect(() => {
|
||||
setLeftRevisionId((current) => (current === "current" || revisions.some((revision) => revision.id === current) ? current : revisions[0]?.id ?? "current"));
|
||||
}, [revisions]);
|
||||
|
||||
const leftDocument = getRevisionDocument(leftRevisionId);
|
||||
const rightDocument = getRevisionDocument(rightRevisionId);
|
||||
const diffRows = buildDiffRows(leftDocument, rightDocument);
|
||||
const metaChanges = buildFieldChanges(leftDocument.metaFields, rightDocument.metaFields);
|
||||
const totalChanges = buildFieldChanges(leftDocument.totalFields, rightDocument.totalFields);
|
||||
|
||||
return (
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{title}</p>
|
||||
<p className="mt-2 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block min-w-[220px]">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</span>
|
||||
<select
|
||||
value={leftRevisionId}
|
||||
onChange={(event) => setLeftRevisionId(event.target.value as string | "current")}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<option key={revision.id} value={revision.id}>
|
||||
{revision.label} | {revision.meta}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block min-w-[220px]">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</span>
|
||||
<select
|
||||
value={rightRevisionId}
|
||||
onChange={(event) => setRightRevisionId(event.target.value as string | "current")}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-sm text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="current">{currentLabel}</option>
|
||||
{revisions.map((revision) => (
|
||||
<option key={revision.id} value={revision.id}>
|
||||
{revision.label} | {revision.meta}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<ComparisonCard label="Baseline" document={leftDocument} />
|
||||
<ComparisonCard label="Compare To" document={rightDocument} />
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Field Changes</p>
|
||||
{metaChanges.length === 0 && totalChanges.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-muted">No header or total changes between the selected revisions.</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{[...metaChanges, ...totalChanges].map((change) => (
|
||||
<div key={change.label} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{change.label}</div>
|
||||
<div className="mt-2 text-sm text-text">
|
||||
{change.leftValue} {"->"} {change.rightValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="rounded-[18px] border border-line/70 bg-page/60 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Line Changes</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Added</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "ADDED").length}</div>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Removed</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "REMOVED").length}</div>
|
||||
</div>
|
||||
<div className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Changed</div>
|
||||
<div className="mt-1 text-base font-bold text-text">{diffRows.filter((row) => row.status === "CHANGED").length}</div>
|
||||
</div>
|
||||
</div>
|
||||
{diffRows.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-muted">No line-level changes between the selected revisions.</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{diffRows.map((row) => (
|
||||
<div key={row.key} className="rounded-[16px] border border-line/70 bg-surface/80 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-text">{row.right?.title ?? row.left?.title}</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{row.status}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Baseline</div>
|
||||
<div className="mt-1 text-sm text-text">
|
||||
{row.left ? `${row.left.quantity} | ${row.left.amountLabel}${row.left.totalLabel ? ` | ${row.left.totalLabel}` : ""}` : "Not present"}
|
||||
</div>
|
||||
{row.left?.subtitle ? <div className="mt-1 text-xs text-muted">{row.left.subtitle}</div> : null}
|
||||
{row.left?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.left.extraLabel}</div> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Compare To</div>
|
||||
<div className="mt-1 text-sm text-text">
|
||||
{row.right ? `${row.right.quantity} | ${row.right.amountLabel}${row.right.totalLabel ? ` | ${row.right.totalLabel}` : ""}` : "Not present"}
|
||||
</div>
|
||||
{row.right?.subtitle ? <div className="mt-1 text-xs text-muted">{row.right.subtitle}</div> : null}
|
||||
{row.right?.extraLabel ? <div className="mt-1 text-xs text-muted">{row.right.extraLabel}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
221
client/src/components/FileAttachmentsPanel.tsx
Normal file
221
client/src/components/FileAttachmentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
22
client/src/components/ProtectedRoute.tsx
Normal file
22
client/src/components/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
|
||||
15
client/src/components/ThemeToggle.tsx
Normal file
15
client/src/components/ThemeToggle.tsx
Normal 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
102
client/src/index.css
Normal 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
896
client/src/lib/api.ts
Normal 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
273
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
|
||||
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal file
21
client/src/modules/crm/CrmAttachmentsPanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal file
72
client/src/modules/crm/CrmContactEntryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal file
13
client/src/modules/crm/CrmContactTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
154
client/src/modules/crm/CrmContactsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
392
client/src/modules/crm/CrmDetailPage.tsx
Normal file
392
client/src/modules/crm/CrmDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
149
client/src/modules/crm/CrmFormPage.tsx
Normal file
149
client/src/modules/crm/CrmFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal file
22
client/src/modules/crm/CrmLifecycleBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
client/src/modules/crm/CrmListPage.tsx
Normal file
212
client/src/modules/crm/CrmListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
199
client/src/modules/crm/CrmRecordForm.tsx
Normal file
199
client/src/modules/crm/CrmRecordForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
client/src/modules/crm/CustomersPage.tsx
Normal file
5
client/src/modules/crm/CustomersPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CrmListPage } from "./CrmListPage";
|
||||
|
||||
export function CustomersPage() {
|
||||
return <CrmListPage entity="customer" />;
|
||||
}
|
||||
5
client/src/modules/crm/VendorsPage.tsx
Normal file
5
client/src/modules/crm/VendorsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CrmListPage } from "./CrmListPage";
|
||||
|
||||
export function VendorsPage() {
|
||||
return <CrmListPage entity="vendor" />;
|
||||
}
|
||||
159
client/src/modules/crm/config.ts
Normal file
159
client/src/modules/crm/config.ts
Normal 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 };
|
||||
593
client/src/modules/dashboard/DashboardPage.tsx
Normal file
593
client/src/modules/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
client/src/modules/gantt/GanttPage.tsx
Normal file
196
client/src/modules/gantt/GanttPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal file
22
client/src/modules/inventory/InventoryAttachmentsPanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
722
client/src/modules/inventory/InventoryDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
1016
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
1016
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InventoryListPage } from "./InventoryListPage";
|
||||
|
||||
export function InventoryItemsPage() {
|
||||
return <InventoryListPage />;
|
||||
}
|
||||
153
client/src/modules/inventory/InventoryListPage.tsx
Normal file
153
client/src/modules/inventory/InventoryListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal file
91
client/src/modules/inventory/WarehouseDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
192
client/src/modules/inventory/WarehouseFormPage.tsx
Normal file
192
client/src/modules/inventory/WarehouseFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
83
client/src/modules/inventory/WarehousesPage.tsx
Normal file
83
client/src/modules/inventory/WarehousesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
133
client/src/modules/inventory/config.ts
Normal file
133
client/src/modules/inventory/config.ts
Normal 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 };
|
||||
75
client/src/modules/login/LoginPage.tsx
Normal file
75
client/src/modules/login/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal file
121
client/src/modules/manufacturing/ManufacturingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
487
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal file
487
client/src/modules/manufacturing/WorkOrderDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
307
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal file
307
client/src/modules/manufacturing/WorkOrderFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
110
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal file
110
client/src/modules/manufacturing/WorkOrderListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
50
client/src/modules/manufacturing/config.ts
Normal file
50
client/src/modules/manufacturing/config.ts
Normal 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: "",
|
||||
};
|
||||
190
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
190
client/src/modules/projects/ProjectDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
544
client/src/modules/projects/ProjectFormPage.tsx
Normal file
544
client/src/modules/projects/ProjectFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
117
client/src/modules/projects/ProjectListPage.tsx
Normal file
117
client/src/modules/projects/ProjectListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal file
7
client/src/modules/projects/ProjectPriorityBadge.tsx
Normal 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>;
|
||||
}
|
||||
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal file
7
client/src/modules/projects/ProjectStatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
5
client/src/modules/projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectListPage } from "./ProjectListPage";
|
||||
|
||||
export function ProjectsPage() {
|
||||
return <ProjectListPage />;
|
||||
}
|
||||
54
client/src/modules/projects/config.ts
Normal file
54
client/src/modules/projects/config.ts
Normal 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: "",
|
||||
};
|
||||
673
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal file
673
client/src/modules/purchasing/PurchaseDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
492
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal file
492
client/src/modules/purchasing/PurchaseFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
100
client/src/modules/purchasing/PurchaseListPage.tsx
Normal file
100
client/src/modules/purchasing/PurchaseListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal file
7
client/src/modules/purchasing/PurchaseStatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
40
client/src/modules/purchasing/config.ts
Normal file
40
client/src/modules/purchasing/config.ts
Normal 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: [],
|
||||
};
|
||||
764
client/src/modules/sales/SalesDetailPage.tsx
Normal file
764
client/src/modules/sales/SalesDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
502
client/src/modules/sales/SalesFormPage.tsx
Normal file
502
client/src/modules/sales/SalesFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
130
client/src/modules/sales/SalesListPage.tsx
Normal file
130
client/src/modules/sales/SalesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal file
9
client/src/modules/sales/SalesStatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
63
client/src/modules/sales/config.ts
Normal file
63
client/src/modules/sales/config.ts
Normal 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: "",
|
||||
};
|
||||
457
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
457
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
248
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
248
client/src/modules/settings/CompanySettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
677
client/src/modules/settings/UserManagementPage.tsx
Normal file
677
client/src/modules/settings/UserManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
274
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal file
274
client/src/modules/shipping/ShipmentDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
202
client/src/modules/shipping/ShipmentFormPage.tsx
Normal file
202
client/src/modules/shipping/ShipmentFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
117
client/src/modules/shipping/ShipmentListPage.tsx
Normal file
117
client/src/modules/shipping/ShipmentListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal file
8
client/src/modules/shipping/ShipmentStatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
5
client/src/modules/shipping/ShipmentsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export function ShipmentsPage() {
|
||||
return <Outlet />;
|
||||
}
|
||||
33
client/src/modules/shipping/config.ts
Normal file
33
client/src/modules/shipping/config.ts
Normal 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: "",
|
||||
};
|
||||
2
client/src/tests/setup.ts
Normal file
2
client/src/tests/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
52
client/src/tests/theme.test.tsx
Normal file
52
client/src/tests/theme.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
78
client/src/theme/ThemeProvider.tsx
Normal file
78
client/src/theme/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
9
client/src/theme/utils.ts
Normal file
9
client/src/theme/utils.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user