projects
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
client/src/modules/projects/ProjectDetailPage.tsx
Normal file
107
client/src/modules/projects/ProjectDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
client/src/modules/projects/ProjectFormPage.tsx
Normal file
198
client/src/modules/projects/ProjectFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
client/src/modules/projects/ProjectListPage.tsx
Normal file
116
client/src/modules/projects/ProjectListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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: "",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user