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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user