manufacturing and gantt

This commit is contained in:
2026-03-15 12:11:46 -05:00
parent a9d31730f8
commit 16582d3cea
26 changed files with 1614 additions and 75 deletions

View File

@@ -3,8 +3,7 @@ import type {
CompanyProfileDto,
CompanyProfileInput,
FileAttachmentDto,
GanttLinkDto,
GanttTaskDto,
PlanningTimelineDto,
LoginRequest,
LoginResponse,
} from "@mrp/shared";
@@ -34,6 +33,8 @@ import type {
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
ManufacturingStationDto,
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
@@ -461,6 +462,12 @@ export const api = {
getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
},
getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
},
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({
@@ -503,8 +510,8 @@ export const api = {
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
getPlanningTimeline(token: string) {
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
},
getSalesCustomers(token: string) {
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);

View File

@@ -55,6 +55,7 @@ export function DashboardPage() {
const [error, setError] = useState<string | null>(null);
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead);
useEffect(() => {
if (!token || !user) {
@@ -150,6 +151,7 @@ export function DashboardPage() {
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
snapshot?.projects !== null,
canReadPlanning,
].filter(Boolean).length;
const customerCount = customers.length;
@@ -398,12 +400,24 @@ export function DashboardPage() {
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
],
},
{
title: "Planning",
eyebrow: "Schedule Visibility",
summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view."
: "Planning read permission is required to surface the live gantt schedule.",
metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" },
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" },
],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
},
];
const futureModules = [
"Stock transfers, allocations, and cycle counts",
"Planning timeline, milestones, and dependency views",
"Sales approvals, revisions, and change history",
"Revision comparison and document restore tooling",
"Audit trails, diagnostics, and system health checks",
];
@@ -461,6 +475,11 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
Open manufacturing
</Link>
{canReadPlanning ? (
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/planning/gantt">
Open planning
</Link>
) : null}
</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>

View File

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

View File

@@ -122,7 +122,7 @@ export function InventoryDetailPage() {
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<section className="grid gap-3 xl:grid-cols-5">
<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">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
@@ -139,6 +139,10 @@ export function InventoryDetailPage() {
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">BOM Lines</p>
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</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">Operations</p>
<div className="mt-2 text-base font-bold text-text">{item.operations.length}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -235,6 +239,47 @@ export function InventoryDetailPage() {
</div>
)}
</section>
{(item.type === "ASSEMBLY" || item.type === "MANUFACTURED") ? (
<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">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station template</h4>
{item.operations.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 station operations are defined for this buildable item yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Position</th>
<th className="px-2 py-2">Station</th>
<th className="px-2 py-2">Setup</th>
<th className="px-2 py-2">Run / Unit</th>
<th className="px-2 py-2">Move</th>
<th className="px-2 py-2">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{item.operations.map((operation) => (
<tr key={operation.id}>
<td className="px-2 py-2 text-muted">{operation.position}</td>
<td className="px-2 py-2">
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-2 py-2 text-muted">{operation.setupMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.runMinutesPerUnit} min</td>
<td className="px-2 py-2 text-muted">{operation.moveMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.notes || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
) : null}
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canManage ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">

View File

@@ -1,10 +1,11 @@
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared";
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 { emptyInventoryBomLineInput, emptyInventoryItemInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
interface InventoryFormPageProps {
mode: "create" | "edit";
@@ -16,6 +17,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
@@ -80,6 +82,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
notes: line.notes,
position: line.position,
})),
operations: item.operations.map((operation) => ({
stationId: operation.stationId,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
position: operation.position,
notes: operation.notes,
})),
});
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded.");
@@ -90,6 +100,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
});
}, [itemId, mode, token]);
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
@@ -123,6 +141,33 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
setComponentSearchTerms((current) => [...current, ""]);
}
function updateOperation(index: number, nextOperation: InventoryItemOperationInput) {
setForm((current) => ({
...current,
operations: current.operations.map((operation, operationIndex) => (operationIndex === index ? nextOperation : operation)),
}));
}
function addOperation() {
setForm((current) => ({
...current,
operations: [
...current.operations,
{
...emptyInventoryOperationInput,
position: current.operations.length === 0 ? 10 : Math.max(...current.operations.map((operation) => operation.position)) + 10,
},
],
}));
}
function removeOperation(index: number) {
setForm((current) => ({
...current,
operations: current.operations.filter((_operation, operationIndex) => operationIndex !== index),
}));
}
function removeBomLine(index: number) {
setForm((current) => ({
...current,
@@ -289,6 +334,74 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
/>
</label>
</section>
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
<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">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station and time template</h4>
<p className="mt-2 text-sm text-muted">These operations are copied automatically into work orders and drive gantt scheduling without manual planner task entry.</p>
</div>
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add operation
</button>
</div>
{form.operations.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">
Add at least one station operation for this buildable item.
</div>
) : (
<div className="mt-5 space-y-4">
{form.operations.map((operation, index) => (
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span>
<select
value={operation.stationId}
onChange={(event) => updateOperation(index, { ...operation, stationId: event.target.value })}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">Select station</option>
{stations.filter((station) => station.isActive).map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Setup</span>
<input type="number" min={0} step={1} value={operation.setupMinutes} onChange={(event) => updateOperation(index, { ...operation, setupMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Run / Unit</span>
<input type="number" min={0} step={1} value={operation.runMinutesPerUnit} onChange={(event) => updateOperation(index, { ...operation, runMinutesPerUnit: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Move</span>
<input type="number" min={0} step={1} value={operation.moveMinutes} onChange={(event) => updateOperation(index, { ...operation, moveMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Position</span>
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeOperation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input value={operation.notes} onChange={(event) => updateOperation(index, { ...operation, notes: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
))}
</div>
)}
</section>
) : null}
<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>

View File

@@ -5,6 +5,7 @@ import {
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
type InventoryItemOperationInput,
type WarehouseInput,
type WarehouseLocationInput,
type InventoryItemStatus,
@@ -22,6 +23,15 @@ export const emptyInventoryBomLineInput: InventoryBomLineInput = {
position: 10,
};
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
stationId: "",
setupMinutes: 0,
runMinutesPerUnit: 0,
moveMinutes: 0,
position: 10,
notes: "",
};
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
name: "",
@@ -35,6 +45,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
defaultPrice: null,
notes: "",
bomLines: [],
operations: [],
};
export const emptyInventoryTransactionInput: InventoryTransactionInput = {

View File

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

View File

@@ -163,11 +163,12 @@ export function WorkOrderDetailPage() {
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-6">
<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">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</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">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</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">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</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">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
@@ -185,6 +186,40 @@ export function WorkOrderDetailPage() {
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
</article>
</div>
<section className="rounded-[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">Operation Plan</p>
{workOrder.operations.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">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
) : (
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Seq</th>
<th className="px-3 py-3">Station</th>
<th className="px-3 py-3">Start</th>
<th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrder.operations.map((operation) => (
<tr key={operation.id} className="bg-surface/70">
<td className="px-3 py-3 text-text">{operation.sequence}</td>
<td className="px-3 py-3">
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form onSubmit={handleIssueSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">

View File

@@ -168,7 +168,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setItemPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div>
</button>
))}
</div>

View File

@@ -78,6 +78,7 @@ export function WorkOrderListPage() {
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Location</th>
<th className="px-3 py-3">Ops</th>
<th className="px-3 py-3">Due</th>
</tr>
</thead>
@@ -95,6 +96,7 @@ export function WorkOrderListPage() {
<td className="px-3 py-3 align-top"><WorkOrderStatusBadge status={workOrder.status} /></td>
<td className="px-3 py-3 align-top text-text">{workOrder.completedQuantity} / {workOrder.quantity}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.operationCount} / {Math.round(workOrder.totalPlannedMinutes / 60)}h</td>
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}