QuotePageClient.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. 'use client'
  2. import { DocumentLockBanner } from '@/components/document-lock-banner'
  3. import { setQuoteEditUnlocked } from '@/features/settings/Actions/documentLockActions'
  4. import type { LockState } from '@/lib/document-lock'
  5. import { useState, useCallback, useEffect } from 'react'
  6. import { useRouter } from 'next/navigation'
  7. import Link from 'next/link'
  8. import { useTranslations } from 'next-intl'
  9. import { Badge } from '@/components/ui/badge'
  10. import { Button } from '@/components/ui/button'
  11. import { ButtonGroup } from '@/components/ui/button-group'
  12. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
  13. import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
  14. import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
  15. import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
  16. import { revokeQuotePublicLink } from '@/features/quotes/Actions/quoteShareActions'
  17. import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
  18. import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
  19. import { QuoteImagesManager } from '@/features/quotes/Components/QuoteImagesManager'
  20. import { QuoteDocumentsManager } from '@/features/quotes/Components/QuoteDocumentsManager'
  21. import {
  22. ArrowLeft,
  23. Camera,
  24. Download,
  25. Eye,
  26. FileText,
  27. Globe,
  28. Loader2,
  29. Mail,
  30. Paperclip,
  31. Save,
  32. Trash2,
  33. } from 'lucide-react'
  34. import { getCurrencySymbol } from '@/lib/format'
  35. import type { QuoteAttachment, QuoteRecord, TabType } from './quote-page-types'
  36. import { statusColors } from './quote-page-types'
  37. import { useQuoteFormState } from './useQuoteFormState'
  38. import { useSaveShortcut } from '@/hooks/use-save-shortcut'
  39. import {
  40. LaborPresetPickerDialog,
  41. type LaborPresetOption,
  42. } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
  43. import { QuotePartsEditor } from './QuotePartsEditor'
  44. import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
  45. import { QuoteLaborEditor } from './QuoteLaborEditor'
  46. import { QuoteNotesEditor } from './QuoteNotesEditor'
  47. import { QuoteRightColumn } from './QuoteRightColumn'
  48. import { VehicleCombobox } from './VehicleCombobox'
  49. import { lineTotal } from '@/features/inventory/Lib/partPricing'
  50. const LG_BREAKPOINT = 1024
  51. function useIsLargeScreen() {
  52. const [isLarge, setIsLarge] = useState(false)
  53. useEffect(() => {
  54. const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`)
  55. const onChange = () => setIsLarge(mql.matches)
  56. mql.addEventListener('change', onChange)
  57. setIsLarge(mql.matches)
  58. return () => mql.removeEventListener('change', onChange)
  59. }, [])
  60. return isLarge
  61. }
  62. export function QuotePageClient({
  63. quote,
  64. organizationId,
  65. lockState,
  66. canUnlock,
  67. currencyCode = 'USD',
  68. defaultTaxRate = 0,
  69. taxEnabled = true,
  70. defaultLaborRate = 0,
  71. laborPresets = [],
  72. inventoryParts = [],
  73. smsEnabled = false,
  74. emailEnabled = false,
  75. imageAttachments = [],
  76. documentAttachments = [],
  77. maxImages,
  78. maxDocuments,
  79. }: {
  80. quote: QuoteRecord
  81. organizationId: string
  82. /**
  83. * Required rather than defaulted: a call site that forgot to wire it would
  84. * render a locked quote as editable, which is the trap this exists to close.
  85. */
  86. lockState: LockState
  87. canUnlock: boolean
  88. currencyCode?: string
  89. defaultTaxRate?: number
  90. taxEnabled?: boolean
  91. defaultLaborRate?: number
  92. laborPresets?: LaborPresetOption[]
  93. inventoryParts?: InventoryPartOption[]
  94. smsEnabled?: boolean
  95. emailEnabled?: boolean
  96. imageAttachments?: QuoteAttachment[]
  97. documentAttachments?: QuoteAttachment[]
  98. maxImages?: number
  99. maxDocuments?: number
  100. }) {
  101. const cs = getCurrencySymbol(currencyCode)
  102. const router = useRouter()
  103. const isLarge = useIsLargeScreen()
  104. const t = useTranslations('quotes')
  105. const tPreview = useTranslations('common.pdfPreview')
  106. const state = useQuoteFormState({
  107. quote,
  108. currencyCode,
  109. defaultTaxRate,
  110. taxEnabled,
  111. defaultLaborRate,
  112. locked: lockState.locked,
  113. t,
  114. })
  115. // Sending is what can lock the quote — by email or by link — so the page is
  116. // re-rendered to pick up the lock rather than leaving the fieldset open and
  117. // the next autosave to be refused.
  118. const handleQuoteSent = useCallback(() => router.refresh(), [router])
  119. useSaveShortcut(() => {
  120. if (state.hasUnsavedChanges) return state.saveNow()
  121. })
  122. const [showPresetPicker, setShowPresetPicker] = useState(false)
  123. const [showPdfPreview, setShowPdfPreview] = useState(false)
  124. const handleSelectPreset = useCallback(
  125. (preset: LaborPresetOption) => {
  126. const newItems = preset.items.map((item) => ({
  127. description: item.description,
  128. hours: item.hours,
  129. rate: item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate,
  130. total:
  131. item.hours *
  132. (item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate),
  133. pricingType: (item.pricingType as 'hourly' | 'service') || 'hourly',
  134. excluded: false,
  135. }))
  136. state.addLaborBulk(newItems)
  137. if (preset.parts?.length) {
  138. const newParts = preset.parts.map((part) => ({
  139. name: part.name,
  140. partNumber: part.partNumber || '',
  141. quantity: part.quantity,
  142. unit: part.unit ?? null,
  143. unitCost: 0,
  144. markupPercent: 0,
  145. unitPrice: part.unitPrice,
  146. total: lineTotal(part.quantity, part.unitPrice),
  147. excluded: false,
  148. inventoryPartId: part.inventoryPartId ?? null,
  149. }))
  150. state.addPartBulk(newParts)
  151. }
  152. },
  153. [defaultLaborRate, state]
  154. )
  155. const handleDescriptionChange = useCallback(
  156. (v: string) => {
  157. state.setDescription(v)
  158. state.markDirty()
  159. },
  160. [state]
  161. )
  162. const handleNotesChange = useCallback(
  163. (v: string) => {
  164. state.setNotes(v)
  165. state.markDirty()
  166. },
  167. [state]
  168. )
  169. const handleNoteTypeChange = useCallback(
  170. (v: 'public' | 'internal') => {
  171. state.setNoteType(v)
  172. },
  173. [state]
  174. )
  175. const handleRevoke = useCallback(async () => {
  176. await revokeQuotePublicLink(quote.id)
  177. router.refresh()
  178. }, [quote.id, router])
  179. const leftColumn = (
  180. <div className="space-y-3">
  181. <QuotePartsEditor
  182. partItems={state.partItems}
  183. currencyCode={currencyCode}
  184. partsSubtotal={state.partsSubtotal}
  185. onUpdate={state.updatePart}
  186. onDelete={state.deletePart}
  187. onAdd={state.addPart}
  188. onAddBulk={state.addPartBulk}
  189. inventoryParts={inventoryParts}
  190. t={t}
  191. />
  192. <QuoteLaborEditor
  193. laborItems={state.laborItems}
  194. currencyCode={currencyCode}
  195. cs={cs}
  196. laborSubtotal={state.laborSubtotal}
  197. onUpdate={state.updateLabor}
  198. onDelete={state.deleteLabor}
  199. onAdd={state.addLabor}
  200. onAddService={state.addService}
  201. hasPresets={laborPresets.length > 0}
  202. onOpenPresets={() => setShowPresetPicker(true)}
  203. t={t}
  204. />
  205. <QuoteNotesEditor
  206. noteType={state.noteType}
  207. onNoteTypeChange={handleNoteTypeChange}
  208. description={state.description}
  209. onDescriptionChange={handleDescriptionChange}
  210. notes={state.notes}
  211. onNotesChange={handleNotesChange}
  212. t={t}
  213. />
  214. </div>
  215. )
  216. const rightColumn = (
  217. <QuoteRightColumn
  218. state={state}
  219. quote={quote}
  220. organizationId={organizationId}
  221. currencyCode={currencyCode}
  222. t={t}
  223. onRevoke={handleRevoke}
  224. />
  225. )
  226. return (
  227. <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
  228. {/* Header */}
  229. <div className="shrink-0 border-b bg-background px-4 py-2">
  230. <div className="flex items-center justify-between">
  231. <Link
  232. href="/quotes"
  233. className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
  234. >
  235. <ArrowLeft className="h-4 w-4 shrink-0" />
  236. <div className="min-w-0">
  237. <div className="flex items-center gap-2">
  238. <Badge
  239. variant="outline"
  240. className={`shrink-0 text-xs capitalize ${statusColors[state.status] || ''}`}
  241. >
  242. {state.status}
  243. </Badge>
  244. <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
  245. </div>
  246. <p className="truncate text-xs text-muted-foreground">
  247. {quote.quoteNumber || t('page.quote')}
  248. {quote.customer ? ` · ${quote.customer.name}` : ''}
  249. </p>
  250. </div>
  251. </Link>
  252. <div className="flex shrink-0 items-center gap-2">
  253. {state.hasUnsavedChanges && (
  254. <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
  255. {t('page.unsavedChanges')}
  256. </span>
  257. )}
  258. {state.showSaved && !state.hasUnsavedChanges && (
  259. <span className="text-xs font-medium text-green-600 dark:text-green-400">
  260. {t('page.saved')}
  261. </span>
  262. )}
  263. <Button
  264. type="submit"
  265. form="quote-form"
  266. size="sm"
  267. disabled={state.saving}
  268. variant={state.hasUnsavedChanges ? 'default' : 'outline'}
  269. className={state.hasUnsavedChanges ? 'animate-pulse' : ''}
  270. >
  271. {state.saving ? (
  272. <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
  273. ) : (
  274. <Save className="mr-1 h-3.5 w-3.5" />
  275. )}
  276. {t('page.save')}
  277. </Button>
  278. <ButtonGroup>
  279. <Button
  280. variant="outline"
  281. size="sm"
  282. disabled={state.saving}
  283. onClick={async () => {
  284. if (state.saving) return
  285. if (state.hasUnsavedChanges) await state.saveNow()
  286. setShowPdfPreview(true)
  287. }}
  288. >
  289. <Eye className="mr-1 h-3.5 w-3.5" />
  290. {tPreview('preview')}
  291. </Button>
  292. <Button
  293. variant="outline"
  294. size="sm"
  295. onClick={state.handleDownloadPDF}
  296. disabled={state.downloading}
  297. >
  298. {state.downloading ? (
  299. <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
  300. ) : (
  301. <Download className="mr-1 h-3.5 w-3.5" />
  302. )}
  303. {t('page.pdf')}
  304. </Button>
  305. <Button
  306. variant="outline"
  307. size="sm"
  308. disabled={state.saving}
  309. onClick={async () => {
  310. if (state.saving) return
  311. if (state.hasUnsavedChanges) await state.saveNow()
  312. state.setShowEmailDialog(true)
  313. }}
  314. >
  315. {state.saving ? (
  316. <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
  317. ) : (
  318. <Mail className="mr-1 h-3.5 w-3.5" />
  319. )}
  320. {t('page.email')}
  321. </Button>
  322. <Button
  323. variant="outline"
  324. size="sm"
  325. disabled={state.saving}
  326. onClick={async () => {
  327. if (state.saving) return
  328. if (state.hasUnsavedChanges) await state.saveNow()
  329. state.setShowShareDialog(true)
  330. }}
  331. >
  332. {state.saving ? (
  333. <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
  334. ) : (
  335. <Globe className="mr-1 h-3.5 w-3.5" />
  336. )}
  337. {t('page.share')}
  338. </Button>
  339. <Button
  340. variant="outline"
  341. size="sm"
  342. className="text-destructive hover:bg-destructive/10"
  343. onClick={state.handleDelete}
  344. >
  345. <Trash2 className="mr-1 h-3.5 w-3.5" />
  346. {t('page.delete')}
  347. </Button>
  348. </ButtonGroup>
  349. </div>
  350. </div>
  351. </div>
  352. {/* Tabs */}
  353. <div className="shrink-0 border-b bg-background px-4">
  354. <div className="flex gap-1">
  355. {[
  356. { key: 'details' as TabType, label: t('page.tabs.details'), icon: FileText },
  357. { key: 'images' as TabType, label: t('page.tabs.images'), icon: Camera },
  358. { key: 'documents' as TabType, label: t('page.tabs.documents'), icon: Paperclip },
  359. ].map(({ key, label, icon: Icon }) => (
  360. <button
  361. key={key}
  362. type="button"
  363. onClick={() => state.setActiveTab(key)}
  364. className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
  365. state.activeTab === key
  366. ? 'border-primary text-primary'
  367. : 'border-transparent text-muted-foreground hover:text-foreground'
  368. }`}
  369. >
  370. <Icon className="h-3.5 w-3.5" />
  371. {label}
  372. </button>
  373. ))}
  374. </div>
  375. </div>
  376. {(lockState.locked || lockState.unlockedAt) && (
  377. <div className="shrink-0 px-4 pt-3">
  378. <DocumentLockBanner
  379. state={lockState}
  380. kind="quote"
  381. canUnlock={canUnlock}
  382. onSetUnlocked={(unlocked) => setQuoteEditUnlocked(quote.id, unlocked)}
  383. />
  384. </div>
  385. )}
  386. {/* Tab Content */}
  387. {state.activeTab === 'details' && (
  388. <form
  389. id="quote-form"
  390. ref={state.formRef}
  391. onSubmit={state.handleSubmit}
  392. className="flex min-h-0 flex-1 flex-col overflow-hidden"
  393. >
  394. {/* A locked quote offers no editing, rather than letting someone
  395. retype a line and meet the refusal on save. display:contents
  396. keeps the layout exactly as it was. */}
  397. <fieldset disabled={lockState.locked} className="contents">
  398. {isLarge ? (
  399. <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
  400. <ResizablePanel defaultSize={75} minSize={40}>
  401. <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
  402. <div className="space-y-3 pb-40">{leftColumn}</div>
  403. </div>
  404. </ResizablePanel>
  405. <ResizableHandle withHandle />
  406. <ResizablePanel defaultSize={25} minSize={15}>
  407. <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
  408. <div className="space-y-3 pb-40">{rightColumn}</div>
  409. </div>
  410. </ResizablePanel>
  411. </ResizablePanelGroup>
  412. ) : (
  413. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  414. <div className="space-y-3 pb-40">
  415. {leftColumn}
  416. {rightColumn}
  417. </div>
  418. </div>
  419. )}
  420. </fieldset>
  421. </form>
  422. )}
  423. {state.activeTab === 'images' && (
  424. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  425. <div className="mx-auto max-w-4xl pb-40">
  426. <QuoteImagesManager
  427. quoteId={quote.id}
  428. initialImages={imageAttachments}
  429. maxImages={maxImages}
  430. />
  431. </div>
  432. </div>
  433. )}
  434. {state.activeTab === 'documents' && (
  435. <div className="flex-1 overflow-y-auto overscroll-contain p-4">
  436. <div className="mx-auto max-w-4xl pb-40">
  437. <QuoteDocumentsManager
  438. quoteId={quote.id}
  439. initialDocuments={documentAttachments}
  440. maxDocuments={maxDocuments}
  441. />
  442. </div>
  443. </div>
  444. )}
  445. <LaborPresetPickerDialog
  446. open={showPresetPicker}
  447. onOpenChange={setShowPresetPicker}
  448. laborPresets={laborPresets}
  449. onSelectPreset={handleSelectPreset}
  450. />
  451. {/* Dialogs */}
  452. <PdfPreviewDialog
  453. open={showPdfPreview}
  454. onOpenChange={setShowPdfPreview}
  455. url={`/api/protected/quotes/${quote.id}/pdf`}
  456. />
  457. <SendEmailDialog
  458. open={state.showEmailDialog}
  459. onOpenChange={state.setShowEmailDialog}
  460. defaultEmail={quote.customer?.email || ''}
  461. entityLabel={t('page.entityLabel')}
  462. onSend={async (email, message, attachPdf) => {
  463. const result = await sendQuoteEmail({
  464. quoteId: quote.id,
  465. recipientEmail: email,
  466. message,
  467. attachPdf,
  468. })
  469. if (result.success) handleQuoteSent()
  470. return result
  471. }}
  472. />
  473. <QuoteShareDialog
  474. open={state.showShareDialog}
  475. onOpenChange={state.setShowShareDialog}
  476. quoteId={quote.id}
  477. organizationId={organizationId}
  478. initialToken={quote.publicToken}
  479. onSent={handleQuoteSent}
  480. customer={quote.customer}
  481. smsEnabled={smsEnabled}
  482. emailEnabled={emailEnabled}
  483. />
  484. <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>
  485. <DialogContent className="max-w-md">
  486. <DialogHeader>
  487. <DialogTitle>{t('page.convertTitle')}</DialogTitle>
  488. </DialogHeader>
  489. <div className="space-y-4">
  490. <p className="text-sm text-muted-foreground">{t('page.convertDescription')}</p>
  491. <VehicleCombobox
  492. value={state.convertVehicleId}
  493. initialVehicle={state.selectedVehicle}
  494. placeholder={t('details.selectVehicle')}
  495. noneLabel={t('details.none')}
  496. onChange={(id) => state.setConvertVehicleId(id)}
  497. />
  498. <div className="flex gap-2">
  499. <Button
  500. type="button"
  501. onClick={state.handleConvert}
  502. disabled={state.converting || !state.convertVehicleId}
  503. >
  504. {state.converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
  505. {t('page.convert')}
  506. </Button>
  507. <Button
  508. type="button"
  509. variant="ghost"
  510. onClick={() => state.setShowConvertDialog(false)}
  511. >
  512. {t('page.cancel')}
  513. </Button>
  514. </div>
  515. </div>
  516. </DialogContent>
  517. </Dialog>
  518. </div>
  519. )
  520. }