| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- 'use client'
- import { DocumentLockBanner } from '@/components/document-lock-banner'
- import { setQuoteEditUnlocked } from '@/features/settings/Actions/documentLockActions'
- import type { LockState } from '@/lib/document-lock'
- import { useState, useCallback, useEffect } from 'react'
- import { useRouter } from 'next/navigation'
- import Link from 'next/link'
- import { useTranslations } from 'next-intl'
- import { Badge } from '@/components/ui/badge'
- import { Button } from '@/components/ui/button'
- import { ButtonGroup } from '@/components/ui/button-group'
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
- import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
- import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
- import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
- import { revokeQuotePublicLink } from '@/features/quotes/Actions/quoteShareActions'
- import { QuoteShareDialog } from '@/features/quotes/Components/QuoteShareDialog'
- import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
- import { QuoteImagesManager } from '@/features/quotes/Components/QuoteImagesManager'
- import { QuoteDocumentsManager } from '@/features/quotes/Components/QuoteDocumentsManager'
- import {
- ArrowLeft,
- Camera,
- Download,
- Eye,
- FileText,
- Globe,
- Loader2,
- Mail,
- Paperclip,
- Save,
- Trash2,
- } from 'lucide-react'
- import { getCurrencySymbol } from '@/lib/format'
- import type { QuoteAttachment, QuoteRecord, TabType } from './quote-page-types'
- import { statusColors } from './quote-page-types'
- import { useQuoteFormState } from './useQuoteFormState'
- import { useSaveShortcut } from '@/hooks/use-save-shortcut'
- import {
- LaborPresetPickerDialog,
- type LaborPresetOption,
- } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
- import { QuotePartsEditor } from './QuotePartsEditor'
- import type { InventoryPartOption } from '@/features/vehicles/Components/service-edit/form-types'
- import { QuoteLaborEditor } from './QuoteLaborEditor'
- import { QuoteNotesEditor } from './QuoteNotesEditor'
- import { QuoteRightColumn } from './QuoteRightColumn'
- import { VehicleCombobox } from './VehicleCombobox'
- import { lineTotal } from '@/features/inventory/Lib/partPricing'
- const LG_BREAKPOINT = 1024
- function useIsLargeScreen() {
- const [isLarge, setIsLarge] = useState(false)
- useEffect(() => {
- const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`)
- const onChange = () => setIsLarge(mql.matches)
- mql.addEventListener('change', onChange)
- setIsLarge(mql.matches)
- return () => mql.removeEventListener('change', onChange)
- }, [])
- return isLarge
- }
- export function QuotePageClient({
- quote,
- organizationId,
- lockState,
- canUnlock,
- currencyCode = 'USD',
- defaultTaxRate = 0,
- taxEnabled = true,
- defaultLaborRate = 0,
- laborPresets = [],
- inventoryParts = [],
- smsEnabled = false,
- emailEnabled = false,
- imageAttachments = [],
- documentAttachments = [],
- maxImages,
- maxDocuments,
- }: {
- quote: QuoteRecord
- organizationId: string
- /**
- * Required rather than defaulted: a call site that forgot to wire it would
- * render a locked quote as editable, which is the trap this exists to close.
- */
- lockState: LockState
- canUnlock: boolean
- currencyCode?: string
- defaultTaxRate?: number
- taxEnabled?: boolean
- defaultLaborRate?: number
- laborPresets?: LaborPresetOption[]
- inventoryParts?: InventoryPartOption[]
- smsEnabled?: boolean
- emailEnabled?: boolean
- imageAttachments?: QuoteAttachment[]
- documentAttachments?: QuoteAttachment[]
- maxImages?: number
- maxDocuments?: number
- }) {
- const cs = getCurrencySymbol(currencyCode)
- const router = useRouter()
- const isLarge = useIsLargeScreen()
- const t = useTranslations('quotes')
- const tPreview = useTranslations('common.pdfPreview')
- const state = useQuoteFormState({
- quote,
- currencyCode,
- defaultTaxRate,
- taxEnabled,
- defaultLaborRate,
- locked: lockState.locked,
- t,
- })
- // Sending is what can lock the quote — by email or by link — so the page is
- // re-rendered to pick up the lock rather than leaving the fieldset open and
- // the next autosave to be refused.
- const handleQuoteSent = useCallback(() => router.refresh(), [router])
- useSaveShortcut(() => {
- if (state.hasUnsavedChanges) return state.saveNow()
- })
- const [showPresetPicker, setShowPresetPicker] = useState(false)
- const [showPdfPreview, setShowPdfPreview] = useState(false)
- const handleSelectPreset = useCallback(
- (preset: LaborPresetOption) => {
- const newItems = preset.items.map((item) => ({
- description: item.description,
- hours: item.hours,
- rate: item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate,
- total:
- item.hours *
- (item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate),
- pricingType: (item.pricingType as 'hourly' | 'service') || 'hourly',
- excluded: false,
- }))
- state.addLaborBulk(newItems)
- if (preset.parts?.length) {
- const newParts = preset.parts.map((part) => ({
- name: part.name,
- partNumber: part.partNumber || '',
- quantity: part.quantity,
- unit: part.unit ?? null,
- unitCost: 0,
- markupPercent: 0,
- unitPrice: part.unitPrice,
- total: lineTotal(part.quantity, part.unitPrice),
- excluded: false,
- inventoryPartId: part.inventoryPartId ?? null,
- }))
- state.addPartBulk(newParts)
- }
- },
- [defaultLaborRate, state]
- )
- const handleDescriptionChange = useCallback(
- (v: string) => {
- state.setDescription(v)
- state.markDirty()
- },
- [state]
- )
- const handleNotesChange = useCallback(
- (v: string) => {
- state.setNotes(v)
- state.markDirty()
- },
- [state]
- )
- const handleNoteTypeChange = useCallback(
- (v: 'public' | 'internal') => {
- state.setNoteType(v)
- },
- [state]
- )
- const handleRevoke = useCallback(async () => {
- await revokeQuotePublicLink(quote.id)
- router.refresh()
- }, [quote.id, router])
- const leftColumn = (
- <div className="space-y-3">
- <QuotePartsEditor
- partItems={state.partItems}
- currencyCode={currencyCode}
- partsSubtotal={state.partsSubtotal}
- onUpdate={state.updatePart}
- onDelete={state.deletePart}
- onAdd={state.addPart}
- onAddBulk={state.addPartBulk}
- inventoryParts={inventoryParts}
- t={t}
- />
- <QuoteLaborEditor
- laborItems={state.laborItems}
- currencyCode={currencyCode}
- cs={cs}
- laborSubtotal={state.laborSubtotal}
- onUpdate={state.updateLabor}
- onDelete={state.deleteLabor}
- onAdd={state.addLabor}
- onAddService={state.addService}
- hasPresets={laborPresets.length > 0}
- onOpenPresets={() => setShowPresetPicker(true)}
- t={t}
- />
- <QuoteNotesEditor
- noteType={state.noteType}
- onNoteTypeChange={handleNoteTypeChange}
- description={state.description}
- onDescriptionChange={handleDescriptionChange}
- notes={state.notes}
- onNotesChange={handleNotesChange}
- t={t}
- />
- </div>
- )
- const rightColumn = (
- <QuoteRightColumn
- state={state}
- quote={quote}
- organizationId={organizationId}
- currencyCode={currencyCode}
- t={t}
- onRevoke={handleRevoke}
- />
- )
- return (
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
- {/* Header */}
- <div className="shrink-0 border-b bg-background px-4 py-2">
- <div className="flex items-center justify-between">
- <Link
- href="/quotes"
- className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
- >
- <ArrowLeft className="h-4 w-4 shrink-0" />
- <div className="min-w-0">
- <div className="flex items-center gap-2">
- <Badge
- variant="outline"
- className={`shrink-0 text-xs capitalize ${statusColors[state.status] || ''}`}
- >
- {state.status}
- </Badge>
- <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
- </div>
- <p className="truncate text-xs text-muted-foreground">
- {quote.quoteNumber || t('page.quote')}
- {quote.customer ? ` · ${quote.customer.name}` : ''}
- </p>
- </div>
- </Link>
- <div className="flex shrink-0 items-center gap-2">
- {state.hasUnsavedChanges && (
- <span className="text-xs font-medium text-amber-600 dark:text-amber-400">
- {t('page.unsavedChanges')}
- </span>
- )}
- {state.showSaved && !state.hasUnsavedChanges && (
- <span className="text-xs font-medium text-green-600 dark:text-green-400">
- {t('page.saved')}
- </span>
- )}
- <Button
- type="submit"
- form="quote-form"
- size="sm"
- disabled={state.saving}
- variant={state.hasUnsavedChanges ? 'default' : 'outline'}
- className={state.hasUnsavedChanges ? 'animate-pulse' : ''}
- >
- {state.saving ? (
- <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
- ) : (
- <Save className="mr-1 h-3.5 w-3.5" />
- )}
- {t('page.save')}
- </Button>
- <ButtonGroup>
- <Button
- variant="outline"
- size="sm"
- disabled={state.saving}
- onClick={async () => {
- if (state.saving) return
- if (state.hasUnsavedChanges) await state.saveNow()
- setShowPdfPreview(true)
- }}
- >
- <Eye className="mr-1 h-3.5 w-3.5" />
- {tPreview('preview')}
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={state.handleDownloadPDF}
- disabled={state.downloading}
- >
- {state.downloading ? (
- <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
- ) : (
- <Download className="mr-1 h-3.5 w-3.5" />
- )}
- {t('page.pdf')}
- </Button>
- <Button
- variant="outline"
- size="sm"
- disabled={state.saving}
- onClick={async () => {
- if (state.saving) return
- if (state.hasUnsavedChanges) await state.saveNow()
- state.setShowEmailDialog(true)
- }}
- >
- {state.saving ? (
- <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
- ) : (
- <Mail className="mr-1 h-3.5 w-3.5" />
- )}
- {t('page.email')}
- </Button>
- <Button
- variant="outline"
- size="sm"
- disabled={state.saving}
- onClick={async () => {
- if (state.saving) return
- if (state.hasUnsavedChanges) await state.saveNow()
- state.setShowShareDialog(true)
- }}
- >
- {state.saving ? (
- <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
- ) : (
- <Globe className="mr-1 h-3.5 w-3.5" />
- )}
- {t('page.share')}
- </Button>
- <Button
- variant="outline"
- size="sm"
- className="text-destructive hover:bg-destructive/10"
- onClick={state.handleDelete}
- >
- <Trash2 className="mr-1 h-3.5 w-3.5" />
- {t('page.delete')}
- </Button>
- </ButtonGroup>
- </div>
- </div>
- </div>
- {/* Tabs */}
- <div className="shrink-0 border-b bg-background px-4">
- <div className="flex gap-1">
- {[
- { key: 'details' as TabType, label: t('page.tabs.details'), icon: FileText },
- { key: 'images' as TabType, label: t('page.tabs.images'), icon: Camera },
- { key: 'documents' as TabType, label: t('page.tabs.documents'), icon: Paperclip },
- ].map(({ key, label, icon: Icon }) => (
- <button
- key={key}
- type="button"
- onClick={() => state.setActiveTab(key)}
- className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
- state.activeTab === key
- ? 'border-primary text-primary'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- <Icon className="h-3.5 w-3.5" />
- {label}
- </button>
- ))}
- </div>
- </div>
- {(lockState.locked || lockState.unlockedAt) && (
- <div className="shrink-0 px-4 pt-3">
- <DocumentLockBanner
- state={lockState}
- kind="quote"
- canUnlock={canUnlock}
- onSetUnlocked={(unlocked) => setQuoteEditUnlocked(quote.id, unlocked)}
- />
- </div>
- )}
- {/* Tab Content */}
- {state.activeTab === 'details' && (
- <form
- id="quote-form"
- ref={state.formRef}
- onSubmit={state.handleSubmit}
- className="flex min-h-0 flex-1 flex-col overflow-hidden"
- >
- {/* A locked quote offers no editing, rather than letting someone
- retype a line and meet the refusal on save. display:contents
- keeps the layout exactly as it was. */}
- <fieldset disabled={lockState.locked} className="contents">
- {isLarge ? (
- <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
- <ResizablePanel defaultSize={75} minSize={40}>
- <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
- <div className="space-y-3 pb-40">{leftColumn}</div>
- </div>
- </ResizablePanel>
- <ResizableHandle withHandle />
- <ResizablePanel defaultSize={25} minSize={15}>
- <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
- <div className="space-y-3 pb-40">{rightColumn}</div>
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="space-y-3 pb-40">
- {leftColumn}
- {rightColumn}
- </div>
- </div>
- )}
- </fieldset>
- </form>
- )}
- {state.activeTab === 'images' && (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="mx-auto max-w-4xl pb-40">
- <QuoteImagesManager
- quoteId={quote.id}
- initialImages={imageAttachments}
- maxImages={maxImages}
- />
- </div>
- </div>
- )}
- {state.activeTab === 'documents' && (
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
- <div className="mx-auto max-w-4xl pb-40">
- <QuoteDocumentsManager
- quoteId={quote.id}
- initialDocuments={documentAttachments}
- maxDocuments={maxDocuments}
- />
- </div>
- </div>
- )}
- <LaborPresetPickerDialog
- open={showPresetPicker}
- onOpenChange={setShowPresetPicker}
- laborPresets={laborPresets}
- onSelectPreset={handleSelectPreset}
- />
- {/* Dialogs */}
- <PdfPreviewDialog
- open={showPdfPreview}
- onOpenChange={setShowPdfPreview}
- url={`/api/protected/quotes/${quote.id}/pdf`}
- />
- <SendEmailDialog
- open={state.showEmailDialog}
- onOpenChange={state.setShowEmailDialog}
- defaultEmail={quote.customer?.email || ''}
- entityLabel={t('page.entityLabel')}
- onSend={async (email, message, attachPdf) => {
- const result = await sendQuoteEmail({
- quoteId: quote.id,
- recipientEmail: email,
- message,
- attachPdf,
- })
- if (result.success) handleQuoteSent()
- return result
- }}
- />
- <QuoteShareDialog
- open={state.showShareDialog}
- onOpenChange={state.setShowShareDialog}
- quoteId={quote.id}
- organizationId={organizationId}
- initialToken={quote.publicToken}
- onSent={handleQuoteSent}
- customer={quote.customer}
- smsEnabled={smsEnabled}
- emailEnabled={emailEnabled}
- />
- <Dialog open={state.showConvertDialog} onOpenChange={state.setShowConvertDialog}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>{t('page.convertTitle')}</DialogTitle>
- </DialogHeader>
- <div className="space-y-4">
- <p className="text-sm text-muted-foreground">{t('page.convertDescription')}</p>
- <VehicleCombobox
- value={state.convertVehicleId}
- initialVehicle={state.selectedVehicle}
- placeholder={t('details.selectVehicle')}
- noneLabel={t('details.none')}
- onChange={(id) => state.setConvertVehicleId(id)}
- />
- <div className="flex gap-2">
- <Button
- type="button"
- onClick={state.handleConvert}
- disabled={state.converting || !state.convertVehicleId}
- >
- {state.converting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {t('page.convert')}
- </Button>
- <Button
- type="button"
- variant="ghost"
- onClick={() => state.setShowConvertDialog(false)}
- >
- {t('page.cancel')}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- </div>
- )
- }
|