useQuoteFormState.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  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 {
  8. updateQuote,
  9. updateQuoteStatus,
  10. deleteQuote,
  11. convertQuoteToServiceRecord,
  12. } from '@/features/quotes/Actions/quoteActions'
  13. import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
  14. import { calculateTotals } from '@/lib/tax'
  15. import { parseTaxComponents } from '@/lib/tax-components'
  16. import { useDeferredCommit } from '@/hooks/use-deferred-commit'
  17. import { isPriceOverridden, lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
  18. import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from './quote-page-types'
  19. import { emptyPart, makeEmptyLabor, makeEmptyService } from './quote-page-types'
  20. interface SelectedVehicle {
  21. id: string
  22. make: string
  23. model: string
  24. year: number
  25. licensePlate: string | null
  26. customerId: string | null
  27. customer: { id: string; name: string } | null
  28. }
  29. interface SelectedCustomer {
  30. id: string
  31. name: string
  32. company: string | null
  33. }
  34. export function useQuoteFormState({
  35. quote,
  36. currencyCode,
  37. defaultTaxRate,
  38. taxEnabled,
  39. defaultLaborRate,
  40. locked = false,
  41. t,
  42. }: {
  43. quote: QuoteRecord
  44. currencyCode: string
  45. defaultTaxRate: number
  46. taxEnabled: boolean
  47. defaultLaborRate: number
  48. /** A locked quote refuses saves, so it must not queue one. */
  49. locked?: boolean
  50. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  51. t: (key: string, values?: any) => string
  52. }) {
  53. const router = useRouter()
  54. const modal = useGlassModal()
  55. const confirm = useConfirm()
  56. // Tab state
  57. const [activeTab, setActiveTab] = useState<'details' | 'images' | 'documents'>('details')
  58. // Form state
  59. const [saving, setSaving] = useState(false)
  60. const [downloading, setDownloading] = useState(false)
  61. const [status, setStatus] = useState(quote.status)
  62. // The customer can answer while the quote is open. A refresh brings the new
  63. // status in as a prop, and the page has to show it rather than the status it
  64. // was opened with; a status chosen here and not yet saved is left alone.
  65. useEffect(() => {
  66. setStatus(quote.status)
  67. }, [quote.status])
  68. const [customerId, setCustomerId] = useState(quote.customer?.id || '')
  69. const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || '')
  70. const [partItems, setPartItems] = useState<QuotePartInput[]>(
  71. quote.partItems.map((p) => ({
  72. partNumber: p.partNumber || '',
  73. name: p.name,
  74. quantity: p.quantity,
  75. unit: p.unit ?? null,
  76. unitCost: p.unitCost ?? 0,
  77. markupPercent: p.markupPercent ?? 0,
  78. unitPrice: p.unitPrice,
  79. total: p.total,
  80. excluded: p.excluded ?? false,
  81. inventoryPartId: p.inventoryPartId ?? null,
  82. // Evaluated once, on settled values from the database; see
  83. // isPriceOverridden for why it must not be recomputed while typing.
  84. priceOverridden: isPriceOverridden({
  85. unitCost: p.unitCost ?? 0,
  86. markupPercent: p.markupPercent ?? 0,
  87. unitPrice: p.unitPrice,
  88. }),
  89. }))
  90. )
  91. const { schedule: scheduleCommit, cancel: cancelCommit } = useDeferredCommit()
  92. const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
  93. quote.laborItems.map((l) => ({
  94. description: l.description,
  95. hours: l.hours,
  96. rate: l.rate,
  97. total: l.total,
  98. pricingType: (l.pricingType as 'hourly' | 'service') || 'hourly',
  99. excluded: l.excluded ?? false,
  100. }))
  101. )
  102. const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate)
  103. const [taxInclusive] = useState<boolean>(quote.taxInclusive ?? false)
  104. // The split the quote was created with; the server re-derives the
  105. // amounts from it on every save.
  106. const [taxComponentDefinitions] = useState(() => parseTaxComponents(quote.taxComponents))
  107. const [discountType, setDiscountType] = useState<string>(quote.discountType || 'none')
  108. const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0)
  109. const [noteType, setNoteType] = useState<'public' | 'internal'>('public')
  110. const [description, setDescription] = useState(quote.description || '')
  111. const [notes, setNotes] = useState(quote.notes || '')
  112. // Dialog state
  113. const [showEmailDialog, setShowEmailDialog] = useState(false)
  114. const [showShareDialog, setShowShareDialog] = useState(false)
  115. const [showConvertDialog, setShowConvertDialog] = useState(false)
  116. const [convertVehicleId, setConvertVehicleId] = useState(quote.vehicle?.id || '')
  117. const [converting, setConverting] = useState(false)
  118. const [resolving, setResolving] = useState(false)
  119. const [defaultValidDate] = useState(() =>
  120. quote.validUntil ? new Date(quote.validUntil).toISOString().split('T')[0] : ''
  121. )
  122. // Track selected vehicle/customer for display (initial from quote data)
  123. const [selectedVehicle, setSelectedVehicle] = useState<SelectedVehicle | null>(
  124. quote.vehicle
  125. ? {
  126. id: quote.vehicle.id,
  127. make: quote.vehicle.make,
  128. model: quote.vehicle.model,
  129. year: quote.vehicle.year,
  130. licensePlate: quote.vehicle.licensePlate,
  131. customerId: quote.customer?.id || null,
  132. customer: quote.customer ? { id: quote.customer.id, name: quote.customer.name } : null,
  133. }
  134. : null
  135. )
  136. const [selectedCustomer, setSelectedCustomer] = useState<SelectedCustomer | null>(
  137. quote.customer
  138. ? { id: quote.customer.id, name: quote.customer.name, company: quote.customer.company }
  139. : null
  140. )
  141. // Unsaved changes tracking & autosave
  142. const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
  143. const [showSaved, setShowSaved] = useState(false)
  144. const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  145. const formRef = useRef<HTMLFormElement>(null)
  146. const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  147. const isSavingRef = useRef(false)
  148. // Custom fields save callback ref
  149. const customFieldsSaveRef = useRef<(() => Promise<{ valid: boolean }>) | null>(null)
  150. const onCustomFieldsReady = useCallback((save: () => Promise<{ valid: boolean }>) => {
  151. customFieldsSaveRef.current = save
  152. }, [])
  153. const markDirty = useCallback(() => {
  154. // A locked quote cannot be saved, so nothing may queue an autosave that
  155. // the server will only refuse five seconds later.
  156. if (locked) return
  157. setHasUnsavedChanges(true)
  158. if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
  159. autosaveTimer.current = setTimeout(() => {
  160. if (!isSavingRef.current && formRef.current) {
  161. formRef.current.requestSubmit()
  162. }
  163. }, 5000)
  164. }, [locked])
  165. // A locked quote refuses a save, so its status is written on its own the
  166. // moment it is chosen. The server still refuses a status that would release
  167. // the lock, which is the owner-or-admin unlock by another route.
  168. const [changingStatus, setChangingStatus] = useState(false)
  169. const changeStatus = async (next: string) => {
  170. // The select also reports a value when the status changes under it, as
  171. // an empty string when no option matches; neither is somebody choosing.
  172. if (!next || next === status) return
  173. if (!locked) {
  174. setStatus(next)
  175. markDirty()
  176. return
  177. }
  178. const previous = status
  179. setStatus(next)
  180. setChangingStatus(true)
  181. const result = await updateQuoteStatus(quote.id, next)
  182. setChangingStatus(false)
  183. if (result.success) {
  184. router.refresh()
  185. } else {
  186. setStatus(previous)
  187. toast.error(result.error || t('page.failedSave'))
  188. }
  189. }
  190. // When the lock engages mid-session, a save already queued can only be
  191. // refused, and "Unsaved changes" would offer one that can never complete.
  192. useEffect(() => {
  193. if (!locked) return
  194. if (autosaveTimer.current) {
  195. clearTimeout(autosaveTimer.current)
  196. autosaveTimer.current = null
  197. }
  198. setHasUnsavedChanges(false)
  199. }, [locked])
  200. useEffect(() => {
  201. return () => {
  202. if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
  203. }
  204. }, [])
  205. useEffect(() => {
  206. if (!hasUnsavedChanges) return
  207. const handler = (e: BeforeUnloadEvent) => {
  208. e.preventDefault()
  209. }
  210. window.addEventListener('beforeunload', handler)
  211. return () => window.removeEventListener('beforeunload', handler)
  212. }, [hasUnsavedChanges])
  213. // Calculations
  214. const partsSubtotal = useMemo(
  215. () => partItems.reduce((sum, p) => (p.excluded ? sum : sum + p.total), 0),
  216. [partItems]
  217. )
  218. const laborSubtotal = useMemo(
  219. () => laborItems.reduce((sum, l) => (l.excluded ? sum : sum + l.total), 0),
  220. [laborItems]
  221. )
  222. const subtotal = partsSubtotal + laborSubtotal
  223. const discountAmount =
  224. discountType === 'percentage'
  225. ? subtotal * (discountValue / 100)
  226. : discountType === 'fixed'
  227. ? Math.min(discountValue, subtotal)
  228. : 0
  229. const {
  230. taxAmount,
  231. totalAmount,
  232. components: taxComponents,
  233. } = calculateTotals({
  234. subtotal,
  235. discountAmount,
  236. taxRate,
  237. taxInclusive,
  238. components: taxComponentDefinitions,
  239. })
  240. // Cost, markup and price stay consistent with each other; repricePartRow
  241. // owns which of them moves, and work order parts use the same rules.
  242. const updatePart = useCallback(
  243. (
  244. index: number,
  245. field: keyof QuotePartInput,
  246. value: string | number | boolean | null,
  247. options: { commit?: boolean } = {}
  248. ) => {
  249. // Any edit ends the wait on the previous one.
  250. cancelCommit()
  251. setPartItems((prev) => {
  252. const updated = [...prev]
  253. const part = { ...updated[index], [field]: value }
  254. if (field === 'unitCost' || field === 'markupPercent' || field === 'unitPrice') {
  255. const priced = repricePartRow(part, field, options)
  256. part.unitPrice = priced.unitPrice
  257. part.markupPercent = priced.markupPercent
  258. part.priceOverridden = priced.priceOverridden
  259. }
  260. if (
  261. field === 'quantity' ||
  262. field === 'unitPrice' ||
  263. field === 'unitCost' ||
  264. field === 'markupPercent'
  265. ) {
  266. part.total = lineTotal(part.quantity, part.unitPrice)
  267. }
  268. updated[index] = part
  269. return updated
  270. })
  271. // A cost typed under a hand-set price restates the margin once typing
  272. // stops, so it lands on its own without needing the field to be left.
  273. if (field === 'unitCost' && !options.commit) {
  274. scheduleCommit(() =>
  275. setPartItems((prev) => {
  276. const row = prev[index]
  277. if (!row?.priceOverridden) return prev
  278. const updated = [...prev]
  279. updated[index] = { ...row, ...repricePartRow(row, 'unitCost', { commit: true }) }
  280. return updated
  281. })
  282. )
  283. }
  284. markDirty()
  285. },
  286. [markDirty, cancelCommit, scheduleCommit]
  287. )
  288. const updateLabor = useCallback(
  289. (index: number, field: keyof QuoteLaborInput, value: string | number | boolean) => {
  290. setLaborItems((prev) => {
  291. const updated = [...prev]
  292. const labor = { ...updated[index], [field]: value }
  293. if (field === 'hours' || field === 'rate')
  294. labor.total = Number(labor.hours) * Number(labor.rate)
  295. updated[index] = labor
  296. return updated
  297. })
  298. markDirty()
  299. },
  300. [markDirty]
  301. )
  302. const addPart = useCallback(() => {
  303. setPartItems((prev) => [...prev, emptyPart()])
  304. markDirty()
  305. }, [markDirty])
  306. const addLabor = useCallback(() => {
  307. setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])
  308. markDirty()
  309. }, [defaultLaborRate, markDirty])
  310. const addService = useCallback(() => {
  311. setLaborItems((prev) => [...prev, makeEmptyService()])
  312. markDirty()
  313. }, [markDirty])
  314. const addLaborBulk = useCallback(
  315. (items: QuoteLaborInput[]) => {
  316. setLaborItems((prev) => [...prev, ...items])
  317. markDirty()
  318. },
  319. [markDirty]
  320. )
  321. const addPartBulk = useCallback(
  322. (items: QuotePartInput[]) => {
  323. setPartItems((prev) => [...prev, ...items])
  324. markDirty()
  325. },
  326. [markDirty]
  327. )
  328. const deletePart = useCallback(
  329. (index: number) => {
  330. setPartItems((prev) => prev.filter((_, j) => j !== index))
  331. markDirty()
  332. },
  333. [markDirty]
  334. )
  335. const deleteLabor = useCallback(
  336. (index: number) => {
  337. setLaborItems((prev) => prev.filter((_, j) => j !== index))
  338. markDirty()
  339. },
  340. [markDirty]
  341. )
  342. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  343. e.preventDefault()
  344. // currentTarget is nulled once the event has finished dispatching, which
  345. // happens during the custom-fields await below. Read the form now.
  346. const form = e.currentTarget
  347. if (isSavingRef.current) return
  348. if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
  349. // Validate and save custom fields before saving; a locked quote saves nothing.
  350. if (!locked && customFieldsSaveRef.current) {
  351. try {
  352. const { valid } = await customFieldsSaveRef.current()
  353. if (!valid) {
  354. toast.error(t('page.customFieldsInvalid'))
  355. return
  356. }
  357. } catch (err) {
  358. console.error('Custom fields save error:', err)
  359. toast.error(t('page.customFieldsSaveFailed'))
  360. return
  361. }
  362. }
  363. isSavingRef.current = true
  364. setSaving(true)
  365. try {
  366. const formData = new FormData(form)
  367. const result = await updateQuote({
  368. id: quote.id,
  369. title: formData.get('title') as string,
  370. // Emptied fields go as '' (and a removed discount as 'none') so the
  371. // update action clears them; undefined would leave the old value.
  372. description,
  373. status,
  374. validUntil: (formData.get('validUntil') as string | null) ?? '',
  375. customerId,
  376. vehicleId,
  377. notes,
  378. partItems: partItems.filter((p) => p.name),
  379. laborItems: laborItems.filter((l) => l.description),
  380. subtotal,
  381. taxRate,
  382. taxInclusive,
  383. taxAmount,
  384. discountType,
  385. discountValue,
  386. discountAmount,
  387. totalAmount,
  388. })
  389. if (result.success) {
  390. setHasUnsavedChanges(false)
  391. if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
  392. setShowSaved(true)
  393. savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000)
  394. router.refresh()
  395. } else {
  396. modal.open('error', 'Error', result.error || t('page.failedSave'))
  397. }
  398. } catch (err) {
  399. console.error('Quote save error:', err)
  400. modal.open('error', 'Error', t('page.failedSave'))
  401. } finally {
  402. // Whatever happened, the save button must stop spinning.
  403. isSavingRef.current = false
  404. setSaving(false)
  405. }
  406. }
  407. const handleDelete = async () => {
  408. const ok = await confirm({
  409. title: t('page.deleteTitle'),
  410. description: t('page.deleteDescription'),
  411. confirmLabel: t('page.delete'),
  412. destructive: true,
  413. })
  414. if (!ok) return
  415. const result = await deleteQuote(quote.id)
  416. if (result.success) {
  417. router.push('/quotes')
  418. router.refresh()
  419. } else {
  420. modal.open('error', 'Error', result.error || t('page.failedDelete'))
  421. }
  422. }
  423. const saveNow = async () => {
  424. if (!formRef.current || isSavingRef.current) return
  425. if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
  426. formRef.current.requestSubmit()
  427. await new Promise<void>((resolve) => {
  428. const check = () => {
  429. if (!isSavingRef.current) return resolve()
  430. setTimeout(check, 50)
  431. }
  432. setTimeout(check, 50)
  433. })
  434. }
  435. const handleDownloadPDF = async () => {
  436. if (hasUnsavedChanges) await saveNow()
  437. setDownloading(true)
  438. try {
  439. const res = await fetch(`/api/protected/quotes/${quote.id}/pdf`)
  440. if (!res.ok) throw new Error(t('page.failedPdf'))
  441. const blob = await res.blob()
  442. const url = URL.createObjectURL(blob)
  443. const a = document.createElement('a')
  444. a.href = url
  445. a.download = `quote-${quote.quoteNumber || quote.id}.pdf`
  446. a.click()
  447. URL.revokeObjectURL(url)
  448. } catch {
  449. modal.open('error', 'Error', t('page.failedPdf'))
  450. }
  451. setDownloading(false)
  452. }
  453. const handleConvert = async () => {
  454. if (hasUnsavedChanges) await saveNow()
  455. if (!convertVehicleId) {
  456. modal.open('error', 'Error', t('page.selectVehicle'))
  457. return
  458. }
  459. setConverting(true)
  460. const result = await convertQuoteToServiceRecord(quote.id, convertVehicleId)
  461. if (result.success && result.data) {
  462. router.push(`/vehicles/${convertVehicleId}/service/${result.data.id}`)
  463. router.refresh()
  464. } else {
  465. modal.open('error', 'Error', result.error || t('page.failedConvert'))
  466. }
  467. setConverting(false)
  468. }
  469. const handleResolveResponse = async () => {
  470. setResolving(true)
  471. const result = await acknowledgeQuoteResponse(quote.id)
  472. if (result.success) {
  473. // A change request goes back to draft; an acceptance keeps its status.
  474. if (result.data) setStatus(result.data.status)
  475. toast.success(t('page.responseResolved'))
  476. router.refresh()
  477. }
  478. setResolving(false)
  479. }
  480. return {
  481. // Tab
  482. activeTab,
  483. setActiveTab,
  484. // Form state
  485. saving,
  486. downloading,
  487. status,
  488. setStatus,
  489. customerId,
  490. setCustomerId,
  491. vehicleId,
  492. setVehicleId,
  493. partItems,
  494. laborItems,
  495. taxRate,
  496. setTaxRate,
  497. taxEnabled,
  498. taxInclusive,
  499. taxComponents,
  500. discountType,
  501. setDiscountType,
  502. discountValue,
  503. setDiscountValue,
  504. noteType,
  505. setNoteType,
  506. description,
  507. setDescription,
  508. notes,
  509. setNotes,
  510. // Dialogs
  511. showEmailDialog,
  512. setShowEmailDialog,
  513. showShareDialog,
  514. setShowShareDialog,
  515. showConvertDialog,
  516. setShowConvertDialog,
  517. convertVehicleId,
  518. setConvertVehicleId,
  519. converting,
  520. resolving,
  521. changeStatus,
  522. changingStatus,
  523. defaultValidDate,
  524. // Unsaved
  525. hasUnsavedChanges,
  526. showSaved,
  527. formRef,
  528. // Computed
  529. partsSubtotal,
  530. laborSubtotal,
  531. subtotal,
  532. discountAmount,
  533. taxAmount,
  534. totalAmount,
  535. selectedVehicle,
  536. setSelectedVehicle,
  537. selectedCustomer,
  538. setSelectedCustomer,
  539. // Callbacks
  540. markDirty,
  541. onCustomFieldsReady,
  542. updatePart,
  543. updateLabor,
  544. addPart,
  545. addPartBulk,
  546. addLabor,
  547. addService,
  548. addLaborBulk,
  549. deletePart,
  550. deleteLabor,
  551. handleSubmit,
  552. handleDelete,
  553. handleDownloadPDF,
  554. handleConvert,
  555. handleResolveResponse,
  556. saveNow,
  557. }
  558. }
  559. export type QuoteFormState = ReturnType<typeof useQuoteFormState>