inventory control

This commit is contained in:
2026-03-15 14:00:12 -05:00
parent 16582d3cea
commit 1fcb0c5480
14 changed files with 986 additions and 205 deletions

View File

@@ -23,8 +23,10 @@ import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOptionDto,
InventoryReservationInput,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransferInput,
InventoryTransactionInput,
InventoryItemType,
WarehouseDetailDto,
@@ -378,6 +380,26 @@ export const api = {
token
);
},
createInventoryTransfer(token: string, itemId: string, payload: InventoryTransferInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transfers`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createInventoryReservation(token: string, itemId: string, payload: InventoryReservationInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/reservations`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getWarehouses(token: string) {
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
},

View File

@@ -1,4 +1,10 @@
import type { InventoryItemDetailDto, InventoryTransactionInput, WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type {
InventoryItemDetailDto,
InventoryReservationInput,
InventoryTransactionInput,
InventoryTransferInput,
WarehouseLocationOptionDto,
} from "@mrp/shared/dist/inventory/types.js";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -11,14 +17,36 @@ import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
const emptyTransferInput: InventoryTransferInput = {
quantity: 1,
fromWarehouseId: "",
fromLocationId: "",
toWarehouseId: "",
toLocationId: "",
notes: "",
};
const emptyReservationInput: InventoryReservationInput = {
quantity: 1,
warehouseId: null,
locationId: null,
notes: "",
};
export function InventoryDetailPage() {
const { token, user } = useAuth();
const { itemId } = useParams();
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
const [isSavingReservation, setIsSavingReservation] = useState(false);
const [status, setStatus] = useState("Loading inventory item...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
@@ -43,20 +71,23 @@ export function InventoryDetailPage() {
.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
setTransactionForm((current) => {
if (current.locationId) {
return current;
}
const firstOption = options[0];
if (!firstOption) {
return;
}
const firstOption = options[0];
return firstOption
? {
...current,
warehouseId: firstOption.warehouseId,
locationId: firstOption.locationId,
}
: current;
});
setTransactionForm((current) => ({
...current,
warehouseId: current.warehouseId || firstOption.warehouseId,
locationId: current.locationId || firstOption.locationId,
}));
setTransferForm((current) => ({
...current,
fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
fromLocationId: current.fromLocationId || firstOption.locationId,
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
toLocationId: current.toLocationId || firstOption.locationId,
}));
})
.catch(() => setLocationOptions([]));
}, [itemId, token]);
@@ -65,6 +96,10 @@ export function InventoryDetailPage() {
setTransactionForm((current) => ({ ...current, [key]: value }));
}
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
setTransferForm((current) => ({ ...current, [key]: value }));
}
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
@@ -92,8 +127,51 @@ export function InventoryDetailPage() {
}
}
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingTransfer(true);
setTransferStatus("Saving transfer...");
try {
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
setItem(nextItem);
setTransferStatus("Transfer recorded.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
setTransferStatus(message);
} finally {
setIsSavingTransfer(false);
}
}
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingReservation(true);
setReservationStatus("Saving reservation...");
try {
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
setItem(nextItem);
setReservationStatus("Reservation recorded.");
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
setReservationStatus(message);
} finally {
setIsSavingReservation(false);
}
}
if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
@@ -111,7 +189,7 @@ export function InventoryDetailPage() {
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to items
</Link>
{canManage ? (
@@ -122,11 +200,20 @@ export function InventoryDetailPage() {
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-7">
<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>
</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">Reserved</p>
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</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">Available</p>
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</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">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
@@ -136,14 +223,15 @@ export function InventoryDetailPage() {
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.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">BOM Lines</p>
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
<div className="mt-2 text-base font-bold text-text">{item.transfers.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>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
<div className="mt-2 text-base font-bold text-text">{item.reservations.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">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
@@ -173,128 +261,41 @@ export function InventoryDetailPage() {
</dl>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{item.notes || "No internal notes recorded for this item yet."}</p>
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
Created {new Date(item.createdAt).toLocaleDateString()}
</div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Current stock by location</p>
{item.stockBalances.length === 0 ? (
<p className="mt-2 text-sm text-muted">No stock has been posted for this item yet.</p>
) : (
<div className="mt-3 space-y-2">
{item.stockBalances.map((balance) => (
<div key={balance.locationId} className="flex items-center justify-between rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm">
<div className="min-w-0">
<div className="font-semibold text-text">
{balance.warehouseCode} / {balance.locationCode}
</div>
<div className="text-xs text-muted">
{balance.warehouseName} · {balance.locationName}
</div>
</div>
<div className="shrink-0 font-semibold text-text">{balance.quantityOnHand}</div>
</div>
))}
</div>
)}
</div>
</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">Bill Of Materials</p>
<h4 className="mt-2 text-lg font-bold text-text">Component structure</h4>
{item.bomLines.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 BOM lines are defined for this 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">Component</th>
<th className="px-2 py-2">Quantity</th>
<th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{item.bomLines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-2 text-muted">{line.position}</td>
<td className="px-2 py-2">
<div className="font-semibold text-text">{line.componentSku}</div>
<div className="mt-1 text-xs text-muted">{line.componentName}</div>
</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">{line.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</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>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
{item.stockBalances.length === 0 ? (
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
) : (
<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 className="mt-4 space-y-2">
{item.stockBalances.map((balance) => (
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<div className="min-w-0">
<div className="font-semibold text-text">
{balance.warehouseCode} / {balance.locationCode}
</div>
<div className="text-xs text-muted">
{balance.warehouseName} / {balance.locationName}
</div>
</div>
<div className="text-right">
<div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
<div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
</div>
</div>
))}
</div>
)}
</section>
) : null}
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
</article>
</div>
<section className="grid gap-3 xl:grid-cols-2">
{canManage ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
<h4 className="mt-2 text-lg font-bold text-text">Record movement</h4>
<p className="mt-2 text-sm text-muted">Post receipts, issues, and adjustments to update on-hand inventory.</p>
<form className="mt-5 space-y-4" onSubmit={handleTransactionSubmit}>
<div className="mt-5 grid gap-3">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
<select
value={transactionForm.transactionType}
onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{inventoryTransactionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -304,29 +305,18 @@ export function InventoryDetailPage() {
</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={transactionForm.quantity}
onChange={(event) => updateTransactionField("quantity", 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"
/>
<input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", 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>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select
value={transactionForm.locationId}
onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateTransactionField("locationId", event.target.value);
if (nextLocation) {
updateTransactionField("warehouseId", nextLocation.warehouseId);
}
}}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<select value={transactionForm.locationId} onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateTransactionField("locationId", event.target.value);
if (nextLocation) {
updateTransactionField("warehouseId", nextLocation.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
@@ -336,38 +326,23 @@ export function InventoryDetailPage() {
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reference</span>
<input
value={transactionForm.reference}
onChange={(event) => updateTransactionField("reference", event.target.value)}
placeholder="PO, WO, adjustment note, etc."
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
<input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." 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">Notes</span>
<textarea
value={transactionForm.notes}
onChange={(event) => updateTransactionField("notes", 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"
/>
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", 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>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{transactionStatus}</span>
<button
type="submit"
disabled={isSavingTransaction || locationOptions.length === 0}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
<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">{transactionStatus}</span>
<button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingTransaction ? "Posting..." : "Post transaction"}
</button>
</div>
</form>
</article>
</div>
</form>
) : null}
<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">Stock History</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent movements</h4>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
{item.recentTransactions.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 stock transactions have been recorded for this item yet.
@@ -388,9 +363,6 @@ export function InventoryDetailPage() {
<div className="mt-2 text-sm font-semibold text-text">
{transaction.warehouseCode} / {transaction.locationCode}
</div>
<div className="text-xs text-muted">
{transaction.warehouseName} · {transaction.locationName}
</div>
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
</div>
@@ -405,6 +377,154 @@ export function InventoryDetailPage() {
)}
</article>
</section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("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>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">From</span>
<select value={transferForm.fromLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("fromLocationId", event.target.value);
if (option) {
updateTransferField("fromWarehouseId", option.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={`from-${option.locationId}`} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">To</span>
<select value={transferForm.toLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("toLocationId", event.target.value);
if (option) {
updateTransferField("toWarehouseId", option.warehouseId);
}
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{locationOptions.map((option) => (
<option key={`to-${option.locationId}`} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", 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>
<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">{transferStatus}</span>
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
</button>
</div>
</div>
</form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, 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">Location</span>
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
setReservationForm((current) => ({
...current,
locationId: event.target.value || null,
warehouseId: option ? option.warehouseId : 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="">Global / not location-specific</option>
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: 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>
<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">{reservationStatus}</span>
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
</button>
</div>
</div>
</form>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-2">
<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">Reservations</p>
{item.reservations.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 reservations have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.reservations.map((reservation) => (
<article key={reservation.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
</div>
<div className="text-xs text-muted">{reservation.status}</div>
</div>
<div className="mt-2 text-xs text-muted">
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
</div>
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
</article>
))}
</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">Transfers</p>
{item.transfers.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 transfers have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.transfers.map((transfer) => (
<article key={transfer.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-text">{transfer.quantity} moved</div>
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
</div>
<div className="mt-2 text-xs text-muted">
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
</div>
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
</article>
))}
</div>
)}
</article>
</section>
<InventoryAttachmentsPanel itemId={item.id} />
</section>
);

View File

@@ -71,7 +71,7 @@ export function ManufacturingPage() {
<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>{station.queueDays} expected wait day(s)</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div>
</div>
@@ -93,7 +93,7 @@ export function ManufacturingPage() {
<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>
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (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">