311 lines
16 KiB
TypeScript
311 lines
16 KiB
TypeScript
import type {
|
|
ManufacturingItemOptionDto,
|
|
ManufacturingProjectOptionDto,
|
|
WorkOrderInput,
|
|
} from "@mrp/shared";
|
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
|
|
import { useAuth } from "../../auth/AuthProvider";
|
|
import { api, ApiError } from "../../lib/api";
|
|
import { emptyWorkOrderInput, workOrderStatusOptions } from "./config";
|
|
|
|
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 seededItemId = searchParams.get("itemId");
|
|
const seededSalesOrderId = searchParams.get("salesOrderId");
|
|
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
|
|
const seededQuantity = searchParams.get("quantity");
|
|
const seededStatus = searchParams.get("status");
|
|
const seededDueDate = searchParams.get("dueDate");
|
|
const seededNotes = searchParams.get("notes");
|
|
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
|
|
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
|
|
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
|
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
|
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
|
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
|
const [itemPickerOpen, setItemPickerOpen] = useState(false);
|
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
|
const [status, setStatus] = useState(mode === "create" ? "Create a new work order." : "Loading work order...");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
api.getManufacturingItemOptions(token).then((options) => {
|
|
setItemOptions(options);
|
|
if (mode === "create" && seededItemId) {
|
|
const seededItem = options.find((option) => option.id === seededItemId);
|
|
if (seededItem) {
|
|
setForm((current) => ({
|
|
...current,
|
|
itemId: seededItem.id,
|
|
salesOrderId: seededSalesOrderId || current.salesOrderId,
|
|
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
|
|
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
|
|
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
|
|
dueDate: seededDueDate || current.dueDate,
|
|
notes: seededNotes || current.notes,
|
|
}));
|
|
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
|
|
}
|
|
}
|
|
}).catch(() => setItemOptions([]));
|
|
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([]));
|
|
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
|
|
|
|
useEffect(() => {
|
|
if (!token || mode !== "edit" || !workOrderId) {
|
|
return;
|
|
}
|
|
|
|
api.getWorkOrder(token, workOrderId)
|
|
.then((workOrder) => {
|
|
setForm({
|
|
itemId: workOrder.itemId,
|
|
projectId: workOrder.projectId,
|
|
salesOrderId: workOrder.salesOrderId,
|
|
salesOrderLineId: workOrder.salesOrderLineId,
|
|
status: workOrder.status,
|
|
quantity: workOrder.quantity,
|
|
warehouseId: workOrder.warehouseId,
|
|
locationId: workOrder.locationId,
|
|
dueDate: workOrder.dueDate,
|
|
notes: workOrder.notes,
|
|
});
|
|
setItemSearchTerm(`${workOrder.itemSku} - ${workOrder.itemName}`);
|
|
setProjectSearchTerm(workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "");
|
|
setStatus("Work order loaded.");
|
|
})
|
|
.catch((error: unknown) => {
|
|
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
|
|
setStatus(message);
|
|
});
|
|
}, [mode, token, workOrderId]);
|
|
|
|
const warehouseOptions = useMemo(
|
|
() => [...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()],
|
|
[locationOptions]
|
|
);
|
|
|
|
const filteredLocationOptions = useMemo(
|
|
() => locationOptions.filter((option) => option.warehouseId === form.warehouseId),
|
|
[form.warehouseId, locationOptions]
|
|
);
|
|
|
|
function updateField<Key extends keyof WorkOrderInput>(key: Key, value: WorkOrderInput[Key]) {
|
|
setForm((current) => ({
|
|
...current,
|
|
[key]: value,
|
|
...(key === "warehouseId" ? { locationId: "" } : {}),
|
|
}));
|
|
}
|
|
|
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setStatus("Saving work order...");
|
|
try {
|
|
const saved = mode === "create" ? await api.createWorkOrder(token, form) : await api.updateWorkOrder(token, workOrderId ?? "", form);
|
|
navigate(`/manufacturing/work-orders/${saved.id}`);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof ApiError ? error.message : "Unable to save work order.";
|
|
setStatus(message);
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function closeEditor() {
|
|
navigate(mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`);
|
|
}
|
|
|
|
return (
|
|
<form className="page-stack" onSubmit={handleSubmit}>
|
|
<section className="surface-panel">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<p className="section-kicker">MANUFACTURING EDITOR</p>
|
|
<h3 className="module-title">{mode === "create" ? "NEW WORK ORDER" : "EDIT WORK ORDER"}</h3>
|
|
</div>
|
|
<button type="button" onClick={closeEditor} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<section className="surface-panel space-y-3">
|
|
<div className="grid gap-3 xl:grid-cols-2">
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
|
|
<div className="relative">
|
|
<input
|
|
value={itemSearchTerm}
|
|
onChange={(event) => {
|
|
setItemSearchTerm(event.target.value);
|
|
updateField("itemId", "");
|
|
setItemPickerOpen(true);
|
|
}}
|
|
onFocus={() => setItemPickerOpen(true)}
|
|
onBlur={() => {
|
|
window.setTimeout(() => {
|
|
setItemPickerOpen(false);
|
|
const selected = itemOptions.find((option) => option.id === form.itemId);
|
|
if (selected) {
|
|
setItemSearchTerm(`${selected.sku} - ${selected.name}`);
|
|
}
|
|
}, 120);
|
|
}}
|
|
placeholder="Search manufactured item"
|
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
/>
|
|
{itemPickerOpen ? (
|
|
<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">
|
|
{itemOptions
|
|
.filter((option) => {
|
|
const query = itemSearchTerm.trim().toLowerCase();
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
return option.sku.toLowerCase().includes(query) || option.name.toLowerCase().includes(query);
|
|
})
|
|
.slice(0, 12)
|
|
.map((option) => (
|
|
<button key={option.id} type="button" onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
updateField("itemId", option.id);
|
|
setItemSearchTerm(`${option.sku} - ${option.name}`);
|
|
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} - {option.operationCount} ops</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Project</span>
|
|
<div className="relative">
|
|
<input
|
|
value={projectSearchTerm}
|
|
onChange={(event) => {
|
|
setProjectSearchTerm(event.target.value);
|
|
updateField("projectId", null);
|
|
setProjectPickerOpen(true);
|
|
}}
|
|
onFocus={() => setProjectPickerOpen(true)}
|
|
onBlur={() => {
|
|
window.setTimeout(() => {
|
|
setProjectPickerOpen(false);
|
|
const selected = projectOptions.find((option) => option.id === form.projectId);
|
|
if (selected) {
|
|
setProjectSearchTerm(`${selected.projectNumber} - ${selected.name}`);
|
|
}
|
|
}, 120);
|
|
}}
|
|
placeholder="Search linked project (optional)"
|
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
/>
|
|
{projectPickerOpen ? (
|
|
<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("projectId", null);
|
|
setProjectSearchTerm("");
|
|
setProjectPickerOpen(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 project</div>
|
|
</button>
|
|
{projectOptions
|
|
.filter((option) => {
|
|
const query = projectSearchTerm.trim().toLowerCase();
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
return option.projectNumber.toLowerCase().includes(query) || option.name.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
|
|
})
|
|
.slice(0, 12)
|
|
.map((option) => (
|
|
<button key={option.id} type="button" onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
updateField("projectId", option.id);
|
|
setProjectSearchTerm(`${option.projectNumber} - ${option.name}`);
|
|
setProjectPickerOpen(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.projectNumber}</div>
|
|
<div className="mt-1 text-xs text-muted">{option.name} - {option.customerName}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 xl:grid-cols-4">
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
|
<select value={form.status} onChange={(event) => updateField("status", event.target.value as WorkOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
|
{workOrderStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
|
<input type="number" min={1} step={1} value={form.quantity} onChange={(event) => updateField("quantity", Number.parseInt(event.target.value, 10) || 1)} 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">Warehouse</span>
|
|
<select value={form.warehouseId} onChange={(event) => updateField("warehouseId", 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 warehouse</option>
|
|
{warehouseOptions.map((option) => <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
|
<select value={form.locationId} onChange={(event) => updateField("locationId", 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 location</option>
|
|
{filteredLocationOptions.map((option) => <option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="block max-w-sm">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
|
|
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-text">Work instructions / notes</span>
|
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
</label>
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
|
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
|
{isSaving ? "Saving..." : mode === "create" ? "Create work order" : "Save changes"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
);
|
|
}
|
|
|