manufacturing stabilization

This commit is contained in:
2026-03-15 11:30:10 -05:00
parent 0596970b99
commit e2254d020e
15 changed files with 492 additions and 60 deletions

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared";
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
@@ -42,7 +44,13 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`);
setContactEntryStatus("Add a timeline entry for this account.");
if (entity === "vendor") {
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
}
return [];
})
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
@@ -250,6 +258,43 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div>
</section>
) : null}
{entity === "vendor" ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchasing Activity</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
</div>
<div className="flex flex-wrap gap-2">
{canManage ? (
<Link to={`/purchasing/orders/new?vendorId=${record.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New purchase order
</Link>
) : null}
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open purchasing
</Link>
</div>
</div>
{relatedPurchaseOrders.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase orders exist for this vendor yet.</div>
) : (
<div className="mt-6 space-y-3">
{relatedPurchaseOrders.slice(0, 8).map((order) => (
<Link key={order.id} to={`/purchasing/orders/${order.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
</div>
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
</div>
</Link>
))}
</div>
)}
</section>
) : null}
<CrmContactsPanel
entity={entity}
ownerId={record.id}

View File

@@ -10,6 +10,7 @@ interface DashboardSnapshot {
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
@@ -69,6 +70,7 @@ export function DashboardPage() {
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
@@ -80,6 +82,7 @@ export function DashboardPage() {
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
@@ -102,11 +105,12 @@ export function DashboardPage() {
vendors: results[1].status === "fulfilled" ? results[1].value : null,
items: results[2].status === "fulfilled" ? results[2].value : null,
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
workOrders: results[4].status === "fulfilled" ? results[4].value : null,
quotes: results[5].status === "fulfilled" ? results[5].value : null,
orders: results[6].status === "fulfilled" ? results[6].value : null,
shipments: results[7].status === "fulfilled" ? results[7].value : null,
projects: results[8].status === "fulfilled" ? results[8].value : null,
purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
workOrders: results[5].status === "fulfilled" ? results[5].value : null,
quotes: results[6].status === "fulfilled" ? results[6].value : null,
orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
@@ -131,6 +135,7 @@ export function DashboardPage() {
const vendors = snapshot?.vendors ?? [];
const items = snapshot?.items ?? [];
const warehouses = snapshot?.warehouses ?? [];
const purchaseOrders = snapshot?.purchaseOrders ?? [];
const workOrders = snapshot?.workOrders ?? [];
const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? [];
@@ -140,6 +145,7 @@ export function DashboardPage() {
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.purchaseOrders !== null,
snapshot?.workOrders !== null,
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
@@ -159,6 +165,11 @@ export function DashboardPage() {
const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
const purchaseOrderCount = purchaseOrders.length;
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
const workOrderCount = workOrders.length;
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
@@ -192,6 +203,7 @@ export function DashboardPage() {
...vendors.map((vendor) => vendor.updatedAt),
...items.map((item) => item.updatedAt),
...warehouses.map((warehouse) => warehouse.updatedAt),
...purchaseOrders.map((order) => order.updatedAt),
...workOrders.map((workOrder) => workOrder.updatedAt),
...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt),
@@ -220,6 +232,15 @@ export function DashboardPage() {
: "Inventory metrics are permission-gated.",
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
},
{
label: "Purchasing Queue",
value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access",
detail:
snapshot?.purchaseOrders !== null
? `${issuedPurchaseOrderCount} issued/approved and ${formatCurrency(purchaseOrderValue)} committed`
: "Purchasing metrics are permission-gated.",
tone: "border-teal-400/30 bg-teal-500/12 text-teal-700 dark:text-teal-300",
},
{
label: "Manufacturing Load",
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
@@ -293,6 +314,22 @@ export function DashboardPage() {
{ label: "Open warehouses", to: "/inventory/warehouses" },
],
},
{
title: "Purchasing",
eyebrow: "Inbound Supply",
summary:
snapshot?.purchaseOrders !== null
? "Purchase orders, open commitments, and current inbound procurement load are now visible from the dashboard."
: "Purchasing read permission is required to surface procurement metrics here.",
metrics: [
{ label: "Open POs", value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access" },
{ label: "Issued", value: snapshot?.purchaseOrders !== null ? `${issuedPurchaseOrderCount}` : "No access" },
{ label: "Committed", value: snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access" },
],
links: [
{ label: "Open purchase orders", to: "/purchasing/orders" },
],
},
{
title: "Manufacturing",
eyebrow: "Execution Load",
@@ -364,9 +401,9 @@ export function DashboardPage() {
];
const futureModules = [
"Vendor invoice attachments and supplier exception queues",
"Stock transfers, allocations, and cycle counts",
"Planning timeline, milestones, and dependency views",
"Sales approvals, revisions, and change history",
"Audit trails, diagnostics, and system health checks",
];
@@ -388,8 +425,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, manufacturing, sales, shipping, and project data. It is intentionally
modular so future purchasing, planning, and audit slices can slot into the same command surface without a redesign.
This landing page now reads directly from live CRM, inventory, purchasing, manufacturing, sales, shipping, and project data. It is
intentionally modular so future planning, approvals, 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">
@@ -415,6 +452,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="/purchasing/orders">
Open purchasing
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
Open projects
</Link>
@@ -437,7 +477,7 @@ export function DashboardPage() {
</div>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-6">
<section className="grid gap-3 xl:grid-cols-7">
{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>
@@ -449,7 +489,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-6">
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-7">
{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>
@@ -473,7 +513,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-6">
<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>
@@ -510,6 +550,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">Purchasing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Inbound supply and commitment load</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 purchase orders</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "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">Open queue</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "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">Committed value</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "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">Manufacturing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4>

View File

@@ -5,7 +5,7 @@ import type {
} from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
@@ -15,6 +15,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { workOrderId } = useParams();
const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -32,9 +34,18 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
}
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then(setProjectOptions).catch(() => setProjectOptions([]));
api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options);
if (mode === "create" && seededProjectId) {
const seededProject = options.find((option) => option.id === seededProjectId);
if (seededProject) {
setForm((current) => ({ ...current, projectId: seededProject.id }));
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
}
}
}).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token]);
}, [mode, seededProjectId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) {

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -13,6 +14,7 @@ export function ProjectDetailPage() {
const { token, user } = useAuth();
const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -26,7 +28,9 @@ export function ProjectDetailPage() {
.then((nextProject) => {
setProject(nextProject);
setStatus("Project loaded.");
return api.getWorkOrders(token, { projectId: nextProject.id });
})
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message);
@@ -93,6 +97,36 @@ export function ProjectDetailPage() {
</div>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Links</p>
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
</div>
{canManage ? (
<Link to={`/manufacturing/work-orders/new?projectId=${project.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New work order
</Link>
) : null}
</div>
{workOrders.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are linked to this project yet.</div>
) : (
<div className="mt-6 space-y-3">
{workOrders.map((workOrder) => (
<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{workOrder.workOrderNumber}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div>
</div>
<div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
</div>
</Link>
))}
</div>
)}
</section>
<FileAttachmentsPanel
ownerType="PROJECT"
ownerId={project.id}

View File

@@ -22,6 +22,16 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
const [orderSearchTerm, setOrderSearchTerm] = useState("");
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
@@ -66,6 +76,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
dueDate: project.dueDate,
notes: project.notes,
});
setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? "");
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
setOrderSearchTerm(project.salesOrderNumber ?? "");
setShipmentSearchTerm(project.shipmentNumber ?? "");
setStatus("Project loaded.");
})
.catch((error: unknown) => {
@@ -88,6 +103,20 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
}));
}
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
setCustomerSearchTerm(selectedCustomer?.name ?? "");
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -128,10 +157,47 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</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>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{customerOptions
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
@@ -149,10 +215,55 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</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>
<div className="relative">
<input
value={ownerSearchTerm}
onChange={(event) => {
setOwnerSearchTerm(event.target.value);
updateField("ownerId", null);
setOwnerPickerOpen(true);
}}
onFocus={() => setOwnerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOwnerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search owner"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{ownerPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", null);
setOwnerSearchTerm("");
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">Unassigned</div>
</button>
{ownerOptions
.filter((owner) => {
const query = ownerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((owner) => (
<button key={owner.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", owner.id);
setOwnerSearchTerm(owner.fullName);
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{owner.fullName}</div>
<div className="mt-1 text-xs text-muted">{owner.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
@@ -162,24 +273,159 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<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>
<div className="relative">
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search quote"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{quotePickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
{quoteOptions
.filter((quote) => {
const query = quoteSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((quote) => (
<button key={quote.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", quote.id);
setQuoteSearchTerm(quote.documentNumber);
setQuotePickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{quote.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<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>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search sales order"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{orderPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
{orderOptions
.filter((order) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((order) => (
<button key={order.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", order.id);
setOrderSearchTerm(order.documentNumber);
setOrderPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<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>
<div className="relative">
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search shipment"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{shipmentPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
{shipmentOptions
.filter((shipment) => {
const query = shipmentSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((shipment) => (
<button key={shipment.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", shipment.id);
setShipmentSearchTerm(shipment.shipmentNumber);
setShipmentPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<label className="block">

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { api, ApiError } from "../../lib/api";
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
@@ -248,7 +249,7 @@ export function PurchaseDetailPage() {
<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">Vendor</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
</dl>
</article>
@@ -422,6 +423,14 @@ export function PurchaseDetailPage() {
)}
</article>
</section>
<FileAttachmentsPanel
ownerType="PURCHASE_ORDER"
ownerId={activeDocument.id}
eyebrow="Supporting Documents"
title="Vendor invoices and backup"
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);

View File

@@ -1,6 +1,6 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
@@ -11,6 +11,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { orderId } = useParams();
const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -30,9 +32,18 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
return;
}
api.getPurchaseVendors(token).then(setVendors).catch(() => setVendors([]));
api.getPurchaseVendors(token).then((nextVendors) => {
setVendors(nextVendors);
if (mode === "create" && seededVendorId) {
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
if (seededVendor) {
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
setVendorSearchTerm(seededVendor.name);
}
}
}).catch(() => setVendors([]));
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [token]);
}, [mode, seededVendorId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !orderId) {