Просмотр исходного кода

Add a PDF preview button next to download on invoices and quotes (#293)

* Add a PDF preview button next to download on invoices and quotes

* Skip the expired-dates prompt when previewing the PDF
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
f5c4f566e8

+ 5 - 0
messages/de/common.json

@@ -95,5 +95,10 @@
     "placeholder": "z. B. Wir haben Probleme mit unserer Serverinfrastruktur und arbeiten daran.",
     "posted": "Hinweis veröffentlicht",
     "cleared": "Hinweis entfernt"
+  },
+  "pdfPreview": {
+    "title": "PDF-Vorschau",
+    "preview": "Vorschau",
+    "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
   }
 }

+ 5 - 0
messages/en/common.json

@@ -95,5 +95,10 @@
     "placeholder": "e.g. We are having trouble with our server infrastructure and are working on it.",
     "posted": "Notice posted",
     "cleared": "Notice cleared"
+  },
+  "pdfPreview": {
+    "title": "PDF preview",
+    "preview": "Preview",
+    "failed": "Could not generate the PDF preview."
   }
 }

+ 5 - 0
messages/es/common.json

@@ -95,5 +95,10 @@
     "placeholder": "p. ej. Tenemos problemas con nuestra infraestructura de servidores y estamos trabajando en ello.",
     "posted": "Aviso publicado",
     "cleared": "Aviso retirado"
+  },
+  "pdfPreview": {
+    "title": "Vista previa del PDF",
+    "preview": "Vista previa",
+    "failed": "No se pudo generar la vista previa del PDF."
   }
 }

+ 5 - 0
messages/fr/common.json

@@ -95,5 +95,10 @@
     "placeholder": "ex. Nous rencontrons des difficultés avec notre infrastructure serveur et y travaillons.",
     "posted": "Message publié",
     "cleared": "Message retiré"
+  },
+  "pdfPreview": {
+    "title": "Aperçu du PDF",
+    "preview": "Aperçu",
+    "failed": "Impossible de générer l'aperçu du PDF."
   }
 }

+ 5 - 0
messages/it/common.json

@@ -95,5 +95,10 @@
     "placeholder": "es. Stiamo riscontrando problemi con la nostra infrastruttura server e ci stiamo lavorando.",
     "posted": "Avviso pubblicato",
     "cleared": "Avviso rimosso"
+  },
+  "pdfPreview": {
+    "title": "Anteprima PDF",
+    "preview": "Anteprima",
+    "failed": "Impossibile generare l'anteprima del PDF."
   }
 }

+ 5 - 0
messages/lt/common.json

@@ -95,5 +95,10 @@
     "placeholder": "pvz. Turime serverių infrastruktūros problemų ir jas sprendžiame.",
     "posted": "Pranešimas paskelbtas",
     "cleared": "Pranešimas pašalintas"
+  },
+  "pdfPreview": {
+    "title": "PDF peržiūra",
+    "preview": "Peržiūra",
+    "failed": "Nepavyko sugeneruoti PDF peržiūros."
   }
 }

+ 5 - 0
messages/nb/common.json

@@ -95,5 +95,10 @@
     "placeholder": "f.eks. Vi har problemer med serverinfrastrukturen vår og jobber med saken.",
     "posted": "Meldingen er publisert",
     "cleared": "Meldingen er fjernet"
+  },
+  "pdfPreview": {
+    "title": "PDF-forhåndsvisning",
+    "preview": "Forhåndsvisning",
+    "failed": "Kunne ikke generere PDF-forhåndsvisningen."
   }
 }

+ 5 - 0
messages/nl/common.json

@@ -95,5 +95,10 @@
     "placeholder": "bijv. We hebben problemen met onze serverinfrastructuur en werken eraan.",
     "posted": "Bericht geplaatst",
     "cleared": "Bericht verwijderd"
+  },
+  "pdfPreview": {
+    "title": "PDF-voorbeeld",
+    "preview": "Voorbeeld",
+    "failed": "Kon het PDF-voorbeeld niet genereren."
   }
 }

+ 5 - 0
messages/pl/common.json

@@ -95,5 +95,10 @@
     "placeholder": "np. Mamy problemy z infrastrukturą serwerową i pracujemy nad nimi.",
     "posted": "Komunikat opublikowany",
     "cleared": "Komunikat usunięty"
