feat(quotes): allow resubmit from any post-submit status, not just rejected

The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.

Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.

Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
This commit is contained in:
Dave 2026-09-07 16:51:55 +02:00
parent 43cdf02ae5
commit 3de1d622b8
4 changed files with 486 additions and 80 deletions

View file

@ -87,7 +87,7 @@ import {
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { createOrderFromQuote, getOrderByQuote, getQuote, getQuoteItems, getQuoteAttachments, formatDate, formatCurrency } from "@/lib/pb-helpers";
import { quoteStatusClassName } from "@/lib/quote-status";
import { quoteStatusClassName, isResubmittableStatus, resubmitStatusMessage } from "@/lib/quote-status";
import QuoteEmailCard from "@/components/quote-email-card";
import RewriteQuoteModal from "@/components/rewrite-quote-modal";
@ -239,57 +239,151 @@ function formatHistoryTimestamp(iso: string | null | undefined): string {
}
/**
* RejectedBanner — destructive-tinted banner shown on /quotes/[id]
* when the rep bounced this quote back to the operator. Sits
* directly below the title strip so the operator sees the reason
* before they start re-pricing.
* ResubmitBanner — affordance that surfaces the "Resubmit for
* Approval" action on `/quotes/[id]` whenever the server will
* accept a resubmit (see `RESUBMITTABLE_STATUSES` in
* `@/lib/quote-status`). Sits directly below the title strip so
* the operator sees the prompt before they start re-pricing.
*
* The banner is sourced from the most recent rejection line in
* `quotes.notes` (see `parseLatestRejection`). It only renders
* when status is `new` AND a rejection line is found — quotes
* that have never been rejected, or whose notes pre-date the
* structured format, fall through to the normal page.
* Two visual variants:
*
* Why we only show it for status `new`: legacy rejected quotes
* (status still `rejected` from before this flow change) surface
* under the Cancelled tab and render as terminal — adding the
* banner there would imply they're actionable. Once everything
* routes through the new flow, status `rejected` becomes
* unreachable and the gate can be relaxed.
* 1. Destructive (red) — the rep bounced this quote back to
* the operator. Triggered when `status === "new"` AND a
* structured rejection line exists in `quotes.notes`. Shows
* the rejection reason (who, when, why) so the operator
* knows what to fix. This is the original "RejectedBanner"
* behaviour, kept verbatim so the existing testid +
* styling survives.
*
* 2. Info (blue) — every other status in
* `RESUBMITTABLE_STATUSES` (emailed / complete / approved /
* follow_up / problem / partial_approved / mail_failed /
* email_failed / rejected-legacy). Shows a status-specific
* one-liner from `resubmitStatusMessage`. No rejection
* context because there isn't one to show — the operator
* is the one driving the resubmit, not the rep.
*
* Why expose the button for every resubmittable status (not just
* rejection):
*
* - `emailed` (waiting reply): the customer came back with
* changes — operator edits quantities/prices and resubmits.
* - `complete` / `approved`: closed/approved quote reopened
* by a follow-up conversation — same audit trail, no need
* to re-quote from scratch.
* - `follow_up` / `problem` / `mail_failed` / `email_failed`:
* operator fixed the underlying issue and wants a fresh
* approval round.
* - `partial_approved`: operator wants a fresh full-approval
* cycle on a previously-partial quote.
*
* The button stays disabled while `resubmitting` is true to
* prevent double-submits; we don't reuse `actionLoading`
* because that flag covers the local-PB status-flip actions and
* the two state machines are independent. Clicking it POSTs
* `/api/v1/quotes/[id]/submit`, which flips `status` to
* `approval_pending` (or returns 422 `unpriced_items` if a line
* is still priced at zero, which the page surfaces as a toast).
*/
function RejectedBanner({ quote }: { quote: Quote }) {
if (quote.status !== "new") return null;
const rejection = parseLatestRejection(quote.notes);
if (!rejection) return null;
function ResubmitBanner({
quote,
onResubmit,
resubmitting,
}: {
quote: Quote;
onResubmit: () => void;
resubmitting: boolean;
}) {
const rejection = quote.status === "new" ? parseLatestRejection(quote.notes) : null;
if (rejection) {
return (
<div
className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
role="alert"
data-testid="rejected-banner"
>
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">
Previously rejected — please fix and resubmit
</div>
<div className="mt-1 text-destructive/90">
<span className="font-medium">{rejection.by}</span>{" "}
<span className="text-destructive/70">rejected this on</span>{" "}
<span className="font-medium">
{formatHistoryTimestamp(rejection.at)}
</span>
.
</div>
{rejection.reason && (
<div className="mt-1.5 rounded-md bg-background/60 px-2 py-1.5 text-foreground">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Reason
</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">
{rejection.reason}
</p>
</div>
)}
</div>
<div className="shrink-0 self-center">
<Button
type="button"
size="sm"
variant="default"
onClick={onResubmit}
disabled={resubmitting}
aria-label="Resubmit quote for approval"
data-testid="resubmit-for-approval"
>
{resubmitting ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Resubmit for Approval
</Button>
</div>
</div>
);
}
// Non-rejection case: only show when the status is one the
// server will accept. Cancelled / claimed / sending / template
// fall through to a normal page render.
if (!isResubmittableStatus(quote.status)) return null;
return (
<div
className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
role="alert"
data-testid="rejected-banner"
className="flex items-start gap-3 rounded-lg border border-info/40 bg-info/10 px-4 py-3 text-sm text-info"
role="status"
data-testid="resubmit-banner"
>
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<Send className="h-4 w-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">
Previously rejected — please fix and resubmit
<div className="font-semibold">Resubmit for approval</div>
<div className="mt-1 text-info/90">
{resubmitStatusMessage(quote.status)}
</div>
<div className="mt-1 text-destructive/90">
<span className="font-medium">{rejection.by}</span>{" "}
<span className="text-destructive/70">rejected this on</span>{" "}
<span className="font-medium">
{formatHistoryTimestamp(rejection.at)}
</span>
.
</div>
{rejection.reason && (
<div className="mt-1.5 rounded-md bg-background/60 px-2 py-1.5 text-foreground">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Reason
</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">
{rejection.reason}
</p>
</div>
)}
</div>
<div className="shrink-0 self-center">
<Button
type="button"
size="sm"
variant="default"
onClick={onResubmit}
disabled={resubmitting}
aria-label="Resubmit quote for approval"
data-testid="resubmit-for-approval"
>
{resubmitting ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Resubmit for Approval
</Button>
</div>
</div>
);
@ -906,6 +1000,13 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
}>>([]);
const [creatingOrder, setCreatingOrder] = useState(false);
const [rewriteOpen, setRewriteOpen] = useState(false);
// Local in-flight flag for the Resubmit-for-Approval button on the
// RejectedBanner. Kept separate from `actionLoading` (which covers
// the local-PB status-flip actions like claim/unclaim/cancel) so
// the two state machines don't interfere — a pending resubmit
// shouldn't disable the kebab-menu actions, and vice versa. Also
// drives the button's `disabled` state and the spinner swap.
const [resubmitting, setResubmitting] = useState(false);
// Outbound attachments — files the operator uploaded to send with
// the quote email. Kept separate from the inbound `attachments`
// list (which the QuoteEmailCard shows) so the two UIs stay
@ -1243,6 +1344,92 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
}
};
/**
* Resubmit a quote for approval. Two flavours:
*
* 1. Post-rejection resubmit — the rep bounced the quote
* back to the operator's queue (status → `new`), the
* operator fixed the items, and now they're sending it
* back into the rep's approval queue.
*
* 2. Post-send resubmit — the customer came back with
* changes on a quote that's been through the pipeline
* once already (status `emailed`, `complete`, `approved`,
* `follow_up`, etc.). The operator edits quantities or
* prices and resubmits without re-quoting from scratch,
* preserving the original quote_number and email
* history.
*
* Both paths hit `POST /api/v1/quotes/[id]/submit`, which
* validates:
*
* - every priced item has `selling_price > 0`
* (otherwise 422 `unpriced_items` with the offending ids)
* - the quote is in a submittable status (the union of
* `new` and `RESUBMITTABLE_STATUSES` — see
* `@/lib/quote-status` for the canonical list)
*
* The unpriced-items gate matters most for the
* post-rejection path (operator reopens the items table to
* fix the rejection reason, and may have left a line at
* zero), but the server applies it uniformly so a partial
* re-edit on an already-sent quote doesn't ship an
* incomplete line.
*
* Idempotency: re-submitting an already-`approval_pending`
* quote is a server-side no-op with `statusChanged: false`.
* We surface that as an info toast so the operator knows
* their click was registered but didn't change anything.
*/
const handleResubmitForApproval = async () => {
setResubmitting(true);
try {
const res = await fetch(`/api/v1/quotes/${id}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = (await res.json().catch(() => ({}))) as {
error?: string;
code?: "quote_not_found" | "invalid_status" | "unpriced_items";
offendingItemIds?: string[];
statusChanged?: boolean;
targetStatus?: string;
};
if (!res.ok) {
if (body.code === "unpriced_items") {
const count = body.offendingItemIds?.length ?? 0;
toast.error(
count > 0
? `Cannot resubmit: ${count} item${count === 1 ? "" : "s"} still have selling_price = 0. Price every line before resubmitting.`
: body.error || "Cannot resubmit: items are missing selling prices.",
);
return;
}
if (body.code === "invalid_status" || body.code === "quote_not_found") {
toast.error(body.error || "Cannot resubmit in current status.");
return;
}
throw new Error(body.error || `HTTP ${res.status}`);
}
if (body.statusChanged === false) {
// Already in approval_pending — no-op. The button shouldn't
// normally be visible in this state (the banner gate is
// status === "new"), but defending against a race where the
// page hasn't refetched yet.
toast.info("Already pending approval");
} else {
toast.success("Resubmitted for approval");
}
fetchQuote();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to resubmit";
toast.error(message);
} finally {
setResubmitting(false);
}
};
const handleDelete = async () => {
try {
const pb = getPocketBase();
@ -2084,18 +2271,31 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</div>
</div>
{/* ── Previously-rejected banner ──────────────────────────
When the rep bounces this quote back to the operator
(status was flipped to "new"), we surface the rejection
reason right under the header so the operator sees
"what to fix" before they start re-pricing. The banner
is sourced from the same `quotes.notes` audit line both
reject paths write — `/api/v1/quotes/[id]/review-approve`
(the review screen) and the queue-card reject on
`/quotes/approvals` — so either rejection surface shows
up here. Hidden when the quote was never rejected, or
when the notes pre-date the structured format. */}
<RejectedBanner quote={quote} />
{/* ── Resubmit-for-approval banner ────────────────────────
Surfaces the "Resubmit for Approval" action on every
status the server will accept (see
`RESUBMITTABLE_STATUSES` in `@/lib/quote-status`).
Two visual variants:
- destructive (red) when status === "new" AND notes
contain a structured rejection line — shows the
rejection reason
- info (blue) for every other resubmittable status
(`emailed`, `complete`, `approved`, `follow_up`,
`problem`, `partial_approved`, `mail_failed`,
`email_failed`, legacy `rejected`) — shows a
status-specific one-liner
Hidden when the status is `cancelled`, `claimed`,
`sending`, `template`, or anything else the server
refuses. The rejection line is parsed from the same
`quotes.notes` audit line both reject paths write —
`/api/v1/quotes/[id]/review-approve` (the review
screen) and the queue-card reject on `/quotes/approvals`
— so either rejection surface shows up here. */}
<ResubmitBanner
quote={quote}
onResubmit={handleResubmitForApproval}
resubmitting={resubmitting}
/>
{/*
Compact customer strip — replaces the old "Customer" sidebar

View file

@ -9,6 +9,8 @@
* the status MEANS to the operator, not after a Tailwind palette
* name. That way the suite works across every theme in the picker.
*/
import type { QuoteStatus } from "@/lib/types";
const quoteStatusClasses: Record<string, string> = {
// In-flight / awaiting further action — info (cool blue)
new: "border-info/30 bg-info/10 text-info",
@ -103,3 +105,99 @@ const STATUS_LABELS: Record<string, string> = {
export function formatQuoteStatus(status: string) {
return STATUS_LABELS[status] || status.replace(/_/g, " ");
}
/**
* Quote statuses that can be re-submitted through the approval queue
* via POST /api/v1/quotes/[id]/submit. Lives in the shared status
* helper so the client UI (banner gate) and the server
* (`submitQuote` in `quotes-service.ts`) draw from the same source
* of truth — a new status added here surfaces in both places without
* a second edit.
*
* Background: before this set existed (pre-2026-09-07), `submitQuote`
* only accepted `new`. Once a quote had been emailed and the customer
* came back asking for changes, the operator's only path was to
* re-quote from scratch, which threw away the original quote_number,
* email tracking, and the customer's audit trail. Re-submitting on
* the same quote preserves every one of those.
*
* Includes:
* - `emailed` → customer came back with changes
* - `complete` → customer came back after closure
* - `partial_approved` → operator wants to re-open a partial
* for a fresh full-approval cycle
* - `mail_failed` → operator fixed the underlying issue
* and wants to retry through approval
* - `email_failed` → ditto, for the legacy alias
* - `approved` → approved but not yet sent; operator
* wants a re-review before sending
* - `follow_up` → operator followed up, now wants a
* fresh approval round
* - `problem` → operator flagged a problem, fixed it,
* now wants re-approval
* - `rejected` → legacy terminal state — still reachable
* for quotes that pre-date the new `new`
* bounce-back flow
*
* Does NOT include (left to throw `invalid_status`):
* - `cancelled` → terminal — operator took it out of the
* queue intentionally
* - `claimed` → operator hasn't priced yet; they should
* "Process Items" first
* - `sending` → SMTP in flight; clobbering would orphan
* the outbound send
* - `template` → not a real quote; templates don't go to
* the approval queue
*/
export const RESUBMITTABLE_STATUSES = new Set<QuoteStatus>([
"emailed",
"complete",
"partial_approved",
"mail_failed",
"email_failed",
"approved",
"follow_up",
"problem",
"rejected",
]);
export function isResubmittableStatus(status: QuoteStatus | string | undefined | null): boolean {
if (!status) return false;
return RESUBMITTABLE_STATUSES.has(status as QuoteStatus);
}
/**
* Per-status one-liner for the info variant of the Resubmit banner.
* Keeps the messaging in one place so the page doesn't grow a forest
* of conditionals. Statuses that have no entry fall through to the
* generic `defaultMessage`. The destructive variant (rejection case)
* owns its own copy in the page component — it carries the
* rejection-reason block which these short messages don't.
*/
const RESUBMIT_MESSAGES: Partial<Record<QuoteStatus, string>> = {
emailed:
"Customer came back with changes — resubmit to send the revised quote through the approval queue.",
complete:
"Quote closed — resubmit to reopen for a fresh approval cycle.",
follow_up:
"Follow-up round complete — resubmit for a fresh approval round.",
problem:
"Problem resolved — resubmit for a fresh approval cycle.",
mail_failed:
"Previous email failed — fix the underlying issue, then resubmit.",
email_failed:
"Previous email failed — fix the underlying issue, then resubmit.",
approved:
"Approved but not yet sent — resubmit to send back through approval before sending.",
partial_approved:
"Partial — resubmit for a fresh full-approval cycle.",
rejected:
"Rejected — fix and resubmit for a fresh approval cycle.",
};
const RESUBMIT_DEFAULT_MESSAGE = "Resubmit for a fresh approval cycle.";
export function resubmitStatusMessage(status: QuoteStatus | string | undefined | null): string {
if (!status) return RESUBMIT_DEFAULT_MESSAGE;
return RESUBMIT_MESSAGES[status as QuoteStatus] ?? RESUBMIT_DEFAULT_MESSAGE;
}

View file

@ -254,6 +254,20 @@ vi.mock("./quote-pdf-service", () => ({
generateQuotePdfMock: vi.fn(),
}));
// Route every `getAdminPocketBase()` call inside `quotes-service`
// through the in-memory pbMock above. Without this mock the tests
// fall through to the real PocketBase instance, which requires
// PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD env vars and a live server
// — neither of which the unit suite depends on for correctness.
// All other service test files in this repo follow the same
// pattern (see delivery-notes-service.test.ts, customers-coordinates.test.ts,
// etc.) — this file was missing the mock since the
// "submitQuote" describe block landed, so every submitQuote test
// silently hit real PB and got a "Quote not found" 404.
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
generateQuoteNumberServer,
findOrCreateCustomer,
@ -2414,15 +2428,16 @@ describe("submitQuote", () => {
expect(stored).toEqual(original);
});
it("rejects a quote in any non-new, non-pending status with code: invalid_status", async () => {
for (const status of [
"cancelled",
"approved",
"emailed",
"rejected",
"partial_approved",
"claimed",
]) {
it("still rejects cancelled and claimed quotes with code: invalid_status", async () => {
// Terminal / pre-pricing states stay locked out of the
// approval queue:
// - cancelled → operator took it out of the queue intentionally
// - claimed → operator hasn't priced yet; they should
// "Process Items" first
// The `sending` and `template` branches are covered by their
// own tests above; this case is just the two statuses that
// are user-driven terminal-or-prerequisite gates.
for (const status of ["cancelled", "claimed"]) {
await pbMock.collection("quotes").create({
id: `q-${status}`,
status,
@ -2441,6 +2456,69 @@ describe("submitQuote", () => {
}
});
it("accepts every resubmittable status and flips it to approval_pending", async () => {
// The resubmittable set (see RESUBMITTABLE_STATUSES in
// @/lib/quote-status) covers post-send resubmits: customer
// came back with changes on an emailed / completed / approved
// quote, operator fixed a problem or follow-up, mail_failed
// retry, or the legacy `rejected` terminal state. Every one
// of them lands on `approval_pending` — `partial` is
// ignored on re-submissions, only fresh `new` submissions
// can opt into `partial_approval_pending`.
const resubmittable = [
"emailed",
"complete",
"approved",
"follow_up",
"problem",
"partial_approved",
"mail_failed",
"email_failed",
"rejected",
];
for (const status of resubmittable) {
await pbMock.collection("quotes").create({
id: `q-${status}`,
status,
});
await pbMock.collection("quote_items").create({
id: `qi-${status}`,
quote: `q-${status}`,
selling_price: 10,
});
// Partial flag is silently swallowed on resubmissions
// (only fresh `new` quotes honour it). Passing it here
// exercises that the resubmission still completes.
const result = await submitQuote(`q-${status}`, undefined, {
partial: true,
});
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("approval_pending");
expect(result.targetStatus).toBe("approval_pending");
}
});
it("ignores the partial flag on resubmissions even when caller asks for it", async () => {
// Specifically pin the "partial is swallowed on resubmit" rule
// so a future refactor can't quietly land a partial_approval_pending
// on a quote that's already been through the pipeline once.
await pbMock.collection("quotes").create({
id: "q-emailed",
status: "emailed",
});
await pbMock.collection("quote_items").create({
id: "qi-emailed",
quote: "q-emailed",
selling_price: 10,
});
const result = await submitQuote("q-emailed", undefined, {
partial: true,
});
expect(result.quote.status).toBe("approval_pending");
});
it("rejects a quote with unpriced lines and returns the offending item ids", async () => {
await pbMock.collection("quotes").create({
id: "q-1",

View file

@ -11,6 +11,7 @@ import {
PLACEHOLDER_CUSTOMER_NAMES,
} from "@/lib/placeholder-customers";
import type { Quote, QuoteItem, Customer, Company, QuoteStatus } from "@/lib/types";
import { RESUBMITTABLE_STATUSES } from "@/lib/quote-status";
/**
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
@ -2534,21 +2535,49 @@ export async function submitQuote(
};
}
// Only `new` quotes can transition via this tool. The full set of
// valid paths to the approval queue is:
// - `new` → here (via `submit`)
// - `partial_approval_pending` → `partial_approved` (via `partial_approve`)
// - `partial_approved` → `approved` (via the operator's review flow)
// Quotes that are already `approved`, `rejected`, `cancelled`,
// `emailed`, etc. have to be re-quoted rather than re-submitted.
if (quote.status !== "new") {
/**
* Re-submittable statuses (added 2026-09-07). Before this set
* existed, `submitQuote` only accepted `new` — once a quote had
* been emailed and the customer came back asking for changes,
* the operator's only path was to re-quote from scratch. That
* throws away the original quote_number, all email tracking,
* and the customer's audit trail. Re-submitting on the same
* quote preserves every one of those — the only thing that
* changes is the PDF (regenerated on next view) and the
* `submitted_at` stamp.
*
* The set itself lives in `@/lib/quote-status` so the client UI
* (banner gate) draws from the same source of truth — a new
* status added there surfaces in both places without a second
* edit. The membership + rationale are documented next to the
* export.
*/
const isFreshSubmission = quote.status === "new";
const isResubmission = RESUBMITTABLE_STATUSES.has(quote.status);
if (!isFreshSubmission && !isResubmission) {
throw new SubmitQuoteError(
`Cannot submit a quote in status "${quote.status}". Only "new" quotes can be submitted for approval. Re-quote if the customer needs a fresh approval cycle.`,
`Cannot submit a quote in status "${quote.status}". Only new quotes and post-send re-submissions can go through the approval queue — re-quote for cancelled or claimed quotes.`,
422,
"invalid_status",
);
}
// Re-submissions always go to full `approval_pending` regardless
// of the `partial` flag — once a quote has been through the
// pipeline once, "submit only some items" no longer makes sense.
// We still honour `partial: true` on a FRESH submission (the
// existing walk-in partial-approval recipe), but ignore it for
// a re-submit. The flag is silently swallowed rather than 422'd
// because the UI button is the same "Resubmit for Approval"
// affordance across both cases — the operator doesn't need to
// think about which mode they're in.
const targetStatus: "approval_pending" | "partial_approval_pending" =
isFreshSubmission && options?.partial
? "partial_approval_pending"
: "approval_pending";
// Fetch every line on the quote (small N — typical quotes have
// < 30 items) and filter in JS. The PB filter parser mock in
// `quotes-service.test.ts` doesn't support `!= null` semantics
@ -2578,6 +2607,13 @@ export async function submitQuote(
// via MCP. The `partial: true` flag on the same `submit` tool keeps
// the state machine documented in one place — there isn't a parallel
// `submit_partial` tool to drift out of sync.
//
// For re-submissions, the gate applies identically — the operator
// might have re-priced an item down to 0 while testing the new
// numbers, and we don't want to send an incomplete quote back to
// the rep. `partial: true` is silently ignored on resubmissions
// (see the targetStatus comment above), so the only way to bypass
// the gate is to actually price the lines.
if (unpricedItems.length > 0 && !options?.partial && !quote.is_no_quote) {
const offendingItemIds = unpricedItems.map((it) => String(it.id));
throw new SubmitQuoteError(
@ -2588,12 +2624,6 @@ export async function submitQuote(
);
}
// Pick the target status. Partial flag wins (operator intent) —
// even if every item happened to be priced, we honour the flag.
// Without the flag and with all items priced: full approval.
const targetStatus: "approval_pending" | "partial_approval_pending" =
options?.partial ? "partial_approval_pending" : "approval_pending";
// Fluid write: Quote.submitted_at / submitted_by are optional fields
// on the Quote type. If/when ops adds them to the PB schema, these
// will start persisting. Right now they're silently dropped, which