feat(09-02): implement admin quote builder UI with server actions

- src/lib/quote-actions.ts: createQuote server action with nanoid tokens and atomic DB transaction
- src/components/admin/quotes/QuoteBuilderForm.tsx: Two-column form (left: client/offer/price inputs, right: live preview)
- src/components/admin/quotes/OfferSelector.tsx: Grouped dropdown for macro/micro offer selection
- src/components/admin/quotes/PriceOverrideInput.tsx: Price input with server validation feedback
- src/components/admin/quotes/QuotePreview.tsx: Live preview card showing offer details and calculated total
- src/app/admin/quotes/new/page.tsx: Quote builder page rendering form with data from DB
- src/app/admin/quotes/new/actions.ts: Re-export of createQuote for page-level action imports
- src/lib/admin-queries.ts: Added getAllOfferMacrosWithMicros() query for form population

Features:
- Admin selects client and offer (required fields)
- Form validates on blur and displays success/error states
- Server action validates data and rejects if total mismatch
- On success, displays public /quote/[token] link with copy button
- Quote saved as draft state with immutable accepted_total

Completes Phase 9 Plan 2 Wave 1: Admin UI Layer

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:34:16 +02:00
parent 5fb34c57a3
commit 614cf0114f
8 changed files with 551 additions and 0 deletions
@@ -0,0 +1,63 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Check, X } from "lucide-react";
import { useState } from "react";
interface PriceOverrideInputProps {
value: string;
onChange: (value: string) => void;
calculatedTotal?: number;
}
export function PriceOverrideInput({ value, onChange, calculatedTotal }: PriceOverrideInputProps) {
const [isTouched, setIsTouched] = useState(false);
const numValue = parseFloat(value) || 0;
const matches = calculatedTotal !== undefined && Math.abs(numValue - calculatedTotal) < 0.01;
return (
<div className="space-y-2">
<Label htmlFor="total">Totale preventivo*</Label>
<div className="relative">
<Input
id="total"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => setIsTouched(true)}
className="pr-10"
required
/>
{isTouched && value && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{matches ? (
<Check size={18} className="text-green-500" />
) : (
<X size={18} className="text-red-500" />
)}
</div>
)}
</div>
{calculatedTotal !== undefined && (
<p className="text-xs text-gray-500">
Prezzo calcolato: {calculatedTotal.toLocaleString("it-IT", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
{isTouched && value && !matches && (
<p className="text-xs text-yellow-600">
Attenzione: il valore inserito non corrisponde al totale calcolato (server verificherà)
</p>
)}
</div>
);
}