feat(delivery-notes): edit the note before quoting, and fix the QuickBooks error
Four things, three of them the operator asked for and one a bug I introduced earlier in this session. ## Delivery note editor, first in the flow New "Delivery note" button ahead of "Create quote": quantity, cost, markup and selling price per line, one Save for the whole note. Sits FIRST deliberately. The quote is priced from the DN's lines, so a wrong quantity or a stale cost here becomes a wrong number on a customer-facing document. Fixing it at the source is the point. It saves through the existing `POST /api/v1/delivery-notes/items/[id]/pricing`, which takes cost + selling + quantity in one call and re-renders the DN PDF. No new endpoint. One Save rather than per-line saves, so a half-typed cost can't be left behind on one line while the others commit. Cost and markup are the inputs; selling is derived, same rule as the quote review modal. A line that already carries a selling price KEEPS it and shows the markup that price actually implies — verified live: a line at R259.74 selling on R200 cost opened showing 29.87%, not the 25% default, so a deliberate price is never overwritten. ## The QuickBooks invoice error The modal refused to send when the order number was empty, and a counter note has no parent order, so it opened blank and blocked. The operator could not tell that an empty field was the only thing wrong. The field now seeds with the DELIVERY NOTE number (which is what `order_number` did before, so nothing changes for notes that have one). QuickBooks needs a number and the DN number is real, unique and customer-recognisable. Still editable. Verified live: a note with no order now opens with DN26100814. ## The signed copy — stated, not toggled The operator asked for an option to include the signed copy. There isn't one to offer: when a signed copy exists it REPLACES the rendered PDF at quote-creation time, so the attachment was already the signed document. A checkbox there would be a control that cannot change anything — the exact complaint that started this work. Instead the send modal now says so plainly when the outgoing PDF is the customer's signed copy. ## My bug The editor's first fetch was a bare `fetch()` with no Authorization header. `assertBrowserSession` 401s without it — the same mistake as earlier this session, caught in the browser because the dialog hung on "Loading items…". Fixed and re-verified. Full suite 3817 passing, tsc clean, no lint warnings.
This commit is contained in:
parent
2387a71cfe
commit
58831683d4
5 changed files with 507 additions and 9 deletions
|
|
@ -1953,7 +1953,13 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
|
|||
open={invoiceModal.open}
|
||||
onOpenChange={(v) => setInvoiceModal({ open: v, saving: false })}
|
||||
deliveryNoteId={deliveryNote.id}
|
||||
orderNumber={deliveryNote.expand?.order?.order_number ?? ""}
|
||||
// Same fallback as the list: a counter note has no parent
|
||||
// order, so seed with the DN number rather than opening the
|
||||
// field empty and blocking the send.
|
||||
orderNumber={
|
||||
deliveryNote.expand?.order?.order_number ??
|
||||
deliveryNote.delivery_note_number
|
||||
}
|
||||
customerQuerySeed={
|
||||
deliveryNote.expand?.company?.company ??
|
||||
deliveryNote.expand?.customer?.customer ??
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog";
|
||||
import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded";
|
||||
import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons";
|
||||
import { DeliveryNoteEditorDialog } from "@/components/delivery-notes/delivery-note-editor-dialog";
|
||||
import { LinkInvoiceDialog } from "@/components/delivery-notes/link-invoice-dialog";
|
||||
import { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog";
|
||||
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
getQuotesForDeliveryNotes,
|
||||
} from "@/lib/pb-helpers";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { hasFile } from "@/lib/file-fields";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
import { fileNames } from "@/lib/file-fields";
|
||||
|
|
@ -234,6 +236,15 @@ export default function DeliveryNotesPage() {
|
|||
letterhead: string;
|
||||
}>({ open: false, noteId: "", quoteId: "", quoteNumber: "", letterhead: "" });
|
||||
const [sendSubmitting, setSendSubmitting] = useState(false);
|
||||
// Which notes have a signed copy on file, so the send modal can say
|
||||
// so. Cheap to compute from data already fetched.
|
||||
const signedCopyDnIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const n of notes) {
|
||||
if (hasFile(n.signed_copy)) ids.add(n.id);
|
||||
}
|
||||
return ids;
|
||||
}, [notes]);
|
||||
|
||||
const openSendModal = useCallback(
|
||||
(note: DeliveryNote, quote: Quote) => {
|
||||
|
|
@ -844,6 +855,20 @@ export default function DeliveryNotesPage() {
|
|||
dnNumber: string;
|
||||
} | null>(null);
|
||||
|
||||
const [noteEditor, setNoteEditor] = useState<{
|
||||
open: boolean;
|
||||
noteId: string;
|
||||
number: string;
|
||||
}>({ open: false, noteId: "", number: "" });
|
||||
|
||||
const openNoteEditor = useCallback((note: DeliveryNote) => {
|
||||
setNoteEditor({
|
||||
open: true,
|
||||
noteId: note.id,
|
||||
number: note.delivery_note_number,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openLinkModal = useCallback((note: DeliveryNote) => {
|
||||
setLinkModal({
|
||||
open: true,
|
||||
|
|
@ -927,6 +952,7 @@ export default function DeliveryNotesPage() {
|
|||
// Slot 1 — "Create quote", or the quote itself once it
|
||||
// exists. The popup means she never leaves this list to
|
||||
// read a quote.
|
||||
"delivery-note": () => openNoteEditor(note),
|
||||
"create-quote": () => openQuoteReview(note),
|
||||
"preview-quote": () => quote && setPreviewQuote(quote),
|
||||
// Slot 2 — send, in a modal on this screen.
|
||||
|
|
@ -944,14 +970,18 @@ export default function DeliveryNotesPage() {
|
|||
</div>
|
||||
);
|
||||
},
|
||||
// `openPoModal` / `handleMarkInvoiced` are no longer referenced
|
||||
// here: the flow control offers quote / send / invoice only. Those
|
||||
// two actions live on the detail page, which still renders the
|
||||
// full set from the same component.
|
||||
// `router` is no longer referenced: every action now happens in
|
||||
// place (quote review, send, invoice, link-invoice) rather than
|
||||
// by navigating.
|
||||
[busyIds, openInvoiceModal, openLinkModal, openQuoteReview, openSendModal, quoteByDnId],
|
||||
// place (edit note, quote review, send, invoice, link-invoice)
|
||||
// rather than by navigating away from the list.
|
||||
[
|
||||
busyIds,
|
||||
openInvoiceModal,
|
||||
openLinkModal,
|
||||
openNoteEditor,
|
||||
openQuoteReview,
|
||||
openSendModal,
|
||||
quoteByDnId,
|
||||
],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -1748,6 +1778,23 @@ export default function DeliveryNotesPage() {
|
|||
on any row that doesn't have a quote yet. The dialog POSTs
|
||||
the create itself with the operator's confirmed pricing;
|
||||
`handleQuoteCreated` only refreshes the row behind it. */}
|
||||
{/* Edit the note's own lines before quoting. */}
|
||||
{noteEditor.noteId && (
|
||||
<DeliveryNoteEditorDialog
|
||||
key={`dn-editor-${noteEditor.noteId}`}
|
||||
open={noteEditor.open}
|
||||
onOpenChange={(v) => setNoteEditor((prev) => ({ ...prev, open: v }))}
|
||||
deliveryNoteId={noteEditor.noteId}
|
||||
deliveryNoteNumber={noteEditor.number}
|
||||
onSaved={() => {
|
||||
setNoteEditor((prev) => ({ ...prev, open: false }));
|
||||
// Refresh so the next quote review starts from what was
|
||||
// just saved rather than the values fetched at page load.
|
||||
void fetchNotes();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Link an existing QuickBooks invoice (the backlog path). */}
|
||||
{linkModal.noteId && (
|
||||
<LinkInvoiceDialog
|
||||
|
|
@ -1803,7 +1850,16 @@ export default function DeliveryNotesPage() {
|
|||
setInvoiceModal((prev) => ({ ...prev, open: v }))
|
||||
}
|
||||
deliveryNoteId={invoiceModal.noteId}
|
||||
orderNumber={invoiceModal.orderNumber}
|
||||
// Fall back to the DELIVERY NOTE number. A counter note has
|
||||
// no parent order, so the field opened empty and the modal's
|
||||
// guard refused the send with "Order number is empty" —
|
||||
// the operator had no way to tell that was the only thing
|
||||
// wrong. QuickBooks invoices need a number, and the DN
|
||||
// number is a real, unique, customer-recognisable one. She
|
||||
// can still change it.
|
||||
orderNumber={
|
||||
invoiceModal.orderNumber || invoiceModal.number
|
||||
}
|
||||
customerQuerySeed={invoiceModal.customerQuery}
|
||||
onInvoiced={(updated) => {
|
||||
setInvoiceModal((prev) => ({ ...prev, open: false }));
|
||||
|
|
@ -1845,6 +1901,10 @@ export default function DeliveryNotesPage() {
|
|||
onOpenChange={(v) => setSendModal((prev) => ({ ...prev, open: v }))}
|
||||
quote={quoteByDnId.get(sendModal.noteId) as Quote}
|
||||
outboundAttachments={[]}
|
||||
// A signed copy REPLACES the rendered PDF when the quote is
|
||||
// created, so tell her which document is going out rather
|
||||
// than offering a checkbox that cannot change anything.
|
||||
pdfIsSignedCopy={signedCopyDnIds.has(sendModal.noteId)}
|
||||
claimerName={null}
|
||||
submitting={sendSubmitting}
|
||||
onSend={async (payload) => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import type { DeliveryNote, DeliveryNoteStatus } from "@/lib/types";
|
|||
* icon or a labelled button.
|
||||
*/
|
||||
export type DnActionId =
|
||||
| "delivery-note"
|
||||
| "create-quote"
|
||||
| "open-quote"
|
||||
| "preview-dn"
|
||||
|
|
@ -258,6 +259,22 @@ function FlowControl({
|
|||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 whitespace-nowrap">
|
||||
{/* Slot 0 — edit the delivery note itself: quantities, costs,
|
||||
margins. Sits FIRST on purpose: the quote is priced from the
|
||||
DN's lines, so anything wrong here becomes a wrong number on
|
||||
a customer-facing document. Fix it at the source. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAction["delivery-note"]}
|
||||
disabled={off || !isLive}
|
||||
data-testid={`dn-flow-note-${note.id}`}
|
||||
title="Edit this delivery note — quantities, costs and markup"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Delivery note
|
||||
</Button>
|
||||
|
||||
{/* Slot 1 — the thing to do next. Quote link once it exists. */}
|
||||
{hasQuote ? (
|
||||
<button
|
||||
|
|
|
|||
385
src/components/delivery-notes/delivery-note-editor-dialog.tsx
Normal file
385
src/components/delivery-notes/delivery-note-editor-dialog.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Edit a delivery note's lines — quantity, cost, markup, selling price.
|
||||
*
|
||||
* ## Why this sits BEFORE "Create quote"
|
||||
*
|
||||
* The quote is priced from the delivery note's lines, so a wrong
|
||||
* quantity or a stale cost on the DN propagates straight onto a
|
||||
* customer-facing document. Editing first means the note and the
|
||||
* quote agree, and the review modal that follows starts from the
|
||||
* numbers the operator just agreed to rather than from whatever the
|
||||
* line happened to carry.
|
||||
*
|
||||
* It saves through `POST /api/v1/delivery-notes/items/[id]/pricing`,
|
||||
* which takes cost + selling + quantity in one call and re-renders
|
||||
* the DN PDF. We deliberately do NOT have a per-line "save" button:
|
||||
* one Save for the whole note, so a half-typed cost can't be left
|
||||
* behind on one line while the others are committed.
|
||||
*
|
||||
* ## Markup is derived, not typed
|
||||
*
|
||||
* Cost and markup are the inputs; selling price is derived from them,
|
||||
* the same rule the quote review modal uses (`src/lib/quote-pricing.ts`).
|
||||
* A line that already carries a selling price keeps it — the office may
|
||||
* have priced it deliberately — and shows the markup that price implies
|
||||
* so the two can never quietly disagree.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
hasCostPrice,
|
||||
repriceWithCostEntry,
|
||||
totalsForLines,
|
||||
type LinePricing,
|
||||
} from "@/lib/quote-pricing";
|
||||
import { formatCurrency } from "@/lib/pb-helpers";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import type { OrderItem } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The markup a cost/selling pair actually implies. Module scope, not
|
||||
* component scope: it is a pure helper used by the loader, which is
|
||||
* declared before the component's other callbacks.
|
||||
*/
|
||||
function markupFor(cost: number, selling: number): number {
|
||||
if (cost <= 0) return 0;
|
||||
return Math.round(((selling - cost) / cost) * 10000) / 100;
|
||||
}
|
||||
|
||||
export interface DeliveryNoteEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
deliveryNoteId: string;
|
||||
deliveryNoteNumber: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function DeliveryNoteEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deliveryNoteId,
|
||||
deliveryNoteNumber,
|
||||
onSaved,
|
||||
}: DeliveryNoteEditorDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lines, setLines] = useState<LinePricing[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
// The Authorization header is NOT optional: dashboard auth
|
||||
// lives in localStorage, so `assertBrowserSession` rejects the
|
||||
// request with 401 unless we forward the PocketBase token.
|
||||
const token = getPocketBase().authStore.token;
|
||||
if (!token) {
|
||||
throw new Error("Your session has expired — please sign in again.");
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/v1/delivery-notes/${encodeURIComponent(deliveryNoteId)}/detail`,
|
||||
{ headers: { Authorization: token }, cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? "Your session has expired — please sign in again."
|
||||
: `Could not load the delivery note (${res.status}).`,
|
||||
);
|
||||
}
|
||||
const detail = (await res.json()) as { items?: OrderItem[] };
|
||||
const items = detail.items ?? [];
|
||||
setLines(
|
||||
items.map((it) => {
|
||||
const cost = Number(it.cost_price) || 0;
|
||||
const selling = Number(it.selling_price) || 0;
|
||||
return {
|
||||
orderItemId: it.id,
|
||||
description: it.description ?? "",
|
||||
quantity: Number(it.quantity) || 1,
|
||||
costPrice: cost,
|
||||
// A line that already has a selling price keeps it; show
|
||||
// the markup that price actually implies.
|
||||
markupPercent:
|
||||
selling > 0
|
||||
? markupFor(cost, selling)
|
||||
: hasCostPrice(cost)
|
||||
? 25
|
||||
: 0,
|
||||
sellingPrice: selling,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : "Failed to load the delivery note",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deliveryNoteId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Deliberate open-transition fetch, not state derived from render:
|
||||
// the rule fires on `load`'s synchronous `setLoading(true)`.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on open, not on every line change
|
||||
}, [open]);
|
||||
|
||||
const totals = useMemo(() => totalsForLines(lines), [lines]);
|
||||
|
||||
const update = (index: number, patch: { costPrice?: number; markupPercent?: number }) => {
|
||||
setLines((prev) =>
|
||||
prev.map((line, i) => (i === index ? repriceWithCostEntry(line, patch) : line)),
|
||||
);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Sequential rather than parallel: these rewrite the same DN
|
||||
// PDF, and concurrent writes to one record race.
|
||||
for (const line of lines) {
|
||||
const res = await fetch(
|
||||
`/api/v1/delivery-notes/items/${encodeURIComponent(line.orderItemId)}/pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
quantity: Math.max(1, Math.round(line.quantity)),
|
||||
cost_price: line.costPrice,
|
||||
selling_price: line.sellingPrice > 0 ? line.sellingPrice : 0,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(
|
||||
body.error || `Could not save line "${line.description}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
toast.success(
|
||||
`Saved ${lines.length} line${lines.length === 1 ? "" : "s"} on ${deliveryNoteNumber}.`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
onSaved?.();
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to save the delivery note",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit delivery note {deliveryNoteNumber}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change quantities, costs and margins. The quote you create
|
||||
next is priced from exactly these numbers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading items…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
) : lines.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
This delivery note has no items to edit.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="px-3 py-2 font-medium">Item</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-24">
|
||||
Qty
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-32">
|
||||
Cost
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-28">
|
||||
Markup %
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-32">
|
||||
Selling
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-32">
|
||||
Line total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, index) => {
|
||||
const costed = hasCostPrice(line.costPrice);
|
||||
return (
|
||||
<tr
|
||||
key={line.orderItemId}
|
||||
className="border-b last:border-b-0"
|
||||
data-testid={`dn-editor-line-${index}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium whitespace-pre-wrap break-words">
|
||||
{line.description || "(no description)"}
|
||||
</div>
|
||||
{!costed && (
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-[11px] text-warn">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
No cost on file
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={line.quantity}
|
||||
onChange={(e) =>
|
||||
setLines((prev) =>
|
||||
prev.map((l, i) =>
|
||||
i === index
|
||||
? {
|
||||
...l,
|
||||
quantity: Math.max(
|
||||
1,
|
||||
Math.round(Number(e.target.value) || 1),
|
||||
),
|
||||
}
|
||||
: l,
|
||||
),
|
||||
)
|
||||
}
|
||||
aria-label={`Quantity for line ${index + 1}`}
|
||||
disabled={saving}
|
||||
className="h-8 text-right tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={line.costPrice === 0 ? "" : line.costPrice}
|
||||
placeholder="0.00"
|
||||
onChange={(e) =>
|
||||
update(index, { costPrice: Number(e.target.value) || 0 })
|
||||
}
|
||||
aria-label={`Cost for line ${index + 1}`}
|
||||
disabled={saving}
|
||||
className="h-8 text-right tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={line.markupPercent === 0 ? "" : line.markupPercent}
|
||||
placeholder="0"
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
markupPercent: Number(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
aria-label={`Markup for line ${index + 1}`}
|
||||
disabled={saving}
|
||||
className="h-8 text-right tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium">
|
||||
{costed ? formatCurrency(line.sellingPrice) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{costed
|
||||
? formatCurrency(line.sellingPrice * line.quantity)
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-6 gap-y-1 rounded-md border bg-muted/30 px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Total cost{" "}
|
||||
<span className="font-semibold tabular-nums text-foreground">
|
||||
{formatCurrency(totals.costTotal)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Total selling{" "}
|
||||
<span className="font-semibold tabular-nums text-foreground">
|
||||
{formatCurrency(totals.sellingTotal)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Margin{" "}
|
||||
<span
|
||||
className={
|
||||
totals.marginValue > 0
|
||||
? "font-semibold tabular-nums text-success"
|
||||
: "font-semibold tabular-nums text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{formatCurrency(totals.marginValue)} (
|
||||
{totals.marginPercent.toFixed(1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
disabled={saving || loading || lines.length === 0}
|
||||
data-testid="dn-editor-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File as FileIcon,
|
||||
|
|
@ -74,6 +75,18 @@ type Props = {
|
|||
quote: Quote;
|
||||
/** Outbound attachments (operator-uploaded proofs + per-item files). */
|
||||
outboundAttachments: QuoteAttachment[];
|
||||
/**
|
||||
* Set when the PDF attached to this quote is the CUSTOMER'S SIGNED
|
||||
* COPY rather than a freshly rendered one.
|
||||
*
|
||||
* There is deliberately no toggle here. When a signed copy exists it
|
||||
* replaces the rendered PDF at quote-creation time, so there is no
|
||||
* choice to offer — a checkbox would be a control that cannot change
|
||||
* anything, which is the exact thing the operator complained about
|
||||
* on this screen. What they need is to KNOW which document is going
|
||||
* out, so we say so plainly instead.
|
||||
*/
|
||||
pdfIsSignedCopy?: boolean;
|
||||
/** Rep's display name (used in the default signature). Pass null if unknown. */
|
||||
claimerName: string | null;
|
||||
/** True while a request is in flight. Disables the footer button + all inputs. */
|
||||
|
|
@ -130,6 +143,7 @@ export function SendQuoteModal({
|
|||
onOpenChange,
|
||||
quote,
|
||||
outboundAttachments,
|
||||
pdfIsSignedCopy = false,
|
||||
claimerName,
|
||||
submitting,
|
||||
submitLabel = "Send",
|
||||
|
|
@ -358,6 +372,19 @@ export function SendQuoteModal({
|
|||
(!!quote.email_error && quote.status !== "sending");
|
||||
const showSendingBanner = quote.status === "sending";
|
||||
|
||||
// Say which document is actually going out when it's the signed
|
||||
// copy, so "the PDF" isn't a guess.
|
||||
const signedCopyNote = pdfIsSignedCopy ? (
|
||||
<p className="flex items-start gap-2 rounded-md border border-success/40 bg-success/5 px-3 py-2 text-xs text-foreground">
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" />
|
||||
<span>
|
||||
The attached PDF is the <strong>customer's signed copy</strong>,
|
||||
not a re-rendered one. Sending it attaches the document they
|
||||
actually signed.
|
||||
</span>
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
// Resolve which attachments the rep currently has selected. The PDF
|
||||
// is always included (can't uncheck the actual quote PDF). The
|
||||
// outbound files default-on; unchecking removes from the set.
|
||||
|
|
@ -644,6 +671,9 @@ export function SendQuoteModal({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Which document is going out, when it's the signed one. */}
|
||||
{signedCopyNote}
|
||||
|
||||
{/* ── Attachments (PDF + outbound proofs) ─────────────── */}
|
||||
{includedAttachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
Loading…
Reference in a new issue