diff --git a/README.md b/README.md index 03f00ae..7d85db9 100644 --- a/README.md +++ b/README.md @@ -9,6 +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 - file storage and PDF rendering ## Workspace @@ -54,7 +55,7 @@ docker build --build-arg NODE_VERSION=22 -t mrp-codex . The container startup script runs `npx prisma migrate deploy` automatically before launching the server. -This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM migrations on Windows. +This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM and inventory migrations on Windows. ## Persistence And Backup @@ -76,6 +77,18 @@ The current CRM foundation supports: Recent CRM features depend on the committed Prisma migrations being applied. If you update the code and do not run migrations, the UI may render fields that are not yet present in the database. +## Inventory + +The current inventory foundation supports: + +- protected item master list, detail, create, and edit flows +- 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 +- seeded sample inventory items and a starter assembly BOM 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. + ## Branding Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand. @@ -88,17 +101,20 @@ Logo uploads are stored through the authenticated file pipeline and are rendered - Apply committed migrations in production: `npm run prisma:deploy` - If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth. -As of March 14, 2026, the latest committed CRM migrations include: +As of March 14, 2026, the latest committed domain migrations include: - CRM status and list filters - CRM contact-history timeline - reseller hierarchy and reseller discount support - CRM commercial terms and account contacts +- CRM lifecycle stages and operational metadata +- inventory item master and BOM foundation ## UI Notes - Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. -- The shell layout is tuned for wider desktop use than the original foundation build, but the client build still emits a Vite chunk-size warning because the app has not been code-split yet. +- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes CRM, inventory, settings, and planning modules from the same app shell. +- The client build still emits a Vite chunk-size warning because the app has not been code-split yet. ## PDF Generation diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index ff9dfb4..a48fa9c 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -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" }, ]; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9a4a9aa..f887e6d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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( + `/api/v1/inventory/items${buildQueryString({ + q: filters?.q, + status: filters?.status, + type: filters?.type, + })}`, + undefined, + token + ); + }, + getInventoryItem(token: string, itemId: string) { + return request(`/api/v1/inventory/items/${itemId}`, undefined, token); + }, + getInventoryItemOptions(token: string) { + return request("/api/v1/inventory/items/options", undefined, token); + }, + createInventoryItem(token: string, payload: InventoryItemInput) { + return request( + "/api/v1/inventory/items", + { + method: "POST", + body: JSON.stringify(payload), + }, + token + ); + }, + updateInventoryItem(token: string, itemId: string, payload: InventoryItemInput) { + return request( + `/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); }, diff --git a/client/src/main.tsx b/client/src/main.tsx index c5dcad4..0ac09c5 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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: }, ], }, + { + element: , + children: [ + { path: "/inventory/items", element: }, + { path: "/inventory/items/:itemId", element: }, + ], + }, { element: , children: [ @@ -51,6 +61,13 @@ const router = createBrowserRouter([ { path: "/crm/vendors/:vendorId/edit", element: }, ], }, + { + element: , + children: [ + { path: "/inventory/items/new", element: }, + { path: "/inventory/items/:itemId/edit", element: }, + ], + }, { element: , children: [{ path: "/planning/gantt", element: }], diff --git a/client/src/modules/dashboard/DashboardPage.tsx b/client/src/modules/dashboard/DashboardPage.tsx index d0f9136..ffba20f 100644 --- a/client/src/modules/dashboard/DashboardPage.tsx +++ b/client/src/modules/dashboard/DashboardPage.tsx @@ -13,6 +13,9 @@ export function DashboardPage() { Manage company profile + + Open inventory + Open gantt preview @@ -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) => (
{item} @@ -35,4 +38,3 @@ export function DashboardPage() {
); } - diff --git a/client/src/modules/inventory/InventoryDetailPage.tsx b/client/src/modules/inventory/InventoryDetailPage.tsx new file mode 100644 index 0000000..308496a --- /dev/null +++ b/client/src/modules/inventory/InventoryDetailPage.tsx @@ -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(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
{status}
; + } + + return ( +
+
+
+
+

Inventory Detail

+

{item.sku}

+

{item.name}

+
+ + +
+

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

+
+
+ + Back to items + + {canManage ? ( + + Edit item + + ) : null} +
+
+
+
+
+

Item Definition

+
+
+
Description
+
{item.description || "No description provided."}
+
+
+
Unit of measure
+
{item.unitOfMeasure}
+
+
+
Default cost
+
{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}
+
+
+
Flags
+
+ {item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"} +
+
+
+
+
+

Internal Notes

+

{item.notes || "No internal notes recorded for this item yet."}

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

Bill Of Materials

+

Component structure

+ {item.bomLines.length === 0 ? ( +
+ No BOM lines are defined for this item yet. +
+ ) : ( +
+ + + + + + + + + + + + {item.bomLines.map((line) => ( + + + + + + + + ))} + +
PositionComponentQuantityUOMNotes
{line.position} +
{line.componentSku}
+
{line.componentName}
+
{line.quantity}{line.unitOfMeasure}{line.notes || "—"}
+
+ )} +
+
+ ); +} diff --git a/client/src/modules/inventory/InventoryFormPage.tsx b/client/src/modules/inventory/InventoryFormPage.tsx new file mode 100644 index 0000000..adf9f54 --- /dev/null +++ b/client/src/modules/inventory/InventoryFormPage.tsx @@ -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(emptyInventoryItemInput); + const [componentOptions, setComponentOptions] = useState([]); + 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: 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) { + 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 ( +
+
+
+
+

Inventory Editor

+

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

+

+ Define item master data and the first revision of the bill of materials for assemblies and manufactured items. +

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