This commit is contained in:
2026-03-15 10:13:53 -05:00
parent 552d4e2844
commit 6644ba2932
30 changed files with 1768 additions and 64 deletions

View File

@@ -15,6 +15,7 @@ const links = [
{ 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: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> },
];
@@ -156,6 +157,19 @@ function GanttIcon() {
);
}
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>
);
}
export function AppShell() {
const { user, logout } = useAuth();

View File

@@ -33,6 +33,17 @@ import type {
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
ProjectCustomerOptionDto,
ProjectDetailDto,
ProjectDocumentOptionDto,
ProjectInput,
ProjectOwnerOptionDto,
ProjectPriority,
ProjectShipmentOptionDto,
ProjectStatus,
ProjectSummaryDto,
} from "@mrp/shared/dist/projects/types.js";
import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
@@ -381,6 +392,58 @@ export const api = {
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
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},
@@ -521,6 +584,32 @@ export const api = {
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: {

View File

@@ -21,6 +21,9 @@ import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
import { ProjectDetailPage } from "./modules/projects/ProjectDetailPage";
import { ProjectFormPage } from "./modules/projects/ProjectFormPage";
import { ProjectsPage } from "./modules/projects/ProjectsPage";
import { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage";
import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage";
import { WarehousesPage } from "./modules/inventory/WarehousesPage";
@@ -66,6 +69,13 @@ const router = createBrowserRouter([
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsRead]} />,
children: [
{ path: "/projects", element: <ProjectsPage /> },
{ path: "/projects/:projectId", element: <ProjectDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [
@@ -98,6 +108,13 @@ const router = createBrowserRouter([
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.projectsWrite]} />,
children: [
{ path: "/projects/new", element: <ProjectFormPage mode="create" /> },
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [

View File

@@ -13,6 +13,7 @@ interface DashboardSnapshot {
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;
refreshedAt: string;
}
@@ -50,6 +51,7 @@ export function DashboardPage() {
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
useEffect(() => {
if (!token || !user) {
@@ -67,6 +69,7 @@ export function DashboardPage() {
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
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([
@@ -77,6 +80,7 @@ export function DashboardPage() {
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),
]);
if (!isMounted) {
@@ -97,6 +101,7 @@ export function DashboardPage() {
quotes: results[4].status === "fulfilled" ? results[4].value : null,
orders: results[5].status === "fulfilled" ? results[5].value : null,
shipments: results[6].status === "fulfilled" ? results[6].value : null,
projects: results[7].status === "fulfilled" ? results[7].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
@@ -124,12 +129,14 @@ export function DashboardPage() {
const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? [];
const projects = snapshot?.projects ?? [];
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
snapshot?.projects !== null,
].filter(Boolean).length;
const customerCount = customers.length;
@@ -157,6 +164,17 @@ export function DashboardPage() {
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 lastActivityAt = [
...customers.map((customer) => customer.updatedAt),
...vendors.map((vendor) => vendor.updatedAt),
@@ -165,6 +183,7 @@ export function DashboardPage() {
...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt),
...shipments.map((shipment) => shipment.updatedAt),
...projects.map((project) => project.updatedAt),
]
.sort()
.at(-1) ?? null;
@@ -206,6 +225,15 @@ export function DashboardPage() {
: "Shipping metrics are permission-gated.",
tone: "border-brand/30 bg-brand/10 text-brand",
},
{
label: "Project Load",
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
detail:
snapshot?.projects !== null
? `${atRiskProjectCount} at risk and ${overdueProjectCount} overdue`
: "Project metrics are permission-gated.",
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
},
];
const modulePanels = [
@@ -277,13 +305,31 @@ export function DashboardPage() {
{ label: "Open packing flow", to: "/sales/orders" },
],
},
{
title: "Projects",
eyebrow: "Program Control",
summary:
snapshot?.projects !== null
? "Project records now tie customers, commercial documents, shipment context, and delivery ownership into one operational surface."
: "Project read permission is required to surface program metrics here.",
metrics: [
{ label: "Active", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access" },
{ label: "At risk", value: snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue", value: snapshot?.projects !== null ? `${overdueProjectCount}` : "No access" },
],
links: [
{ label: "Open projects", to: "/projects" },
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
],
},
];
const futureModules = [
"Purchase-order queue and supplier receipts",
"Vendor invoice attachments and supplier exception queues",
"Stock transfers, allocations, and cycle counts",
"Shipping labels, bills of lading, and delivery exceptions",
"Manufacturing schedule, work orders, and bottleneck metrics",
"Manufacturing work orders, routings, and bottleneck metrics",
"Planning timeline, milestones, and dependency views",
"Audit trails, diagnostics, and system health checks",
];
return (
@@ -304,8 +350,8 @@ export function DashboardPage() {
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
This landing page now reads directly from live CRM, inventory, sales, and shipping data. It is intentionally modular so future purchasing,
manufacturing, and audit slices can slot into the same command surface without a redesign.
This landing page now reads directly from live CRM, inventory, sales, shipping, and project data. It is intentionally modular so future
purchasing, manufacturing, and audit slices can slot into the same command surface without a redesign.
</p>
<div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
@@ -331,6 +377,9 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
Open inventory
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
Open projects
</Link>
</div>
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
</div>
@@ -347,7 +396,7 @@ export function DashboardPage() {
</div>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<section className="grid gap-3 xl:grid-cols-5">
{metricCards.map((card) => (
<article key={card.label} className="rounded-[24px] 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>
@@ -359,7 +408,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-5">
{modulePanels.map((panel) => (
<article key={panel.title} className="rounded-[28px] 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">{panel.eyebrow}</p>
@@ -383,7 +432,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-3">
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[28px] 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">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
@@ -420,6 +469,24 @@ export function DashboardPage() {
</div>
</div>
</article>
<article className="rounded-[28px] 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">Project Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Total projects</span>
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${projectCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">At risk</span>
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Overdue</span>
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${overdueProjectCount}` : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] 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">Shipping Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Execution and delivery status</h4>

View File

@@ -0,0 +1,107 @@
import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
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 [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.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message);
});
}, [projectId, token]);
if (!project) {
return <div className="rounded-[28px] 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-[28px] 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-[24px] 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-[24px] 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-[24px] 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-[24px] 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-[28px] 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-[28px] 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-[28px] 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>
<FileAttachmentsPanel
ownerType="PROJECT"
ownerId={project.id}
eyebrow="Project Documents"
title="Program file hub"
description="Store drawings, revision references, correspondence, and support files directly on the project record."
emptyMessage="No project files have been uploaded yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);
}

View File

@@ -0,0 +1,198 @@
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 { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyProjectInput, projectPriorityOptions, projectStatusOptions } from "./config";
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 [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
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,
});
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,
}
: {}),
}));
}
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-[28px] 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-[28px] 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>
<select value={form.customerId} onChange={(event) => updateField("customerId", 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 customer</option>
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}
</select>
</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>
<select value={form.ownerId ?? ""} onChange={(event) => updateField("ownerId", 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="">Unassigned</option>
{ownerOptions.map((owner) => <option key={owner.id} value={owner.id}>{owner.fullName}</option>)}
</select>
</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>
<select value={form.salesQuoteId ?? ""} onChange={(event) => updateField("salesQuoteId", 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 linked quote</option>
{quoteOptions.map((quote) => <option key={quote.id} value={quote.id}>{quote.documentNumber}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<select value={form.salesOrderId ?? ""} onChange={(event) => updateField("salesOrderId", 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 linked sales order</option>
{orderOptions.map((order) => <option key={order.id} value={order.id}>{order.documentNumber}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<select value={form.shipmentId ?? ""} onChange={(event) => updateField("shipmentId", 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 linked shipment</option>
{shipmentOptions.map((shipment) => <option key={shipment.id} value={shipment.id}>{shipment.shipmentNumber}</option>)}
</select>
</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-3xl 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>
</form>
);
}

View File

@@ -0,0 +1,116 @@
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-[28px] 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-[28px] 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-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No projects are available for the current filters.</div>
) : (
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Project</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Owner</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Priority</th>
<th className="px-2 py-2">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{projects.map((project) => (
<tr key={project.id}>
<td className="px-2 py-2">
<Link to={`/projects/${project.id}`} className="font-semibold text-text hover:text-brand">{project.projectNumber}</Link>
<div className="mt-1 text-xs text-muted">{project.name}</div>
</td>
<td className="px-2 py-2 text-muted">{project.customerName}</td>
<td className="px-2 py-2 text-muted">{project.ownerName || "Unassigned"}</td>
<td className="px-2 py-2"><ProjectStatusBadge status={project.status} /></td>
<td className="px-2 py-2"><ProjectPriorityBadge priority={project.priority} /></td>
<td className="px-2 py-2 text-muted">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "No due date"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { shipmentStatusOptions } from "./config";
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
@@ -15,7 +16,7 @@ export function ShipmentDetailPage() {
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
const [status, setStatus] = useState("Loading shipment...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isRenderingPdf, setIsRenderingPdf] = useState(false);
const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null);
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
@@ -56,24 +57,48 @@ export function ShipmentDetailPage() {
}
}
async function handleOpenPackingSlip() {
async function handleOpenDocument(kind: "packing-slip" | "label" | "bol") {
if (!token || !shipment) {
return;
}
setIsRenderingPdf(true);
setStatus("Rendering packing slip PDF...");
setActiveDocumentAction(kind);
setStatus(
kind === "packing-slip"
? "Rendering packing slip PDF..."
: kind === "label"
? "Rendering shipping label PDF..."
: "Rendering bill of lading PDF..."
);
try {
const blob = await api.getShipmentPackingSlipPdf(token, shipment.id);
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("Packing slip PDF rendered.");
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 : "Unable to render packing slip PDF.";
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 {
setIsRenderingPdf(false);
setActiveDocumentAction(null);
}
}
@@ -94,8 +119,14 @@ export function ShipmentDetailPage() {
<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={handleOpenPackingSlip} disabled={isRenderingPdf} 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">
{isRenderingPdf ? "Rendering PDF..." : "Open packing slip"}
<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>
@@ -168,6 +199,14 @@ export function ShipmentDetailPage() {
</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."
/>
</section>
);
}