useQuoteFormState.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. "use client";
  2. import { useState, useCallback, useEffect, useRef, useMemo } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { toast } from "sonner";
  5. import { useGlassModal } from "@/components/glass-modal";
  6. import { useConfirm } from "@/components/confirm-dialog";
  7. import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
  8. import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
  9. import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from "./quote-page-types";
  10. import { emptyPart, makeEmptyLabor } from "./quote-page-types";
  11. interface SelectedVehicle {
  12. id: string;
  13. make: string;
  14. model: string;
  15. year: number;
  16. licensePlate: string | null;
  17. customerId: string | null;
  18. customer: { id: string; name: string } | null;
  19. }
  20. interface SelectedCustomer {
  21. id: string;
  22. name: string;
  23. company: string | null;
  24. }
  25. export function useQuoteFormState({
  26. quote,
  27. currencyCode,
  28. defaultTaxRate,
  29. taxEnabled,
  30. defaultLaborRate,
  31. t,
  32. }: {
  33. quote: QuoteRecord;
  34. currencyCode: string;
  35. defaultTaxRate: number;
  36. taxEnabled: boolean;
  37. defaultLaborRate: number;
  38. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  39. t: (key: string, values?: any) => string;
  40. }) {
  41. const router = useRouter();
  42. const modal = useGlassModal();
  43. const confirm = useConfirm();
  44. // Tab state
  45. const [activeTab, setActiveTab] = useState<"details" | "images" | "documents">("details");
  46. // Form state
  47. const [saving, setSaving] = useState(false);
  48. const [downloading, setDownloading] = useState(false);
  49. const [status, setStatus] = useState(quote.status);
  50. const [customerId, setCustomerId] = useState(quote.customer?.id || "");
  51. const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || "");
  52. const [partItems, setPartItems] = useState<QuotePartInput[]>(
  53. quote.partItems.map((p) => ({ partNumber: p.partNumber || "", name: p.name, quantity: p.quantity, unitPrice: p.unitPrice, total: p.total, excluded: p.excluded ?? false }))
  54. );
  55. const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
  56. quote.laborItems.map((l) => ({ description: l.description, hours: l.hours, rate: l.rate, total: l.total, excluded: l.excluded ?? false }))
  57. );
  58. const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate);
  59. const [discountType, setDiscountType] = useState<string>(quote.discountType || "none");
  60. const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0);
  61. const [noteType, setNoteType] = useState<"public" | "internal">("public");
  62. const [description, setDescription] = useState(quote.description || "");
  63. const [notes, setNotes] = useState(quote.notes || "");
  64. // Dialog state
  65. const [showEmailDialog, setShowEmailDialog] = useState(false);
  66. const [showShareDialog, setShowShareDialog] = useState(false);
  67. const [showConvertDialog, setShowConvertDialog] = useState(false);
  68. const [convertVehicleId, setConvertVehicleId] = useState(quote.vehicle?.id || "");
  69. const [converting, setConverting] = useState(false);
  70. const [resolving, setResolving] = useState(false);
  71. const [defaultValidDate] = useState(() =>
  72. quote.validUntil ? new Date(quote.validUntil).toISOString().split("T")[0] : ""
  73. );
  74. // Track selected vehicle/customer for display (initial from quote data)
  75. const [selectedVehicle, setSelectedVehicle] = useState<SelectedVehicle | null>(
  76. quote.vehicle
  77. ? {
  78. id: quote.vehicle.id,
  79. make: quote.vehicle.make,
  80. model: quote.vehicle.model,
  81. year: quote.vehicle.year,
  82. licensePlate: quote.vehicle.licensePlate,
  83. customerId: quote.customer?.id || null,
  84. customer: quote.customer ? { id: quote.customer.id, name: quote.customer.name } : null,
  85. }
  86. : null
  87. );
  88. const [selectedCustomer, setSelectedCustomer] = useState<SelectedCustomer | null>(
  89. quote.customer
  90. ? { id: quote.customer.id, name: quote.customer.name, company: quote.customer.company }
  91. : null
  92. );
  93. // Unsaved changes tracking & autosave
  94. const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
  95. const [showSaved, setShowSaved] = useState(false);
  96. const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  97. const formRef = useRef<HTMLFormElement>(null);
  98. const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  99. const isSavingRef = useRef(false);
  100. const markDirty = useCallback(() => {
  101. setHasUnsavedChanges(true);
  102. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  103. autosaveTimer.current = setTimeout(() => {
  104. if (!isSavingRef.current && formRef.current) {
  105. formRef.current.requestSubmit();
  106. }
  107. }, 5000);
  108. }, []);
  109. useEffect(() => {
  110. return () => {
  111. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  112. };
  113. }, []);
  114. useEffect(() => {
  115. if (!hasUnsavedChanges) return;
  116. const handler = (e: BeforeUnloadEvent) => {
  117. e.preventDefault();
  118. };
  119. window.addEventListener("beforeunload", handler);
  120. return () => window.removeEventListener("beforeunload", handler);
  121. }, [hasUnsavedChanges]);
  122. // Calculations
  123. const partsSubtotal = useMemo(() => partItems.reduce((sum, p) => p.excluded ? sum : sum + p.total, 0), [partItems]);
  124. const laborSubtotal = useMemo(() => laborItems.reduce((sum, l) => l.excluded ? sum : sum + l.total, 0), [laborItems]);
  125. const subtotal = partsSubtotal + laborSubtotal;
  126. const discountAmount = discountType === "percentage"
  127. ? subtotal * (discountValue / 100)
  128. : discountType === "fixed"
  129. ? Math.min(discountValue, subtotal)
  130. : 0;
  131. const taxAmount = (subtotal - discountAmount) * (taxRate / 100);
  132. const totalAmount = subtotal - discountAmount + taxAmount;
  133. const updatePart = useCallback((index: number, field: keyof QuotePartInput, value: string | number | boolean) => {
  134. setPartItems((prev) => {
  135. const updated = [...prev];
  136. const part = { ...updated[index], [field]: value };
  137. if (field === "quantity" || field === "unitPrice") part.total = Number(part.quantity) * Number(part.unitPrice);
  138. updated[index] = part;
  139. return updated;
  140. });
  141. markDirty();
  142. }, [markDirty]);
  143. const updateLabor = useCallback((index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
  144. setLaborItems((prev) => {
  145. const updated = [...prev];
  146. const labor = { ...updated[index], [field]: value };
  147. if (field === "hours" || field === "rate") labor.total = Number(labor.hours) * Number(labor.rate);
  148. updated[index] = labor;
  149. return updated;
  150. });
  151. markDirty();
  152. }, [markDirty]);
  153. const addPart = useCallback(() => {
  154. setPartItems((prev) => [...prev, emptyPart()]);
  155. markDirty();
  156. }, [markDirty]);
  157. const addLabor = useCallback(() => {
  158. setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)]);
  159. markDirty();
  160. }, [defaultLaborRate, markDirty]);
  161. const deletePart = useCallback((index: number) => {
  162. setPartItems((prev) => prev.filter((_, j) => j !== index));
  163. markDirty();
  164. }, [markDirty]);
  165. const deleteLabor = useCallback((index: number) => {
  166. setLaborItems((prev) => prev.filter((_, j) => j !== index));
  167. markDirty();
  168. }, [markDirty]);
  169. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  170. e.preventDefault();
  171. if (isSavingRef.current) return;
  172. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  173. isSavingRef.current = true;
  174. setSaving(true);
  175. const formData = new FormData(e.currentTarget);
  176. const result = await updateQuote({
  177. id: quote.id,
  178. title: formData.get("title") as string,
  179. description: description || undefined,
  180. status,
  181. validUntil: (formData.get("validUntil") as string) || undefined,
  182. customerId: customerId || undefined,
  183. vehicleId: vehicleId || undefined,
  184. notes: notes || undefined,
  185. partItems: partItems.filter((p) => p.name),
  186. laborItems: laborItems.filter((l) => l.description),
  187. subtotal,
  188. taxRate,
  189. taxAmount,
  190. discountType: discountType === "none" ? undefined : discountType,
  191. discountValue,
  192. discountAmount,
  193. totalAmount,
  194. });
  195. if (result.success) {
  196. setHasUnsavedChanges(false);
  197. if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
  198. setShowSaved(true);
  199. savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000);
  200. router.refresh();
  201. } else {
  202. modal.open("error", "Error", result.error || t("page.failedSave"));
  203. }
  204. isSavingRef.current = false;
  205. setSaving(false);
  206. };
  207. const handleDelete = async () => {
  208. const ok = await confirm({ title: t("page.deleteTitle"), description: t("page.deleteDescription"), confirmLabel: t("page.delete"), destructive: true });
  209. if (!ok) return;
  210. const result = await deleteQuote(quote.id);
  211. if (result.success) {
  212. router.push("/quotes");
  213. router.refresh();
  214. } else {
  215. modal.open("error", "Error", result.error || t("page.failedDelete"));
  216. }
  217. };
  218. const saveNow = async () => {
  219. if (!formRef.current || isSavingRef.current) return;
  220. if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
  221. formRef.current.requestSubmit();
  222. await new Promise<void>((resolve) => {
  223. const check = () => {
  224. if (!isSavingRef.current) return resolve();
  225. setTimeout(check, 50);
  226. };
  227. setTimeout(check, 50);
  228. });
  229. };
  230. const handleDownloadPDF = async () => {
  231. if (hasUnsavedChanges) await saveNow();
  232. setDownloading(true);
  233. try {
  234. const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`);
  235. if (!res.ok) throw new Error(t("page.failedPdf"));
  236. const blob = await res.blob();
  237. const url = URL.createObjectURL(blob);
  238. const a = document.createElement("a");
  239. a.href = url;
  240. a.download = `quote-${quote.quoteNumber || quote.id}.pdf`;
  241. a.click();
  242. URL.revokeObjectURL(url);
  243. } catch {
  244. modal.open("error", "Error", t("page.failedPdf"));
  245. }
  246. setDownloading(false);
  247. };
  248. const handleConvert = async () => {
  249. if (hasUnsavedChanges) await saveNow();
  250. if (!convertVehicleId) {
  251. modal.open("error", "Error", t("page.selectVehicle"));
  252. return;
  253. }
  254. setConverting(true);
  255. const result = await convertQuoteToServiceRecord(quote.id, convertVehicleId);
  256. if (result.success && result.data) {
  257. router.push(`/vehicles/${convertVehicleId}/service/${result.data.id}`);
  258. router.refresh();
  259. } else {
  260. modal.open("error", "Error", result.error || t("page.failedConvert"));
  261. }
  262. setConverting(false);
  263. };
  264. const handleResolveResponse = async () => {
  265. setResolving(true);
  266. const result = await acknowledgeQuoteResponse(quote.id);
  267. if (result.success) {
  268. setStatus("draft");
  269. toast.success(t("page.responseResolved"));
  270. router.refresh();
  271. }
  272. setResolving(false);
  273. };
  274. return {
  275. // Tab
  276. activeTab, setActiveTab,
  277. // Form state
  278. saving, downloading, status, setStatus,
  279. customerId, setCustomerId, vehicleId, setVehicleId,
  280. partItems, laborItems,
  281. taxRate, setTaxRate, taxEnabled,
  282. discountType, setDiscountType, discountValue, setDiscountValue,
  283. noteType, setNoteType, description, setDescription, notes, setNotes,
  284. // Dialogs
  285. showEmailDialog, setShowEmailDialog,
  286. showShareDialog, setShowShareDialog,
  287. showConvertDialog, setShowConvertDialog,
  288. convertVehicleId, setConvertVehicleId, converting,
  289. resolving, defaultValidDate,
  290. // Unsaved
  291. hasUnsavedChanges, showSaved, formRef,
  292. // Computed
  293. partsSubtotal, laborSubtotal, subtotal, discountAmount, taxAmount, totalAmount,
  294. selectedVehicle, setSelectedVehicle, selectedCustomer, setSelectedCustomer,
  295. // Callbacks
  296. markDirty, updatePart, updateLabor, addPart, addLabor, deletePart, deleteLabor,
  297. handleSubmit, handleDelete, handleDownloadPDF,
  298. handleConvert, handleResolveResponse, saveNow,
  299. };
  300. }
  301. export type QuoteFormState = ReturnType<typeof useQuoteFormState>;