manufacturing and gantt
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user