QuoteRightColumn.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. 'use client'
  2. import { memo, useState } from 'react'
  3. import Link from 'next/link'
  4. import { Button } from '@/components/ui/button'
  5. import { Input } from '@/components/ui/input'
  6. import { DateInput } from '@/components/ui/date-input'
  7. import { Label } from '@/components/ui/label'
  8. import {
  9. Select,
  10. SelectContent,
  11. SelectItem,
  12. SelectTrigger,
  13. SelectValue,
  14. } from '@/components/ui/select'
  15. import {
  16. ArrowRight,
  17. Car,
  18. Check,
  19. ClipboardCheck,
  20. Loader2,
  21. MessageSquare,
  22. Users,
  23. X,
  24. } from 'lucide-react'
  25. import { SharedLinkCard } from '@/components/shared-link-card'
  26. import { useFormatCurrency } from '@/components/currency-settings-context'
  27. import { netLineTotal } from '@/lib/tax'
  28. import { taxComponentLabel } from '@/lib/tax-components'
  29. import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
  30. import type { QuoteFormState } from './useQuoteFormState'
  31. import type { QuoteRecord } from './quote-page-types'
  32. import { VehicleCombobox } from './VehicleCombobox'
  33. import { CustomerCombobox } from './CustomerCombobox'
  34. interface QuoteRightColumnProps {
  35. state: QuoteFormState
  36. quote: QuoteRecord
  37. organizationId: string
  38. currencyCode: string
  39. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  40. t: (key: string, values?: any) => string
  41. onRevoke: () => Promise<void>
  42. }
  43. export const QuoteRightColumn = memo(function QuoteRightColumn({
  44. state,
  45. quote,
  46. organizationId,
  47. currencyCode,
  48. t,
  49. onRevoke,
  50. }: QuoteRightColumnProps) {
  51. const formatCurrency = useFormatCurrency()
  52. const [validUntil, setValidUntil] = useState(state.defaultValidDate)
  53. return (
  54. <div className="space-y-3">
  55. {/* Convert to Work Order */}
  56. {quote.status !== 'converted' && (
  57. <div className="rounded-lg border p-3">
  58. <Button
  59. type="button"
  60. variant="outline"
  61. size="sm"
  62. className="w-full"
  63. onClick={() => state.setShowConvertDialog(true)}
  64. >
  65. <ArrowRight className="mr-1 h-3.5 w-3.5" /> {t('page.convertToWorkOrder')}
  66. </Button>
  67. </div>
  68. )}
  69. {/* Shared Link */}
  70. {quote.publicToken && (
  71. <SharedLinkCard
  72. publicToken={quote.publicToken}
  73. organizationId={organizationId}
  74. type="quote"
  75. sharedAt={quote.sharedAt}
  76. viewCount={quote.viewCount}
  77. lastViewedAt={quote.lastViewedAt}
  78. onRevoke={onRevoke}
  79. />
  80. )}
  81. {/* Vehicle & Customer */}
  82. <div className="rounded-lg border p-3 space-y-3">
  83. <div className="space-y-1">
  84. <Label className="text-xs">{t('details.customer')}</Label>
  85. <CustomerCombobox
  86. value={state.customerId}
  87. initialCustomer={state.selectedCustomer}
  88. placeholder={t('details.selectCustomer')}
  89. noneLabel={t('details.none')}
  90. onChange={(id, customer) => {
  91. state.setCustomerId(id)
  92. state.setSelectedCustomer(customer)
  93. // A vehicle belonging to another customer no longer fits
  94. if (id && state.selectedVehicle && state.selectedVehicle.customerId !== id) {
  95. state.setVehicleId('')
  96. state.setSelectedVehicle(null)
  97. }
  98. state.markDirty()
  99. }}
  100. />
  101. </div>
  102. {state.selectedCustomer && (
  103. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  104. <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  105. <Link
  106. href={`/customers/${state.selectedCustomer.id}`}
  107. target="_blank"
  108. className="min-w-0 flex-1 text-sm hover:underline"
  109. >
  110. <span className="font-medium">{state.selectedCustomer.name}</span>
  111. {state.selectedCustomer.company && (
  112. <span className="ml-1.5 text-muted-foreground">
  113. {state.selectedCustomer.company}
  114. </span>
  115. )}
  116. </Link>
  117. <Button
  118. type="button"
  119. variant="ghost"
  120. size="icon"
  121. className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
  122. onClick={() => {
  123. state.setCustomerId('')
  124. state.setSelectedCustomer(null)
  125. state.markDirty()
  126. }}
  127. aria-label={t('details.clearCustomer')}
  128. >
  129. <X className="h-3 w-3" />
  130. </Button>
  131. </div>
  132. )}
  133. <div className="space-y-1">
  134. <Label className="text-xs">{t('details.vehicle')}</Label>
  135. <VehicleCombobox
  136. value={state.vehicleId}
  137. customerId={state.customerId || undefined}
  138. initialVehicle={state.selectedVehicle}
  139. placeholder={t('details.selectVehicle')}
  140. noneLabel={t('details.none')}
  141. onChange={(id, vehicle) => {
  142. state.setVehicleId(id)
  143. state.setSelectedVehicle(vehicle)
  144. if (vehicle?.customerId) {
  145. state.setCustomerId(vehicle.customerId)
  146. if (vehicle.customer) {
  147. state.setSelectedCustomer({
  148. id: vehicle.customer.id,
  149. name: vehicle.customer.name,
  150. company: null,
  151. })
  152. }
  153. }
  154. state.markDirty()
  155. }}
  156. />
  157. </div>
  158. {state.selectedVehicle && (
  159. <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
  160. <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  161. <Link
  162. href={`/vehicles/${state.selectedVehicle.id}`}
  163. target="_blank"
  164. className="min-w-0 flex-1 text-sm hover:underline"
  165. >
  166. <span className="font-medium">
  167. {state.selectedVehicle.year} {state.selectedVehicle.make}{' '}
  168. {state.selectedVehicle.model}
  169. </span>
  170. {state.selectedVehicle.licensePlate && (
  171. <span className="ml-1.5 text-muted-foreground">
  172. {state.selectedVehicle.licensePlate}
  173. </span>
  174. )}
  175. </Link>
  176. <Button
  177. type="button"
  178. variant="ghost"
  179. size="icon"
  180. className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
  181. onClick={() => {
  182. state.setVehicleId('')
  183. state.setSelectedVehicle(null)
  184. state.markDirty()
  185. }}
  186. aria-label={t('details.clearVehicle')}
  187. >
  188. <X className="h-3 w-3" />
  189. </Button>
  190. </div>
  191. )}
  192. </div>
  193. {/* Quote Details */}
  194. <div className="rounded-lg border p-3 space-y-3">
  195. <h3 className="text-sm font-semibold">{t('details.title')}</h3>
  196. <div className="space-y-1">
  197. <Label htmlFor="title" className="text-xs">
  198. {t('details.titleLabel')}
  199. </Label>
  200. <Input
  201. id="title"
  202. name="title"
  203. placeholder={t('details.titlePlaceholder')}
  204. defaultValue={quote.title}
  205. required
  206. onChange={state.markDirty}
  207. />
  208. </div>
  209. <div className="grid grid-cols-2 gap-2">
  210. <div className="space-y-1">
  211. <Label className="text-xs">{t('details.status')}</Label>
  212. <Select
  213. value={state.status}
  214. onValueChange={(v) => {
  215. state.setStatus(v)
  216. state.markDirty()
  217. }}
  218. >
  219. <SelectTrigger>
  220. <SelectValue />
  221. </SelectTrigger>
  222. <SelectContent>
  223. <SelectItem value="draft">{t('details.statusDraft')}</SelectItem>
  224. <SelectItem value="sent">{t('details.statusSent')}</SelectItem>
  225. <SelectItem value="accepted">{t('details.statusAccepted')}</SelectItem>
  226. <SelectItem value="rejected">{t('details.statusRejected')}</SelectItem>
  227. </SelectContent>
  228. </Select>
  229. </div>
  230. <div className="space-y-1">
  231. <Label htmlFor="validUntil" className="text-xs">
  232. {t('details.validUntil')}
  233. </Label>
  234. <DateInput
  235. id="validUntil"
  236. name="validUntil"
  237. value={validUntil}
  238. onChange={(v) => {
  239. setValidUntil(v)
  240. state.markDirty()
  241. }}
  242. />
  243. </div>
  244. </div>
  245. {quote.inspectionId && (
  246. <Link
  247. href={`/inspections/${quote.inspectionId}`}
  248. 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"
  249. >
  250. <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
  251. <span>{t('details.viewInspection')}</span>
  252. </Link>
  253. )}
  254. </div>
  255. {/* Custom Fields */}
  256. <CustomFieldsForm
  257. entityId={quote.id}
  258. entityType="quote"
  259. onValuesReady={state.onCustomFieldsReady}
  260. onChange={state.markDirty}
  261. />
  262. {/* Customer Response */}
  263. {quote.customerMessage &&
  264. (state.status === 'changes_requested' || state.status === 'accepted') && (
  265. <div
  266. className={`rounded-lg border p-3 space-y-2 ${
  267. state.status === 'changes_requested'
  268. ? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20'
  269. : 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20'
  270. }`}
  271. >
  272. <div className="flex items-center justify-between">
  273. <h3
  274. className={`flex items-center gap-1.5 text-sm font-semibold ${
  275. state.status === 'changes_requested'
  276. ? 'text-orange-700 dark:text-orange-400'
  277. : 'text-emerald-700 dark:text-emerald-400'
  278. }`}
  279. >
  280. <MessageSquare className="h-3.5 w-3.5" />
  281. {state.status === 'changes_requested'
  282. ? t('page.changesRequested')
  283. : t('page.quoteAccepted')}
  284. </h3>
  285. <span
  286. className={`text-[10px] ${
  287. state.status === 'changes_requested'
  288. ? 'text-orange-500 dark:text-orange-500'
  289. : 'text-emerald-500 dark:text-emerald-500'
  290. }`}
  291. >
  292. {new Date(quote.updatedAt).toLocaleDateString(undefined, {
  293. month: 'short',
  294. day: 'numeric',
  295. year: 'numeric',
  296. })}{' '}
  297. {new Date(quote.updatedAt).toLocaleTimeString(undefined, {
  298. hour: '2-digit',
  299. minute: '2-digit',
  300. })}
  301. </span>
  302. </div>
  303. <p
  304. className={`text-sm ${
  305. state.status === 'changes_requested'
  306. ? 'text-orange-600 dark:text-orange-400'
  307. : 'text-emerald-600 dark:text-emerald-400'
  308. }`}
  309. >
  310. &ldquo;{quote.customerMessage}&rdquo;
  311. </p>
  312. <Button
  313. type="button"
  314. variant="outline"
  315. size="sm"
  316. className="w-full"
  317. disabled={state.resolving}
  318. onClick={state.handleResolveResponse}
  319. >
  320. {state.resolving ? (
  321. <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
  322. ) : (
  323. <Check className="mr-1 h-3.5 w-3.5" />
  324. )}
  325. {t('page.markResolved')}
  326. </Button>
  327. </div>
  328. )}
  329. {/* Totals */}
  330. {(() => {
  331. // Universal display: net per category, net subtotal, net discount, tax, gross total.
  332. // Same layout as the quote PDF/share view so the user always sees the breakdown.
  333. const displayPartsSubtotal = netLineTotal(
  334. state.partsSubtotal,
  335. state.taxRate,
  336. state.taxInclusive
  337. )
  338. const displayLaborSubtotal = netLineTotal(
  339. state.laborSubtotal,
  340. state.taxRate,
  341. state.taxInclusive
  342. )
  343. const displaySubtotal = netLineTotal(state.subtotal, state.taxRate, state.taxInclusive)
  344. const displayDiscountAmount = netLineTotal(
  345. state.discountAmount,
  346. state.taxRate,
  347. state.taxInclusive
  348. )
  349. return (
  350. <div className="rounded-lg border p-3 space-y-2">
  351. <h3 className="text-sm font-semibold">{t('totals.title')}</h3>
  352. <div className="space-y-2">
  353. <div className="flex items-center justify-between text-sm">
  354. <span className="text-muted-foreground">{t('totals.parts')}</span>
  355. <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
  356. </div>
  357. <div className="flex items-center justify-between text-sm">
  358. <span className="text-muted-foreground">{t('totals.labor')}</span>
  359. <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>
  360. </div>
  361. <div className="flex items-center justify-between text-sm">
  362. <span className="text-muted-foreground">{t('totals.subtotal')}</span>
  363. <span className="font-medium">{formatCurrency(displaySubtotal, currencyCode)}</span>
  364. </div>
  365. <div className="flex items-center justify-between text-sm">
  366. <div className="flex items-center gap-2">
  367. <span className="text-muted-foreground">{t('totals.discount')}</span>
  368. <Select
  369. value={state.discountType}
  370. onValueChange={(v) => {
  371. state.setDiscountType(v)
  372. state.markDirty()
  373. }}
  374. >
  375. <SelectTrigger className="h-7 w-28 text-xs">
  376. <SelectValue />
  377. </SelectTrigger>
  378. <SelectContent>
  379. <SelectItem value="none">{t('totals.discountNone')}</SelectItem>
  380. <SelectItem value="percentage">{t('totals.discountPercentage')}</SelectItem>
  381. <SelectItem value="fixed">{t('totals.discountFixed')}</SelectItem>
  382. </SelectContent>
  383. </Select>
  384. {state.discountType !== 'none' && (
  385. <Input
  386. type="number"
  387. min="0"
  388. step="0.01"
  389. value={state.discountValue}
  390. onChange={(e) => {
  391. state.setDiscountValue(e.target.value === '' ? 0 : Number(e.target.value))
  392. state.markDirty()
  393. }}
  394. className="h-7 w-20 text-right text-xs"
  395. />
  396. )}
  397. {state.discountType === 'percentage' && (
  398. <span className="text-muted-foreground">%</span>
  399. )}
  400. </div>
  401. {displayDiscountAmount > 0 && (
  402. <span className="text-destructive">
  403. {formatCurrency(-displayDiscountAmount, currencyCode)}
  404. </span>
  405. )}
  406. </div>
  407. {state.taxEnabled && state.taxComponents && state.taxComponents.length > 0 ? (
  408. state.taxComponents.map((component) => (
  409. <div
  410. key={component.name}
  411. className="flex items-center justify-between text-sm"
  412. data-testid="tax-component-row"
  413. >
  414. <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
  415. <span>{formatCurrency(component.amount, currencyCode)}</span>
  416. </div>
  417. ))
  418. ) : state.taxEnabled ? (
  419. <div className="flex items-center justify-between text-sm">
  420. <div className="flex items-center gap-2">
  421. <span className="text-muted-foreground">{t('totals.tax')}</span>
  422. <Input
  423. type="number"
  424. min="0"
  425. step="0.1"
  426. value={state.taxRate}
  427. onChange={(e) => {
  428. state.setTaxRate(e.target.value === '' ? 0 : Number(e.target.value))
  429. state.markDirty()
  430. }}
  431. className="h-7 w-20 text-right text-xs"
  432. />
  433. <span className="text-muted-foreground">%</span>
  434. </div>
  435. <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
  436. </div>
  437. ) : null}
  438. <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
  439. <span>{t('totals.total')}</span>
  440. <span>{formatCurrency(state.totalAmount, currencyCode)}</span>
  441. </div>
  442. {state.taxInclusive && (
  443. <p className="text-xs text-muted-foreground italic">
  444. {t('totals.inclusiveModeHint')}
  445. </p>
  446. )}
  447. </div>
  448. </div>
  449. )
  450. })()}
  451. </div>
  452. )
  453. })