Add complete end-to-end AI-powered quote extraction workflow: - Disable PB SDK auto-cancellation to support parallel batch requests - Add required runtime dependencies for AI processing and document parsing - Implement AI settings backend, operator UI, and REST API routes - Add quote ingestion, insight population, and manual re-run AI endpoints - Build service layers for quote items, AI settings, and structured item insights - Fix attachment upload handling and quote item history blob parsing - Add supporting migrations, smoke tests, and shared utility functions - Export previously private helper functions for cross-service reuse
252 lines
7.3 KiB
TypeScript
252 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ProcessAddItemForm — inline form for the operator to add a new
|
|
* `quote_item` row when the AI extractor left the quote empty
|
|
* (or only partially filled). Posts to the new
|
|
* `POST /api/v1/quotes/[id]/items` endpoint and on success calls
|
|
* the `onAdded` callback so the host page can refresh.
|
|
*
|
|
* Used in two places on the process page:
|
|
* 1. The empty state at the top of the items table (replaces the
|
|
* old "Add items from the quote detail page" dead-end).
|
|
* 2. A "Add item" button in the items table header (for when
|
|
* items already exist and the operator wants to add more).
|
|
*
|
|
* Mirrors the validation rules of the backend service:
|
|
* - description is required (trimmed, non-empty)
|
|
* - quantity defaults to 1 and must be > 0
|
|
* - supplier is optional (relation id)
|
|
* - unit / notes are optional
|
|
*
|
|
* No pricing fields — the operator adds the cost + markup later via
|
|
* the existing pricing modal after the row is in the table.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { SupplierSearch } from "@/components/ui/supplier-search";
|
|
import { Plus, X, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import type { Supplier } from "@/lib/types";
|
|
|
|
interface ProcessAddItemFormProps {
|
|
quoteId: string;
|
|
suppliers: Supplier[];
|
|
/** Called after a successful add. The host page refreshes items. */
|
|
onAdded: () => void;
|
|
/** Optional cancel handler — only used when the form is in a
|
|
* modal-style chrome (header button). The empty-state version
|
|
* shows the form inline and never cancels. */
|
|
onCancel?: () => void;
|
|
/** Optional compact mode (rows 1, 2 only) for the header button. */
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function ProcessAddItemForm({
|
|
quoteId,
|
|
suppliers,
|
|
onAdded,
|
|
onCancel,
|
|
compact = false,
|
|
}: ProcessAddItemFormProps) {
|
|
const [description, setDescription] = useState("");
|
|
const [quantity, setQuantity] = useState("1");
|
|
const [unit, setUnit] = useState("");
|
|
const [supplier, setSupplier] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const reset = () => {
|
|
setDescription("");
|
|
setQuantity("1");
|
|
setUnit("");
|
|
setSupplier("");
|
|
setNotes("");
|
|
};
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const desc = description.trim();
|
|
if (!desc) {
|
|
toast.error("Description is required");
|
|
return;
|
|
}
|
|
const qty = Number(quantity);
|
|
if (!Number.isFinite(qty) || qty <= 0) {
|
|
toast.error("Quantity must be a number greater than 0");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/quotes/${quoteId}/items`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
description: desc,
|
|
quantity: qty,
|
|
unit: unit.trim(),
|
|
supplier: supplier.trim() || undefined,
|
|
notes: notes.trim(),
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
};
|
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
}
|
|
toast.success("Item added");
|
|
reset();
|
|
onAdded();
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error ? err.message : "Failed to add item",
|
|
);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={submit}
|
|
className="rounded-lg border border-dashed border-primary/30 bg-primary/5 p-4 space-y-3"
|
|
data-testid="process-add-item-form"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-medium flex items-center gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
Add item
|
|
</p>
|
|
{onCancel && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onCancel}
|
|
className="h-6 px-2"
|
|
aria-label="Cancel add item"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label
|
|
htmlFor="add-item-description"
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Description <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="add-item-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="e.g. M16 x 50mm hex bolt, grade 8.8, zinc"
|
|
rows={compact ? 2 : 3}
|
|
className="mt-1"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div>
|
|
<Label
|
|
htmlFor="add-item-qty"
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Qty
|
|
</Label>
|
|
<Input
|
|
id="add-item-qty"
|
|
type="number"
|
|
min={1}
|
|
step="any"
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(e.target.value)}
|
|
className="mt-1 h-8"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label
|
|
htmlFor="add-item-unit"
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Unit
|
|
</Label>
|
|
<Input
|
|
id="add-item-unit"
|
|
value={unit}
|
|
onChange={(e) => setUnit(e.target.value)}
|
|
placeholder="each"
|
|
className="mt-1 h-8"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label className="text-xs text-muted-foreground">Supplier</Label>
|
|
<div className="mt-1">
|
|
<SupplierSearch
|
|
value={supplier}
|
|
suppliers={suppliers}
|
|
onValueChange={setSupplier}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!compact && (
|
|
<div>
|
|
<Label
|
|
htmlFor="add-item-notes"
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Notes
|
|
</Label>
|
|
<Input
|
|
id="add-item-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="e.g. customer asked for fastest possible lead time"
|
|
className="mt-1 h-8"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
{onCancel && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onCancel}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={submitting}
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90 border-primary shadow-sm"
|
|
data-testid="process-add-item-submit"
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
Adding…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
Add item
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default ProcessAddItemForm;
|