This commit is contained in:
2026-03-14 23:03:17 -05:00
parent ce6dec316e
commit 8bf69c67e0
22 changed files with 1660 additions and 175 deletions

View File

@@ -10,6 +10,8 @@ const links = [
{ to: "/crm/vendors", label: "Vendors" },
{ to: "/inventory/items", label: "Inventory" },
{ to: "/inventory/warehouses", label: "Warehouses" },
{ to: "/sales/quotes", label: "Quotes" },
{ to: "/sales/orders", label: "Sales Orders" },
{ to: "/planning/gantt", label: "Gantt" },
];

View File

@@ -0,0 +1,194 @@
import type { FileAttachmentDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../auth/AuthProvider";
import { api, ApiError } from "../lib/api";
interface FileAttachmentsPanelProps {
ownerType: string;
ownerId: string;
eyebrow: string;
title: string;
description: string;
emptyMessage: string;
onAttachmentCountChange?: (count: number) => void;
}
function formatFileSize(sizeBytes: number) {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`;
}
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileAttachmentsPanel({
ownerType,
ownerId,
eyebrow,
title,
description,
emptyMessage,
onAttachmentCountChange,
}: FileAttachmentsPanelProps) {
const { token, user } = useAuth();
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
useEffect(() => {
if (!token || !canReadFiles) {
return;
}
api
.getAttachments(token, ownerType, ownerId)
.then((nextAttachments) => {
setAttachments(nextAttachments);
onAttachmentCountChange?.(nextAttachments.length);
setStatus(nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
setStatus(message);
});
}, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !token || !canWriteFiles) {
return;
}
setIsUploading(true);
setStatus("Uploading attachment...");
try {
const attachment = await api.uploadFile(token, file, ownerType, ownerId);
setAttachments((current) => {
const nextAttachments = [attachment, ...current];
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment uploaded.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
setStatus(message);
} finally {
setIsUploading(false);
event.target.value = "";
}
}
async function handleOpen(attachment: FileAttachmentDto) {
if (!token) {
return;
}
try {
const blob = await api.getFileContentBlob(token, attachment.id);
const objectUrl = window.URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to open attachment.";
setStatus(message);
}
}
async function handleDelete(attachment: FileAttachmentDto) {
if (!token || !canWriteFiles) {
return;
}
setDeletingAttachmentId(attachment.id);
setStatus(`Deleting ${attachment.originalName}...`);
try {
await api.deleteAttachment(token, attachment.id);
setAttachments((current) => {
const nextAttachments = current.filter((item) => item.id !== attachment.id);
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment deleted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message);
} finally {
setDeletingAttachmentId(null);
}
}
return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{eyebrow}</p>
<h4 className="mt-2 text-lg font-bold text-text">{title}</h4>
<p className="mt-2 text-sm text-muted">{description}</p>
</div>
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
{isUploading ? "Uploading..." : "Upload file"}
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
</label>
) : null}
</div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
{!canReadFiles ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
You do not have permission to view file attachments.
</div>
) : attachments.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
{emptyMessage}
</div>
) : (
<div className="mt-5 space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex flex-col gap-2 rounded-3xl border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
<p className="mt-1 text-xs text-muted">
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
</p>
</div>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => handleOpen(attachment)}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
>
Open
</button>
{canWriteFiles ? (
<button
type="button"
onClick={() => handleDelete(attachment)}
disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
</button>
) : null}
</div>
</div>
))}
</div>
)}
</article>
);
}

View File

@@ -33,6 +33,13 @@ import type {
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesDocumentStatus,
SalesDocumentSummaryDto,
} from "@mrp/shared/dist/sales/types.js";
export class ApiError extends Error {
constructor(message: string, public readonly code: string) {
@@ -362,6 +369,47 @@ export const api = {
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},
getSalesCustomers(token: string) {
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
},
getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
return request<SalesDocumentSummaryDto[]>(
`/api/v1/sales/quotes${buildQueryString({
q: filters?.q,
status: filters?.status,
})}`,
undefined,
token
);
},
getQuote(token: string, quoteId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, undefined, token);
},
createQuote(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/quotes", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) {
return request<SalesDocumentSummaryDto[]>(
`/api/v1/sales/orders${buildQueryString({
q: filters?.q,
status: filters?.status,
})}`,
undefined,
token
);
},
getSalesOrder(token: string, orderId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
},
createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
async getCompanyProfilePreviewPdf(token: string) {
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
headers: {

View File

@@ -21,6 +21,9 @@ 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 { SalesDetailPage } from "./modules/sales/SalesDetailPage";
import { SalesFormPage } from "./modules/sales/SalesFormPage";
import { SalesListPage } from "./modules/sales/SalesListPage";
import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css";
@@ -57,6 +60,15 @@ const router = createBrowserRouter([
{ path: "/inventory/warehouses/:warehouseId", element: <WarehouseDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesRead]} />,
children: [
{ path: "/sales/quotes", element: <SalesListPage entity="quote" /> },
{ path: "/sales/quotes/:quoteId", element: <SalesDetailPage entity="quote" /> },
{ path: "/sales/orders", element: <SalesListPage entity="order" /> },
{ path: "/sales/orders/:orderId", element: <SalesDetailPage entity="order" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
@@ -66,6 +78,15 @@ const router = createBrowserRouter([
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.salesWrite]} />,
children: [
{ path: "/sales/quotes/new", element: <SalesFormPage entity="quote" mode="create" /> },
{ path: "/sales/quotes/:quoteId/edit", element: <SalesFormPage entity="quote" mode="edit" /> },
{ path: "/sales/orders/new", element: <SalesFormPage entity="order" mode="create" /> },
{ path: "/sales/orders/:orderId/edit", element: <SalesFormPage entity="order" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [

View File

@@ -1,9 +1,4 @@
import type { FileAttachmentDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
interface CrmAttachmentsPanelProps {
ownerType: string;
@@ -11,176 +6,16 @@ interface CrmAttachmentsPanelProps {
onAttachmentCountChange?: (count: number) => void;
}
function formatFileSize(sizeBytes: number) {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`;
}
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function CrmAttachmentsPanel({ ownerType, ownerId, onAttachmentCountChange }: CrmAttachmentsPanelProps) {
const { token, user } = useAuth();
const [attachments, setAttachments] = useState<FileAttachmentDto[]>([]);
const [status, setStatus] = useState("Loading attachments...");
const [isUploading, setIsUploading] = useState(false);
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
useEffect(() => {
if (!token || !canReadFiles) {
return;
}
api
.getAttachments(token, ownerType, ownerId)
.then((nextAttachments) => {
setAttachments(nextAttachments);
onAttachmentCountChange?.(nextAttachments.length);
setStatus(
nextAttachments.length === 0 ? "No attachments uploaded yet." : `${nextAttachments.length} attachment(s) available.`
);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load attachments.";
setStatus(message);
});
}, [canReadFiles, onAttachmentCountChange, ownerId, ownerType, token]);
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !token || !canWriteFiles) {
return;
}
setIsUploading(true);
setStatus("Uploading attachment...");
try {
const attachment = await api.uploadFile(token, file, ownerType, ownerId);
setAttachments((current) => {
const nextAttachments = [attachment, ...current];
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment uploaded.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to upload attachment.";
setStatus(message);
} finally {
setIsUploading(false);
event.target.value = "";
}
}
async function handleOpen(attachment: FileAttachmentDto) {
if (!token) {
return;
}
try {
const blob = await api.getFileContentBlob(token, attachment.id);
const objectUrl = window.URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to open attachment.";
setStatus(message);
}
}
async function handleDelete(attachment: FileAttachmentDto) {
if (!token || !canWriteFiles) {
return;
}
setDeletingAttachmentId(attachment.id);
setStatus(`Deleting ${attachment.originalName}...`);
try {
await api.deleteAttachment(token, attachment.id);
setAttachments((current) => {
const nextAttachments = current.filter((item) => item.id !== attachment.id);
onAttachmentCountChange?.(nextAttachments.length);
return nextAttachments;
});
setStatus("Attachment deleted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
setStatus(message);
} finally {
setDeletingAttachmentId(null);
}
}
return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Attachments</p>
<h4 className="mt-2 text-lg font-bold text-text">Shared files</h4>
<p className="mt-2 text-sm text-muted">
Drawings, customer markups, vendor documents, and other reference files linked to this record.
</p>
</div>
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
{isUploading ? "Uploading..." : "Upload file"}
<input className="hidden" type="file" onChange={handleUpload} disabled={isUploading} />
</label>
) : null}
</div>
<div className="mt-5 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
{!canReadFiles ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
You do not have permission to view file attachments.
</div>
) : attachments.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No attachments have been added to this record yet.
</div>
) : (
<div className="mt-5 space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex flex-col gap-2 rounded-3xl border border-line/70 bg-page/60 px-2 py-2 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text">{attachment.originalName}</p>
<p className="mt-1 text-xs text-muted">
{attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()}
</p>
</div>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => handleOpen(attachment)}
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text"
>
Open
</button>
{canWriteFiles ? (
<button
type="button"
onClick={() => handleDelete(attachment)}
disabled={deletingAttachmentId === attachment.id}
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
{deletingAttachmentId === attachment.id ? "Deleting..." : "Delete"}
</button>
) : null}
</div>
</div>
))}
</div>
)}
</article>
<FileAttachmentsPanel
ownerType={ownerType}
ownerId={ownerId}
eyebrow="Attachments"
title="Shared files"
description="Drawings, customer markups, vendor documents, and other reference files linked to this record."
emptyMessage="No attachments have been added to this record yet."
onAttachmentCountChange={onAttachmentCountChange}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { inventoryFileOwnerType } from "./config";
export function InventoryAttachmentsPanel({
itemId,
onAttachmentCountChange,
}: {
itemId: string;
onAttachmentCountChange?: (count: number) => void;
}) {
return (
<FileAttachmentsPanel
ownerType={inventoryFileOwnerType}
ownerId={itemId}
eyebrow="Attachments"
title="Drawings and support docs"
description="Store drawings, cut sheets, work instructions, and other manufacturing support files on the item record."
emptyMessage="No drawings or support documents have been added to this item yet."
onAttachmentCountChange={onAttachmentCountChange}
/>
);
}

