sales documents
This commit is contained in:
@@ -58,6 +58,7 @@ import type {
|
||||
SalesCustomerOptionDto,
|
||||
SalesDocumentDetailDto,
|
||||
SalesDocumentInput,
|
||||
SalesDocumentRevisionDto,
|
||||
SalesDocumentStatus,
|
||||
SalesDocumentSummaryDto,
|
||||
} from "@mrp/shared/dist/sales/types.js";
|
||||
@@ -537,6 +538,12 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
approveQuote(token: string, quoteId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/approve`, { method: "POST" }, token);
|
||||
},
|
||||
getQuoteRevisions(token: string, quoteId: string) {
|
||||
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/quotes/${quoteId}/revisions`, undefined, token);
|
||||
},
|
||||
convertQuoteToSalesOrder(token: string, quoteId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/quotes/${quoteId}/convert`, { method: "POST" }, token);
|
||||
},
|
||||
@@ -566,6 +573,12 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
approveSalesOrder(token: string, orderId: string) {
|
||||
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}/approve`, { method: "POST" }, token);
|
||||
},
|
||||
getSalesOrderRevisions(token: string, orderId: string) {
|
||||
return request<SalesDocumentRevisionDto[]>(`/api/v1/sales/orders/${orderId}/revisions`, undefined, token);
|
||||
},
|
||||
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
|
||||
return request<PurchaseOrderSummaryDto[]>(
|
||||
`/api/v1/purchasing/orders${buildQueryString({
|
||||
|
||||
@@ -21,6 +21,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
|
||||
@@ -119,6 +120,27 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApproving(true);
|
||||
setStatus(`Approving ${config.singularLabel.toLowerCase()}...`);
|
||||
|
||||
try {
|
||||
const nextDocument =
|
||||
entity === "quote" ? await api.approveQuote(token, activeDocument.id) : await api.approveSalesOrder(token, activeDocument.id);
|
||||
setDocument(nextDocument);
|
||||
setStatus(`${config.singularLabel} approved.`);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : `Unable to approve ${config.singularLabel.toLowerCase()}.`;
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsApproving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
@@ -129,6 +151,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<p className="mt-1 text-sm text-text">{activeDocument.customerName}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<SalesStatusBadge status={activeDocument.status} />
|
||||
<span className="inline-flex items-center rounded-full border border-line/70 px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
Rev {activeDocument.currentRevisionNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -148,6 +173,16 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<Link to={`${config.routeBase}/${activeDocument.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>
|
||||
{activeDocument.status !== "APPROVED" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-emerald-400/40 px-2 py-2 text-sm font-semibold text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-emerald-300"
|
||||
>
|
||||
{isApproving ? "Approving..." : "Approve"}
|
||||
</button>
|
||||
) : null}
|
||||
{entity === "quote" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -205,8 +240,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.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">${activeDocument.subtotal.toFixed(2)}</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Approval</p>
|
||||
<div className="mt-2 text-base font-bold text-text">{activeDocument.approvedAt ? new Date(activeDocument.approvedAt).toLocaleDateString() : "Pending"}</div>
|
||||
<div className="mt-1 text-xs text-muted">{activeDocument.approvedByName ?? "No approver recorded"}</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="grid gap-3 xl:grid-cols-4">
|
||||
@@ -229,6 +265,36 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Revision History</p>
|
||||
<p className="mt-2 text-sm text-muted">Automatic snapshots are recorded when the document changes status, content, or approval state.</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeDocument.revisions.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 revisions have been recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-3">
|
||||
{activeDocument.revisions.map((revision) => (
|
||||
<article key={revision.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">Rev {revision.revisionNumber}</div>
|
||||
<div className="mt-1 text-sm text-text">{revision.reason}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted">
|
||||
<div>{new Date(revision.createdAt).toLocaleString()}</div>
|
||||
<div className="mt-1">{revision.createdByName ?? "System"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
taxPercent: document.taxPercent,
|
||||
freightAmount: document.freightAmount,
|
||||
notes: document.notes,
|
||||
revisionReason: "",
|
||||
lines: document.lines.map((line) => ({
|
||||
itemId: line.itemId,
|
||||
description: line.description,
|
||||
@@ -290,6 +291,17 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
|
||||
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
{mode === "edit" ? (
|
||||
<label className="block xl:max-w-xl">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Revision Reason</span>
|
||||
<input
|
||||
value={form.revisionReason ?? ""}
|
||||
onChange={(event) => updateField("revisionReason", event.target.value)}
|
||||
placeholder="What changed in this revision?"
|
||||
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}
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Discount %</span>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<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">Revision</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>
|
||||
@@ -110,6 +111,10 @@ export function SalesListPage({ entity }: { entity: SalesDocumentEntity }) {
|
||||
<td className="px-2 py-2">
|
||||
<SalesStatusBadge status={document.status} />
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">
|
||||
Rev {document.currentRevisionNumber}
|
||||
{document.approvedAt ? <div className="mt-1 text-xs text-muted">Approved</div> : null}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-muted">{new Date(document.issueDate).toLocaleDateString()}</td>
|
||||
<td className="px-2 py-2 text-muted">${document.total.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-muted">{document.lineCount}</td>
|
||||
|
||||
@@ -59,4 +59,5 @@ export const emptySalesDocumentInput: SalesDocumentInput = {
|
||||
freightAmount: 0,
|
||||
notes: "",
|
||||
lines: [],
|
||||
revisionReason: "",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user