| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- 'use client'
- import { memo, useState } from 'react'
- import Link from 'next/link'
- import { Button } from '@/components/ui/button'
- import { Input } from '@/components/ui/input'
- import { DateInput } from '@/components/ui/date-input'
- import { Label } from '@/components/ui/label'
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from '@/components/ui/select'
- import {
- ArrowRight,
- Car,
- Check,
- ClipboardCheck,
- Loader2,
- MessageSquare,
- Users,
- X,
- } from 'lucide-react'
- import { SharedLinkCard } from '@/components/shared-link-card'
- import { useFormatCurrency } from '@/components/currency-settings-context'
- import { netLineTotal } from '@/lib/tax'
- import { taxComponentLabel } from '@/lib/tax-components'
- import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
- import type { QuoteFormState } from './useQuoteFormState'
- import type { QuoteRecord } from './quote-page-types'
- import { VehicleCombobox } from './VehicleCombobox'
- import { CustomerCombobox } from './CustomerCombobox'
- interface QuoteRightColumnProps {
- state: QuoteFormState
- quote: QuoteRecord
- organizationId: string
- currencyCode: string
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- t: (key: string, values?: any) => string
- onRevoke: () => Promise<void>
- }
- export const QuoteRightColumn = memo(function QuoteRightColumn({
- state,
- quote,
- organizationId,
- currencyCode,
- t,
- onRevoke,
- }: QuoteRightColumnProps) {
- const formatCurrency = useFormatCurrency()
- const [validUntil, setValidUntil] = useState(state.defaultValidDate)
- return (
- <div className="space-y-3">
- {/* Convert to Work Order */}
- {quote.status !== 'converted' && (
- <div className="rounded-lg border p-3">
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="w-full"
- onClick={() => state.setShowConvertDialog(true)}
- >
- <ArrowRight className="mr-1 h-3.5 w-3.5" /> {t('page.convertToWorkOrder')}
- </Button>
- </div>
- )}
- {/* Shared Link */}
- {quote.publicToken && (
- <SharedLinkCard
- publicToken={quote.publicToken}
- organizationId={organizationId}
- type="quote"
- sharedAt={quote.sharedAt}
- viewCount={quote.viewCount}
- lastViewedAt={quote.lastViewedAt}
- onRevoke={onRevoke}
- />
- )}
- {/* Vehicle & Customer */}
- <div className="rounded-lg border p-3 space-y-3">
- <div className="space-y-1">
- <Label className="text-xs">{t('details.customer')}</Label>
- <CustomerCombobox
- value={state.customerId}
- initialCustomer={state.selectedCustomer}
- placeholder={t('details.selectCustomer')}
- noneLabel={t('details.none')}
- onChange={(id, customer) => {
- state.setCustomerId(id)
- state.setSelectedCustomer(customer)
- // A vehicle belonging to another customer no longer fits
- if (id && state.selectedVehicle && state.selectedVehicle.customerId !== id) {
- state.setVehicleId('')
- state.setSelectedVehicle(null)
- }
- state.markDirty()
- }}
- />
- </div>
- {state.selectedCustomer && (
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
- <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- <Link
- href={`/customers/${state.selectedCustomer.id}`}
- target="_blank"
- className="min-w-0 flex-1 text-sm hover:underline"
- >
- <span className="font-medium">{state.selectedCustomer.name}</span>
- {state.selectedCustomer.company && (
- <span className="ml-1.5 text-muted-foreground">
- {state.selectedCustomer.company}
- </span>
- )}
- </Link>
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
- onClick={() => {
- state.setCustomerId('')
- state.setSelectedCustomer(null)
- state.markDirty()
- }}
- aria-label={t('details.clearCustomer')}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- )}
- <div className="space-y-1">
- <Label className="text-xs">{t('details.vehicle')}</Label>
- <VehicleCombobox
- value={state.vehicleId}
- customerId={state.customerId || undefined}
- initialVehicle={state.selectedVehicle}
- placeholder={t('details.selectVehicle')}
- noneLabel={t('details.none')}
- onChange={(id, vehicle) => {
- state.setVehicleId(id)
- state.setSelectedVehicle(vehicle)
- if (vehicle?.customerId) {
- state.setCustomerId(vehicle.customerId)
- if (vehicle.customer) {
- state.setSelectedCustomer({
- id: vehicle.customer.id,
- name: vehicle.customer.name,
- company: null,
- })
- }
- }
- state.markDirty()
- }}
- />
- </div>
- {state.selectedVehicle && (
- <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
- <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- <Link
- href={`/vehicles/${state.selectedVehicle.id}`}
- target="_blank"
- className="min-w-0 flex-1 text-sm hover:underline"
- >
- <span className="font-medium">
- {state.selectedVehicle.year} {state.selectedVehicle.make}{' '}
- {state.selectedVehicle.model}
- </span>
- {state.selectedVehicle.licensePlate && (
- <span className="ml-1.5 text-muted-foreground">
- {state.selectedVehicle.licensePlate}
- </span>
- )}
- </Link>
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
- onClick={() => {
- state.setVehicleId('')
- state.setSelectedVehicle(null)
- state.markDirty()
- }}
- aria-label={t('details.clearVehicle')}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- )}
- </div>
- {/* Quote Details */}
- <div className="rounded-lg border p-3 space-y-3">
- <h3 className="text-sm font-semibold">{t('details.title')}</h3>
- <div className="space-y-1">
- <Label htmlFor="title" className="text-xs">
- {t('details.titleLabel')}
- </Label>
- <Input
- id="title"
- name="title"
- placeholder={t('details.titlePlaceholder')}
- defaultValue={quote.title}
- required
- onChange={state.markDirty}
- />
- </div>
- <div className="grid grid-cols-2 gap-2">
- <div className="space-y-1">
- <Label className="text-xs">{t('details.status')}</Label>
- <Select
- value={state.status}
- onValueChange={(v) => {
- state.setStatus(v)
- state.markDirty()
- }}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="draft">{t('details.statusDraft')}</SelectItem>
- <SelectItem value="sent">{t('details.statusSent')}</SelectItem>
- <SelectItem value="accepted">{t('details.statusAccepted')}</SelectItem>
- <SelectItem value="rejected">{t('details.statusRejected')}</SelectItem>
- </SelectContent>
- </Select>
- </div>
- <div className="space-y-1">
- <Label htmlFor="validUntil" className="text-xs">
- {t('details.validUntil')}
- </Label>
- <DateInput
- id="validUntil"
- name="validUntil"
- value={validUntil}
- onChange={(v) => {
- setValidUntil(v)
- state.markDirty()
- }}
- />
- </div>
- </div>
- {quote.inspectionId && (
- <Link
- href={`/inspections/${quote.inspectionId}`}
- 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"
- >
- <ClipboardCheck className="h-3.5 w-3.5 shrink-0" />
- <span>{t('details.viewInspection')}</span>
- </Link>
- )}
- </div>
- {/* Custom Fields */}
- <CustomFieldsForm
- entityId={quote.id}
- entityType="quote"
- onValuesReady={state.onCustomFieldsReady}
- onChange={state.markDirty}
- />
- {/* Customer Response */}
- {quote.customerMessage &&
- (state.status === 'changes_requested' || state.status === 'accepted') && (
- <div
- className={`rounded-lg border p-3 space-y-2 ${
- state.status === 'changes_requested'
- ? 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20'
- : 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20'
- }`}
- >
- <div className="flex items-center justify-between">
- <h3
- className={`flex items-center gap-1.5 text-sm font-semibold ${
- state.status === 'changes_requested'
- ? 'text-orange-700 dark:text-orange-400'
- : 'text-emerald-700 dark:text-emerald-400'
- }`}
- >
- <MessageSquare className="h-3.5 w-3.5" />
- {state.status === 'changes_requested'
- ? t('page.changesRequested')
- : t('page.quoteAccepted')}
- </h3>
- <span
- className={`text-[10px] ${
- state.status === 'changes_requested'
- ? 'text-orange-500 dark:text-orange-500'
- : 'text-emerald-500 dark:text-emerald-500'
- }`}
- >
- {new Date(quote.updatedAt).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}{' '}
- {new Date(quote.updatedAt).toLocaleTimeString(undefined, {
- hour: '2-digit',
- minute: '2-digit',
- })}
- </span>
- </div>
- <p
- className={`text-sm ${
- state.status === 'changes_requested'
- ? 'text-orange-600 dark:text-orange-400'
- : 'text-emerald-600 dark:text-emerald-400'
- }`}
- >
- “{quote.customerMessage}”
- </p>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="w-full"
- disabled={state.resolving}
- onClick={state.handleResolveResponse}
- >
- {state.resolving ? (
- <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
- ) : (
- <Check className="mr-1 h-3.5 w-3.5" />
- )}
- {t('page.markResolved')}
- </Button>
- </div>
- )}
- {/* Totals */}
- {(() => {
- // Universal display: net per category, net subtotal, net discount, tax, gross total.
- // Same layout as the quote PDF/share view so the user always sees the breakdown.
- const displayPartsSubtotal = netLineTotal(
- state.partsSubtotal,
- state.taxRate,
- state.taxInclusive
- )
- const displayLaborSubtotal = netLineTotal(
- state.laborSubtotal,
- state.taxRate,
- state.taxInclusive
- )
- const displaySubtotal = netLineTotal(state.subtotal, state.taxRate, state.taxInclusive)
- const displayDiscountAmount = netLineTotal(
- state.discountAmount,
- state.taxRate,
- state.taxInclusive
- )
- return (
- <div className="rounded-lg border p-3 space-y-2">
- <h3 className="text-sm font-semibold">{t('totals.title')}</h3>
- <div className="space-y-2">
- <div className="flex items-center justify-between text-sm">
- <span className="text-muted-foreground">{t('totals.parts')}</span>
- <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
- </div>
- <div className="flex items-center justify-between text-sm">
- <span className="text-muted-foreground">{t('totals.labor')}</span>
- <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>
- </div>
- <div className="flex items-center justify-between text-sm">
- <span className="text-muted-foreground">{t('totals.subtotal')}</span>
- <span className="font-medium">{formatCurrency(displaySubtotal, currencyCode)}</span>
- </div>
- <div className="flex items-center justify-between text-sm">
- <div className="flex items-center gap-2">
- <span className="text-muted-foreground">{t('totals.discount')}</span>
- <Select
- value={state.discountType}
- onValueChange={(v) => {
- state.setDiscountType(v)
- state.markDirty()
- }}
- >
- <SelectTrigger className="h-7 w-28 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="none">{t('totals.discountNone')}</SelectItem>
- <SelectItem value="percentage">{t('totals.discountPercentage')}</SelectItem>
- <SelectItem value="fixed">{t('totals.discountFixed')}</SelectItem>
- </SelectContent>
- </Select>
- {state.discountType !== 'none' && (
- <Input
- type="number"
- min="0"
- step="0.01"
- value={state.discountValue}
- onChange={(e) => {
- state.setDiscountValue(e.target.value === '' ? 0 : Number(e.target.value))
- state.markDirty()
- }}
- className="h-7 w-20 text-right text-xs"
- />
- )}
- {state.discountType === 'percentage' && (
- <span className="text-muted-foreground">%</span>
- )}
- </div>
- {displayDiscountAmount > 0 && (
- <span className="text-destructive">
- {formatCurrency(-displayDiscountAmount, currencyCode)}
- </span>
- )}
- </div>
- {state.taxEnabled && state.taxComponents && state.taxComponents.length > 0 ? (
- state.taxComponents.map((component) => (
- <div
- key={component.name}
- className="flex items-center justify-between text-sm"
- data-testid="tax-component-row"
- >
- <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
- <span>{formatCurrency(component.amount, currencyCode)}</span>
- </div>
- ))
- ) : state.taxEnabled ? (
- <div className="flex items-center justify-between text-sm">
- <div className="flex items-center gap-2">
- <span className="text-muted-foreground">{t('totals.tax')}</span>
- <Input
- type="number"
- min="0"
- step="0.1"
- value={state.taxRate}
- onChange={(e) => {
- state.setTaxRate(e.target.value === '' ? 0 : Number(e.target.value))
- state.markDirty()
- }}
- className="h-7 w-20 text-right text-xs"
- />
- <span className="text-muted-foreground">%</span>
- </div>
- <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
- </div>
- ) : null}
- <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
- <span>{t('totals.total')}</span>
- <span>{formatCurrency(state.totalAmount, currencyCode)}</span>
- </div>
- {state.taxInclusive && (
- <p className="text-xs text-muted-foreground italic">
- {t('totals.inclusiveModeHint')}
- </p>
- )}
- </div>
- </div>
- )
- })()}
- </div>
- )
- })
|