diff --git a/README.md b/README.md index 7d85db9..0033392 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Current foundation scope includes: - CRM customers and vendors with create/edit/detail workflows - CRM search, filtering, status tagging, and reseller hierarchy - CRM contact history, account contacts, and shared attachments -- inventory item master and BOM foundation +- inventory item master, BOM, warehouse, and stock-location foundation - file storage and PDF rendering ## Workspace @@ -85,7 +85,10 @@ The current inventory foundation supports: - SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, and notes fields - BOM header and BOM line editing directly on the item form - BOM detail display with component SKU, name, quantity, unit, notes, and position +- protected warehouse list, detail, create, and edit flows +- nested stock-location management inside each warehouse record - seeded sample inventory items and a starter assembly BOM during bootstrap +- seeded sample warehouse and stock locations during bootstrap This module introduces `inventory.read` and `inventory.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role. @@ -109,6 +112,7 @@ As of March 14, 2026, the latest committed domain migrations include: - CRM commercial terms and account contacts - CRM lifecycle stages and operational metadata - inventory item master and BOM foundation +- warehouse and stock-location foundation ## UI Notes diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index a48fa9c..dd31b2a 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -9,6 +9,7 @@ const links = [ { to: "/crm/customers", label: "Customers" }, { to: "/crm/vendors", label: "Vendors" }, { to: "/inventory/items", label: "Inventory" }, + { to: "/inventory/warehouses", label: "Warehouses" }, { to: "/planning/gantt", label: "Gantt" }, ]; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f887e6d..160591e 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -27,6 +27,9 @@ import type { InventoryItemStatus, InventoryItemSummaryDto, InventoryItemType, + WarehouseDetailDto, + WarehouseInput, + WarehouseSummaryDto, } from "@mrp/shared/dist/inventory/types.js"; export class ApiError extends Error { @@ -315,6 +318,32 @@ export const api = { token ); }, + getWarehouses(token: string) { + return request("/api/v1/inventory/warehouses", undefined, token); + }, + getWarehouse(token: string, warehouseId: string) { + return request(`/api/v1/inventory/warehouses/${warehouseId}`, undefined, token); + }, + createWarehouse(token: string, payload: WarehouseInput) { + return request( + "/api/v1/inventory/warehouses", + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + }, + updateWarehouse(token: string, warehouseId: string, payload: WarehouseInput) { + return request( + `/api/v1/inventory/warehouses/${warehouseId}`, + { + method: "PUT", + body: JSON.stringify(payload), + }, + token + ); + }, getGanttDemo(token: string) { return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token); }, diff --git a/client/src/main.tsx b/client/src/main.tsx index 0ac09c5..4bd15e4 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -18,6 +18,9 @@ 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 { WarehouseDetailPage } from "./modules/inventory/WarehouseDetailPage"; +import { WarehouseFormPage } from "./modules/inventory/WarehouseFormPage"; +import { WarehousesPage } from "./modules/inventory/WarehousesPage"; import { ThemeProvider } from "./theme/ThemeProvider"; import "./index.css"; @@ -50,6 +53,8 @@ const router = createBrowserRouter([ children: [ { path: "/inventory/items", element: }, { path: "/inventory/items/:itemId", element: }, + { path: "/inventory/warehouses", element: }, + { path: "/inventory/warehouses/:warehouseId", element: }, ], }, { @@ -66,6 +71,8 @@ const router = createBrowserRouter([ children: [ { path: "/inventory/items/new", element: }, { path: "/inventory/items/:itemId/edit", element: }, + { path: "/inventory/warehouses/new", element: }, + { path: "/inventory/warehouses/:warehouseId/edit", element: }, ], }, { diff --git a/client/src/modules/inventory/WarehouseDetailPage.tsx b/client/src/modules/inventory/WarehouseDetailPage.tsx new file mode 100644 index 0000000..d04fc9a --- /dev/null +++ b/client/src/modules/inventory/WarehouseDetailPage.tsx @@ -0,0 +1,90 @@ +import type { WarehouseDetailDto, WarehouseLocationDto } 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"; + +export function WarehouseDetailPage() { + const { token, user } = useAuth(); + const { warehouseId } = useParams(); + const [warehouse, setWarehouse] = useState(null); + const [status, setStatus] = useState("Loading warehouse..."); + + const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false; + + useEffect(() => { + if (!token || !warehouseId) { + return; + } + + api + .getWarehouse(token, warehouseId) + .then((nextWarehouse) => { + setWarehouse(nextWarehouse); + setStatus("Warehouse loaded."); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load warehouse."; + setStatus(message); + }); + }, [token, warehouseId]); + + if (!warehouse) { + return
{status}
; + } + + return ( +
+
+
+
+

Warehouse Detail

+

{warehouse.code}

+

{warehouse.name}

+

Last updated {new Date(warehouse.updatedAt).toLocaleString()}.

+
+
+ + Back to warehouses + + {canManage ? ( + + Edit warehouse + + ) : null} +
+
+
+
+
+

Notes

+

{warehouse.notes || "No warehouse notes recorded."}

+
+ Created {new Date(warehouse.createdAt).toLocaleDateString()} +
+
+
+

Locations

+

Stock locations

+ {warehouse.locations.length === 0 ? ( +
+ No stock locations have been defined for this warehouse yet. +
+ ) : ( +
+ {warehouse.locations.map((location: WarehouseLocationDto) => ( +
+
{location.code}
+
{location.name}
+
{location.notes || "No notes."}
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/client/src/modules/inventory/WarehouseFormPage.tsx b/client/src/modules/inventory/WarehouseFormPage.tsx new file mode 100644 index 0000000..b95627b --- /dev/null +++ b/client/src/modules/inventory/WarehouseFormPage.tsx @@ -0,0 +1,172 @@ +import type { WarehouseInput, WarehouseLocationInput } 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 { emptyWarehouseInput, emptyWarehouseLocationInput } from "./config"; + +export function WarehouseFormPage({ mode }: { mode: "create" | "edit" }) { + const navigate = useNavigate(); + const { warehouseId } = useParams(); + const { token } = useAuth(); + const [form, setForm] = useState(emptyWarehouseInput); + const [status, setStatus] = useState(mode === "create" ? "Create a new warehouse." : "Loading warehouse..."); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (mode !== "edit" || !token || !warehouseId) { + return; + } + + api + .getWarehouse(token, warehouseId) + .then((warehouse) => { + setForm({ + code: warehouse.code, + name: warehouse.name, + notes: warehouse.notes, + locations: warehouse.locations.map((location: WarehouseLocationInput) => ({ + code: location.code, + name: location.name, + notes: location.notes, + })), + }); + setStatus("Warehouse loaded."); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load warehouse."; + setStatus(message); + }); + }, [mode, token, warehouseId]); + + function updateField(key: Key, value: WarehouseInput[Key]) { + setForm((current: WarehouseInput) => ({ ...current, [key]: value })); + } + + function updateLocation(index: number, nextLocation: WarehouseLocationInput) { + setForm((current: WarehouseInput) => ({ + ...current, + locations: current.locations.map((location: WarehouseLocationInput, locationIndex: number) => + locationIndex === index ? nextLocation : location + ), + })); + } + + function addLocation() { + setForm((current: WarehouseInput) => ({ + ...current, + locations: [...current.locations, emptyWarehouseLocationInput], + })); + } + + function removeLocation(index: number) { + setForm((current: WarehouseInput) => ({ + ...current, + locations: current.locations.filter((_location: WarehouseLocationInput, locationIndex: number) => locationIndex !== index), + })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token) { + return; + } + + setIsSaving(true); + setStatus("Saving warehouse..."); + + try { + const saved = + mode === "create" ? await api.createWarehouse(token, form) : await api.updateWarehouse(token, warehouseId ?? "", form); + navigate(`/inventory/warehouses/${saved.id}`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to save warehouse."; + setStatus(message); + setIsSaving(false); + } + } + + return ( +
+
+
+
+

Warehouse Editor

+

{mode === "create" ? "New Warehouse" : "Edit Warehouse"}

+
+ + Cancel + +
+
+
+
+ + +
+