QuoteForm.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. "use client";
  2. import { useState, useCallback, useEffect } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { toast } from "sonner";
  5. import Link from "next/link";
  6. import { useTranslations } from "next-intl";
  7. import { Button } from "@/components/ui/button";
  8. import { Input } from "@/components/ui/input";
  9. import { Label } from "@/components/ui/label";
  10. import {
  11. Select,
  12. SelectContent,
  13. SelectItem,
  14. SelectTrigger,
  15. SelectValue,
  16. } from "@/components/ui/select";
  17. import {
  18. ResizablePanelGroup,
  19. ResizablePanel,
  20. ResizableHandle,
  21. } from "@/components/ui/resizable";
  22. import { useGlassModal } from "@/components/glass-modal";
  23. import { createQuote, updateQuote } from "@/features/quotes/Actions/quoteActions";
  24. import type { QuotePartInput, QuoteLaborInput } from "@/features/quotes/Schema/quoteSchema";
  25. import { RichTextEditor } from "@/features/vehicles/Components/service-edit/RichTextEditor";
  26. import { ArrowLeft, Car, ClipboardCheck, FileText, Loader2, Plus, Save, Trash2, Users, X } from "lucide-react";
  27. import { formatCurrency, getCurrencySymbol } from "@/lib/format";
  28. interface CustomerOption {
  29. id: string;
  30. name: string;
  31. email: string | null;
  32. company: string | null;
  33. }
  34. interface VehicleOption {
  35. id: string;
  36. make: string;
  37. model: string;
  38. year: number;
  39. licensePlate: string | null;
  40. customerId: string | null;
  41. customerName: string | null;
  42. }
  43. interface InitialData {
  44. id: string;
  45. title: string;
  46. description: string | null;
  47. status: string;
  48. validUntil: string | null;
  49. customerId: string | null;
  50. vehicleId: string | null;
  51. notes: string | null;
  52. partItems: QuotePartInput[];
  53. laborItems: QuoteLaborInput[];
  54. subtotal: number;
  55. taxRate: number;
  56. taxAmount: number;
  57. discountType: string | null;
  58. discountValue: number;
  59. discountAmount: number;
  60. totalAmount: number;
  61. }
  62. const emptyPart = (): QuotePartInput => ({
  63. partNumber: "",
  64. name: "",
  65. quantity: 1,
  66. unitPrice: 0,
  67. total: 0,
  68. excluded: false,
  69. });
  70. const makeEmptyLabor = (defaultRate: number): QuoteLaborInput => ({
  71. description: "",
  72. hours: 0,
  73. rate: defaultRate,
  74. total: 0,
  75. excluded: false,
  76. });
  77. const LG_BREAKPOINT = 1024;
  78. function useIsLargeScreen() {
  79. const [isLarge, setIsLarge] = useState(false);
  80. useEffect(() => {
  81. const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
  82. const onChange = () => setIsLarge(mql.matches);
  83. mql.addEventListener("change", onChange);
  84. setIsLarge(mql.matches);
  85. return () => mql.removeEventListener("change", onChange);
  86. }, []);
  87. return isLarge;
  88. }
  89. interface PrefillData {
  90. title?: string;
  91. vehicleId?: string;
  92. customerId?: string;
  93. inspectionId?: string;
  94. laborItems?: (Omit<QuoteLaborInput, 'excluded'> & { excluded?: boolean })[];
  95. }
  96. export function QuoteForm({
  97. currencyCode = "USD",
  98. defaultTaxRate = 0,
  99. taxEnabled = true,
  100. defaultLaborRate = 0,
  101. quoteValidDays = 30,
  102. customers = [],
  103. vehicles = [],
  104. initialData,
  105. prefill,
  106. }: {
  107. currencyCode?: string;
  108. defaultTaxRate?: number;
  109. taxEnabled?: boolean;
  110. defaultLaborRate?: number;
  111. quoteValidDays?: number;
  112. customers?: CustomerOption[];
  113. vehicles?: VehicleOption[];
  114. initialData?: InitialData;
  115. prefill?: PrefillData;
  116. }) {
  117. const cs = getCurrencySymbol(currencyCode);
  118. const isEdit = !!initialData;
  119. const router = useRouter();
  120. const modal = useGlassModal();
  121. const isLarge = useIsLargeScreen();
  122. const t = useTranslations("quotes");
  123. const [loading, setLoading] = useState(false);
  124. const [status, setStatus] = useState(initialData?.status || "draft");
  125. const [customerId, setCustomerId] = useState(initialData?.customerId || prefill?.customerId || "");
  126. const [vehicleId, setVehicleId] = useState(initialData?.vehicleId || prefill?.vehicleId || "");
  127. const [partItems, setPartItems] = useState<QuotePartInput[]>(initialData?.partItems || []);
  128. const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(initialData?.laborItems || (prefill?.laborItems?.map((l) => ({ ...l, excluded: l.excluded ?? false }))) || []);
  129. const [taxRate, setTaxRate] = useState(initialData?.taxRate ?? defaultTaxRate);
  130. const [discountType, setDiscountType] = useState<string>(initialData?.discountType || "none");
  131. const [discountValue, setDiscountValue] = useState(initialData?.discountValue ?? 0);
  132. const [noteType, setNoteType] = useState<"public" | "internal">("public");
  133. const [description, setDescription] = useState(initialData?.description || "");
  134. const [notes, setNotes] = useState(initialData?.notes || "");
  135. const [defaultValidDate] = useState(() =>
  136. initialData?.validUntil
  137. ? initialData.validUntil.split("T")[0]
  138. : new Date(Date.now() + quoteValidDays * 86400000).toISOString().split("T")[0]
  139. );
  140. const partsSubtotal = partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0);
  141. const laborSubtotal = laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0);
  142. const subtotal = partsSubtotal + laborSubtotal;
  143. const discountAmount = discountType === "percentage"
  144. ? subtotal * (discountValue / 100)
  145. : discountType === "fixed"
  146. ? Math.min(discountValue, subtotal)
  147. : 0;
  148. const taxAmount = (subtotal - discountAmount) * (taxRate / 100);
  149. const totalAmount = subtotal - discountAmount + taxAmount;
  150. const selectedVehicle = vehicles.find((v) => v.id === vehicleId);
  151. const selectedCustomer = customers.find((c) => c.id === customerId);
  152. const handleVehicleChange = (v: string) => {
  153. const vid = v === "none" ? "" : v;
  154. setVehicleId(vid);
  155. if (vid) {
  156. const vehicle = vehicles.find((veh) => veh.id === vid);
  157. if (vehicle?.customerId) {
  158. setCustomerId(vehicle.customerId);
  159. }
  160. }
  161. };
  162. const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
  163. setPartItems((prev) => {
  164. const updated = [...prev];
  165. const part = { ...updated[index], [field]: value };
  166. if (field === "quantity" || field === "unitPrice") {
  167. part.total = Number(part.quantity) * Number(part.unitPrice);
  168. }
  169. updated[index] = part;
  170. return updated;
  171. });
  172. }, []);
  173. const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
  174. setLaborItems((prev) => {
  175. const updated = [...prev];
  176. const labor = { ...updated[index], [field]: value };
  177. if (field === "hours" || field === "rate") {
  178. labor.total = Number(labor.hours) * Number(labor.rate);
  179. }
  180. updated[index] = labor;
  181. return updated;
  182. });
  183. }, []);
  184. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  185. e.preventDefault();
  186. setLoading(true);
  187. const formData = new FormData(e.currentTarget);
  188. const payload = {
  189. title: formData.get("title") as string,
  190. description: description || undefined,
  191. status,
  192. validUntil: (formData.get("validUntil") as string) || undefined,
  193. customerId: customerId || undefined,
  194. vehicleId: vehicleId || undefined,
  195. notes: notes || undefined,
  196. inspectionId: prefill?.inspectionId || undefined,
  197. partItems: partItems.filter((p) => p.name),
  198. laborItems: laborItems.filter((l) => l.description),
  199. subtotal,
  200. taxRate,
  201. taxAmount,
  202. discountType: discountType === "none" ? undefined : discountType,
  203. discountValue,
  204. discountAmount,
  205. totalAmount,
  206. };
  207. const result = isEdit
  208. ? await updateQuote({ id: initialData.id, ...payload })
  209. : await createQuote(payload);
  210. if (result.success && result.data) {
  211. toast.success(isEdit ? t("form.updated") : t("form.created"));
  212. router.push(`/quotes/${result.data.id}`);
  213. router.refresh();
  214. } else {
  215. modal.open("error", "Error", result.error || (isEdit ? t("form.failedUpdate") : t("form.failedCreate")));
  216. }
  217. setLoading(false);
  218. };
  219. // --- Left column: Parts, Labor, Notes ---
  220. const leftColumn = (
  221. <div className="space-y-3">
  222. {/* Parts */}
  223. <div className="rounded-lg border p-3 space-y-2">
  224. <div className="flex items-center justify-between">
  225. <h3 className="text-sm font-semibold">{t("parts.title")}</h3>
  226. <Button type="button" variant="outline" size="sm" onClick={() => setPartItems([...partItems, emptyPart()])}>
  227. <Plus className="mr-1 h-3.5 w-3.5" /> {t("parts.addPart")}
  228. </Button>
  229. </div>
  230. {partItems.length > 0 && (
  231. <>
  232. <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
  233. <span>{t("parts.partNumber")}</span><span>{t("parts.name")}</span><span>{t("parts.qty")}</span><span>{t("parts.unitPrice")}</span><span>{t("parts.total")}</span><span />
  234. </div>
  235. {partItems.map((part, i) => (
  236. <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
  237. <Input placeholder={t("parts.partNumber")} value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
  238. <Input placeholder={t("parts.namePlaceholder")} value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
  239. <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
  240. <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
  241. <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
  242. <div className="flex items-center gap-1">
  243. <input type="checkbox" checked={part.excluded ?? false} onChange={(e) => updatePart(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
  244. <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}>
  245. <Trash2 className="h-4 w-4" />
  246. </Button>
  247. </div>
  248. </div>
  249. ))}
  250. <button
  251. type="button"
  252. className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
  253. onClick={() => setPartItems([...partItems, emptyPart()])}
  254. >
  255. <Plus className="h-4 w-4" />
  256. </button>
  257. <div className="flex justify-end pt-1 text-sm">
  258. <span className="font-medium">{t("parts.subtotal", { amount: formatCurrency(partsSubtotal, currencyCode) })}</span>
  259. </div>
  260. </>
  261. )}
  262. {partItems.length === 0 && (
  263. <button
  264. type="button"
  265. className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
  266. onClick={() => setPartItems([...partItems, emptyPart()])}
  267. >
  268. <Plus className="mr-1 h-4 w-4" />
  269. <span className="text-sm">{t("parts.addPart")}</span>
  270. </button>
  271. )}
  272. </div>
  273. {/* Labor */}
  274. <div className="rounded-lg border p-3 space-y-2">
  275. <div className="flex items-center justify-between">
  276. <h3 className="text-sm font-semibold">{t("labor.title")}</h3>
  277. <Button type="button" variant="outline" size="sm" onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}>
  278. <Plus className="mr-1 h-3.5 w-3.5" /> {t("labor.addLabor")}
  279. </Button>
  280. </div>
  281. {laborItems.length > 0 && (
  282. <>
  283. <div className="hidden grid-cols-[2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
  284. <span>{t("labor.description")}</span><span>{t("labor.hours")}</span><span>{t("labor.rate", { currency: cs })}</span><span>{t("labor.total")}</span><span />
  285. </div>
  286. {laborItems.map((labor, i) => (
  287. <div key={i} className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
  288. <Input placeholder={t("labor.descriptionPlaceholder")} value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
  289. <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
  290. <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
  291. <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
  292. <div className="flex items-center gap-1">
  293. <input type="checkbox" checked={labor.excluded ?? false} onChange={(e) => updateLabor(i, "excluded", e.target.checked)} className="h-4 w-4 rounded border-gray-300" title="Exclude from total" />
  294. <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}>
  295. <Trash2 className="h-4 w-4" />
  296. </Button>
  297. </div>
  298. </div>
  299. ))}
  300. <button
  301. type="button"
  302. className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
  303. onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}
  304. >
  305. <Plus className="h-4 w-4" />
  306. </button>
  307. <div className="flex justify-end pt-1 text-sm">
  308. <span className="font-medium">{t("labor.subtotal", { amount: formatCurrency(laborSubtotal, currencyCode) })}</span>
  309. </div>
  310. </>
  311. )}
  312. {laborItems.length === 0 && (
  313. <button
  314. type="button"
  315. className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
  316. onClick={() => setLaborItems([...laborItems, makeEmptyLabor(defaultLaborRate)])}
  317. >
  318. <Plus className="mr-1 h-4 w-4" />
  319. <span className="text-sm">{t("labor.addLabor")}</span>
  320. </button>
  321. )}
  322. </div>
  323. {/* Notes */}
  324. <div className="rounded-lg border p-3 space-y-3">
  325. <div className="flex items-center justify-between">
  326. <h3 className="flex items-center gap-2 text-sm font-semibold">
  327. <FileText className="h-3.5 w-3.5" />
  328. {t("notes.title")}
  329. </h3>
  330. <Select value={noteType} onValueChange={(v) => setNoteType(v as "public" | "internal")}>
  331. <SelectTrigger className="h-7 w-[120px] text-xs">
  332. <SelectValue />
  333. </SelectTrigger>
  334. <SelectContent>
  335. <SelectItem value="public">{t("notes.public")}</SelectItem>
  336. <SelectItem value="internal">{t("notes.internal")}</SelectItem>
  337. </SelectContent>
  338. </Select>
  339. </div>
  340. {noteType === "public" && (
  341. <div className="space-y-1">
  342. <RichTextEditor
  343. content={description}
  344. onChange={setDescription}
  345. placeholder={t("notes.publicPlaceholder")}
  346. />
  347. <p className="text-xs text-muted-foreground">{t("notes.publicHelper")}</p>
  348. </div>
  349. )}
  350. {noteType === "internal" && (
  351. <div className="space-y-1">
  352. <RichTextEditor
  353. content={notes}
  354. onChange={setNotes}
  355. placeholder={t("notes.internalPlaceholder")}
  356. />
  357. <p className="text-xs text-muted-foreground">{t("notes.internalHelper")}</p>
  358. </div>
  359. )}
  360. </div>
  361. </div>
  362. );
  363. // --- Right column: Vehicle & Customer, Quote Details, Totals ---
  364. const rightColumn = (
  365. <div className="space-y-3">
  366. {/* Vehicle & Customer */}
  367. <div className="rounded-lg border p-3 space-y-3">
  368. <div className="space-y-1">
  369. <Label className="text-xs">{t("details.vehicle")}</Label>
  370. <Select value={vehicleId || "none"} onValueChange={handleVehicleChange}>
  371. <SelectTrigger><SelectValue placeholder={t("details.selectVehicle")} /></SelectTrigger>
  372. <SelectContent>
  373. <SelectItem value="none">{t("details.none")}</SelectItem>
  374. {vehicles.map((v) => (
  375. <SelectItem key={v.id} value={v.id}>{v.year} {v.make} {v.model}{v.licensePlate ? ` (${v.licensePlate})` : ""}</SelectItem>
  376. ))}
  377. </SelectContent>
  378. </Select>
  379. </div>
  380. {selectedVehicle && (
  381. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  382. <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  383. <div className="min-w-0 flex-1 text-sm">
  384. <span className="font-medium">{selectedVehicle.year} {selectedVehicle.make} {selectedVehicle.model}</span>
  385. {selectedVehicle.licensePlate && (
  386. <span className="ml-1.5 text-muted-foreground">{selectedVehicle.licensePlate}</span>
  387. )}
  388. </div>
  389. <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setVehicleId("")}>
  390. <X className="h-3 w-3" />
  391. </Button>
  392. </div>
  393. )}
  394. <div className="space-y-1">
  395. <Label className="text-xs">{t("details.customer")}</Label>
  396. <Select value={customerId || "none"} onValueChange={(v) => setCustomerId(v === "none" ? "" : v)}>
  397. <SelectTrigger><SelectValue placeholder={t("details.selectCustomer")} /></SelectTrigger>
  398. <SelectContent>
  399. <SelectItem value="none">{t("details.none")}</SelectItem>
  400. {customers.map((c) => (
  401. <SelectItem key={c.id} value={c.id}>{c.name}{c.company ? ` (${c.company})` : ""}</SelectItem>
  402. ))}
  403. </SelectContent>
  404. </Select>
  405. </div>
  406. {selectedCustomer && (
  407. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  408. <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  409. <div className="min-w-0 flex-1 text-sm">
  410. <span className="font-medium">{selectedCustomer.name}</span>
  411. {selectedCustomer.company && (
  412. <span className="ml-1.5 text-muted-foreground">{selectedCustomer.company}</span>
  413. )}
  414. </div>
  415. <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setCustomerId("")}>
  416. <X className="h-3 w-3" />
  417. </Button>
  418. </div>
  419. )}
  420. </div>
  421. {/* Quote Details */}
  422. <div className="rounded-lg border p-3 space-y-3">
  423. <h3 className="text-sm font-semibold">{t("details.title")}</h3>
  424. <div className="space-y-1">
  425. <Label htmlFor="title" className="text-xs">{t("details.titleLabel")}</Label>
  426. <Input id="title" name="title" placeholder={t("details.titlePlaceholder")} defaultValue={initialData?.title || prefill?.title || ""} required />
  427. </div>
  428. <div className="grid grid-cols-2 gap-2">
  429. <div className="space-y-1">
  430. <Label className="text-xs">{t("details.status")}</Label>
  431. <Select value={status} onValueChange={setStatus}>
  432. <SelectTrigger><SelectValue /></SelectTrigger>
  433. <SelectContent>
  434. <SelectItem value="draft">{t("details.statusDraft")}</SelectItem>
  435. <SelectItem value="sent">{t("details.statusSent")}</SelectItem>
  436. <SelectItem value="accepted">{t("details.statusAccepted")}</SelectItem>
  437. <SelectItem value="rejected">{t("details.statusRejected")}</SelectItem>
  438. </SelectContent>
  439. </Select>
  440. </div>
  441. <div className="space-y-1">
  442. <Label htmlFor="validUntil" className="text-xs">{t("details.validUntil")}</Label>
  443. <Input id="validUntil" name="validUntil" type="date" defaultValue={defaultValidDate} />
  444. </div>
  445. </div>
  446. {prefill?.inspectionId && (
  447. <Link
  448. href={`/inspections/${prefill.inspectionId}`}
  449. className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
  450. >
  451. <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
  452. <span>{t("details.viewInspection")}</span>
  453. </Link>
  454. )}
  455. </div>
  456. {/* Totals */}
  457. <div className="rounded-lg border p-3 space-y-2">
  458. <h3 className="text-sm font-semibold">{t("totals.title")}</h3>
  459. <div className="space-y-2">
  460. <div className="flex items-center justify-between text-sm">
  461. <span className="text-muted-foreground">{t("totals.parts")}</span>
  462. <span>{formatCurrency(partsSubtotal, currencyCode)}</span>
  463. </div>
  464. <div className="flex items-center justify-between text-sm">
  465. <span className="text-muted-foreground">{t("totals.labor")}</span>
  466. <span>{formatCurrency(laborSubtotal, currencyCode)}</span>
  467. </div>
  468. <div className="flex items-center justify-between text-sm">
  469. <span className="text-muted-foreground">{t("totals.subtotal")}</span>
  470. <span className="font-medium">{formatCurrency(subtotal, currencyCode)}</span>
  471. </div>
  472. <div className="flex items-center justify-between text-sm">
  473. <div className="flex items-center gap-2">
  474. <span className="text-muted-foreground">{t("totals.discount")}</span>
  475. <Select value={discountType} onValueChange={setDiscountType}>
  476. <SelectTrigger className="h-7 w-28 text-xs"><SelectValue /></SelectTrigger>
  477. <SelectContent>
  478. <SelectItem value="none">{t("totals.discountNone")}</SelectItem>
  479. <SelectItem value="percentage">{t("totals.discountPercentage")}</SelectItem>
  480. <SelectItem value="fixed">{t("totals.discountFixed")}</SelectItem>
  481. </SelectContent>
  482. </Select>
  483. {discountType !== "none" && (
  484. <Input
  485. type="number" min="0" step="0.01"
  486. value={discountValue}
  487. onChange={(e) => setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value))}
  488. className="h-7 w-20 text-right text-xs"
  489. />
  490. )}
  491. {discountType === "percentage" && <span className="text-muted-foreground">%</span>}
  492. </div>
  493. {discountAmount > 0 && (
  494. <span className="text-destructive">{formatCurrency(-discountAmount, currencyCode)}</span>
  495. )}
  496. </div>
  497. {taxEnabled && (
  498. <div className="flex items-center justify-between text-sm">
  499. <div className="flex items-center gap-2">
  500. <span className="text-muted-foreground">{t("totals.tax")}</span>
  501. <Input
  502. type="number" min="0" step="0.1"
  503. value={taxRate}
  504. onChange={(e) => setTaxRate(e.target.value === "" ? 0 : Number(e.target.value))}
  505. className="h-7 w-20 text-right text-xs"
  506. />
  507. <span className="text-muted-foreground">%</span>
  508. </div>
  509. <span>{formatCurrency(taxAmount, currencyCode)}</span>
  510. </div>
  511. )}
  512. <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
  513. <span>{t("totals.total")}</span>
  514. <span>{formatCurrency(totalAmount, currencyCode)}</span>
  515. </div>
  516. </div>
  517. </div>
  518. </div>
  519. );
  520. return (
  521. <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
  522. {/* Header */}
  523. <div className="flex items-center justify-between border-b px-4 py-2">
  524. <div className="flex items-center gap-3">
  525. <Link
  526. href="/quotes"
  527. className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
  528. >
  529. <ArrowLeft className="h-4 w-4" />
  530. </Link>
  531. <h1 className="text-lg font-semibold">{isEdit ? t("form.editQuote") : t("form.newQuote")}</h1>
  532. </div>
  533. <div className="flex items-center gap-2">
  534. <Button type="button" variant="outline" size="sm" asChild>
  535. <Link href="/quotes">{t("form.cancel")}</Link>
  536. </Button>
  537. <Button type="submit" size="sm" disabled={loading}>
  538. {loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Save className="mr-1 h-3.5 w-3.5" />}
  539. {isEdit ? t("form.updateQuote") : t("form.createQuote")}
  540. </Button>
  541. </div>
  542. </div>
  543. {/* Content */}
  544. {isLarge ? (
  545. <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
  546. <ResizablePanel defaultSize={75} minSize={40}>
  547. <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
  548. <div className="space-y-3 pb-40">{leftColumn}</div>
  549. </div>
  550. </ResizablePanel>
  551. <ResizableHandle withHandle />
  552. <ResizablePanel defaultSize={25} minSize={15}>
  553. <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
  554. <div className="space-y-3 pb-40">{rightColumn}</div>
  555. </div>
  556. </ResizablePanel>
  557. </ResizablePanelGroup>
  558. ) : (
  559. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  560. <div className="space-y-3 pb-40">
  561. {leftColumn}
  562. {rightColumn}
  563. </div>
  564. </div>
  565. )}
  566. </form>
  567. );
  568. }