inventory

This commit is contained in:
2026-03-14 22:37:09 -05:00
parent 6589581908
commit 10b47da724
14 changed files with 651 additions and 43 deletions

View File

@@ -26,9 +26,11 @@ import type {
InventoryItemOptionDto,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransactionInput,
InventoryItemType,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
@@ -298,6 +300,9 @@ export const api = {
getInventoryItemOptions(token: string) {
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
},
getWarehouseLocationOptions(token: string) {
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
},
createInventoryItem(token: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
"/api/v1/inventory/items",
@@ -318,6 +323,16 @@ export const api = {
token
);
},
createInventoryTransaction(token: string, itemId: string, payload: InventoryTransactionInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transactions`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getWarehouses(token: string) {
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
},

View File

@@ -1,17 +1,23 @@
import type { InventoryItemDetailDto } from "@mrp/shared/dist/inventory/types.js";
import type { InventoryItemDetailDto, InventoryTransactionInput, 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";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
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 [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
const [status, setStatus] = useState("Loading inventory item...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
@@ -31,8 +37,60 @@ export function InventoryDetailPage() {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
api
.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
setTransactionForm((current) => {
if (current.locationId) {
return current;
}
const firstOption = options[0];
return firstOption
? {
...current,
warehouseId: firstOption.warehouseId,
locationId: firstOption.locationId,
}
: current;
});
})
.catch(() => setLocationOptions([]));
}, [itemId, token]);
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
setTransactionForm((current) => ({ ...current, [key]: value }));
}
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingTransaction(true);
setTransactionStatus("Saving stock transaction...");
try {
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
setItem(nextItem);
setTransactionStatus("Stock transaction recorded.");
setTransactionForm((current) => ({
...emptyInventoryTransactionInput,
transactionType: current.transactionType,
warehouseId: current.warehouseId,
locationId: current.locationId,
}));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
setTransactionStatus(message);
} finally {
setIsSavingTransaction(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>;
}
@@ -63,6 +121,24 @@ export function InventoryDetailPage() {
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<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">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.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">Transactions</p>
<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>
</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>
@@ -93,6 +169,28 @@ export function InventoryDetailPage() {
<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">
@@ -132,6 +230,131 @@ export function InventoryDetailPage() {
</div>
)}
</section>
<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">
<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="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"
>
{inventoryTransactionOptions.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={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"
>
{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">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"
/>
</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"
/>
</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"
>
{isSavingTransaction ? "Posting..." : "Post transaction"}
</button>
</div>
</form>
</article>
) : 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>
{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.
</div>
) : (
<div className="mt-6 space-y-3">
{item.recentTransactions.map((transaction) => (
<article key={transaction.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<InventoryTransactionTypeBadge type={transaction.transactionType} />
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
{transaction.signedQuantity >= 0 ? "+" : ""}
{transaction.signedQuantity}
</span>
</div>
<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>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
<div className="mt-1">{transaction.createdByName}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
</section>
);
}

View File

@@ -374,10 +374,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input
type="number"
min={0.0001}
step={0.0001}
min={1}
step={1}
value={line.quantity}
onChange={(event) => updateBomLine(index, { ...line, quantity: Number(event.target.value) })}
onChange={(event) => updateBomLine(index, { ...line, quantity: 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>

View File

@@ -0,0 +1,13 @@
import type { InventoryTransactionType } from "@mrp/shared/dist/inventory/types.js";
import { inventoryTransactionOptions, inventoryTransactionPalette } from "./config";
export function InventoryTransactionTypeBadge({ type }: { type: InventoryTransactionType }) {
const label = inventoryTransactionOptions.find((option) => option.value === type)?.label ?? type;
return (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${inventoryTransactionPalette[type]}`}>
{label}
</span>
);
}

View File

@@ -1,6 +1,7 @@
import {
inventoryItemStatuses,
inventoryItemTypes,
inventoryTransactionTypes,
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
@@ -8,6 +9,8 @@ import {
type WarehouseLocationInput,
type InventoryItemStatus,
type InventoryItemType,
type InventoryTransactionInput,
type InventoryTransactionType,
type InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
@@ -33,6 +36,15 @@ export const emptyInventoryItemInput: InventoryItemInput = {
bomLines: [],
};
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
transactionType: "RECEIPT",
quantity: 1,
warehouseId: "",
locationId: "",
reference: "",
notes: "",
};
export const emptyWarehouseLocationInput: WarehouseLocationInput = {
code: "",
name: "",
@@ -87,4 +99,18 @@ export const inventoryTypePalette: Record<InventoryItemType, string> = {
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure };
export const inventoryTransactionOptions: Array<{ value: InventoryTransactionType; label: string }> = [
{ value: "RECEIPT", label: "Receipt" },
{ value: "ISSUE", label: "Issue" },
{ value: "ADJUSTMENT_IN", label: "Adjustment In" },
{ value: "ADJUSTMENT_OUT", label: "Adjustment Out" },
];
export const inventoryTransactionPalette: Record<InventoryTransactionType, string> = {
RECEIPT: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ISSUE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
ADJUSTMENT_IN: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ADJUSTMENT_OUT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure };