Browse Source

Let a portal customer read an invoice without downloading it (#367)

The portal only offered View for an invoice the workshop had shared, and
Download for the rest, while quotes could be read in place. A new portal
page draws the same sheet the PDF prints for the signed-in customer's own
completed invoices, with the download beside it and the shared copy
offered when there is one. View is now always there in the list.
Bernt Christian Egeland 3 weeks ago
parent
commit
c4c42e1783

+ 3 - 1
messages/de/portal.json

@@ -105,7 +105,9 @@
     "download": "Herunterladen",
     "paid": "bezahlt",
     "partial": "teilweise",
-    "unpaid": "unbezahlt"
+    "unpaid": "unbezahlt",
+    "back": "Zurück zu den Rechnungen",
+    "openShared": "Freigegebene Kopie öffnen"
   },
   "quotes": {
     "title": "Angebote",

+ 3 - 1
messages/en/portal.json

@@ -105,7 +105,9 @@
     "download": "Download",
     "paid": "paid",
     "partial": "partial",
-    "unpaid": "unpaid"
+    "unpaid": "unpaid",
+    "back": "Back to invoices",
+    "openShared": "Open the shared copy"
   },
   "quotes": {
     "title": "Quotes",

+ 3 - 1
messages/es/portal.json

@@ -105,7 +105,9 @@
     "download": "Descargar",
     "paid": "pagado",
     "partial": "parcial",
-    "unpaid": "sin pagar"
+    "unpaid": "sin pagar",
+    "back": "Volver a las facturas",
+    "openShared": "Abrir la copia compartida"
   },
   "quotes": {
     "title": "Presupuestos",

+ 3 - 1
messages/fr/portal.json

@@ -105,7 +105,9 @@
     "download": "Télécharger",
     "paid": "payé",
     "partial": "partiel",
-    "unpaid": "impayé"
+    "unpaid": "impayé",
+    "back": "Retour aux factures",
+    "openShared": "Ouvrir la copie partagée"
   },
   "quotes": {
     "title": "Devis",

+ 3 - 1
messages/it/portal.json

@@ -105,7 +105,9 @@
     "download": "Scarica",
     "paid": "pagato",
     "partial": "parziale",
-    "unpaid": "non pagato"
+    "unpaid": "non pagato",
+    "back": "Torna alle fatture",
+    "openShared": "Apri la copia condivisa"
   },
   "quotes": {
     "title": "Preventivi",

+ 3 - 1
messages/lt/portal.json

@@ -105,7 +105,9 @@
     "download": "Atsisiųsti",
     "paid": "apmokėta",
     "partial": "dalinai",
-    "unpaid": "neapmokėta"
+    "unpaid": "neapmokėta",
+    "back": "Atgal į sąskaitas",
+    "openShared": "Atidaryti bendrinamą kopiją"
   },
   "quotes": {
     "title": "Pasiūlymai",

+ 3 - 1
messages/nb/portal.json

@@ -105,7 +105,9 @@
     "download": "Last ned",
     "paid": "betalt",
     "partial": "delbetalt",
-    "unpaid": "ubetalt"
+    "unpaid": "ubetalt",
+    "back": "Tilbake til fakturaer",
+    "openShared": "Åpne den delte kopien"
   },
   "quotes": {
     "title": "Tilbud",

+ 3 - 1
messages/nl/portal.json

@@ -105,7 +105,9 @@
     "download": "Downloaden",
     "paid": "betaald",
     "partial": "gedeeltelijk",
-    "unpaid": "onbetaald"
+    "unpaid": "onbetaald",
+    "back": "Terug naar facturen",
+    "openShared": "Gedeelde kopie openen"
   },
   "quotes": {
     "title": "Offertes",

+ 3 - 1
messages/pl/portal.json

@@ -105,7 +105,9 @@
     "download": "Pobierz",
     "paid": "zapłacono",
     "partial": "częściowo",
-    "unpaid": "niezapłacono"
+    "unpaid": "niezapłacono",
+    "back": "Powrót do faktur",
+    "openShared": "Otwórz udostępnioną kopię"
   },
   "quotes": {
     "title": "Wyceny",

+ 3 - 1
messages/pt-BR/portal.json

@@ -105,7 +105,9 @@
     "download": "Baixar",
     "paid": "pago",
     "partial": "parcial",
-    "unpaid": "não pago"
+    "unpaid": "não pago",
+    "back": "Voltar para faturas",
+    "openShared": "Abrir a cópia compartilhada"
   },
   "quotes": {
     "title": "Orçamentos",

+ 3 - 1
messages/ru/portal.json

@@ -105,7 +105,9 @@
     "download": "Скачать",
     "paid": "оплачено",
     "partial": "частично",
-    "unpaid": "не оплачено"
+    "unpaid": "не оплачено",
+    "back": "Назад к счетам",
+    "openShared": "Открыть общедоступную копию"
   },
   "quotes": {
     "title": "Предложения",

+ 3 - 1
messages/tr/portal.json

@@ -105,7 +105,9 @@
     "download": "İndir",
     "paid": "ödendi",
     "partial": "kısmi",
-    "unpaid": "ödenmedi"
+    "unpaid": "ödenmedi",
+    "back": "Faturalara dön",
+    "openShared": "Paylaşılan kopyayı aç"
   },
   "quotes": {
     "title": "Teklifler",

+ 78 - 0
src/app/(public)/portal/[orgId]/invoices/[invoiceId]/page.tsx

@@ -0,0 +1,78 @@
+import { ArrowLeft, Download, ExternalLink } from 'lucide-react'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { getTranslations } from 'next-intl/server'
+import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import { getPortalInvoiceSheet } from '@/features/portal/Actions/portalActions'
+import { PortalShell } from '@/features/portal/Components/PortalShell'
+
+/**
+ * An invoice read in the portal, the way a quote can be.
+ *
+ * Until now an invoice the workshop had not shared could only be downloaded
+ * here. This draws the same sheet the PDF prints, for the signed-in customer
+ * and their own invoices only, with the download beside it. When the
+ * workshop has shared a link, that page is offered too, since it is where
+ * paying online lives.
+ */
+export default async function PortalInvoicePage({
+  params,
+}: {
+  params: Promise<{ orgId: string; invoiceId: string }>
+}) {
+  const { orgId, invoiceId } = await params
+  const t = await getTranslations('portal.invoices')
+  const result = await getPortalInvoiceSheet(invoiceId)
+
+  if (!result.success) {
+    return (
+      <PortalShell orgId={orgId}>
+        <p className="text-muted-foreground">{t('failedToLoad')}</p>
+      </PortalShell>
+    )
+  }
+  if (!result.data) notFound()
+  const invoice = result.data
+
+  return (
+    <PortalShell orgId={orgId}>
+      <div className="space-y-6">
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+          <div>
+            <Link
+              href={`/portal/${orgId}/invoices`}
+              className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
+            >
+              <ArrowLeft className="h-4 w-4" />
+              {t('back')}
+            </Link>
+            <h1 className="mt-1 text-2xl font-bold">
+              {invoice.invoiceNumber ? `${t('invoice')} ${invoice.invoiceNumber}` : invoice.title}
+            </h1>
+          </div>
+          <div className="flex items-center gap-4">
+            {invoice.publicToken && (
+              <Link
+                href={`/share/invoice/${orgId}/${invoice.publicToken}`}
+                className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
+              >
+                <ExternalLink className="h-4 w-4" />
+                {t('openShared')}
+              </Link>
+            )}
+            <a
+              href={`/portal/${orgId}/invoices/${invoice.id}/pdf`}
+              download
+              className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
+            >
+              <Download className="h-4 w-4" />
+              {t('download')}
+            </a>
+          </div>
+        </div>
+
+        <SpecSheet spec={invoice.spec} />
+      </div>
+    </PortalShell>
+  )
+}

+ 20 - 16
src/app/(public)/portal/[orgId]/invoices/page.tsx

@@ -97,14 +97,16 @@ export default async function PortalInvoicesPage({
                       </span>
                     </div>
                     <div className="mt-3 flex items-center gap-4">
-                      {inv.publicToken && (
-                        <Link
-                          href={`/share/invoice/${orgId}/${inv.publicToken}`}
-                          className="text-sm text-primary hover:underline"
-                        >
-                          {t('view')}
-                        </Link>
-                      )}
+                      <Link
+                        href={
+                          inv.publicToken
+                            ? `/share/invoice/${orgId}/${inv.publicToken}`
+                            : `/portal/${orgId}/invoices/${inv.id}`
+                        }
+                        className="text-sm text-primary hover:underline"
+                      >
+                        {t('view')}
+                      </Link>
                       <a
                         href={`/portal/${orgId}/invoices/${inv.id}/pdf`}
                         download
@@ -165,14 +167,16 @@ export default async function PortalInvoicesPage({
                         </TableCell>
                         <TableCell>
                           <div className="flex items-center gap-3">
-                            {inv.publicToken && (
-                              <Link
-                                href={`/share/invoice/${orgId}/${inv.publicToken}`}
-                                className="text-sm text-primary hover:underline"
-                              >
-                                {t('view')}
-                              </Link>
-                            )}
+                            <Link
+                              href={
+                                inv.publicToken
+                                  ? `/share/invoice/${orgId}/${inv.publicToken}`
+                                  : `/portal/${orgId}/invoices/${inv.id}`
+                              }
+                              className="text-sm text-primary hover:underline"
+                            >
+                              {t('view')}
+                            </Link>
                             <a
                               href={`/portal/${orgId}/invoices/${inv.id}/pdf`}
                               download

+ 60 - 0
src/features/portal/Actions/portalActions.ts

@@ -9,6 +9,13 @@ import { sendServiceRequestAlert } from '@/features/portal/Lib/sendServiceReques
 import { withAuth, type ActionResult } from '@/lib/with-auth'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { headers } from 'next/headers'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+import { assembleInvoicePrint } from '@/features/invoices/Lib/assembleInvoicePrint'
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { getFeatures } from '@/lib/features'
+import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 
 type PortalResult<T = unknown> = {
   success: boolean
@@ -272,6 +279,59 @@ export async function getPortalInvoices() {
   })
 }
 
+/**
+ * One invoice as the sheet the customer would be printed, for reading in the
+ * portal without downloading the PDF. The same assembly and spec the PDF and
+ * the shared copy come from, so the three cannot disagree. Only the
+ * customer's own completed records, the same rule as the list.
+ */
+export async function getPortalInvoiceSheet(invoiceId: string) {
+  return withPortalAuth(async ({ customerId, organizationId }) => {
+    const record = await db.serviceRecord.findFirst({
+      where: {
+        id: invoiceId,
+        organizationId,
+        OR: [{ vehicle: { customerId } }, { customerId }],
+        status: 'completed',
+      },
+      select: { id: true, title: true, invoiceNumber: true, publicToken: true },
+    })
+    if (!record) return null
+
+    const assembly = await assembleInvoicePrint(record.id)
+    if (!assembly) return null
+
+    const acceptLanguage = (await headers()).get('accept-language')
+    const locale = await resolveCustomerLocale(organizationId, acceptLanguage)
+    const [labels, features] = await Promise.all([
+      loadPrintLabels(locale, assembly.labelSettings),
+      getFeatures(organizationId),
+    ])
+    const torqvoiceLogoDataUri = features.brandingRemoved
+      ? undefined
+      : await getTorqvoiceLogoDataUri()
+
+    const spec = buildInvoicePrintSpec({
+      data: assembly.data,
+      workshop: assembly.workshop,
+      invoiceSettings: assembly.invoiceSettings,
+      paymentSummary: assembly.paymentSummary,
+      logoDataUri: assembly.logoDataUri,
+      template: assembly.template,
+      torqvoiceLogoDataUri,
+      labels,
+    })
+
+    return {
+      id: record.id,
+      title: record.title,
+      invoiceNumber: record.invoiceNumber,
+      publicToken: record.publicToken,
+      spec,
+    }
+  })
+}
+
 // ─── Quotes ───────────────────────────────────────────────────────────────
 
 export async function getPortalQuotes() {