diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6fbeeea..f38db62 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -391,6 +391,16 @@ export const api = { updateQuote(token: string, quoteId: string, payload: SalesDocumentInput) { return request(`/api/v1/sales/quotes/${quoteId}`, { method: "PUT", body: JSON.stringify(payload) }, token); }, + updateQuoteStatus(token: string, quoteId: string, status: SalesDocumentStatus) { + return request( + `/api/v1/sales/quotes/${quoteId}/status`, + { method: "PATCH", body: JSON.stringify({ status }) }, + token + ); + }, + convertQuoteToSalesOrder(token: string, quoteId: string) { + return request(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token); + }, getSalesOrders(token: string, filters?: { q?: string; status?: SalesDocumentStatus }) { return request( `/api/v1/sales/orders${buildQueryString({ @@ -410,6 +420,13 @@ export const api = { updateSalesOrder(token: string, orderId: string, payload: SalesDocumentInput) { return request(`/api/v1/sales/orders/${orderId}`, { method: "PUT", body: JSON.stringify(payload) }, token); }, + updateSalesOrderStatus(token: string, orderId: string, status: SalesDocumentStatus) { + return request( + `/api/v1/sales/orders/${orderId}/status`, + { method: "PATCH", body: JSON.stringify({ status }) }, + token + ); + }, async getCompanyProfilePreviewPdf(token: string) { const response = await fetch("/api/v1/documents/company-profile-preview.pdf", { headers: { diff --git a/client/src/modules/sales/SalesDetailPage.tsx b/client/src/modules/sales/SalesDetailPage.tsx index 5cbe576..ccf0060 100644 --- a/client/src/modules/sales/SalesDetailPage.tsx +++ b/client/src/modules/sales/SalesDetailPage.tsx @@ -1,20 +1,23 @@ import { permissions } from "@mrp/shared"; -import type { SalesDocumentDetailDto } from "@mrp/shared/dist/sales/types.js"; +import type { SalesDocumentDetailDto, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js"; import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; -import { salesConfigs, type SalesDocumentEntity } from "./config"; +import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config"; import { SalesStatusBadge } from "./SalesStatusBadge"; export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const { token, user } = useAuth(); + const navigate = useNavigate(); 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 [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [isConverting, setIsConverting] = useState(false); const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; @@ -39,16 +42,59 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { return
{status}
; } + const activeDocument = document; + + async function handleStatusChange(nextStatus: SalesDocumentStatus) { + if (!token) { + return; + } + + setIsUpdatingStatus(true); + setStatus(`Updating ${config.singularLabel.toLowerCase()} status...`); + + try { + const nextDocument = + entity === "quote" + ? await api.updateQuoteStatus(token, activeDocument.id, nextStatus) + : await api.updateSalesOrderStatus(token, activeDocument.id, nextStatus); + setDocument(nextDocument); + setStatus(`${config.singularLabel} status updated.`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : `Unable to update ${config.singularLabel.toLowerCase()} status.`; + setStatus(message); + } finally { + setIsUpdatingStatus(false); + } + } + + async function handleConvert() { + if (!token || entity !== "quote") { + return; + } + + setIsConverting(true); + setStatus("Converting quote to sales order..."); + + try { + const order = await api.convertQuoteToSalesOrder(token, activeDocument.id); + navigate(`/sales/orders/${order.id}`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to convert quote to sales order."; + setStatus(message); + setIsConverting(false); + } + } + return (

{config.detailEyebrow}

-

{document.documentNumber}

-

{document.customerName}

+

{activeDocument.documentNumber}

+

{activeDocument.customerName}

- +
@@ -56,29 +102,64 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { Back to {config.collectionLabel.toLowerCase()} {canManage ? ( - - Edit {config.singularLabel.toLowerCase()} - + <> + + Edit {config.singularLabel.toLowerCase()} + + {entity === "quote" ? ( + + ) : null} + ) : null}
+ {canManage ? ( +
+
+
+

Quick Actions

+

Update document status without opening the full editor.

+
+
+ {salesStatusOptions.map((option) => ( + + ))} +
+
+
+ ) : null}

Issue Date

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

Expires

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

Lines

-
{document.lineCount}
+
{activeDocument.lineCount}

Subtotal

-
${document.subtotal.toFixed(2)}
+
${activeDocument.subtotal.toFixed(2)}
@@ -87,22 +168,22 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
Account
-
{document.customerName}
+
{activeDocument.customerName}
Email
-
{document.customerEmail}
+
{activeDocument.customerEmail}

Notes

-

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

+

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

Line Items

- {document.lines.length === 0 ? ( + {activeDocument.lines.length === 0 ? (
No line items have been added yet.
@@ -120,7 +201,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { - {document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => ( + {activeDocument.lines.map((line: SalesDocumentDetailDto["lines"][number]) => (
{line.itemSku}
diff --git a/server/src/modules/sales/router.ts b/server/src/modules/sales/router.ts index d7c4a34..e574461 100644 --- a/server/src/modules/sales/router.ts +++ b/server/src/modules/sales/router.ts @@ -7,10 +7,12 @@ import { fail, ok } from "../../lib/http.js"; import { requirePermissions } from "../../lib/rbac.js"; import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js"; import { + convertQuoteToSalesOrder, createSalesDocument, getSalesDocumentById, listSalesCustomerOptions, listSalesDocuments, + updateSalesDocumentStatus, updateSalesDocument, } from "./service.js"; @@ -45,6 +47,10 @@ const salesListQuerySchema = z.object({ status: z.enum(salesDocumentStatuses).optional(), }); +const salesStatusUpdateSchema = z.object({ + status: z.enum(salesDocumentStatuses), +}); + function getRouteParam(value: unknown) { return typeof value === "string" ? value : null; } @@ -111,6 +117,39 @@ salesRouter.put("/quotes/:quoteId", requirePermissions([permissions.salesWrite]) return ok(response, result.document); }); +salesRouter.patch("/quotes/:quoteId/status", 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 = salesStatusUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return fail(response, 400, "INVALID_INPUT", "Quote status payload is invalid."); + } + + const result = await updateSalesDocumentStatus("QUOTE", quoteId, parsed.data.status); + if (!result.ok) { + return fail(response, 400, "INVALID_INPUT", result.reason); + } + + return ok(response, result.document); +}); + +salesRouter.post("/quotes/:quoteId/convert", 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 result = await convertQuoteToSalesOrder(quoteId); + if (!result.ok) { + return fail(response, 400, "INVALID_INPUT", result.reason); + } + + return ok(response, result.document, 201); +}); + salesRouter.get("/orders", requirePermissions([permissions.salesRead]), async (request, response) => { const parsed = salesListQuerySchema.safeParse(request.query); if (!parsed.success) { @@ -172,3 +211,22 @@ salesRouter.put("/orders/:orderId", requirePermissions([permissions.salesWrite]) return ok(response, result.document); }); + +salesRouter.patch("/orders/:orderId/status", 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 = salesStatusUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return fail(response, 400, "INVALID_INPUT", "Sales order status payload is invalid."); + } + + const result = await updateSalesDocumentStatus("ORDER", orderId, parsed.data.status); + if (!result.ok) { + return fail(response, 400, "INVALID_INPUT", result.reason); + } + + return ok(response, result.document); +}); diff --git a/server/src/modules/sales/service.ts b/server/src/modules/sales/service.ts index 69ea2a9..a731a47 100644 --- a/server/src/modules/sales/service.ts +++ b/server/src/modules/sales/service.ts @@ -329,3 +329,61 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s const detail = await getSalesDocumentById(type, documentId); return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved document." }; } + +export async function updateSalesDocumentStatus(type: SalesDocumentType, documentId: string, status: SalesDocumentStatus) { + 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." }; + } + + await documentConfig[type].update({ + where: { id: documentId }, + data: { status }, + 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 updated document." }; +} + +export async function convertQuoteToSalesOrder(quoteId: string) { + const quote = await documentConfig.QUOTE.findUnique({ + where: { id: quoteId }, + include: buildInclude("QUOTE"), + }); + + if (!quote) { + return { ok: false as const, reason: "Quote was not found." }; + } + + const mappedQuote = mapDocument(quote as SalesDocumentRecord); + const nextOrderNumber = await nextDocumentNumber("ORDER"); + + const created = await documentConfig.ORDER.create({ + data: { + documentNumber: nextOrderNumber, + customerId: mappedQuote.customerId, + status: "DRAFT", + issueDate: new Date(), + notes: mappedQuote.notes ? `Converted from ${mappedQuote.documentNumber}\n\n${mappedQuote.notes}` : `Converted from ${mappedQuote.documentNumber}`, + lines: { + create: mappedQuote.lines.map((line) => ({ + itemId: line.itemId, + description: line.description, + quantity: line.quantity, + unitOfMeasure: line.unitOfMeasure, + unitPrice: line.unitPrice, + position: line.position, + })), + }, + }, + select: { id: true }, + }); + + const order = await getSalesDocumentById("ORDER", created.id); + return order ? { ok: true as const, document: order } : { ok: false as const, reason: "Unable to load converted sales order." }; +}