inventory1
This commit is contained in:
22
README.md
22
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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 /> }],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
137
client/src/modules/inventory/InventoryDetailPage.tsx
Normal file
137
client/src/modules/inventory/InventoryDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
client/src/modules/inventory/InventoryFormPage.tsx
Normal file
355
client/src/modules/inventory/InventoryFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
5
client/src/modules/inventory/InventoryItemsPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InventoryListPage } from "./InventoryListPage";
|
||||
|
||||
export function InventoryItemsPage() {
|
||||
return <InventoryListPage />;
|
||||
}
|
||||
147
client/src/modules/inventory/InventoryListPage.tsx
Normal file
147
client/src/modules/inventory/InventoryListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal file
17
client/src/modules/inventory/InventoryStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal file
18
client/src/modules/inventory/InventoryTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
client/src/modules/inventory/config.ts
Normal file
75
client/src/modules/inventory/config.ts
Normal 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 };
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "InventoryItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sku" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"isSellable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"defaultCost" REAL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "InventoryBomLine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"parentItemId" TEXT NOT NULL,
|
||||
"componentItemId" TEXT NOT NULL,
|
||||
"quantity" REAL NOT NULL,
|
||||
"unitOfMeasure" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "InventoryBomLine_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "InventoryBomLine_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
|
||||
CREATE INDEX "InventoryBomLine_parentItemId_position_idx" ON "InventoryBomLine"("parentItemId", "position");
|
||||
CREATE INDEX "InventoryBomLine_componentItemId_idx" ON "InventoryBomLine"("componentItemId");
|
||||
@@ -101,6 +101,24 @@ model FileAttachment {
|
||||
companyLogoFor CompanyProfile? @relation("CompanyLogo")
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
sku String @unique
|
||||
name String
|
||||
description String
|
||||
type String
|
||||
status String
|
||||
unitOfMeasure String
|
||||
isSellable Boolean @default(true)
|
||||
isPurchasable Boolean @default(true)
|
||||
defaultCost Float?
|
||||
notes String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
bomLines InventoryBomLine[] @relation("InventoryBomParent")
|
||||
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -134,6 +152,23 @@ model Customer {
|
||||
childCustomers Customer[] @relation("CustomerHierarchy")
|
||||
}
|
||||
|
||||
model InventoryBomLine {
|
||||
id String @id @default(cuid())
|
||||
parentItemId String
|
||||
componentItemId String
|
||||
quantity Float
|
||||
unitOfMeasure String
|
||||
notes String
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parentItem InventoryItem @relation("InventoryBomParent", fields: [parentItemId], references: [id], onDelete: Cascade)
|
||||
componentItem InventoryItem @relation("InventoryBomComponent", fields: [componentItemId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([parentItemId, position])
|
||||
@@index([componentItemId])
|
||||
}
|
||||
|
||||
model Vendor {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
||||
@@ -16,6 +16,7 @@ import { crmRouter } from "./modules/crm/router.js";
|
||||
import { documentsRouter } from "./modules/documents/router.js";
|
||||
import { filesRouter } from "./modules/files/router.js";
|
||||
import { ganttRouter } from "./modules/gantt/router.js";
|
||||
import { inventoryRouter } from "./modules/inventory/router.js";
|
||||
import { settingsRouter } from "./modules/settings/router.js";
|
||||
|
||||
export function createApp() {
|
||||
@@ -50,6 +51,7 @@ export function createApp() {
|
||||
app.use("/api/v1", settingsRouter);
|
||||
app.use("/api/v1/files", filesRouter);
|
||||
app.use("/api/v1/crm", crmRouter);
|
||||
app.use("/api/v1/inventory", inventoryRouter);
|
||||
app.use("/api/v1/gantt", ganttRouter);
|
||||
app.use("/api/v1/documents", documentsRouter);
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
|
||||
[permissions.companyWrite]: "Update company settings",
|
||||
[permissions.crmRead]: "View CRM records",
|
||||
[permissions.crmWrite]: "Manage CRM records",
|
||||
[permissions.inventoryRead]: "View inventory items and BOMs",
|
||||
[permissions.inventoryWrite]: "Manage inventory items and BOMs",
|
||||
[permissions.filesRead]: "View attached files",
|
||||
[permissions.filesWrite]: "Upload and manage attached files",
|
||||
[permissions.ganttRead]: "View gantt timelines",
|
||||
@@ -160,5 +162,72 @@ export async function bootstrapAppData() {
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((await prisma.inventoryItem.count()) === 0) {
|
||||
const plate = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "RM-PLATE-AL-125",
|
||||
name: "Aluminum Plate 1/8in",
|
||||
description: "Raw aluminum plate stock for fabricated assemblies.",
|
||||
type: "PURCHASED",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: false,
|
||||
isPurchasable: true,
|
||||
defaultCost: 42.5,
|
||||
notes: "Primary sheet stock for enclosure fabrication.",
|
||||
},
|
||||
});
|
||||
|
||||
const fastener = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "HW-SCREW-832",
|
||||
name: "8-32 Socket Head Screw",
|
||||
description: "Standard socket head cap screw for enclosure assemblies.",
|
||||
type: "PURCHASED",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: false,
|
||||
isPurchasable: true,
|
||||
defaultCost: 0.18,
|
||||
notes: "Bulk hardware item.",
|
||||
},
|
||||
});
|
||||
|
||||
const assembly = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: "FG-CTRL-BASE",
|
||||
name: "Control Base Assembly",
|
||||
description: "Base enclosure assembly for standard control packages.",
|
||||
type: "ASSEMBLY",
|
||||
status: "ACTIVE",
|
||||
unitOfMeasure: "EA",
|
||||
isSellable: true,
|
||||
isPurchasable: false,
|
||||
defaultCost: 118,
|
||||
notes: "Starter BOM for the inventory foundation slice.",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.inventoryBomLine.createMany({
|
||||
data: [
|
||||
{
|
||||
parentItemId: assembly.id,
|
||||
componentItemId: plate.id,
|
||||
quantity: 2,
|
||||
unitOfMeasure: "EA",
|
||||
notes: "Side panel blanks",
|
||||
position: 10,
|
||||
},
|
||||
{
|
||||
parentItemId: assembly.id,
|
||||
componentItemId: fastener.id,
|
||||
quantity: 12,
|
||||
unitOfMeasure: "EA",
|
||||
notes: "General assembly hardware",
|
||||
position: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
115
server/src/modules/inventory/router.ts
Normal file
115
server/src/modules/inventory/router.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createInventoryItem,
|
||||
getInventoryItemById,
|
||||
listInventoryItemOptions,
|
||||
listInventoryItems,
|
||||
updateInventoryItem,
|
||||
} from "./service.js";
|
||||
|
||||
const bomLineSchema = z.object({
|
||||
componentItemId: z.string().trim().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
|
||||
notes: z.string(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const inventoryItemSchema = z.object({
|
||||
sku: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string(),
|
||||
type: z.enum(inventoryItemTypes),
|
||||
status: z.enum(inventoryItemStatuses),
|
||||
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
|
||||
isSellable: z.boolean(),
|
||||
isPurchasable: z.boolean(),
|
||||
defaultCost: z.number().nonnegative().nullable(),
|
||||
notes: z.string(),
|
||||
bomLines: z.array(bomLineSchema),
|
||||
});
|
||||
|
||||
const inventoryListQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.enum(inventoryItemStatuses).optional(),
|
||||
type: z.enum(inventoryItemTypes).optional(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export const inventoryRouter = Router();
|
||||
|
||||
inventoryRouter.get("/items", requirePermissions([permissions.inventoryRead]), async (request, response) => {
|
||||
const parsed = inventoryListQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(
|
||||
response,
|
||||
await listInventoryItems({
|
||||
query: parsed.data.q,
|
||||
status: parsed.data.status,
|
||||
type: parsed.data.type,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
|
||||
return ok(response, await listInventoryItemOptions());
|
||||
});
|
||||
|
||||
inventoryRouter.get("/items/:itemId", requirePermissions([permissions.inventoryRead]), async (request, response) => {
|
||||
const itemId = getRouteParam(request.params.itemId);
|
||||
if (!itemId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
|
||||
}
|
||||
|
||||
const item = await getInventoryItemById(itemId);
|
||||
if (!item) {
|
||||
return fail(response, 404, "INVENTORY_ITEM_NOT_FOUND", "Inventory item was not found.");
|
||||
}
|
||||
|
||||
return ok(response, item);
|
||||
});
|
||||
|
||||
inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
const parsed = inventoryItemSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await createInventoryItem(parsed.data);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, item, 201);
|
||||
});
|
||||
|
||||
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
const itemId = getRouteParam(request.params.itemId);
|
||||
if (!itemId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = inventoryItemSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await updateInventoryItem(itemId, parsed.data);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item or BOM references are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, item);
|
||||
});
|
||||
327
server/src/modules/inventory/service.ts
Normal file
327
server/src/modules/inventory/service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type {
|
||||
InventoryBomLineDto,
|
||||
InventoryBomLineInput,
|
||||
InventoryItemDetailDto,
|
||||
InventoryItemInput,
|
||||
InventoryItemStatus,
|
||||
InventoryItemSummaryDto,
|
||||
InventoryItemType,
|
||||
InventoryUnitOfMeasure,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
type BomLineRecord = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
unitOfMeasure: string;
|
||||
notes: string;
|
||||
position: number;
|
||||
componentItem: {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
type InventoryDetailRecord = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
unitOfMeasure: string;
|
||||
isSellable: boolean;
|
||||
isPurchasable: boolean;
|
||||
defaultCost: number | null;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
bomLines: BomLineRecord[];
|
||||
};
|
||||
|
||||
function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
|
||||
return {
|
||||
id: record.id,
|
||||
componentItemId: record.componentItem.id,
|
||||
componentSku: record.componentItem.sku,
|
||||
componentName: record.componentItem.name,
|
||||
quantity: record.quantity,
|
||||
unitOfMeasure: record.unitOfMeasure as InventoryUnitOfMeasure,
|
||||
notes: record.notes,
|
||||
position: record.position,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSummary(record: {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
unitOfMeasure: string;
|
||||
isSellable: boolean;
|
||||
isPurchasable: boolean;
|
||||
updatedAt: Date;
|
||||
_count: { bomLines: number };
|
||||
}): InventoryItemSummaryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
sku: record.sku,
|
||||
name: record.name,
|
||||
type: record.type as InventoryItemType,
|
||||
status: record.status as InventoryItemStatus,
|
||||
unitOfMeasure: record.unitOfMeasure as InventoryUnitOfMeasure,
|
||||
isSellable: record.isSellable,
|
||||
isPurchasable: record.isPurchasable,
|
||||
bomLineCount: record._count.bomLines,
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
|
||||
return {
|
||||
...mapSummary({
|
||||
id: record.id,
|
||||
sku: record.sku,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
status: record.status,
|
||||
unitOfMeasure: record.unitOfMeasure,
|
||||
isSellable: record.isSellable,
|
||||
isPurchasable: record.isPurchasable,
|
||||
updatedAt: record.updatedAt,
|
||||
_count: { bomLines: record.bomLines.length },
|
||||
}),
|
||||
description: record.description,
|
||||
defaultCost: record.defaultCost,
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
|
||||
};
|
||||
}
|
||||
|
||||
interface InventoryListFilters {
|
||||
query?: string;
|
||||
status?: InventoryItemStatus;
|
||||
type?: InventoryItemType;
|
||||
}
|
||||
|
||||
function buildWhereClause(filters: InventoryListFilters) {
|
||||
const trimmedQuery = filters.query?.trim();
|
||||
|
||||
return {
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.type ? { type: filters.type } : {}),
|
||||
...(trimmedQuery
|
||||
? {
|
||||
OR: [
|
||||
{ sku: { contains: trimmedQuery } },
|
||||
{ name: { contains: trimmedQuery } },
|
||||
{ description: { contains: trimmedQuery } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
|
||||
return bomLines
|
||||
.map((line, index) => ({
|
||||
componentItemId: line.componentItemId,
|
||||
quantity: line.quantity,
|
||||
unitOfMeasure: line.unitOfMeasure,
|
||||
notes: line.notes,
|
||||
position: line.position ?? (index + 1) * 10,
|
||||
}))
|
||||
.filter((line) => line.componentItemId.trim().length > 0);
|
||||
}
|
||||
|
||||
async function validateBomLines(parentItemId: string | null, bomLines: InventoryBomLineInput[]) {
|
||||
const normalized = normalizeBomLines(bomLines);
|
||||
|
||||
if (normalized.some((line) => line.quantity <= 0)) {
|
||||
return { ok: false as const, reason: "BOM line quantity must be greater than zero." };
|
||||
}
|
||||
|
||||
if (parentItemId && normalized.some((line) => line.componentItemId === parentItemId)) {
|
||||
return { ok: false as const, reason: "An item cannot reference itself in its BOM." };
|
||||
}
|
||||
|
||||
const componentIds = [...new Set(normalized.map((line) => line.componentItemId))];
|
||||
if (componentIds.length === 0) {
|
||||
return { ok: true as const, bomLines: normalized };
|
||||
}
|
||||
|
||||
const existingComponents = await prisma.inventoryItem.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: componentIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingComponents.length !== componentIds.length) {
|
||||
return { ok: false as const, reason: "One or more BOM components do not exist." };
|
||||
}
|
||||
|
||||
return { ok: true as const, bomLines: normalized };
|
||||
}
|
||||
|
||||
export async function listInventoryItems(filters: InventoryListFilters = {}) {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
where: buildWhereClause(filters),
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
bomLines: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sku: "asc" }],
|
||||
});
|
||||
|
||||
return items.map(mapSummary);
|
||||
}
|
||||
|
||||
export async function listInventoryItemOptions() {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
where: {
|
||||
status: {
|
||||
not: "OBSOLETE",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: [{ sku: "asc" }],
|
||||
});
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getInventoryItemById(itemId: string) {
|
||||
const item = await prisma.inventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
include: {
|
||||
bomLines: {
|
||||
include: {
|
||||
componentItem: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return item ? mapDetail(item) : null;
|
||||
}
|
||||
|
||||
export async function createInventoryItem(payload: InventoryItemInput) {
|
||||
const validatedBom = await validateBomLines(null, payload.bomLines);
|
||||
if (!validatedBom.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = await prisma.inventoryItem.create({
|
||||
data: {
|
||||
sku: payload.sku,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
type: payload.type,
|
||||
status: payload.status,
|
||||
unitOfMeasure: payload.unitOfMeasure,
|
||||
isSellable: payload.isSellable,
|
||||
isPurchasable: payload.isPurchasable,
|
||||
defaultCost: payload.defaultCost,
|
||||
notes: payload.notes,
|
||||
bomLines: validatedBom.bomLines.length
|
||||
? {
|
||||
create: validatedBom.bomLines,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
bomLines: {
|
||||
include: {
|
||||
componentItem: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(item);
|
||||
}
|
||||
|
||||
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) {
|
||||
const existingItem = await prisma.inventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
if (!existingItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validatedBom = await validateBomLines(itemId, payload.bomLines);
|
||||
if (!validatedBom.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = await prisma.inventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
sku: payload.sku,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
type: payload.type,
|
||||
status: payload.status,
|
||||
unitOfMeasure: payload.unitOfMeasure,
|
||||
isSellable: payload.isSellable,
|
||||
isPurchasable: payload.isPurchasable,
|
||||
defaultCost: payload.defaultCost,
|
||||
notes: payload.notes,
|
||||
bomLines: {
|
||||
deleteMany: {},
|
||||
create: validatedBom.bomLines,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
bomLines: {
|
||||
include: {
|
||||
componentItem: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(item);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export const permissions = {
|
||||
companyWrite: "company.write",
|
||||
crmRead: "crm.read",
|
||||
crmWrite: "crm.write",
|
||||
inventoryRead: "inventory.read",
|
||||
inventoryWrite: "inventory.write",
|
||||
filesRead: "files.read",
|
||||
filesWrite: "files.write",
|
||||
ganttRead: "gantt.read",
|
||||
@@ -14,4 +16,3 @@ export const permissions = {
|
||||
export type PermissionKey = (typeof permissions)[keyof typeof permissions];
|
||||
|
||||
export const defaultAdminPermissions: PermissionKey[] = Object.values(permissions);
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./company/types.js";
|
||||
export * from "./crm/types.js";
|
||||
export * from "./files/types.js";
|
||||
export * from "./gantt/types.js";
|
||||
export * from "./inventory/types.js";
|
||||
|
||||
67
shared/src/inventory/types.ts
Normal file
67
shared/src/inventory/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const inventoryItemTypes = ["PURCHASED", "MANUFACTURED", "ASSEMBLY", "SERVICE"] as const;
|
||||
export const inventoryItemStatuses = ["DRAFT", "ACTIVE", "OBSOLETE"] as const;
|
||||
export const inventoryUnitsOfMeasure = ["EA", "FT", "IN", "LB", "KG", "SET"] as const;
|
||||
|
||||
export type InventoryItemType = (typeof inventoryItemTypes)[number];
|
||||
export type InventoryItemStatus = (typeof inventoryItemStatuses)[number];
|
||||
export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number];
|
||||
|
||||
export interface InventoryBomLineDto {
|
||||
id: string;
|
||||
componentItemId: string;
|
||||
componentSku: string;
|
||||
componentName: string;
|
||||
quantity: number;
|
||||
unitOfMeasure: InventoryUnitOfMeasure;
|
||||
notes: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface InventoryBomLineInput {
|
||||
componentItemId: string;
|
||||
quantity: number;
|
||||
unitOfMeasure: InventoryUnitOfMeasure;
|
||||
notes: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface InventoryItemOptionDto {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InventoryItemSummaryDto {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
type: InventoryItemType;
|
||||
status: InventoryItemStatus;
|
||||
unitOfMeasure: InventoryUnitOfMeasure;
|
||||
isSellable: boolean;
|
||||
isPurchasable: boolean;
|
||||
bomLineCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
|
||||
description: string;
|
||||
defaultCost: number | null;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
bomLines: InventoryBomLineDto[];
|
||||
}
|
||||
|
||||
export interface InventoryItemInput {
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: InventoryItemType;
|
||||
status: InventoryItemStatus;
|
||||
unitOfMeasure: InventoryUnitOfMeasure;
|
||||
isSellable: boolean;
|
||||
isPurchasable: boolean;
|
||||
defaultCost: number | null;
|
||||
notes: string;
|
||||
bomLines: InventoryBomLineInput[];
|
||||
}
|
||||
Reference in New Issue
Block a user