614cf0114f
- 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>
64 lines
1.8 KiB
TypeScript
64 lines
1.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|