- Add Supplier Map component to suppliers page and reorganize sidebar navigation - Implement picking slip PDF generation with supplier per line item and picked-by signature footer - Add invoice bulk print tracking API and view models to track printed invoice state - Extend company search and form to support email/telephone fields and improve searchability - Fix data table debounce race condition by using useRef for onSearch handler - Fix supplier page fetch race condition with debounced timeout - Reduce list page item counts to 10 per page for customers, companies, and suppliers - Update company search filter to include email and telephone in search queries - Add comprehensive test suite for new and existing components and APIs - Add pdf-lib dependency for PDF generation and update related tooling - Clean up unused code and fix minor linting issues
29 KiB
New Quote Form Redesign Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Redesign the New Quote sheet to use a combined customer/company picker, replace pricing-oriented item rows with intake-oriented item cards, and support per-item manual uploads for photos, voice notes, and files.
Architecture: Keep PocketBase relations for quote.customer and quote.company, but reuse the New Order combined contact-picker interaction so operators see readable company/customer names instead of raw relation ids. Split the new UI into focused quote-form subcomponents, and reuse the existing quote_item_attachments server-upload pattern through a quote-specific route that the form calls only after quote + quote-item creation succeeds.
Tech Stack: Next.js App Router, React client components, shadcn/ui (Popover, Command, Sheet, Textarea, Badge), PocketBase client + admin helpers, Vitest, React Testing Library
File Structure
Create
src/components/quote-form.test.tsx- Focused client test for combined picker behavior, item-card rendering, validation, and submit/upload flow.
src/components/quote-form-contact-picker.tsx- Reusable combined picker for
Customer or Company, mirroring theNew Ordersearch interaction.
- Reusable combined picker for
src/components/quote-form-item-card.tsx- Focused card UI for one draft quote item, including qty, multi-line description, notes, and pending-file list.
src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts- Admin-backed file-upload endpoint for manual quote-item attachments created from
New Quote.
- Admin-backed file-upload endpoint for manual quote-item attachments created from
src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts- Route tests for multipart upload validation and successful
quote_item_attachmentswrites.
- Route tests for multipart upload validation and successful
Modify
src/components/quote-form.tsx- Replace separate selects with the combined picker, switch to item cards, remove supplier/cost/sell, and orchestrate post-create uploads.
src/lib/types.ts- Add draft-only helper types if needed for pending quote-form item files; do not change persisted PB models unless required by compilation.
Reference Only
src/app/(app)/orders/new/page.tsx- Existing combined customer/company picker to mirror.
src/app/api/v1/sprint/items/[id]/attachments/upload/route.ts- Existing admin upload route pattern for
quote_item_attachments.
- Existing admin upload route pattern for
Task 1: Lock the New Quote behavior down with failing form tests
Files:
-
Create:
src/components/quote-form.test.tsx -
Reference:
src/components/company-form.test.tsx -
Reference:
src/app/(app)/orders/new/page.tsx -
Step 1: Write the failing quote-form tests
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QuoteForm } from "./quote-form";
const {
createMock,
getAllCustomersMock,
getAllCompaniesMock,
generateQuoteNumberMock,
toastErrorMock,
toastSuccessMock,
fetchMock,
} = vi.hoisted(() => ({
createMock: vi.fn(),
getAllCustomersMock: vi.fn(),
getAllCompaniesMock: vi.fn(),
generateQuoteNumberMock: vi.fn(),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn(),
fetchMock: vi.fn(),
}));
vi.stubGlobal("fetch", fetchMock);
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: (name: string) => ({
create: (payload: unknown) => createMock(name, payload),
}),
}),
}));
vi.mock("@/lib/pb-helpers", () => ({
getAllCustomers: () => getAllCustomersMock(),
getAllCompanies: () => getAllCompaniesMock(),
generateQuoteNumber: () => generateQuoteNumberMock(),
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
success: toastSuccessMock,
},
}));
describe("QuoteForm", () => {
beforeEach(() => {
createMock.mockReset();
getAllCustomersMock.mockReset();
getAllCompaniesMock.mockReset();
generateQuoteNumberMock.mockReset();
toastErrorMock.mockReset();
toastSuccessMock.mockReset();
fetchMock.mockReset();
getAllCompaniesMock.mockResolvedValue([
{ id: "co-1", company: "SJ COETZ" },
{ id: "co-2", company: "Walk-in Company" },
]);
getAllCustomersMock.mockResolvedValue([
{ id: "cu-1", customer: "SJ BOOKKEEPING", company: "co-1" },
]);
generateQuoteNumberMock.mockResolvedValue("Q-2026-07-001");
});
it("uses a combined customer/company picker and auto-links the company", async () => {
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
expect(screen.getByText("SJ COETZ")).toBeInTheDocument();
expect(screen.getByText("SJ BOOKKEEPING")).toBeInTheDocument();
});
it("renders item cards without supplier, cost, or sell fields", async () => {
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
expect(await screen.findByLabelText(/item description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/item notes/i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Select...")).not.toBeInTheDocument();
expect(screen.queryByText("Cost")).not.toBeInTheDocument();
expect(screen.queryByText("Sell")).not.toBeInTheDocument();
});
it("uploads per-item files after creating the quote and quote items", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockResolvedValueOnce({ id: "item-1" });
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: "att-1" }),
});
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Need pricing on magnetic drill" },
});
const file = new File(["voice"], "voice-note.m4a", { type: "audio/mp4" });
fireEvent.change(screen.getByLabelText(/upload attachments/i), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(createMock).toHaveBeenNthCalledWith(
1,
"quotes",
expect.objectContaining({
quote_number: "Q-2026-07-001",
customer: "cu-1",
company: "co-1",
}),
);
expect(createMock).toHaveBeenNthCalledWith(
2,
"quote_items",
expect.objectContaining({
quote: "quote-1",
description: "Need pricing on magnetic drill",
quantity: 1,
}),
);
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/quotes/items/item-1/attachments/upload",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
});
});
});
- Step 2: Run the test to verify it fails
Run:
npm run test:run -- src/components/quote-form.test.tsx
Expected:
FAIL src/components/quote-form.test.tsx
× uses a combined customer/company picker and auto-links the company
× renders item cards without supplier, cost, or sell fields
× uploads per-item files after creating the quote and quote items
- Step 3: Fix the test harness until it describes the final behavior cleanly
beforeEach(() => {
Object.defineProperty(globalThis, "crypto", {
value: { randomUUID: () => "draft-item-1" },
configurable: true,
});
});
function mockPbCreateSequence() {
createMock.mockImplementation(async (collection: string, payload: unknown) => {
if (collection === "quotes") return { id: "quote-1", ...(payload as object) };
if (collection === "quote_items") return { id: "item-1", ...(payload as object) };
throw new Error(`Unexpected collection ${collection}`);
});
}
- Step 4: Re-run the tests and keep them red only for missing implementation
Run:
npm run test:run -- src/components/quote-form.test.tsx
Expected:
FAIL src/components/quote-form.test.tsx
AssertionError: Unable to find role="button" with name /select customer or company/i
- Step 5: Commit the test-first checkpoint
git add src/components/quote-form.test.tsx
git commit -m "test: cover new quote form redesign"
Task 2: Refactor the quote form into a combined picker plus intake-style item cards
Files:
-
Create:
src/components/quote-form-contact-picker.tsx -
Create:
src/components/quote-form-item-card.tsx -
Modify:
src/components/quote-form.tsx -
Test:
src/components/quote-form.test.tsx -
Reference:
src/app/(app)/orders/new/page.tsx -
Step 1: Add the focused subcomponents
// src/components/quote-form-contact-picker.tsx
type QuoteContactOption =
| { kind: "customer"; customerId: string; customerName: string; companyId: string; companyName: string }
| { kind: "company"; companyId: string; companyName: string };
export function QuoteFormContactPicker(props: {
value: QuoteContactOption | null;
options: QuoteContactOption[];
disabled?: boolean;
onSelect: (option: QuoteContactOption) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label="Select customer or company"
className="w-full justify-between inline-flex items-center rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<span>
{props.value
? props.value.kind === "customer"
? `${props.value.companyName} · ${props.value.customerName}`
: `${props.value.companyName} · No contact`
: "Select customer or company"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search customer or company…" />
<CommandList>
<CommandEmpty>No customer or company found.</CommandEmpty>
<CommandGroup heading="Customers">
{props.options.filter((o) => o.kind === "customer").map((option) => (
<CommandItem
key={`customer-${option.customerId}`}
value={`${option.companyName} ${option.customerName}`}
onSelect={() => {
props.onSelect(option);
setOpen(false);
}}
>
<div className="flex flex-col">
<span className="font-medium">{option.companyName}</span>
<span className="text-xs text-muted-foreground">{option.customerName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// src/components/quote-form-item-card.tsx
export type DraftQuoteItem = {
id: string;
quantity: number;
description: string;
notes: string;
files: File[];
};
export function QuoteFormItemCard(props: {
item: DraftQuoteItem;
onChange: (patch: Partial<DraftQuoteItem>) => void;
onRemove: () => void;
}) {
return (
<div className="rounded-xl border border-border/70 bg-card p-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-[96px_minmax(0,1fr)]">
<div>
<Label htmlFor={`qty-${props.item.id}`}>Qty</Label>
<Input
id={`qty-${props.item.id}`}
type="number"
min={1}
value={props.item.quantity}
onChange={(e) => props.onChange({ quantity: Number(e.target.value) || 1 })}
/>
</div>
<div>
<Label htmlFor={`description-${props.item.id}`}>Item Description</Label>
<Textarea
id={`description-${props.item.id}`}
rows={4}
value={props.item.description}
onChange={(e) => props.onChange({ description: e.target.value })}
/>
</div>
</div>
<div>
<Label htmlFor={`notes-${props.item.id}`}>Item Notes</Label>
<Textarea
id={`notes-${props.item.id}`}
rows={2}
value={props.item.notes}
onChange={(e) => props.onChange({ notes: e.target.value })}
/>
</div>
<div>
<Label htmlFor={`files-${props.item.id}`}>Upload attachments</Label>
<Input
id={`files-${props.item.id}`}
type="file"
multiple
accept="image/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.csv"
onChange={(e) => props.onChange({ files: Array.from(e.target.files ?? []) })}
/>
</div>
</div>
);
}
- Step 2: Run the tests to confirm the new components are not wired yet
Run:
npm run test:run -- src/components/quote-form.test.tsx
Expected:
FAIL src/components/quote-form.test.tsx
AssertionError: Unable to find role="button" with name /select customer or company/i
- Step 3: Rewire
quote-form.tsxto use the new components
const [form, setForm] = useState({
customer: "",
company: "",
rfq_number: "",
customer_request: "",
notes: "",
});
const [items, setItems] = useState<DraftQuoteItem[]>([]);
const pickerOptions = useMemo(() => {
const customerOptions = customers
.map((customer) => {
const company = companies.find((row) => row.id === customer.company);
if (!company) return null;
return {
kind: "customer" as const,
customerId: customer.id,
customerName: customer.customer,
companyId: company.id,
companyName: company.company,
};
})
.filter(Boolean);
const linkedCompanyIds = new Set(customers.map((row) => row.company).filter(Boolean));
const companyOnlyOptions = companies
.filter((company) => !linkedCompanyIds.has(company.id))
.map((company) => ({
kind: "company" as const,
companyId: company.id,
companyName: company.company,
}));
return [...customerOptions, ...companyOnlyOptions];
}, [customers, companies]);
const selectedOption = useMemo(() => {
if (form.customer) {
return pickerOptions.find(
(option) => option.kind === "customer" && option.customerId === form.customer,
) ?? null;
}
if (form.company) {
return pickerOptions.find(
(option) => option.kind === "company" && option.companyId === form.company,
) ?? null;
}
return null;
}, [form.customer, form.company, pickerOptions]);
<QuoteFormContactPicker
value={selectedOption}
options={pickerOptions}
disabled={dataLoading}
onSelect={(option) => {
if (option.kind === "customer") {
setForm((current) => ({
...current,
customer: option.customerId,
company: option.companyId,
}));
return;
}
setForm((current) => ({
...current,
customer: "",
company: option.companyId,
}));
}}
/>
{items.map((item) => (
<QuoteFormItemCard
key={item.id}
item={item}
onChange={(patch) =>
setItems((current) => current.map((row) => (row.id === item.id ? { ...row, ...patch } : row)))
}
onRemove={() => setItems((current) => current.filter((row) => row.id !== item.id))}
/>
))}
- Step 4: Run the form tests until the UI assertions pass
Run:
npm run test:run -- src/components/quote-form.test.tsx
Expected:
FAIL src/components/quote-form.test.tsx
× uploads per-item files after creating the quote and quote items
- Step 5: Commit the UI refactor
git add src/components/quote-form.tsx src/components/quote-form-contact-picker.tsx src/components/quote-form-item-card.tsx src/components/quote-form.test.tsx
git commit -m "feat: redesign new quote form intake UI"
Task 3: Add a quote-item attachment upload route for New Quote
Files:
-
Create:
src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts -
Create:
src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts -
Reference:
src/app/api/v1/sprint/items/[id]/attachments/upload/route.ts -
Step 1: Write the failing upload-route tests
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const createMock = vi.fn();
const getIdentityMock = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({ create: createMock }),
}),
}));
vi.mock("@/lib/dev-identity", () => ({
getServerIdentity: (...args: unknown[]) => getIdentityMock(...args),
}));
import { POST } from "./route";
describe("POST /api/v1/quotes/items/[id]/attachments/upload", () => {
beforeEach(() => {
createMock.mockReset();
getIdentityMock.mockReset();
getIdentityMock.mockReturnValue("ops@example.com");
});
it("400s when there is no file", async () => {
const req = new NextRequest("http://localhost/api/v1/quotes/items/item-1/attachments/upload", {
method: "POST",
body: new FormData(),
});
const res = await POST(req, { params: Promise.resolve({ id: "item-1" }) });
expect(res.status).toBe(400);
});
it("creates a manual attachment row for the quote item", async () => {
const fd = new FormData();
fd.append("file", new File(["audio"], "voice-note.m4a", { type: "audio/mp4" }));
fd.append("notes", "Customer sent a voice note");
const req = new NextRequest("http://localhost/api/v1/quotes/items/item-1/attachments/upload", {
method: "POST",
body: fd,
});
const res = await POST(req, { params: Promise.resolve({ id: "item-1" }) });
expect(res.status).toBe(201);
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
quote_item: "item-1",
source: "manual",
notes: "Customer sent a voice note",
uploaded_by: "ops@example.com",
}),
);
});
});
- Step 2: Run the route test to verify it fails
Run:
npm run test:run -- 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
Expected:
FAIL src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts
Error: Failed to resolve import "./route"
- Step 3: Implement the route by mirroring the sprint upload pattern
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getServerIdentity } from "@/lib/dev-identity";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const formData = await req.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file in request (expected field 'file')" }, { status: 400 });
}
const notes = ((formData.get("notes") as string) || "").trim();
const adminPb = await getAdminPocketBase();
const originalName = file.name || "attachment";
const taggedName = originalName.startsWith("[manual]") ? originalName : `[manual] ${originalName}`;
const created = await adminPb.collection("quote_item_attachments").create({
quote_item: id,
filename: taggedName,
file,
content_type: file.type || "",
size_bytes: file.size,
source: "manual",
notes,
uploaded_by: getServerIdentity(req),
});
return NextResponse.json(
{
id: created.id,
filename: created.filename,
file: created.file,
size_bytes: created.size_bytes,
content_type: created.content_type,
source: created.source,
},
{ status: 201 },
);
}
- Step 4: Run the route tests and make sure they pass
Run:
npm run test:run -- 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
Expected:
PASS src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts
✓ 400s when there is no file
✓ creates a manual attachment row for the quote item
- Step 5: Commit the route
git add 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts' 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
git commit -m "feat: add quote item attachment upload route"
Task 4: Wire the quote submit flow to create items first and upload files second
Files:
-
Modify:
src/components/quote-form.tsx -
Test:
src/components/quote-form.test.tsx -
Reference:
src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts -
Step 1: Tighten the submit-flow tests around ordering and partial failures
it("stops later uploads when a quote item create fails", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockRejectedValueOnce(new Error("item create failed"));
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Broken create path" },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(fetchMock).not.toHaveBeenCalled();
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/partially created/i),
);
});
});
it("shows a partial-success message when attachment upload fails", async () => {
createMock
.mockResolvedValueOnce({ id: "quote-1" })
.mockResolvedValueOnce({ id: "item-1" });
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: "Upload failed" }) });
render(<QuoteForm open onOpenChange={vi.fn()} onSaved={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /select customer or company/i }));
fireEvent.click(screen.getByRole("option", { name: /sj bookkeeping/i }));
fireEvent.click(screen.getByRole("button", { name: /add item/i }));
fireEvent.change(screen.getByLabelText(/item description/i), {
target: { value: "Needs photo attached" },
});
fireEvent.change(screen.getByLabelText(/upload attachments/i), {
target: { files: [new File(["img"], "photo.jpg", { type: "image/jpeg" })] },
});
fireEvent.click(screen.getByRole("button", { name: /create quote/i }));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/attachments failed/i),
);
});
});
- Step 2: Run the form tests and confirm only submit orchestration is still red
Run:
npm run test:run -- src/components/quote-form.test.tsx
Expected:
FAIL src/components/quote-form.test.tsx
× uploads per-item files after creating the quote and quote items
× stops later uploads when a quote item create fails
× shows a partial-success message when attachment upload fails
- Step 3: Implement ordered create/upload flow in
quote-form.tsx
async function uploadDraftItemFiles(itemId: string, files: File[]) {
const failures: string[] = [];
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
const response = await fetch(`/api/v1/quotes/items/${itemId}/attachments/upload`, {
method: "POST",
body: fd,
});
if (!response.ok) {
failures.push(file.name);
}
}
return failures;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.company) {
toast.error("Customer or company is required");
return;
}
if (items.length === 0 || items.some((item) => item.description.trim().length === 0)) {
toast.error("Add at least one quote item with a description");
return;
}
setLoading(true);
try {
const pb = getPocketBase();
const quote = await pb.collection("quotes").create({
quote_number: await generateQuoteNumber(),
customer: form.customer || null,
company: form.company,
rfq_number: form.rfq_number || null,
description: form.customer_request || "",
notes: form.notes || "",
status: "new",
company_letterhead: "CNL",
});
const createdItems: Array<{ draft: DraftQuoteItem; createdId: string }> = [];
for (const item of items) {
const created = await pb.collection("quote_items").create({
quote: quote.id,
description: item.description.trim(),
quantity: item.quantity || 1,
notes: item.notes || "",
user_edited: true,
});
createdItems.push({ draft: item, createdId: created.id });
}
const failedUploads: string[] = [];
for (const row of createdItems) {
failedUploads.push(...await uploadDraftItemFiles(row.createdId, row.draft.files));
}
if (failedUploads.length > 0) {
toast.error(`Quote created, but some attachments failed: ${failedUploads.join(", ")}`);
} else {
toast.success(`Quote ${quote.quote_number} created`);
}
onSaved();
onOpenChange(false);
resetForm();
} catch (err) {
const message = err instanceof Error && err.message.includes("item")
? "Quote partially created. Review the quote before continuing."
: err instanceof Error
? err.message
: "Failed to create quote";
toast.error(message);
} finally {
setLoading(false);
}
};
- Step 4: Run the focused tests, then lint the touched files
Run:
npm run test:run -- src/components/quote-form.test.tsx 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
npx eslint src/components/quote-form.tsx src/components/quote-form-contact-picker.tsx src/components/quote-form-item-card.tsx src/components/quote-form.test.tsx 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts' 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
Expected:
PASS src/components/quote-form.test.tsx
PASS src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts
✔ No ESLint warnings or errors
- Step 5: Commit the submit-flow integration
git add src/components/quote-form.tsx src/components/quote-form.test.tsx
git commit -m "feat: add quote item upload flow"
Task 5: Final integration check from the quotes page entry point
Files:
-
Modify:
src/components/quote-form.tsx(only if cleanup is needed after manual verification) -
Reference:
src/app/(app)/quotes/page.tsx -
Step 1: Run the full targeted suite for the touched quote path
Run:
npm run test:run -- src/components/quote-form.test.tsx 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
Expected:
PASS src/components/quote-form.test.tsx
PASS src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts
- Step 2: Start the app and open the quotes page
Run:
npm run dev
Expected:
Ready in
Local: http://localhost:3000
- Step 3: Manually verify the New Quote flow
Use this checklist:
1. Open /quotes and click "New Quote"
2. Open "Customer or Company" and confirm customer rows show company on top, customer below
3. Select "SJ BOOKKEEPING" and confirm the summary shows both SJ COETZ and SJ BOOKKEEPING
4. Add one item and confirm the UI shows Qty, Item Description, Item Notes, and Upload attachments
5. Confirm there is no Supplier, Cost, or Sell field
6. Attach one image or audio file and create the quote
7. Confirm success toast (or partial-upload toast if you forced an upload failure)
- Step 4: Clean up any small regression discovered during manual verification
// Keep this step narrow. Only apply a small follow-up if manual
// verification exposes a label, spacing, or state-reset bug. Re-run:
// npm run test:run -- src/components/quote-form.test.tsx
// npx eslint src/components/quote-form.tsx
- Step 5: Commit the verified feature
git add src/components/quote-form.tsx src/components/quote-form-contact-picker.tsx src/components/quote-form-item-card.tsx src/components/quote-form.test.tsx 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.ts' 'src/app/api/v1/quotes/items/[id]/attachments/upload/route.test.ts'
git commit -m "feat: redesign new quote intake flow"
Self-Review
Spec coverage
- Combined customer/company field: covered in Task 1 + Task 2
- Company-first customer hierarchy: covered in Task 2
- Remove supplier/cost/sell: covered in Task 1 + Task 2
- Multi-line item descriptions and stacked cards: covered in Task 2
- Per-item uploads: covered in Task 1 + Task 3 + Task 4
- Partial-failure behavior for item create vs upload create: covered in Task 4
Placeholder scan
- No
TODO,TBD, or “implement later” markers remain - Commands, files, and route paths are concrete
Type consistency
- Draft UI type is
DraftQuoteItem - Upload route path is consistently
/api/v1/quotes/items/[id]/attachments/upload - Persisted attachment source is consistently
"manual"