diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 3d1bea7..5bc7a94 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -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" }, ]; diff --git a/client/src/components/FileAttachmentsPanel.tsx b/client/src/components/FileAttachmentsPanel.tsx new file mode 100644 index 0000000..f6c5836 --- /dev/null +++ b/client/src/components/FileAttachmentsPanel.tsx @@ -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([]); + const [status, setStatus] = useState("Loading attachments..."); + const [isUploading, setIsUploading] = useState(false); + const [deletingAttachmentId, setDeletingAttachmentId] = useState(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) { + 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 ( +
+
+
+

{eyebrow}

+

{title}

+

{description}

+
+ {canWriteFiles ? ( + + ) : null} +
+
{status}
+ {!canReadFiles ? ( +
+ You do not have permission to view file attachments. +
+ ) : attachments.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {attachments.map((attachment) => ( +
+
+

{attachment.originalName}

+

+ {attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()} +

+
+
+ + {canWriteFiles ? ( + + ) : null} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index d8b90f4..6fbeeea 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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("/api/v1/sales/customers/options", undefined, token); + }, + getQuotes(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) { + return request( + `/api/v1/sales/quotes${buildQueryString({ + q: filters?.q, + status: filters?.status, + })}`, + undefined, + token + ); + }, + getQuote(token: string, quoteId: string) { + return request(`/api/v1/sales/quotes/${quoteId}`, undefined, token); + }, + createQuote(token: string, payload: SalesDocumentInput) { + return request("/api/v1/sales/quotes", { method: "POST", body: JSON.stringify(payload) }, token); + }, + updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) { + return request(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token); + }, + getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) { + return request( + `/api/v1/sales/orders${buildQueryString({ + q: filters?.q, + status: filters?.status, + })}`, + undefined, + token + ); + }, + getSalesOrder(token: string, orderId: string) { + return request(`/api/v1/sales/orders/${orderId}`, undefined, token); + }, + createSalesOrder(token: string, payload: SalesDocumentInput) { + return request("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token); + }, + updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) { + return request(`/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: { diff --git a/client/src/main.tsx b/client/src/main.tsx index 4bd15e4..2c9de68 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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: }, ], }, + { + element: , + children: [ + { path: "/sales/quotes", element: }, + { path: "/sales/quotes/:quoteId", element: }, + { path: "/sales/orders", element: }, + { path: "/sales/orders/:orderId", element: }, + ], + }, { element: , children: [ @@ -66,6 +78,15 @@ const router = createBrowserRouter([ { path: "/crm/vendors/:vendorId/edit", element: }, ], }, + { + element: , + children: [ + { path: "/sales/quotes/new", element: }, + { path: "/sales/quotes/:quoteId/edit", element: }, + { path: "/sales/orders/new", element: }, + { path: "/sales/orders/:orderId/edit", element: }, + ], + }, { element: , children: [ diff --git a/client/src/modules/crm/CrmAttachmentsPanel.tsx b/client/src/modules/crm/CrmAttachmentsPanel.tsx index 7ad9a74..fd838b1 100644 --- a/client/src/modules/crm/CrmAttachmentsPanel.tsx +++ b/client/src/modules/crm/CrmAttachmentsPanel.tsx @@ -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([]); - const [status, setStatus] = useState("Loading attachments..."); - const [isUploading, setIsUploading] = useState(false); - const [deletingAttachmentId, setDeletingAttachmentId] = useState(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) { - 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 ( -
-
-
-

Attachments

-

Shared files

-

- Drawings, customer markups, vendor documents, and other reference files linked to this record. -

-
- {canWriteFiles ? ( - - ) : null} -
-
{status}
- {!canReadFiles ? ( -
- You do not have permission to view file attachments. -
- ) : attachments.length === 0 ? ( -
- No attachments have been added to this record yet. -
- ) : ( -
- {attachments.map((attachment) => ( -
-
-

{attachment.originalName}

-

- {attachment.mimeType} · {formatFileSize(attachment.sizeBytes)} · {new Date(attachment.createdAt).toLocaleString()} -

-
-
- - {canWriteFiles ? ( - - ) : null} -
-
- ))} -
- )} -
+ ); } diff --git a/client/src/modules/inventory/InventoryAttachmentsPanel.tsx b/client/src/modules/inventory/InventoryAttachmentsPanel.tsx new file mode 100644 index 0000000..3c29c40 --- /dev/null +++ b/client/src/modules/inventory/InventoryAttachmentsPanel.tsx @@ -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 ( + + ); +} diff --git a/client/src/modules/inventory/InventoryDetailPage.tsx b/client/src/modules/inventory/InventoryDetailPage.tsx index fcab444..ce4557e 100644 --- a/client/src/modules/inventory/InventoryDetailPage.tsx +++ b/client/src/modules/inventory/InventoryDetailPage.tsx @@ -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() { )} + ); } diff --git a/client/src/modules/inventory/config.ts b/client/src/modules/inventory/config.ts index e5652fa..a838c9b 100644 --- a/client/src/modules/inventory/config.ts +++ b/client/src/modules/inventory/config.ts @@ -92,6 +92,8 @@ export const inventoryStatusPalette: Record = { 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 = { 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", diff --git a/client/src/modules/sales/SalesDetailPage.tsx b/client/src/modules/sales/SalesDetailPage.tsx new file mode 100644 index 0000000..5cbe576 --- /dev/null +++ b/client/src/modules/sales/SalesDetailPage.tsx @@ -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(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
{status}
; + } + + return ( +
+
+
+
+

{config.detailEyebrow}

+

{document.documentNumber}

+

{document.customerName}

+
+ +
+
+
+ + Back to {config.collectionLabel.toLowerCase()} + + {canManage ? ( + + Edit {config.singularLabel.toLowerCase()} + + ) : null} +
+
+
+
+
+

Issue Date

+
{new Date(document.issueDate).toLocaleDateString()}
+
+
+

Expires

+
{document.expiresAt ? new Date(document.expiresAt).toLocaleDateString() : "N/A"}
+
+
+

Lines

+
{document.lineCount}
+
+
+

Subtotal

+
${document.subtotal.toFixed(2)}
+
+
+
+
+

Customer

+
+
+
Account
+
{document.customerName}
+
+
+
Email
+
{document.customerEmail}
+
+
+
+
+

Notes

+

{document.notes || "No notes recorded for this document."}

+
+
+
+

Line Items

+ {document.lines.length === 0 ? ( +
+ No line items have been added yet. +
+ ) : ( +
+ + + + + + + + + + + + + {document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => ( + + + + + + + + + ))} + +
ItemDescriptionQtyUOMUnit PriceTotal
+
{line.itemSku}
+
{line.itemName}
+
{line.description}{line.quantity}{line.unitOfMeasure}${line.unitPrice.toFixed(2)}${line.lineTotal.toFixed(2)}
+
+ )} +
+
+ ); +} diff --git a/client/src/modules/sales/SalesFormPage.tsx b/client/src/modules/sales/SalesFormPage.tsx new file mode 100644 index 0000000..1e4cc52 --- /dev/null +++ b/client/src/modules/sales/SalesFormPage.tsx @@ -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(emptySalesDocumentInput); + const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`); + const [customers, setCustomers] = useState([]); + const [itemOptions, setItemOptions] = useState([]); + const [lineSearchTerms, setLineSearchTerms] = useState([]); + const [activeLinePicker, setActiveLinePicker] = useState(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: 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) { + 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 ( +
+
+
+
+

{config.detailEyebrow} Editor

+

{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}

+
+ + Cancel + +
+
+
+
+ + + +
+ {entity === "quote" ? ( + + ) : null} +