inventory1

This commit is contained in:
2026-03-14 21:10:35 -05:00
parent df3f1412f6
commit d21e2e3c0b
21 changed files with 1492 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ const links = [
{ to: "/settings/company", label: "Company Settings" },
{ to: "/crm/customers", label: "Customers" },
{ to: "/crm/vendors", label: "Vendors" },
{ to: "/inventory/items", label: "Inventory" },
{ to: "/planning/gantt", label: "Gantt" },
];

View File

@@ -20,6 +20,14 @@ import type {
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOptionDto,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryItemType,
} from "@mrp/shared/dist/inventory/types.js";
export class ApiError extends Error {
constructor(message: string, public readonly code: string) {
@@ -270,6 +278,43 @@ export const api = {
token
);
},
getInventoryItems(token: string, filters?: { q?: string; status?: InventoryItemStatus; type?: InventoryItemType }) {
return request<InventoryItemSummaryDto[]>(
`/api/v1/inventory/items${buildQueryString({
q: filters?.q,
status: filters?.status,
type: filters?.type,
})}`,
undefined,
token
);
},
getInventoryItem(token: string, itemId: string) {
return request<InventoryItemDetailDto>(`/api/v1/inventory/items/${itemId}`, undefined, token);
},
getInventoryItemOptions(token: string) {
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
},
createInventoryItem(token: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
"/api/v1/inventory/items",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateInventoryItem(token: string, itemId: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},

View File

@@ -15,6 +15,9 @@ import { CrmFormPage } from "./modules/crm/CrmFormPage";
import { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage";
import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage";
import { InventoryFormPage } from "./modules/inventory/InventoryFormPage";
import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css";
@@ -42,6 +45,13 @@ const router = createBrowserRouter([
{ path: "/crm/vendors/:vendorId", element: <CrmDetailPage entity="vendor" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryRead]} />,
children: [
{ path: "/inventory/items", element: <InventoryItemsPage /> },
{ path: "/inventory/items/:itemId", element: <InventoryDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
@@ -51,6 +61,13 @@ const router = createBrowserRouter([
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [
{ path: "/inventory/items/new", element: <InventoryFormPage mode="create" /> },
{ path: "/inventory/items/:itemId/edit", element: <InventoryFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: <GanttPage /> }],

View File

@@ -13,6 +13,9 @@ export function DashboardPage() {
<Link className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white" to="/settings/company">
Manage company profile
</Link>
<Link className="rounded-2xl border border-line/70 px-5 py-3 text-sm font-semibold text-text" to="/inventory/items">
Open inventory
</Link>
<Link className="rounded-2xl border border-line/70 px-5 py-3 text-sm font-semibold text-text" to="/planning/gantt">
Open gantt preview
</Link>
@@ -24,7 +27,7 @@ export function DashboardPage() {
{[
"CRM reference entities are seeded and available via protected APIs.",
"Company Settings drives runtime brand tokens and PDF identity.",
"The next module phase can add BOMs, orders, and shipping documents without app-shell refactors.",
"Inventory item master and BOM records now have a dedicated protected module.",
].map((item) => (
<div key={item} className="rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-text">
{item}
@@ -35,4 +38,3 @@ export function DashboardPage() {
</div>
);
}

View File

@@ -0,0 +1,137 @@
import type { InventoryItemDetailDto } 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 { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
export function InventoryDetailPage() {
const { token, user } = useAuth();
const { itemId } = useParams();
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
const [status, setStatus] = useState("Loading inventory item...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token || !itemId) {
return;
}
api
.getInventoryItem(token, itemId)
.then((nextItem) => {
setItem(nextItem);
setStatus("Inventory item loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
}, [itemId, token]);
if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
<h3 className="mt-3 text-3xl font-bold text-text">{item.sku}</h3>
<p className="mt-2 text-base text-text">{item.name}</p>
<div className="mt-4 flex flex-wrap gap-3">
<InventoryTypeBadge type={item.type} />
<InventoryStatusBadge status={item.status} />
</div>
<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-4 py-3 text-sm font-semibold text-text">
Back to items
</Link>
{canManage ? (
<Link to={`/inventory/items/${item.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
Edit item
</Link>
) : null}
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
<dl className="mt-5 grid gap-4 xl:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
<dd className="mt-2 text-sm leading-7 text-text">{item.description || "No description provided."}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unit of measure</dt>
<dd className="mt-2 text-sm text-text">{item.unitOfMeasure}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default cost</dt>
<dd className="mt-2 text-sm text-text">{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<dd className="mt-2 text-sm text-text">
{item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"}
</dd>
</div>
</dl>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
<p className="mt-4 whitespace-pre-line text-sm leading-7 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-4 py-4 text-sm text-muted">
Created {new Date(item.createdAt).toLocaleDateString()}
</div>
</article>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p>
<h4 className="mt-3 text-xl 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-6 py-12 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-4 py-3">Position</th>
<th className="px-4 py-3">Component</th>
<th className="px-4 py-3">Quantity</th>
<th className="px-4 py-3">UOM</th>
<th className="px-4 py-3">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{item.bomLines.map((line) => (
<tr key={line.id}>
<td className="px-4 py-3 text-muted">{line.position}</td>
<td className="px-4 py-3">
<div className="font-semibold text-text">{line.componentSku}</div>
<div className="mt-1 text-xs text-muted">{line.componentName}</div>
</td>
<td className="px-4 py-3 text-muted">{line.quantity}</td>
<td className="px-4 py-3 text-muted">{line.unitOfMeasure}</td>
<td className="px-4 py-3 text-muted">{line.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
);
}

View File

@@ -0,0 +1,355 @@
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
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";
interface InventoryFormPageProps {
mode: "create" | "edit";
}
export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const navigate = useNavigate();
const { token } = useAuth();
const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!token) {
return;
}
api
.getInventoryItemOptions(token)
.then((options) => setComponentOptions(options.filter((option) => option.id !== itemId)))
.catch(() => setComponentOptions([]));
}, [itemId, token]);
useEffect(() => {
if (mode !== "edit" || !token || !itemId) {
return;
}
api
.getInventoryItem(token, itemId)
.then((item) => {
setForm({
sku: item.sku,
name: item.name,
description: item.description,
type: item.type,
status: item.status,
unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable,
isPurchasable: item.isPurchasable,
defaultCost: item.defaultCost,
notes: item.notes,
bomLines: item.bomLines.map((line) => ({
componentItemId: line.componentItemId,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
notes: line.notes,
position: line.position,
})),
});
setStatus("Inventory item loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
}, [itemId, mode, token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function updateBomLine(index: number, nextLine: InventoryBomLineInput) {
setForm((current) => ({
...current,
bomLines: current.bomLines.map((line, lineIndex) => (lineIndex === index ? nextLine : line)),
}));
}
function addBomLine() {
setForm((current) => ({
...current,
bomLines: [
...current.bomLines,
{
...emptyInventoryBomLineInput,
position: current.bomLines.length === 0 ? 10 : Math.max(...current.bomLines.map((line) => line.position)) + 10,
},
],
}));
}
function removeBomLine(index: number) {
setForm((current) => ({
...current,
bomLines: current.bomLines.filter((_line, lineIndex) => lineIndex !== index),
}));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving inventory item...");
try {
const saved =
mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form);
navigate(`/inventory/items/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save inventory item.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Editor</p>
<h3 className="mt-3 text-2xl font-bold text-text">{mode === "create" ? "New Item" : "Edit Item"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
</p>
</div>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</section>
<section className="space-y-5 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">SKU</span>
<input
value={form.sku}
onChange={(event) => updateField("sku", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
<input
value={form.name}
onChange={(event) => updateField("name", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Default cost</span>
<input
type="number"
min={0}
step={0.01}
value={form.defaultCost ?? ""}
onChange={(event) => updateField("defaultCost", event.target.value === "" ? null : Number(event.target.value))}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<div className="grid gap-4 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Type</span>
<select
value={form.type}
onChange={(event) => updateField("type", event.target.value as InventoryItemInput["type"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryTypeOptions.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">Status</span>
<select
value={form.status}
onChange={(event) => updateField("status", event.target.value as InventoryItemInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryStatusOptions.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">Unit of measure</span>
<select
value={form.unitOfMeasure}
onChange={(event) => updateField("unitOfMeasure", event.target.value as InventoryItemInput["unitOfMeasure"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
<span className="text-sm font-semibold text-text">Sellable</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.isPurchasable}
onChange={(event) => updateField("isPurchasable", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Purchasable</span>
</label>
</div>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={form.description}
onChange={(event) => updateField("description", event.target.value)}
rows={4}
className="w-full rounded-3xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Internal notes</span>
<textarea
value={form.notes}
onChange={(event) => updateField("notes", event.target.value)}
rows={4}
className="w-full rounded-3xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p>
<h4 className="mt-3 text-xl font-bold text-text">Component lines</h4>
<p className="mt-2 text-sm text-muted">Add BOM components for manufactured or assembly items. Purchased and service items can be saved without BOM lines.</p>
</div>
<button
type="button"
onClick={addBomLine}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
>
Add BOM line
</button>
</div>
{form.bomLines.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-10 text-center text-sm text-muted">
No BOM lines added yet.
</div>
) : (
<div className="mt-5 space-y-4">
{form.bomLines.map((line, index) => (
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-4">
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
<select
value={line.componentItemId}
onChange={(event) => updateBomLine(index, { ...line, componentItemId: event.target.value })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
<option value="">Select component</option>
{componentOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.sku} - {option.name}
</option>
))}
</select>
</label>
<label className="block">
<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}
value={line.quantity}
onChange={(event) => updateBomLine(index, { ...line, quantity: Number(event.target.value) })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 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">UOM</span>
<select
value={line.unitOfMeasure}
onChange={(event) => updateBomLine(index, { ...line, unitOfMeasure: event.target.value as InventoryBomLineInput["unitOfMeasure"] })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</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={line.position}
onChange={(event) => updateBomLine(index, { ...line, position: Number(event.target.value) })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<div className="flex items-end">
<button
type="button"
onClick={() => removeBomLine(index)}
className="rounded-2xl border border-rose-400/40 px-4 py-3 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={line.notes}
onChange={(event) => updateBomLine(index, { ...line, notes: event.target.value })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
))}
</div>
)}
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 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-5 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "Saving..." : mode === "create" ? "Create item" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,5 @@
import { InventoryListPage } from "./InventoryListPage";
export function InventoryItemsPage() {
return <InventoryListPage />;
}

View File

@@ -0,0 +1,147 @@
import { permissions } from "@mrp/shared";
import type { InventoryItemStatus, InventoryItemSummaryDto, InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { inventoryStatusFilters, inventoryTypeFilters } from "./config";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
export function InventoryListPage() {
const { token, user } = useAuth();
const [items, setItems] = useState<InventoryItemSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | InventoryItemStatus>("ALL");
const [typeFilter, setTypeFilter] = useState<"ALL" | InventoryItemType>("ALL");
const [status, setStatus] = useState("Loading inventory items...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getInventoryItems(token, {
q: searchTerm.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
type: typeFilter === "ALL" ? undefined : typeFilter,
})
.then((nextItems) => {
setItems(nextItems);
setStatus(`${nextItems.length} item(s) matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory items.";
setStatus(message);
});
}, [searchTerm, statusFilter, token, typeFilter]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory</p>
<h3 className="mt-3 text-2xl font-bold text-text">Item Master</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Core item and BOM definitions for purchased parts, manufactured items, assemblies, and service SKUs.
</p>
</div>
{canManage ? (
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
New item
</Link>
) : null}
</div>
<div className="mt-6 grid gap-4 rounded-3xl border border-line/70 bg-page/60 p-4 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by SKU, item name, or description"
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 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">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | InventoryItemStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Type</span>
<select
value={typeFilter}
onChange={(event) => setTypeFilter(event.target.value as "ALL" | InventoryItemType)}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{inventoryTypeFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-4 py-3 text-sm text-muted">{status}</div>
{items.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-12 text-center text-sm text-muted">
No inventory items have been added 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-4 py-3">Item</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">UOM</th>
<th className="px-4 py-3">Flags</th>
<th className="px-4 py-3">BOM</th>
<th className="px-4 py-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{items.map((item) => (
<tr key={item.id} className="transition hover:bg-page/70">
<td className="px-4 py-3">
<Link to={`/inventory/items/${item.id}`} className="font-semibold text-text hover:text-brand">
{item.sku}
</Link>
<div className="mt-1 text-xs text-muted">{item.name}</div>
</td>
<td className="px-4 py-3">
<InventoryTypeBadge type={item.type} />
</td>
<td className="px-4 py-3">
<InventoryStatusBadge status={item.status} />
</td>
<td className="px-4 py-3 text-muted">{item.unitOfMeasure}</td>
<td className="px-4 py-3 text-xs text-muted">
<div>{item.isSellable ? "Sellable" : "Not sellable"}</div>
<div>{item.isPurchasable ? "Purchasable" : "Not purchasable"}</div>
</td>
<td className="px-4 py-3 text-muted">{item.bomLineCount} lines</td>
<td className="px-4 py-3 text-muted">{new Date(item.updatedAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,17 @@
import type { InventoryItemStatus } from "@mrp/shared/dist/inventory/types.js";
import { inventoryStatusPalette } from "./config";
const labels: Record<InventoryItemStatus, string> = {
DRAFT: "Draft",
ACTIVE: "Active",
OBSOLETE: "Obsolete",
};
export function InventoryStatusBadge({ status }: { status: InventoryItemStatus }) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryStatusPalette[status]}`}>
{labels[status]}
</span>
);
}

View File

@@ -0,0 +1,18 @@
import type { InventoryItemType } from "@mrp/shared/dist/inventory/types.js";
import { inventoryTypePalette } from "./config";
const labels: Record<InventoryItemType, string> = {
PURCHASED: "Purchased",
MANUFACTURED: "Manufactured",
ASSEMBLY: "Assembly",
SERVICE: "Service",
};
export function InventoryTypeBadge({ type }: { type: InventoryItemType }) {
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] ${inventoryTypePalette[type]}`}>
{labels[type]}
</span>
);
}

View File

@@ -0,0 +1,75 @@
import {
inventoryItemStatuses,
inventoryItemTypes,
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
type InventoryItemStatus,
type InventoryItemType,
type InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
export const emptyInventoryBomLineInput: InventoryBomLineInput = {
componentItemId: "",
quantity: 1,
unitOfMeasure: "EA",
notes: "",
position: 10,
};
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
name: "",
description: "",
type: "PURCHASED",
status: "ACTIVE",
unitOfMeasure: "EA",
isSellable: true,
isPurchasable: true,
defaultCost: null,
notes: "",
bomLines: [],
};
export const inventoryTypeOptions: Array<{ value: InventoryItemType; label: string }> = [
{ value: "PURCHASED", label: "Purchased" },
{ value: "MANUFACTURED", label: "Manufactured" },
{ value: "ASSEMBLY", label: "Assembly" },
{ value: "SERVICE", label: "Service" },
];
export const inventoryStatusOptions: Array<{ value: InventoryItemStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "OBSOLETE", label: "Obsolete" },
];
export const inventoryStatusFilters: Array<{ value: "ALL" | InventoryItemStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...inventoryStatusOptions,
];
export const inventoryTypeFilters: Array<{ value: "ALL" | InventoryItemType; label: string }> = [
{ value: "ALL", label: "All item types" },
...inventoryTypeOptions,
];
export const inventoryUnitOptions: Array<{ value: InventoryUnitOfMeasure; label: string }> = inventoryUnitsOfMeasure.map((unit) => ({
value: unit,
label: unit,
}));
export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
OBSOLETE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const inventoryTypePalette: Record<InventoryItemType, string> = {
PURCHASED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
MANUFACTURED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
ASSEMBLY: "border border-brand/30 bg-brand/10 text-brand",
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure };