WFDASH-WEBAPP/src/components/quotes/process-add-item-form.tsx
SJCoetz 63048e51c9 feat: add AI quote processing pipeline and admin settings UI
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
2026-08-09 16:17:29 +02:00

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;