View File

@@ -6,6 +6,7 @@ import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
@@ -355,6 +356,7 @@ export function InventoryDetailPage() {
)}
</article>
</section>
<InventoryAttachmentsPanel itemId={item.id} />
</section>
);
}

View File

@@ -92,6 +92,8 @@ export const inventoryStatusPalette: Record<InventoryItemStatus, string> = {
OBSOLETE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const inventoryFileOwnerType = "inventory-item";
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",

View File

@@ -0,0 +1,143 @@
import { permissions } from "@mrp/shared";
import type { SalesDocumentDetailDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { salesConfigs, type SalesDocumentEntity } from "./config";
import { SalesStatusBadge } from "./SalesStatusBadge";
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const { token, user } = useAuth();
const { quoteId, orderId } = useParams();
const config = salesConfigs[entity];
const documentId = entity === "quote" ? quoteId : orderId;
const [document, setDocument] = useState<SalesDocumentDetailDto | null>(null);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
useEffect(() => {
if (!token || !documentId) {
return;
}
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
loader
.then((nextDocument) => {
setDocument(nextDocument);
setStatus(`${config.singularLabel} loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, documentId, entity, token]);
if (!document) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 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-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow}</p>
<h3 className="mt-2 text-xl font-bold text-text">{document.documentNumber}</h3>
<p className="mt-1 text-sm text-text">{document.customerName}</p>
<div className="mt-3 flex flex-wrap gap-2">
<SalesStatusBadge status={document.status} />
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link to={config.routeBase} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to {config.collectionLabel.toLowerCase()}
</Link>
{canManage ? (
<Link to={`${config.routeBase}/${document.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
Edit {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p>
<div className="mt-2 text-base font-bold text-text">{new Date(document.issueDate).toLocaleDateString()}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Expires</p>
<div className="mt-2 text-base font-bold text-text">{document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A"}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p>
<div className="mt-2 text-base font-bold text-text">{document.lineCount}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p>
<div className="mt-2 text-base font-bold text-text">${document.subtotal.toFixed(2)}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer</p>
<dl className="mt-5 grid gap-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt>
<dd className="mt-1 text-sm text-text">{document.customerName}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
<dd className="mt-1 text-sm text-text">{document.customerEmail}</dd>
</div>
</dl>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{document.notes || "No notes recorded for this document."}</p>
</article>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
{document.lines.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No line 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-2 py-2">Item</th>
<th className="px-2 py-2">Description</th>
<th className="px-2 py-2">Qty</th>
<th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Unit Price</th>
<th className="px-2 py-2">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => (
<tr key={line.id}>
<td className="px-2 py-2">
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
</td>
<td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">${line.unitPrice.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
);
}

View File

@@ -0,0 +1,316 @@
import type { InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { SalesCustomerOptionDto, SalesDocumentDetailDto, SalesDocumentInput, SalesLineInput } from "@mrp/shared/dist/sales/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 { inventoryUnitOptions } from "../inventory/config";
import { emptySalesDocumentInput, salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config";
export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { quoteId, orderId } = useParams();
const documentId = entity === "quote" ? quoteId : orderId;
const config = salesConfigs[entity];
const [form, setForm] = useState<SalesDocumentInput>(emptySalesDocumentInput);
const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`);
const [customers, setCustomers] = useState<SalesCustomerOptionDto[]>([]);
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!token) {
return;
}
api.getSalesCustomers(token).then(setCustomers).catch(() => setCustomers([]));
api.getInventoryItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
}, [token]);
useEffect(() => {
if (!token || mode !== "edit" || !documentId) {
return;
}
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
loader
.then((document) => {
setForm({
customerId: document.customerId,
status: document.status,
issueDate: document.issueDate,
expiresAt: entity === "quote" ? document.expiresAt : null,
notes: document.notes,
lines: document.lines.map((line) => ({
itemId: line.itemId,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
position: line.position,
})),
});
setLineSearchTerms(document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => line.itemSku));
setStatus(`${config.singularLabel} loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, documentId, entity, mode, token]);
function updateField<Key extends keyof SalesDocumentInput>(key: Key, value: SalesDocumentInput[Key]) {
setForm((current: SalesDocumentInput) => ({ ...current, [key]: value }));
}
function updateLine(index: number, nextLine: SalesLineInput) {
setForm((current: SalesDocumentInput) => ({
...current,
lines: current.lines.map((line: SalesLineInput, lineIndex: number) => (lineIndex === index ? nextLine : line)),
}));
}
function updateLineSearchTerm(index: number, value: string) {
setLineSearchTerms((current: string[]) => {
const next = [...current];
next[index] = value;
return next;
});
}
function addLine() {
setForm((current: SalesDocumentInput) => ({
...current,
lines: [
...current.lines,
{
itemId: "",
description: "",
quantity: 1,
unitOfMeasure: "EA",
unitPrice: 0,
position: current.lines.length === 0 ? 10 : Math.max(...current.lines.map((line: SalesLineInput) => line.position)) + 10,
},
],
}));
setLineSearchTerms((current: string[]) => [...current, ""]);
}
function removeLine(index: number) {
setForm((current: SalesDocumentInput) => ({
...current,
lines: current.lines.filter((_line: SalesLineInput, lineIndex: number) => lineIndex !== index),
}));
setLineSearchTerms((current: string[]) => current.filter((_term: string, termIndex: number) => termIndex !== index));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
try {
const saved =
entity === "quote"
? mode === "create"
? await api.createQuote(token, form)
: await api.updateQuote(token, documentId ?? "", form)
: mode === "create"
? await api.createSalesOrder(token, { ...form, expiresAt: null })
: await api.updateSalesOrder(token, documentId ?? "", { ...form, expiresAt: null });
navigate(`${config.routeBase}/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.detailEyebrow} Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}</h3>
</div>
<Link to={mode === "create" ? config.routeBase : `${config.routeBase}/${documentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<select
value={form.customerId}
onChange={(event) => updateField("customerId", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">Select customer</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</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 SalesDocumentInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{salesStatusOptions.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">Issue date</span>
<input
type="date"
value={form.issueDate.slice(0, 10)}
onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
{entity === "quote" ? (
<label className="block xl:max-w-sm">
<span className="mb-2 block text-sm font-semibold text-text">Expiration date</span>
<input
type="date"
value={form.expiresAt ? form.expiresAt.slice(0, 10) : ""}
onChange={(event) => updateField("expiresAt", event.target.value ? new Date(event.target.value).toISOString() : null)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
) : null}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea
value={form.notes}
onChange={(event) => updateField("notes", event.target.value)}
rows={3}
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
<h4 className="mt-2 text-lg font-bold text-text">Commercial lines</h4>
</div>
<button type="button" onClick={addLine} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add line
</button>
</div>
{form.lines.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No line items added yet.
</div>
) : (
<div className="mt-5 space-y-4">
{form.lines.map((line: SalesLineInput, index: number) => (
<div key={index} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.25fr_1.4fr_0.55fr_0.55fr_0.75fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">SKU</span>
<div className="relative">
<input
value={lineSearchTerms[index] ?? ""}
onChange={(event) => {
updateLineSearchTerm(index, event.target.value);
updateLine(index, { ...line, itemId: "" });
setActiveLinePicker(index);
}}
onFocus={() => setActiveLinePicker(index)}
onBlur={() => window.setTimeout(() => setActiveLinePicker((current) => (current === index ? null : current)), 120)}
placeholder="Search by SKU"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{activeLinePicker === index ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => option.sku.toLowerCase().includes((lineSearchTerms[index] ?? "").trim().toLowerCase()))
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateLine(index, {
...line,
itemId: option.id,
description: line.description || option.name,
});
updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null);
}}
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
>
{option.sku}
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<input value={line.description} onChange={(event) => updateLine(index, { ...line, description: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 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">Qty</span>
<input type="number" min={1} step={1} value={line.quantity} onChange={(event) => updateLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<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) => updateLine(index, { ...line, unitOfMeasure: event.target.value as SalesLineInput["unitOfMeasure"] })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 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">Unit Price</span>
<input type="number" min={0} step={0.01} value={line.unitPrice} onChange={(event) => updateLine(index, { ...line, unitPrice: Number(event.target.value) })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeLine(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
</div>
))}
</div>
)}
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? `Create ${config.singularLabel.toLowerCase()}` : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,124 @@
import { permissions } from "@mrp/shared";
import type { SalesDocumentStatus, SalesDocumentSummaryDto } from "@mrp/shared/dist/sales/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 { salesConfigs, salesStatusFilters, type SalesDocumentEntity } from "./config";
import { SalesStatusBadge } from "./SalesStatusBadge";
export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
const { token, user } = useAuth();
const config = salesConfigs[entity];
const [documents, setDocuments] = useState<SalesDocumentSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | SalesDocumentStatus>("ALL");
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
const loader =
entity === "quote"
? api.getQuotes(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
: api.getSalesOrders(token, { q: searchTerm.trim() || undefined, status: statusFilter === "ALL" ? undefined : statusFilter });
loader
.then((nextDocuments) => {
setDocuments(nextDocuments);
setStatus(`${nextDocuments.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.collectionLabel, entity, searchTerm, statusFilter, token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{config.listEyebrow}</p>
<h3 className="mt-2 text-lg font-bold text-text">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Customer-facing commercial documents for pricing, commitment, and downstream fulfillment planning.
</p>
</div>
{canManage ? (
<Link to={`${config.routeBase}/new`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_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 ${config.collectionLabel.toLowerCase()} by document number or customer`}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 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" | SalesDocumentStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{salesStatusFilters.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-2 py-2 text-sm text-muted">{status}</div>
{documents.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No {config.collectionLabel.toLowerCase()} 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-2 py-2">Document</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Issue Date</th>
<th className="px-2 py-2">Value</th>
<th className="px-2 py-2">Lines</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{documents.map((document) => (
<tr key={document.id} className="transition hover:bg-page/70">
<td className="px-2 py-2">
<Link to={`${config.routeBase}/${document.id}`} className="font-semibold text-text hover:text-brand">
{document.documentNumber}
</Link>
</td>
<td className="px-2 py-2 text-muted">{document.customerName}</td>
<td className="px-2 py-2">
<SalesStatusBadge status={document.status} />
</td>
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
<td className="px-2 py-2 text-muted">${document.subtotal.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

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

View File

@@ -0,0 +1,59 @@
import type { SalesDocumentInput, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js";
export type SalesDocumentEntity = "quote" | "order";
interface SalesModuleConfig {
entity: SalesDocumentEntity;
singularLabel: string;
collectionLabel: string;
routeBase: string;
detailEyebrow: string;
listEyebrow: string;
}
export const salesConfigs: Record<SalesDocumentEntity, SalesModuleConfig> = {
quote: {
entity: "quote",
singularLabel: "Quote",
collectionLabel: "Quotes",
routeBase: "/sales/quotes",
detailEyebrow: "Sales Quote",
listEyebrow: "Sales",
},
order: {
entity: "order",
singularLabel: "Sales Order",
collectionLabel: "Sales Orders",
routeBase: "/sales/orders",
detailEyebrow: "Sales Order",
listEyebrow: "Sales",
},
};
export const salesStatusOptions: Array<{ value: SalesDocumentStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "ISSUED", label: "Issued" },
{ value: "APPROVED", label: "Approved" },
{ value: "CLOSED", label: "Closed" },
];
export const salesStatusFilters: Array<{ value: "ALL" | SalesDocumentStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...salesStatusOptions,
];
export const salesStatusPalette: Record<SalesDocumentStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ISSUED: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
APPROVED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
CLOSED: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export const emptySalesDocumentInput: SalesDocumentInput = {
customerId: "",
status: "DRAFT",
issueDate: new Date().toISOString(),
expiresAt: null,
notes: "",
lines: [],
};

View File

@@ -0,0 +1,70 @@
-- CreateTable
CREATE TABLE "SalesQuote" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"expiresAt" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesQuoteLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"quoteId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuoteLine_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesQuoteLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrderLine_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "SalesQuote_documentNumber_key" ON "SalesQuote"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesQuoteLine_quoteId_position_idx" ON "SalesQuoteLine"("quoteId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "SalesOrder_documentNumber_key" ON "SalesOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesOrderLine_orderId_position_idx" ON "SalesOrderLine"("orderId", "position");

View File

@@ -119,6 +119,8 @@ model InventoryItem {
bomLines InventoryBomLine[] @relation("InventoryBomParent")
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
inventoryTransactions InventoryTransaction[]
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
}
model Warehouse {
@@ -163,6 +165,8 @@ model Customer {
contacts CrmContact[]
parentCustomer Customer? @relation("CustomerHierarchy", fields: [parentCustomerId], references: [id], onDelete: SetNull)
childCustomers Customer[] @relation("CustomerHierarchy")
salesQuotes SalesQuote[]
salesOrders SalesOrder[]
}
model InventoryBomLine {
@@ -277,3 +281,64 @@ model CrmContact {
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}
model SalesQuote {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
expiresAt DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesQuoteLine[]
}
model SalesQuoteLine {
id String @id @default(cuid())
quoteId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote SalesQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([quoteId, position])
}
model SalesOrder {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesOrderLine[]
}
model SalesOrderLine {
id String @id @default(cuid())
orderId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([orderId, position])
}

View File

@@ -17,6 +17,7 @@ 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 { salesRouter } from "./modules/sales/router.js";
import { settingsRouter } from "./modules/settings/router.js";
export function createApp() {
@@ -52,6 +53,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/gantt", ganttRouter);
app.use("/api/v1/documents", documentsRouter);

View File

@@ -17,6 +17,7 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.shippingRead]: "View shipping data",
};

View File

@@ -0,0 +1,174 @@
import { permissions } from "@mrp/shared";
import { salesDocumentStatuses, type SalesDocumentType } from "@mrp/shared/dist/sales/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import {
createSalesDocument,
getSalesDocumentById,
listSalesCustomerOptions,
listSalesDocuments,
updateSalesDocument,
} from "./service.js";
const salesLineSchema = z.object({
itemId: z.string().trim().min(1),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
unitPrice: z.number().nonnegative(),
position: z.number().int().nonnegative(),
});
const quoteSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
expiresAt: z.string().datetime().nullable(),
notes: z.string(),
lines: z.array(salesLineSchema),
});
const orderSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
notes: z.string(),
lines: z.array(salesLineSchema),
});
const salesListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(salesDocumentStatuses).optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const salesRouter = Router();
salesRouter.get("/customers/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesCustomerOptions());
});
salesRouter.get("/quotes", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote filters are invalid.");
}
return ok(response, await listSalesDocuments("QUOTE", parsed.data));
});
salesRouter.get("/quotes/:quoteId", requirePermissions([permissions.salesRead]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const quote = await getSalesDocumentById("QUOTE", quoteId);
if (!quote) {
return fail(response, 404, "QUOTE_NOT_FOUND", "Quote was not found.");
}
return ok(response, quote);
});
salesRouter.post("/quotes", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await createSalesDocument("QUOTE", parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/quotes/:quoteId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await updateSalesDocument("QUOTE", quoteId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/orders", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order filters are invalid.");
}
return ok(response, await listSalesDocuments("ORDER", parsed.data));
});
salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const order = await getSalesDocumentById("ORDER", orderId);
if (!order) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, order);
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await createSalesDocument("ORDER", {
...parsed.data,
expiresAt: null,
});
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/orders/:orderId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await updateSalesDocument("ORDER", orderId, {
...parsed.data,
expiresAt: null,
});
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});

View File

@@ -0,0 +1,331 @@
import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesDocumentStatus,
SalesDocumentSummaryDto,
SalesDocumentType,
SalesLineInput,
} from "@mrp/shared/dist/sales/types.js";
import { prisma } from "../../lib/prisma.js";
type SalesLineRecord = {
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitPrice: number;
position: number;
item: {
id: string;
sku: string;
name: string;
};
};
type SalesDocumentRecord = {
id: string;
documentNumber: string;
status: string;
issueDate: Date;
expiresAt?: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
customer: {
id: string;
name: string;
email: string;
};
lines: SalesLineRecord[];
};
const documentConfig = {
QUOTE: {
prefix: "Q",
findMany: (prisma as any).salesQuote.findMany.bind((prisma as any).salesQuote),
findUnique: (prisma as any).salesQuote.findUnique.bind((prisma as any).salesQuote),
create: (prisma as any).salesQuote.create.bind((prisma as any).salesQuote),
update: (prisma as any).salesQuote.update.bind((prisma as any).salesQuote),
count: (prisma as any).salesQuote.count.bind((prisma as any).salesQuote),
},
ORDER: {
prefix: "SO",
findMany: (prisma as any).salesOrder.findMany.bind((prisma as any).salesOrder),
findUnique: (prisma as any).salesOrder.findUnique.bind((prisma as any).salesOrder),
create: (prisma as any).salesOrder.create.bind((prisma as any).salesOrder),
update: (prisma as any).salesOrder.update.bind((prisma as any).salesOrder),
count: (prisma as any).salesOrder.count.bind((prisma as any).salesOrder),
},
} as const;
function normalizeLines(lines: SalesLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
unitPrice: Number(line.unitPrice),
position: line.position ?? (index + 1) * 10,
}))
.filter((line) => line.itemId.trim().length > 0);
}
async function validateLines(lines: SalesLineInput[]) {
const normalized = normalizeLines(lines);
if (normalized.length === 0) {
return { ok: false as const, reason: "At least one line item is required." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Line quantity must be a whole number greater than zero." };
}
if (normalized.some((line) => Number.isNaN(line.unitPrice) || line.unitPrice < 0)) {
return { ok: false as const, reason: "Unit price must be zero or greater." };
}
const itemIds = [...new Set(normalized.map((line) => line.itemId))];
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
select: { id: true },
});
if (items.length !== itemIds.length) {
return { ok: false as const, reason: "One or more sales lines reference an invalid inventory item." };
}
return { ok: true as const, lines: normalized };
}
function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as SalesDocumentDetailDto["lines"][number]["unitOfMeasure"],
unitPrice: line.unitPrice,
lineTotal: line.quantity * line.unitPrice,
position: line.position,
}));
return {
id: record.id,
documentNumber: record.documentNumber,
customerId: record.customer.id,
customerName: record.customer.name,
customerEmail: record.customer.email,
status: record.status as SalesDocumentStatus,
subtotal: lines.reduce((sum, line) => sum + line.lineTotal, 0),
issueDate: record.issueDate.toISOString(),
expiresAt: "expiresAt" in record && record.expiresAt ? record.expiresAt.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
};
}
async function nextDocumentNumber(type: SalesDocumentType) {
const next = (await documentConfig[type].count()) + 1;
return `${documentConfig[type].prefix}-${String(next).padStart(5, "0")}`;
}
function buildInclude(type: SalesDocumentType) {
const base = {
customer: {
select: {
id: true,
name: true,
email: true,
},
},
};
if (type === "QUOTE") {
return {
...base,
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
return {
...base,
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
export async function listSalesCustomerOptions(): Promise<SalesCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }],
});
return customers;
}
export async function listSalesDocuments(type: SalesDocumentType, filters: { q?: string; status?: SalesDocumentStatus } = {}) {
const query = filters.q?.trim();
const records = await documentConfig[type].findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(query
? {
OR: [
{ documentNumber: { contains: query } },
{ customer: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(type),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return records.map((record: unknown) => {
const detail = mapDocument(record as SalesDocumentRecord);
const summary: SalesDocumentSummaryDto = {
id: detail.id,
documentNumber: detail.documentNumber,
customerId: detail.customerId,
customerName: detail.customerName,
status: detail.status,
subtotal: detail.subtotal,
issueDate: detail.issueDate,
updatedAt: detail.updatedAt,
lineCount: detail.lineCount,
};
return summary;
});
}
export async function getSalesDocumentById(type: SalesDocumentType, documentId: string) {
const record = await documentConfig[type].findUnique({
where: { id: documentId },
include: buildInclude(type),
});
return record ? mapDocument(record as SalesDocumentRecord) : null;
}
export async function createSalesDocument(type: SalesDocumentType, payload: SalesDocumentInput) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
const documentNumber = await nextDocumentNumber(type);
const created = await documentConfig[type].create({
data: {
documentNumber,
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getSalesDocumentById(type, created.id);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." };
}
export async function updateSalesDocument(type: SalesDocumentType, documentId: string, payload: SalesDocumentInput) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
notes: payload.notes,
lines: {
deleteMany: {},
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getSalesDocumentById(type, documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." };
}

View File

@@ -10,6 +10,7 @@ export const permissions = {
filesWrite: "files.write",
ganttRead: "gantt.read",
salesRead: "sales.read",
salesWrite: "sales.write",
shippingRead: "shipping.read",
} as const;

View File

@@ -6,3 +6,4 @@ export * from "./crm/types.js";
export * from "./files/types.js";
export * from "./gantt/types.js";
export * from "./inventory/types.js";
export * from "./sales/types.js";

63
shared/src/sales/types.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { InventoryUnitOfMeasure } from "../inventory/types.js";
export const salesDocumentStatuses = ["DRAFT", "ISSUED", "APPROVED", "CLOSED"] as const;
export type SalesDocumentStatus = (typeof salesDocumentStatuses)[number];
export type SalesDocumentType = "QUOTE" | "ORDER";
export interface SalesCustomerOptionDto {
id: string;
name: string;
email: string;
}
export interface SalesLineDto {
id: string;
itemId: string;
itemSku: string;
itemName: string;
description: string;
quantity: number;
unitOfMeasure: InventoryUnitOfMeasure;
unitPrice: number;
lineTotal: number;
position: number;
}
export interface SalesLineInput {
itemId: string;
description: string;
quantity: number;
unitOfMeasure: InventoryUnitOfMeasure;
unitPrice: number;
position: number;
}
export interface SalesDocumentSummaryDto {
id: string;
documentNumber: string;
customerId: string;
customerName: string;
status: SalesDocumentStatus;
subtotal: number;
issueDate: string;
updatedAt: string;
lineCount: number;
}
export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
customerEmail: string;
notes: string;
expiresAt: string | null;
createdAt: string;
lines: SalesLineDto[];
}
export interface SalesDocumentInput {
customerId: string;
status: SalesDocumentStatus;
issueDate: string;
expiresAt: string | null;
notes: string;
lines: SalesLineInput[];
}