|
|
@@ -1,5 +1,6 @@
|
|
|
'use client'
|
|
|
|
|
|
+import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
|
|
|
import { DocumentLockBanner } from '@/components/document-lock-banner'
|
|
|
import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
|
|
|
import { InvoiceDesignMenu } from './InvoiceDesignMenu'
|
|
|
@@ -88,6 +89,7 @@ export function ServicePageClient({
|
|
|
defaultTaxRate,
|
|
|
taxEnabled,
|
|
|
defaultLaborRate,
|
|
|
+ shopFee = null,
|
|
|
initialData,
|
|
|
inventoryParts,
|
|
|
initialVehicle,
|
|
|
@@ -212,6 +214,7 @@ export function ServicePageClient({
|
|
|
currentUserName,
|
|
|
record,
|
|
|
locked: lockState.locked,
|
|
|
+ shopFee,
|
|
|
})
|
|
|
|
|
|
// Anything that happens to this job somewhere else: a technician billing
|
|
|
@@ -598,432 +601,340 @@ export function ServicePageClient({
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
|
- {modern ? (
|
|
|
- <ModernHero
|
|
|
- record={record}
|
|
|
- status={formState.status}
|
|
|
- paymentStatus={formState.paymentStatus}
|
|
|
- warranty={formState.warranty}
|
|
|
- actions={
|
|
|
- <>
|
|
|
- <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
|
|
|
- <ServiceHeaderActions showSave {...headerActionProps} />
|
|
|
- </>
|
|
|
- }
|
|
|
- title={title}
|
|
|
- onTitleChange={(next) => {
|
|
|
- setTitle(next)
|
|
|
- // Saved the moment the field is left, on its own, not with the
|
|
|
- // form's autosave five seconds later: a rename followed by the
|
|
|
- // back arrow inside those seconds was lost. Only a refused save
|
|
|
- // marks the form dirty, so the ordinary path can try again.
|
|
|
- void updateServiceRecordTitle(record.id, next).then((result) => {
|
|
|
- if (result.success) {
|
|
|
- formState.flashSaved()
|
|
|
- } else {
|
|
|
- toast.error(result.error || t('page.failedUpdate'))
|
|
|
- formState.markDirty()
|
|
|
- }
|
|
|
- })
|
|
|
- }}
|
|
|
- type={record.vehicle ? formState.type : undefined}
|
|
|
- onTypeChange={formState.dirtySetType}
|
|
|
- locked={lockState.locked}
|
|
|
- />
|
|
|
- ) : (
|
|
|
- <UnifiedServiceHeader
|
|
|
- presence={<PresenceChips kind="serviceRecord" id={record.id} />}
|
|
|
- vehicleId={vehicleId}
|
|
|
- vehicleName={formState.vehicleName}
|
|
|
- title={record.title}
|
|
|
- status={formState.status}
|
|
|
- paymentStatus={formState.paymentStatus}
|
|
|
- activeTab={activeTab}
|
|
|
- onTabChange={handleTabChange}
|
|
|
- tabCounts={{
|
|
|
- images: imageAttachmentsForManager.length,
|
|
|
- video: videoAttachments.length,
|
|
|
- documents: documentAttachments.length,
|
|
|
- statusReports: statusReports.length,
|
|
|
- }}
|
|
|
- {...headerActionProps}
|
|
|
- />
|
|
|
- )}
|
|
|
-
|
|
|
- {!modern && <TryNewLayoutBanner onTry={() => void switchLayout('modern', 'banner')} />}
|
|
|
+ <ShopFeeProvider value={shopFee}>
|
|
|
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
|
+ {modern ? (
|
|
|
+ <ModernHero
|
|
|
+ record={record}
|
|
|
+ status={formState.status}
|
|
|
+ paymentStatus={formState.paymentStatus}
|
|
|
+ warranty={formState.warranty}
|
|
|
+ actions={
|
|
|
+ <>
|
|
|
+ <PresenceChips kind="serviceRecord" id={record.id} className="mr-1" />
|
|
|
+ <ServiceHeaderActions showSave {...headerActionProps} />
|
|
|
+ </>
|
|
|
+ }
|
|
|
+ title={title}
|
|
|
+ onTitleChange={(next) => {
|
|
|
+ setTitle(next)
|
|
|
+ // Saved the moment the field is left, on its own, not with the
|
|
|
+ // form's autosave five seconds later: a rename followed by the
|
|
|
+ // back arrow inside those seconds was lost. Only a refused save
|
|
|
+ // marks the form dirty, so the ordinary path can try again.
|
|
|
+ void updateServiceRecordTitle(record.id, next).then((result) => {
|
|
|
+ if (result.success) {
|
|
|
+ formState.flashSaved()
|
|
|
+ } else {
|
|
|
+ toast.error(result.error || t('page.failedUpdate'))
|
|
|
+ formState.markDirty()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ type={record.vehicle ? formState.type : undefined}
|
|
|
+ onTypeChange={formState.dirtySetType}
|
|
|
+ locked={lockState.locked}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <UnifiedServiceHeader
|
|
|
+ presence={<PresenceChips kind="serviceRecord" id={record.id} />}
|
|
|
+ vehicleId={vehicleId}
|
|
|
+ vehicleName={formState.vehicleName}
|
|
|
+ title={record.title}
|
|
|
+ status={formState.status}
|
|
|
+ paymentStatus={formState.paymentStatus}
|
|
|
+ activeTab={activeTab}
|
|
|
+ onTabChange={handleTabChange}
|
|
|
+ tabCounts={{
|
|
|
+ images: imageAttachmentsForManager.length,
|
|
|
+ video: videoAttachments.length,
|
|
|
+ documents: documentAttachments.length,
|
|
|
+ statusReports: statusReports.length,
|
|
|
+ }}
|
|
|
+ {...headerActionProps}
|
|
|
+ />
|
|
|
+ )}
|
|
|
|
|
|
- <LaborAddedBanner
|
|
|
- count={formState.laborAddedElsewhere.length}
|
|
|
- onShow={formState.applyLaborAddedElsewhere}
|
|
|
- />
|
|
|
+ {!modern && <TryNewLayoutBanner onTry={() => void switchLayout('modern', 'banner')} />}
|
|
|
|
|
|
- {(lockState.locked || lockState.unlockedAt) && (
|
|
|
- <div className="shrink-0 px-4 pt-3">
|
|
|
- <DocumentLockBanner
|
|
|
- state={lockState}
|
|
|
- kind="invoice"
|
|
|
- canUnlock={canUnlock}
|
|
|
- onSetUnlocked={(unlocked) => setInvoiceEditUnlocked(record.id, unlocked)}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ <LaborAddedBanner
|
|
|
+ count={formState.laborAddedElsewhere.length}
|
|
|
+ onShow={formState.applyLaborAddedElsewhere}
|
|
|
+ />
|
|
|
|
|
|
- {activeTab === 'details' && (
|
|
|
- <WorkOrderLayoutProvider value={layout}>
|
|
|
- {modern ? (
|
|
|
- <ModernDetails
|
|
|
- {...leftColumnProps}
|
|
|
- {...rightColumnProps}
|
|
|
- workOrderStatuses={workOrderStatuses}
|
|
|
- onNotifyForStatus={handleNotifyForStatus}
|
|
|
- locked={lockState.locked}
|
|
|
- lockedLabel={t('invoice.lockedFieldsetLabel')}
|
|
|
- form={{
|
|
|
- id: 'service-record-form',
|
|
|
- ref: formState.formRef,
|
|
|
- onSubmit: actions.handleSubmit,
|
|
|
- onInput: formState.markDirty,
|
|
|
- }}
|
|
|
- files={{
|
|
|
- images: imageAttachmentsForManager,
|
|
|
- dropoff: dropoffAttachments,
|
|
|
- videos: videoAttachments,
|
|
|
- documents: documentAttachments,
|
|
|
- maxImages: maxImagesPerService,
|
|
|
- maxDiagnostics: maxDiagnosticsPerService,
|
|
|
- maxDocuments: maxDocumentsPerService,
|
|
|
- // A link to one of the classic tabs opens that tab here.
|
|
|
- initialTab: isFileTab || storedTab === 'statusReports' ? storedTab : undefined,
|
|
|
- statusReports: { count: statusReports.length, list: statusReportList },
|
|
|
- }}
|
|
|
- onPreviewInvoice={previewInvoice}
|
|
|
- onSendToCustomer={shareInvoice}
|
|
|
- onBackToClassic={() => void switchLayout('classic', 'footer')}
|
|
|
- title={title}
|
|
|
- aiTranscription={aiTranscription}
|
|
|
- dictationMode={dictationMode}
|
|
|
- onAddFindingForConcern={(concernId) =>
|
|
|
- obsControlsRef.current?.onAddFinding(concernId)
|
|
|
- }
|
|
|
- scrollToFiles={isFileTab || storedTab === 'statusReports'}
|
|
|
+ {(lockState.locked || lockState.unlockedAt) && (
|
|
|
+ <div className="shrink-0 px-4 pt-3">
|
|
|
+ <DocumentLockBanner
|
|
|
+ state={lockState}
|
|
|
+ kind="invoice"
|
|
|
+ canUnlock={canUnlock}
|
|
|
+ onSetUnlocked={(unlocked) => setInvoiceEditUnlocked(record.id, unlocked)}
|
|
|
/>
|
|
|
- ) : (
|
|
|
- // noValidate, and the rules checked in handleSubmit instead. An
|
|
|
- // autosave submits through requestSubmit, which native validation
|
|
|
- // stops with no message and no request; and the rules that matter
|
|
|
- // most here — a priced part with no name, labour with hours and no
|
|
|
- // description — are not ones a `required` attribute can state.
|
|
|
- <form
|
|
|
- id="service-record-form"
|
|
|
- ref={formState.formRef}
|
|
|
- onSubmit={actions.handleSubmit}
|
|
|
- onInput={formState.markDirty}
|
|
|
- className="flex min-h-0 flex-1 flex-col"
|
|
|
- noValidate
|
|
|
- >
|
|
|
- {/* A locked invoice offers no editing at all, rather than letting
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {activeTab === 'details' && (
|
|
|
+ <WorkOrderLayoutProvider value={layout}>
|
|
|
+ {modern ? (
|
|
|
+ <ModernDetails
|
|
|
+ {...leftColumnProps}
|
|
|
+ {...rightColumnProps}
|
|
|
+ workOrderStatuses={workOrderStatuses}
|
|
|
+ onNotifyForStatus={handleNotifyForStatus}
|
|
|
+ locked={lockState.locked}
|
|
|
+ lockedLabel={t('invoice.lockedFieldsetLabel')}
|
|
|
+ form={{
|
|
|
+ id: 'service-record-form',
|
|
|
+ ref: formState.formRef,
|
|
|
+ onSubmit: actions.handleSubmit,
|
|
|
+ onInput: formState.markDirty,
|
|
|
+ }}
|
|
|
+ files={{
|
|
|
+ images: imageAttachmentsForManager,
|
|
|
+ dropoff: dropoffAttachments,
|
|
|
+ videos: videoAttachments,
|
|
|
+ documents: documentAttachments,
|
|
|
+ maxImages: maxImagesPerService,
|
|
|
+ maxDiagnostics: maxDiagnosticsPerService,
|
|
|
+ maxDocuments: maxDocumentsPerService,
|
|
|
+ // A link to one of the classic tabs opens that tab here.
|
|
|
+ initialTab: isFileTab || storedTab === 'statusReports' ? storedTab : undefined,
|
|
|
+ statusReports: { count: statusReports.length, list: statusReportList },
|
|
|
+ }}
|
|
|
+ onPreviewInvoice={previewInvoice}
|
|
|
+ onSendToCustomer={shareInvoice}
|
|
|
+ onBackToClassic={() => void switchLayout('classic', 'footer')}
|
|
|
+ title={title}
|
|
|
+ aiTranscription={aiTranscription}
|
|
|
+ dictationMode={dictationMode}
|
|
|
+ onAddFindingForConcern={(concernId) =>
|
|
|
+ obsControlsRef.current?.onAddFinding(concernId)
|
|
|
+ }
|
|
|
+ scrollToFiles={isFileTab || storedTab === 'statusReports'}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ // noValidate, and the rules checked in handleSubmit instead. An
|
|
|
+ // autosave submits through requestSubmit, which native validation
|
|
|
+ // stops with no message and no request; and the rules that matter
|
|
|
+ // most here — a priced part with no name, labour with hours and no
|
|
|
+ // description — are not ones a `required` attribute can state.
|
|
|
+ <form
|
|
|
+ id="service-record-form"
|
|
|
+ ref={formState.formRef}
|
|
|
+ onSubmit={actions.handleSubmit}
|
|
|
+ onInput={formState.markDirty}
|
|
|
+ className="flex min-h-0 flex-1 flex-col"
|
|
|
+ noValidate
|
|
|
+ >
|
|
|
+ {/* A locked invoice offers no editing at all, rather than letting
|
|
|
someone retype a line and meet the refusal on save. The
|
|
|
fieldset disables every control inside it natively;
|
|
|
display:contents keeps the layout exactly as it was. */}
|
|
|
- <fieldset
|
|
|
- disabled={lockState.locked}
|
|
|
- className="contents"
|
|
|
- aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
|
|
|
- >
|
|
|
- <ServiceDetailContent
|
|
|
- leftColumn={<DetailsLeftColumn {...leftColumnProps} />}
|
|
|
- rightColumn={<DetailsRightColumn {...rightColumnProps} />}
|
|
|
- />
|
|
|
- </fieldset>
|
|
|
- </form>
|
|
|
- )}
|
|
|
- {vehicleId && (
|
|
|
- <ObservationsManager
|
|
|
- vehicleId={vehicleId}
|
|
|
+ <fieldset
|
|
|
+ disabled={lockState.locked}
|
|
|
+ className="contents"
|
|
|
+ aria-label={lockState.locked ? t('invoice.lockedFieldsetLabel') : undefined}
|
|
|
+ >
|
|
|
+ <ServiceDetailContent
|
|
|
+ leftColumn={<DetailsLeftColumn {...leftColumnProps} />}
|
|
|
+ rightColumn={<DetailsRightColumn {...rightColumnProps} />}
|
|
|
+ />
|
|
|
+ </fieldset>
|
|
|
+ </form>
|
|
|
+ )}
|
|
|
+ {vehicleId && (
|
|
|
+ <ObservationsManager
|
|
|
+ vehicleId={vehicleId}
|
|
|
+ serviceRecordId={record.id}
|
|
|
+ openObservations={openObservations}
|
|
|
+ onAddObservations={handleAddObservationsToWorkOrder}
|
|
|
+ addingObservations={addingObservations}
|
|
|
+ // Saved concerns only: a row still being typed has no id yet, so
|
|
|
+ // there is nothing a finding could point at.
|
|
|
+ concerns={formState.concerns.filter(
|
|
|
+ (c): c is { id: string; description: string; sortOrder: number } =>
|
|
|
+ Boolean(c.id) && Boolean(c.description.trim())
|
|
|
+ )}
|
|
|
+ onControlsReady={(c) => {
|
|
|
+ obsControlsRef.current = c
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </WorkOrderLayoutProvider>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {activeTab === 'images' && (
|
|
|
+ <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
+ <ServiceImagesManager
|
|
|
serviceRecordId={record.id}
|
|
|
- openObservations={openObservations}
|
|
|
- onAddObservations={handleAddObservationsToWorkOrder}
|
|
|
- addingObservations={addingObservations}
|
|
|
- // Saved concerns only: a row still being typed has no id yet, so
|
|
|
- // there is nothing a finding could point at.
|
|
|
- concerns={formState.concerns.filter(
|
|
|
- (c): c is { id: string; description: string; sortOrder: number } =>
|
|
|
- Boolean(c.id) && Boolean(c.description.trim())
|
|
|
- )}
|
|
|
- onControlsReady={(c) => {
|
|
|
- obsControlsRef.current = c
|
|
|
- }}
|
|
|
+ initialImages={imageAttachmentsForManager}
|
|
|
+ maxImages={maxImagesPerService}
|
|
|
+ customerId={customer?.id}
|
|
|
/>
|
|
|
- )}
|
|
|
- </WorkOrderLayoutProvider>
|
|
|
- )}
|
|
|
-
|
|
|
- {activeTab === 'images' && (
|
|
|
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
- <ServiceImagesManager
|
|
|
- serviceRecordId={record.id}
|
|
|
- initialImages={imageAttachmentsForManager}
|
|
|
- maxImages={maxImagesPerService}
|
|
|
- customerId={customer?.id}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {activeTab === 'video' && (
|
|
|
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
- <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {activeTab === 'documents' && (
|
|
|
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
- <ServiceDocumentsManager
|
|
|
- serviceRecordId={record.id}
|
|
|
- initialDocuments={documentAttachments}
|
|
|
- maxDiagnostics={maxDiagnosticsPerService}
|
|
|
- maxDocuments={maxDocumentsPerService}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- {activeTab === 'statusReports' && (
|
|
|
- <div className="flex-1 overflow-y-auto overscroll-contain p-4">{statusReportList}</div>
|
|
|
- )}
|
|
|
+ {activeTab === 'video' && (
|
|
|
+ <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
+ <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- <InventoryPickerDialog
|
|
|
- open={formState.showInventoryPicker}
|
|
|
- onOpenChange={formState.setShowInventoryPicker}
|
|
|
- inventoryParts={inventoryParts}
|
|
|
- currencyCode={currencyCode}
|
|
|
- onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
|
|
|
- defaultMarkupPercent={defaultMarkupPercent}
|
|
|
- markupAppliesToInventory={markupAppliesToInventory}
|
|
|
- />
|
|
|
+ {activeTab === 'documents' && (
|
|
|
+ <div className="flex-1 overflow-y-auto overscroll-contain p-4">
|
|
|
+ <ServiceDocumentsManager
|
|
|
+ serviceRecordId={record.id}
|
|
|
+ initialDocuments={documentAttachments}
|
|
|
+ maxDiagnostics={maxDiagnosticsPerService}
|
|
|
+ maxDocuments={maxDocumentsPerService}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
- <LaborPresetPickerDialog
|
|
|
- open={formState.showPresetPicker}
|
|
|
- onOpenChange={formState.setShowPresetPicker}
|
|
|
- laborPresets={laborPresets}
|
|
|
- onSelectPreset={handleSelectPreset}
|
|
|
- />
|
|
|
+ {activeTab === 'statusReports' && (
|
|
|
+ <div className="flex-1 overflow-y-auto overscroll-contain p-4">{statusReportList}</div>
|
|
|
+ )}
|
|
|
|
|
|
- <BarcodeScannerDialog
|
|
|
- open={formState.showBarcodeScanner}
|
|
|
- onOpenChange={formState.setShowBarcodeScanner}
|
|
|
- onScan={handleBarcodeScan}
|
|
|
- title={t('parts.scanTitle')}
|
|
|
- />
|
|
|
+ <InventoryPickerDialog
|
|
|
+ open={formState.showInventoryPicker}
|
|
|
+ onOpenChange={formState.setShowInventoryPicker}
|
|
|
+ inventoryParts={inventoryParts}
|
|
|
+ currencyCode={currencyCode}
|
|
|
+ onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
|
|
|
+ defaultMarkupPercent={defaultMarkupPercent}
|
|
|
+ markupAppliesToInventory={markupAppliesToInventory}
|
|
|
+ />
|
|
|
|
|
|
- <ImageCarousel
|
|
|
- images={formState.imageAttachments}
|
|
|
- currentIndex={actions.carouselIndex}
|
|
|
- onClose={() => actions.setCarouselIndex(null)}
|
|
|
- onChangeIndex={actions.setCarouselIndex}
|
|
|
- />
|
|
|
+ <LaborPresetPickerDialog
|
|
|
+ open={formState.showPresetPicker}
|
|
|
+ onOpenChange={formState.setShowPresetPicker}
|
|
|
+ laborPresets={laborPresets}
|
|
|
+ onSelectPreset={handleSelectPreset}
|
|
|
+ />
|
|
|
|
|
|
- <PdfPreviewDialog
|
|
|
- open={showPdfPreview}
|
|
|
- onOpenChange={setShowPdfPreview}
|
|
|
- url={`/api/protected/services/${record.id}/pdf`}
|
|
|
- />
|
|
|
+ <BarcodeScannerDialog
|
|
|
+ open={formState.showBarcodeScanner}
|
|
|
+ onOpenChange={formState.setShowBarcodeScanner}
|
|
|
+ onScan={handleBarcodeScan}
|
|
|
+ title={t('parts.scanTitle')}
|
|
|
+ />
|
|
|
|
|
|
- <SendEmailDialog
|
|
|
- open={actions.showEmailDialog}
|
|
|
- onOpenChange={actions.setShowEmailDialog}
|
|
|
- defaultEmail={customer?.email || ''}
|
|
|
- entityLabel={t('invoice.entityLabel')}
|
|
|
- onSend={async (email, message, attachPdf) => {
|
|
|
- const result = await sendInvoiceEmail({
|
|
|
- serviceRecordId: record.id,
|
|
|
- recipientEmail: email,
|
|
|
- message,
|
|
|
- attachPdf,
|
|
|
- })
|
|
|
- // Deliberately not awaited: the email dialog should show "sent" the
|
|
|
- // moment it is, not sit spinning behind the "mark completed"
|
|
|
- // confirmation that handleInvoiceSent may raise.
|
|
|
- if (result.success) void handleInvoiceSent()
|
|
|
- return result
|
|
|
- }}
|
|
|
- />
|
|
|
+ <ImageCarousel
|
|
|
+ images={formState.imageAttachments}
|
|
|
+ currentIndex={actions.carouselIndex}
|
|
|
+ onClose={() => actions.setCarouselIndex(null)}
|
|
|
+ onChangeIndex={actions.setCarouselIndex}
|
|
|
+ />
|
|
|
|
|
|
- <ShareDialog
|
|
|
- open={actions.showShareDialog}
|
|
|
- onOpenChange={actions.setShowShareDialog}
|
|
|
- recordId={record.id}
|
|
|
- organizationId={organizationId}
|
|
|
- initialToken={record.publicToken}
|
|
|
- onSent={handleInvoiceSent}
|
|
|
- customer={customer}
|
|
|
- smsEnabled={smsEnabled}
|
|
|
- emailEnabled={emailEnabled}
|
|
|
- />
|
|
|
+ <PdfPreviewDialog
|
|
|
+ open={showPdfPreview}
|
|
|
+ onOpenChange={setShowPdfPreview}
|
|
|
+ url={`/api/protected/services/${record.id}/pdf`}
|
|
|
+ />
|
|
|
|
|
|
- {customer && (
|
|
|
- <>
|
|
|
- <NotifyCustomerDialog
|
|
|
- open={actions.showPaymentNotifyDialog}
|
|
|
- onOpenChange={actions.setShowPaymentNotifyDialog}
|
|
|
- customer={customer}
|
|
|
- vehicle={record.vehicle}
|
|
|
- defaultMessage={actions.paymentNotifyMessage}
|
|
|
- emailSubject={t('invoice.emailSubject')}
|
|
|
- smsEnabled={smsEnabled}
|
|
|
- emailEnabled={emailEnabled}
|
|
|
- relatedEntityType="service-record"
|
|
|
- relatedEntityId={record.id}
|
|
|
- />
|
|
|
- <NotifyCustomerDialog
|
|
|
- open={showNotifyDialog}
|
|
|
- onOpenChange={setShowNotifyDialog}
|
|
|
- customer={customer}
|
|
|
- vehicle={record.vehicle}
|
|
|
- defaultMessage={notifyMessage}
|
|
|
- emailSubject={t('invoice.statusEmailSubject')}
|
|
|
- smsEnabled={smsEnabled}
|
|
|
- emailEnabled={emailEnabled}
|
|
|
- relatedEntityType="service-record"
|
|
|
- relatedEntityId={record.id}
|
|
|
- />
|
|
|
- </>
|
|
|
- )}
|
|
|
+ <SendEmailDialog
|
|
|
+ open={actions.showEmailDialog}
|
|
|
+ onOpenChange={actions.setShowEmailDialog}
|
|
|
+ defaultEmail={customer?.email || ''}
|
|
|
+ entityLabel={t('invoice.entityLabel')}
|
|
|
+ onSend={async (email, message, attachPdf) => {
|
|
|
+ const result = await sendInvoiceEmail({
|
|
|
+ serviceRecordId: record.id,
|
|
|
+ recipientEmail: email,
|
|
|
+ message,
|
|
|
+ attachPdf,
|
|
|
+ })
|
|
|
+ // Deliberately not awaited: the email dialog should show "sent" the
|
|
|
+ // moment it is, not sit spinning behind the "mark completed"
|
|
|
+ // confirmation that handleInvoiceSent may raise.
|
|
|
+ if (result.success) void handleInvoiceSent()
|
|
|
+ return result
|
|
|
+ }}
|
|
|
+ />
|
|
|
|
|
|
- {/* Expired dates check dialog */}
|
|
|
- <Dialog
|
|
|
- open={showDateCheck}
|
|
|
- onOpenChange={(open) => {
|
|
|
- if (!open) {
|
|
|
- dateCheckResolveRef.current?.(false)
|
|
|
- dateCheckResolveRef.current = null
|
|
|
- setCustomizingDates(false)
|
|
|
- }
|
|
|
- setShowDateCheck(open)
|
|
|
- }}
|
|
|
- >
|
|
|
- <DialogContent className="sm:max-w-sm">
|
|
|
- <DialogHeader>
|
|
|
- <DialogTitle className="flex items-center gap-2">
|
|
|
- <AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
|
- {t('page.datesExpiredTitle')}
|
|
|
- </DialogTitle>
|
|
|
- <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
|
|
|
- </DialogHeader>
|
|
|
+ <ShareDialog
|
|
|
+ open={actions.showShareDialog}
|
|
|
+ onOpenChange={actions.setShowShareDialog}
|
|
|
+ recordId={record.id}
|
|
|
+ organizationId={organizationId}
|
|
|
+ initialToken={record.publicToken}
|
|
|
+ onSent={handleInvoiceSent}
|
|
|
+ customer={customer}
|
|
|
+ smsEnabled={smsEnabled}
|
|
|
+ emailEnabled={emailEnabled}
|
|
|
+ />
|
|
|
|
|
|
- {!customizingDates ? (
|
|
|
- <div className="flex flex-col gap-2 pt-2">
|
|
|
- <Button
|
|
|
- disabled={updatingDates}
|
|
|
- onClick={async () => {
|
|
|
- if (updatingDates) return
|
|
|
- const now = new Date(new Date().toISOString().split('T')[0])
|
|
|
- const due =
|
|
|
- defaultDueDays > 0
|
|
|
- ? new Date(now.getTime() + defaultDueDays * 86400000)
|
|
|
- : new Date(now.getTime() + 14 * 86400000)
|
|
|
- setPendingInvoiceDate(now)
|
|
|
- setPendingDueDate(due)
|
|
|
- setUpdatingDates(true)
|
|
|
- try {
|
|
|
- await updateServiceRecord({
|
|
|
- id: record.id,
|
|
|
- invoiceDate: toISODate(now),
|
|
|
- invoiceDueDate: toISODate(due),
|
|
|
- })
|
|
|
- setShowDateCheck(false)
|
|
|
- dateCheckResolveRef.current?.(true)
|
|
|
- dateCheckResolveRef.current = null
|
|
|
- router.refresh()
|
|
|
- } finally {
|
|
|
- setUpdatingDates(false)
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
- {t('page.datesExpiredUseToday')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- disabled={updatingDates}
|
|
|
- onClick={() => {
|
|
|
- setShowDateCheck(false)
|
|
|
- dateCheckResolveRef.current?.(true)
|
|
|
- dateCheckResolveRef.current = null
|
|
|
- }}
|
|
|
- >
|
|
|
- {t('page.datesExpiredProceed')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- variant="ghost"
|
|
|
- size="sm"
|
|
|
- disabled={updatingDates}
|
|
|
- onClick={() => setCustomizingDates(true)}
|
|
|
- >
|
|
|
- {t('page.datesExpiredCustomize')}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- ) : (
|
|
|
- <>
|
|
|
- <div className="space-y-3">
|
|
|
- <div className="space-y-1">
|
|
|
- <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
|
|
|
- <Popover>
|
|
|
- <PopoverTrigger asChild>
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- className="w-full justify-start text-left font-normal h-9 text-sm"
|
|
|
- >
|
|
|
- <CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
|
|
- <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
|
|
|
- </Button>
|
|
|
- </PopoverTrigger>
|
|
|
- <PopoverContent className="w-auto p-0" align="start">
|
|
|
- <Calendar
|
|
|
- mode="single"
|
|
|
- selected={pendingInvoiceDate}
|
|
|
- onSelect={(d) => d && setPendingInvoiceDate(d)}
|
|
|
- />
|
|
|
- </PopoverContent>
|
|
|
- </Popover>
|
|
|
- </div>
|
|
|
+ {customer && (
|
|
|
+ <>
|
|
|
+ <NotifyCustomerDialog
|
|
|
+ open={actions.showPaymentNotifyDialog}
|
|
|
+ onOpenChange={actions.setShowPaymentNotifyDialog}
|
|
|
+ customer={customer}
|
|
|
+ vehicle={record.vehicle}
|
|
|
+ defaultMessage={actions.paymentNotifyMessage}
|
|
|
+ emailSubject={t('invoice.emailSubject')}
|
|
|
+ smsEnabled={smsEnabled}
|
|
|
+ emailEnabled={emailEnabled}
|
|
|
+ relatedEntityType="service-record"
|
|
|
+ relatedEntityId={record.id}
|
|
|
+ />
|
|
|
+ <NotifyCustomerDialog
|
|
|
+ open={showNotifyDialog}
|
|
|
+ onOpenChange={setShowNotifyDialog}
|
|
|
+ customer={customer}
|
|
|
+ vehicle={record.vehicle}
|
|
|
+ defaultMessage={notifyMessage}
|
|
|
+ emailSubject={t('invoice.statusEmailSubject')}
|
|
|
+ smsEnabled={smsEnabled}
|
|
|
+ emailEnabled={emailEnabled}
|
|
|
+ relatedEntityType="service-record"
|
|
|
+ relatedEntityId={record.id}
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
|
|
|
- <div className="space-y-1">
|
|
|
- <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
|
|
|
- <Popover>
|
|
|
- <PopoverTrigger asChild>
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- className="w-full justify-start text-left font-normal h-9 text-sm"
|
|
|
- >
|
|
|
- <CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
|
|
- <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
|
|
|
- </Button>
|
|
|
- </PopoverTrigger>
|
|
|
- <PopoverContent className="w-auto p-0" align="start">
|
|
|
- <Calendar
|
|
|
- mode="single"
|
|
|
- selected={pendingDueDate}
|
|
|
- onSelect={(d) => d && setPendingDueDate(d)}
|
|
|
- />
|
|
|
- </PopoverContent>
|
|
|
- </Popover>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ {/* Expired dates check dialog */}
|
|
|
+ <Dialog
|
|
|
+ open={showDateCheck}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (!open) {
|
|
|
+ dateCheckResolveRef.current?.(false)
|
|
|
+ dateCheckResolveRef.current = null
|
|
|
+ setCustomizingDates(false)
|
|
|
+ }
|
|
|
+ setShowDateCheck(open)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <DialogContent className="sm:max-w-sm">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle className="flex items-center gap-2">
|
|
|
+ <AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
|
+ {t('page.datesExpiredTitle')}
|
|
|
+ </DialogTitle>
|
|
|
+ <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
|
|
|
+ {!customizingDates ? (
|
|
|
<div className="flex flex-col gap-2 pt-2">
|
|
|
<Button
|
|
|
disabled={updatingDates}
|
|
|
onClick={async () => {
|
|
|
if (updatingDates) return
|
|
|
+ const now = new Date(new Date().toISOString().split('T')[0])
|
|
|
+ const due =
|
|
|
+ defaultDueDays > 0
|
|
|
+ ? new Date(now.getTime() + defaultDueDays * 86400000)
|
|
|
+ : new Date(now.getTime() + 14 * 86400000)
|
|
|
+ setPendingInvoiceDate(now)
|
|
|
+ setPendingDueDate(due)
|
|
|
setUpdatingDates(true)
|
|
|
try {
|
|
|
await updateServiceRecord({
|
|
|
id: record.id,
|
|
|
- invoiceDate: toISODate(pendingInvoiceDate),
|
|
|
- invoiceDueDate: toISODate(pendingDueDate),
|
|
|
+ invoiceDate: toISODate(now),
|
|
|
+ invoiceDueDate: toISODate(due),
|
|
|
})
|
|
|
setShowDateCheck(false)
|
|
|
- setCustomizingDates(false)
|
|
|
dateCheckResolveRef.current?.(true)
|
|
|
dateCheckResolveRef.current = null
|
|
|
router.refresh()
|
|
|
@@ -1033,20 +944,114 @@ export function ServicePageClient({
|
|
|
}}
|
|
|
>
|
|
|
{updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
- {t('page.datesExpiredUpdate')}
|
|
|
+ {t('page.datesExpiredUseToday')}
|
|
|
</Button>
|
|
|
<Button
|
|
|
variant="outline"
|
|
|
disabled={updatingDates}
|
|
|
- onClick={() => setCustomizingDates(false)}
|
|
|
+ onClick={() => {
|
|
|
+ setShowDateCheck(false)
|
|
|
+ dateCheckResolveRef.current?.(true)
|
|
|
+ dateCheckResolveRef.current = null
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('page.datesExpiredProceed')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ disabled={updatingDates}
|
|
|
+ onClick={() => setCustomizingDates(true)}
|
|
|
>
|
|
|
- {t('page.datesExpiredBack')}
|
|
|
+ {t('page.datesExpiredCustomize')}
|
|
|
</Button>
|
|
|
</div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </DialogContent>
|
|
|
- </Dialog>
|
|
|
- </div>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <div className="space-y-1">
|
|
|
+ <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
|
|
|
+ <Popover>
|
|
|
+ <PopoverTrigger asChild>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ className="w-full justify-start text-left font-normal h-9 text-sm"
|
|
|
+ >
|
|
|
+ <CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
|
|
+ <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
|
|
|
+ </Button>
|
|
|
+ </PopoverTrigger>
|
|
|
+ <PopoverContent className="w-auto p-0" align="start">
|
|
|
+ <Calendar
|
|
|
+ mode="single"
|
|
|
+ selected={pendingInvoiceDate}
|
|
|
+ onSelect={(d) => d && setPendingInvoiceDate(d)}
|
|
|
+ />
|
|
|
+ </PopoverContent>
|
|
|
+ </Popover>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-1">
|
|
|
+ <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
|
|
|
+ <Popover>
|
|
|
+ <PopoverTrigger asChild>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ className="w-full justify-start text-left font-normal h-9 text-sm"
|
|
|
+ >
|
|
|
+ <CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
|
|
+ <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
|
|
|
+ </Button>
|
|
|
+ </PopoverTrigger>
|
|
|
+ <PopoverContent className="w-auto p-0" align="start">
|
|
|
+ <Calendar
|
|
|
+ mode="single"
|
|
|
+ selected={pendingDueDate}
|
|
|
+ onSelect={(d) => d && setPendingDueDate(d)}
|
|
|
+ />
|
|
|
+ </PopoverContent>
|
|
|
+ </Popover>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex flex-col gap-2 pt-2">
|
|
|
+ <Button
|
|
|
+ disabled={updatingDates}
|
|
|
+ onClick={async () => {
|
|
|
+ if (updatingDates) return
|
|
|
+ setUpdatingDates(true)
|
|
|
+ try {
|
|
|
+ await updateServiceRecord({
|
|
|
+ id: record.id,
|
|
|
+ invoiceDate: toISODate(pendingInvoiceDate),
|
|
|
+ invoiceDueDate: toISODate(pendingDueDate),
|
|
|
+ })
|
|
|
+ setShowDateCheck(false)
|
|
|
+ setCustomizingDates(false)
|
|
|
+ dateCheckResolveRef.current?.(true)
|
|
|
+ dateCheckResolveRef.current = null
|
|
|
+ router.refresh()
|
|
|
+ } finally {
|
|
|
+ setUpdatingDates(false)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {updatingDates && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
+ {t('page.datesExpiredUpdate')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ disabled={updatingDates}
|
|
|
+ onClick={() => setCustomizingDates(false)}
|
|
|
+ >
|
|
|
+ {t('page.datesExpiredBack')}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+ </div>
|
|
|
+ </ShopFeeProvider>
|
|
|
)
|
|
|
}
|