+  },
+  "pdfPreview": {
+    "title": "Podgląd PDF",
+    "preview": "Podgląd",
+    "failed": "Nie udało się wygenerować podglądu PDF."
   }
 }

+ 5 - 0
messages/pt-BR/common.json

@@ -95,5 +95,10 @@
     "placeholder": "ex. Estamos com problemas na nossa infraestrutura de servidores e já estamos trabalhando nisso.",
     "posted": "Aviso publicado",
     "cleared": "Aviso removido"
+  },
+  "pdfPreview": {
+    "title": "Pré-visualização do PDF",
+    "preview": "Pré-visualização",
+    "failed": "Não foi possível gerar a pré-visualização do PDF."
   }
 }

+ 5 - 0
messages/ru/common.json

@@ -95,5 +95,10 @@
     "placeholder": "напр. У нас проблемы с серверной инфраструктурой, мы уже работаем над этим.",
     "posted": "Сообщение опубликовано",
     "cleared": "Сообщение убрано"
+  },
+  "pdfPreview": {
+    "title": "Предпросмотр PDF",
+    "preview": "Предпросмотр",
+    "failed": "Не удалось создать предпросмотр PDF."
   }
 }

+ 5 - 0
messages/tr/common.json

@@ -95,5 +95,10 @@
     "placeholder": "ör. Sunucu altyapımızda sorun yaşıyoruz ve üzerinde çalışıyoruz.",
     "posted": "Duyuru yayınlandı",
     "cleared": "Duyuru kaldırıldı"
+  },
+  "pdfPreview": {
+    "title": "PDF önizleme",
+    "preview": "Önizleme",
+    "failed": "PDF önizlemesi oluşturulamadı."
   }
 }

+ 74 - 0
src/components/pdf-preview-dialog.tsx

@@ -0,0 +1,74 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Loader2 } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+
+interface PdfPreviewDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  /** Endpoint that answers the finished PDF, e.g. /api/protected/services/[id]/pdf */
+  url: string
+}
+
+/**
+ * Shows the document's real PDF in a dialog, next to the download button.
+ *
+ * The file is fetched fresh on every open rather than cached, so the preview
+ * always matches what download, email, and share would produce right now —
+ * which is the whole point of looking before sending.
+ */
+export function PdfPreviewDialog({ open, onOpenChange, url }: PdfPreviewDialogProps) {
+  const t = useTranslations('common.pdfPreview')
+  const [blobUrl, setBlobUrl] = useState<string | null>(null)
+  const [failed, setFailed] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    let cancelled = false
+    let created: string | null = null
+    setBlobUrl(null)
+    setFailed(false)
+
+    fetch(url)
+      .then(async (res) => {
+        if (!res.ok) throw new Error('Failed')
+        const blob = await res.blob()
+        if (cancelled) return
+        created = URL.createObjectURL(blob)
+        setBlobUrl(created)
+      })
+      .catch(() => {
+        if (!cancelled) setFailed(true)
+      })
+
+    return () => {
+      cancelled = true
+      if (created) URL.revokeObjectURL(created)
+    }
+  }, [open, url])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="flex h-[90vh] flex-col sm:max-w-5xl">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+        </DialogHeader>
+        <div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
+          {failed ? (
+            <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
+              {t('failed')}
+            </div>
+          ) : blobUrl ? (
+            <iframe src={blobUrl} title={t('title')} className="h-full w-full" />
+          ) : (
+            <div className="flex h-full items-center justify-center">
+              <Loader2 className="size-6 animate-spin text-muted-foreground" />
+            </div>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 23 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -16,12 +16,14 @@ 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,
@@ -104,6 +106,7 @@ export function QuotePageClient({
   const router = useRouter()
   const isLarge = useIsLargeScreen()
   const t = useTranslations('quotes')
+  const tPreview = useTranslations('common.pdfPreview')
 
   const state = useQuoteFormState({
     quote,
@@ -125,6 +128,7 @@ export function QuotePageClient({
   })
 
   const [showPresetPicker, setShowPresetPicker] = useState(false)
+  const [showPdfPreview, setShowPdfPreview] = useState(false)
 
   const handleSelectPreset = useCallback(
     (preset: LaborPresetOption) => {
@@ -286,6 +290,19 @@ export function QuotePageClient({
               {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"
@@ -453,6 +470,12 @@ export function QuotePageClient({
       />
 
       {/* Dialogs */}
+      <PdfPreviewDialog
+        open={showPdfPreview}
+        onOpenChange={setShowPdfPreview}
+        url={`/api/protected/quotes/${quote.id}/pdf`}
+      />
+
       <SendEmailDialog
         open={state.showEmailDialog}
         onOpenChange={state.setShowEmailDialog}

+ 14 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -14,6 +14,7 @@ import { ServiceDetailContent } from '../service-detail/ServiceDetailContent'
 import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
 import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
+import { PdfPreviewDialog } from '@/components/pdf-preview-dialog'
 import { InventoryPickerDialog } from '../service-edit/InventoryPickerDialog'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
 import { useHardwareScanner } from '@/hooks/use-hardware-scanner'
@@ -213,6 +214,7 @@ export function ServicePageClient({
     'waiting-parts': SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
     completed: SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
   }
+  const [showPdfPreview, setShowPdfPreview] = useState(false)
   const [showNotifyDialog, setShowNotifyDialog] = useState(false)
   const [notifyMessage, setNotifyMessage] = useState('')
 
@@ -357,6 +359,12 @@ export function ServicePageClient({
           if (formState.hasUnsavedChanges) await actions.saveNow()
           actions.handleDownloadPDF()
         }}
+        onPreviewPDF={async () => {
+          // No expired-dates prompt here: previewing is just looking, and the
+          // check still runs on download, email, and share.
+          if (formState.hasUnsavedChanges) await actions.saveNow()
+          setShowPdfPreview(true)
+        }}
         onDelete={actions.handleDelete}
         onShowEmail={async () => {
           if (!(await checkDates())) return
@@ -554,6 +562,12 @@ export function ServicePageClient({
         onChangeIndex={actions.setCarouselIndex}
       />
 
+      <PdfPreviewDialog
+        open={showPdfPreview}
+        onOpenChange={setShowPdfPreview}
+        url={`/api/protected/services/${record.id}/pdf`}
+      />
+
       <SendEmailDialog
         open={actions.showEmailDialog}
         onOpenChange={actions.setShowEmailDialog}

+ 5 - 1
src/features/vehicles/Components/service-page/UnifiedServiceHeader.tsx

@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
 import { ButtonGroup } from '@/components/ui/button-group'
 import { IconActionButton } from '@/components/icon-action-button'
 import { cn } from '@/lib/utils'
-import { ArrowLeft, Download, Globe, Mail, MessageSquare, Save, Trash2 } from 'lucide-react'
+import { ArrowLeft, Download, Eye, Globe, Mail, MessageSquare, Save, Trash2 } from 'lucide-react'
 import { statusColors, paymentStatusColors, paymentStatusLabels } from '../service-detail/types'
 
 export type ServiceTab = 'details' | 'images' | 'video' | 'documents' | 'statusReports'
@@ -32,6 +32,7 @@ interface UnifiedServiceHeaderProps {
   hasUnsavedChanges?: boolean
   showSaved?: boolean
   onDownloadPDF: () => void
+  onPreviewPDF: () => void
   onDelete: () => void
   onShowEmail: () => void
   onShowShare: () => void
@@ -53,6 +54,7 @@ export function UnifiedServiceHeader({
   hasUnsavedChanges = false,
   showSaved = false,
   onDownloadPDF,
+  onPreviewPDF,
   onDelete,
   onShowEmail,
   onShowShare,
@@ -60,6 +62,7 @@ export function UnifiedServiceHeader({
   hasCustomer = false,
 }: UnifiedServiceHeaderProps) {
   const t = useTranslations('service.header')
+  const tPreview = useTranslations('common.pdfPreview')
   const tabs: { label: string; value: ServiceTab }[] = [
     { label: t('tabs.details'), value: 'details' },
     { label: t('tabs.images'), value: 'images' },
@@ -117,6 +120,7 @@ export function UnifiedServiceHeader({
             </>
           )}
           <ButtonGroup>
+            <IconActionButton label={tPreview('preview')} icon={Eye} onClick={onPreviewPDF} />
             <IconActionButton
               label={t('pdf')}
               icon={Download}