Browse Source

Add service history PDF export per vehicle (#117)

* export history

* lint
Bernt Christian Egeland 6 months ago
parent
commit
0bd1fdeb05

+ 14 - 0
messages/de/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Ihr Portal ansehen: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "SERVICEHISTORIE",
+    "totalServices": "Gesamt Services",
+    "totalPartsUsed": "Teile verwendet",
+    "totalLaborItems": "Arbeitsposten",
+    "grandTotal": "Gesamtsumme",
+    "serviceRecords": "Service-Einträge",
+    "date": "Datum",
+    "serviceTitle": "Service",
+    "type": "Typ",
+    "status": "Status",
+    "mileageCol": "Kilometerstand",
+    "mileageColMarine": "Motorstunden"
+  },
   "common": {
     "poweredBy": "Bereitgestellt von"
   }

+ 1 - 0
messages/de/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Ausr\u00fcstung",
     "inspection": "Inspektion",
     "newWorkOrder": "Neuer Arbeitsauftrag",
+    "exportServiceHistory": "Historie exportieren",
     "table": {
       "date": "Datum",
       "title": "Titel",

+ 14 - 0
messages/en/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "View your portal: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "SERVICE HISTORY",
+    "totalServices": "Total Services",
+    "totalPartsUsed": "Parts Used",
+    "totalLaborItems": "Labor Items",
+    "grandTotal": "Grand Total",
+    "serviceRecords": "Service Records",
+    "date": "Date",
+    "serviceTitle": "Service",
+    "type": "Type",
+    "status": "Status",
+    "mileageCol": "Mileage",
+    "mileageColMarine": "Engine Hrs"
+  },
   "common": {
     "poweredBy": "Powered by"
   }

+ 1 - 0
messages/en/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Upgrade",
     "inspection": "Inspection",
     "newWorkOrder": "New Work Order",
+    "exportServiceHistory": "Export History",
     "table": {
       "date": "Date",
       "title": "Title",

+ 14 - 0
messages/es/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Ver su portal: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "HISTORIAL DE SERVICIO",
+    "totalServices": "Total Servicios",
+    "totalPartsUsed": "Piezas Usadas",
+    "totalLaborItems": "Elementos de Mano de Obra",
+    "grandTotal": "Total General",
+    "serviceRecords": "Registros de Servicio",
+    "date": "Fecha",
+    "serviceTitle": "Servicio",
+    "type": "Tipo",
+    "status": "Estado",
+    "mileageCol": "Kilometraje",
+    "mileageColMarine": "Horas Motor"
+  },
   "common": {
     "poweredBy": "Desarrollado por"
   }

+ 1 - 0
messages/es/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Mejora",
     "inspection": "Inspeccion",
     "newWorkOrder": "Nueva orden de trabajo",
+    "exportServiceHistory": "Exportar Historial",
     "table": {
       "date": "Fecha",
       "title": "Titulo",

+ 14 - 0
messages/fr/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Consultez votre portail : {url}",
     "tel": "Tél. : {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "HISTORIQUE D'ENTRETIEN",
+    "totalServices": "Total Services",
+    "totalPartsUsed": "Pièces Utilisées",
+    "totalLaborItems": "Éléments Main-d'œuvre",
+    "grandTotal": "Total Général",
+    "serviceRecords": "Registres de Service",
+    "date": "Date",
+    "serviceTitle": "Service",
+    "type": "Type",
+    "status": "Statut",
+    "mileageCol": "Kilométrage",
+    "mileageColMarine": "Heures Moteur"
+  },
   "common": {
     "poweredBy": "Propulsé par"
   }

+ 1 - 0
messages/fr/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Am\u00e9lioration",
     "inspection": "Inspection",
     "newWorkOrder": "Nouvel ordre de travail",
+    "exportServiceHistory": "Exporter l'historique",
     "table": {
       "date": "Date",
       "title": "Titre",

+ 14 - 0
messages/it/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Visualizza il tuo portale: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "STORICO ASSISTENZA",
+    "totalServices": "Totale Servizi",
+    "totalPartsUsed": "Ricambi Utilizzati",
+    "totalLaborItems": "Voci Manodopera",
+    "grandTotal": "Totale Generale",
+    "serviceRecords": "Registri di Servizio",
+    "date": "Data",
+    "serviceTitle": "Servizio",
+    "type": "Tipo",
+    "status": "Stato",
+    "mileageCol": "Chilometraggio",
+    "mileageColMarine": "Ore Motore"
+  },
   "common": {
     "poweredBy": "Powered by"
   }

+ 1 - 0
messages/it/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Aggiornamento",
     "inspection": "Ispezione",
     "newWorkOrder": "Nuovo Ordine di Lavoro",
+    "exportServiceHistory": "Esporta Storico",
     "table": {
       "date": "Data",
       "title": "Titolo",

+ 14 - 0
messages/nb/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Se portalen din: {url}",
     "tel": "Tlf: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "SERVICEHISTORIKK",
+    "totalServices": "Totalt Tjenester",
+    "totalPartsUsed": "Deler Brukt",
+    "totalLaborItems": "Arbeidsposter",
+    "grandTotal": "Totalsum",
+    "serviceRecords": "Serviceoppføringer",
+    "date": "Dato",
+    "serviceTitle": "Tjeneste",
+    "type": "Type",
+    "status": "Status",
+    "mileageCol": "Kilometerstand",
+    "mileageColMarine": "Motortimer"
+  },
   "common": {
     "poweredBy": "Drevet av"
   }

+ 1 - 0
messages/nb/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Oppgradering",
     "inspection": "Inspeksjon",
     "newWorkOrder": "Ny arbeidsordre",
+    "exportServiceHistory": "Eksporter historikk",
     "table": {
       "date": "Dato",
       "title": "Tittel",

+ 14 - 0
messages/nl/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Bekijk uw portaal: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "SERVICEGESCHIEDENIS",
+    "totalServices": "Totaal Services",
+    "totalPartsUsed": "Onderdelen Gebruikt",
+    "totalLaborItems": "Arbeidsitems",
+    "grandTotal": "Eindtotaal",
+    "serviceRecords": "Serviceoverzicht",
+    "date": "Datum",
+    "serviceTitle": "Service",
+    "type": "Type",
+    "status": "Status",
+    "mileageCol": "Kilometerstand",
+    "mileageColMarine": "Motoruren"
+  },
   "common": {
     "poweredBy": "Mogelijk gemaakt door"
   }

+ 1 - 0
messages/nl/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Upgrade",
     "inspection": "Keuring",
     "newWorkOrder": "Nieuwe werkorder",
+    "exportServiceHistory": "Geschiedenis exporteren",
     "table": {
       "date": "Datum",
       "title": "Titel",

+ 14 - 0
messages/pl/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Zobacz swój portal: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "HISTORIA SERWISOWA",
+    "totalServices": "Łącznie Usług",
+    "totalPartsUsed": "Użyte Części",
+    "totalLaborItems": "Pozycje Robocizny",
+    "grandTotal": "Suma Całkowita",
+    "serviceRecords": "Zapisy Serwisowe",
+    "date": "Data",
+    "serviceTitle": "Usługa",
+    "type": "Typ",
+    "status": "Status",
+    "mileageCol": "Przebieg",
+    "mileageColMarine": "Godziny Silnika"
+  },
   "common": {
     "poweredBy": "Obsługiwane przez"
   }

+ 1 - 0
messages/pl/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Ulepszenie",
     "inspection": "Przegląd",
     "newWorkOrder": "Nowe zlecenie",
+    "exportServiceHistory": "Eksportuj historię",
     "table": {
       "date": "Data",
       "title": "Tytuł",

+ 14 - 0
messages/pt-BR/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Acesse seu portal: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "HISTÓRICO DE SERVIÇO",
+    "totalServices": "Total de Serviços",
+    "totalPartsUsed": "Peças Utilizadas",
+    "totalLaborItems": "Itens de Mão de Obra",
+    "grandTotal": "Total Geral",
+    "serviceRecords": "Registros de Serviço",
+    "date": "Data",
+    "serviceTitle": "Serviço",
+    "type": "Tipo",
+    "status": "Status",
+    "mileageCol": "Quilometragem",
+    "mileageColMarine": "Horas Motor"
+  },
   "common": {
     "poweredBy": "Desenvolvido por"
   }

+ 1 - 0
messages/pt-BR/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Melhoria",
     "inspection": "Inspeção",
     "newWorkOrder": "Nova Ordem de Serviço",
+    "exportServiceHistory": "Exportar Histórico",
     "table": {
       "date": "Data",
       "title": "Título",

+ 14 - 0
messages/ru/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Ваш портал: {url}",
     "tel": "Тел: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "ИСТОРИЯ ОБСЛУЖИВАНИЯ",
+    "totalServices": "Всего услуг",
+    "totalPartsUsed": "Использовано деталей",
+    "totalLaborItems": "Позиции работ",
+    "grandTotal": "Итого",
+    "serviceRecords": "Записи обслуживания",
+    "date": "Дата",
+    "serviceTitle": "Услуга",
+    "type": "Тип",
+    "status": "Статус",
+    "mileageCol": "Пробег",
+    "mileageColMarine": "Моточасы"
+  },
   "common": {
     "poweredBy": "Работает на"
   }

+ 1 - 0
messages/ru/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Модернизация",
     "inspection": "Осмотр",
     "newWorkOrder": "Новый заказ-наряд",
+    "exportServiceHistory": "Экспорт истории",
     "table": {
       "date": "Дата",
       "title": "Название",

+ 14 - 0
messages/tr/pdf.json

@@ -105,6 +105,20 @@
     "viewPortal": "Portalınızı görüntüleyin: {url}",
     "tel": "Tel: {phone}"
   },
+  "serviceHistory": {
+    "serviceHistoryTitle": "SERVİS GEÇMİŞİ",
+    "totalServices": "Toplam Servis",
+    "totalPartsUsed": "Kullanılan Parçalar",
+    "totalLaborItems": "İşçilik Kalemleri",
+    "grandTotal": "Genel Toplam",
+    "serviceRecords": "Servis Kayıtları",
+    "date": "Tarih",
+    "serviceTitle": "Servis",
+    "type": "Tür",
+    "status": "Durum",
+    "mileageCol": "Kilometre",
+    "mileageColMarine": "Motor Saati"
+  },
   "common": {
     "poweredBy": "Destekleyen"
   }

+ 1 - 0
messages/tr/vehicles.json

@@ -137,6 +137,7 @@
     "upgrade": "Yükseltme",
     "inspection": "Muayene",
     "newWorkOrder": "Yeni İş Emri",
+    "exportServiceHistory": "Geçmişi Dışa Aktar",
     "table": {
       "date": "Tarih",
       "title": "Başlık",

+ 45 - 6
src/app/(authenticated)/vehicles/[id]/service-records-table.tsx

@@ -29,11 +29,13 @@ import {
   ChevronRight,
   ChevronsLeft,
   ChevronsRight,
+  Download,
   Loader2,
   Paperclip,
   Plus,
   Search,
 } from "lucide-react";
+import { toast } from "sonner";
 import { useTranslations } from "next-intl";
 import { useServiceType } from "@/components/service-type-context";
 
@@ -85,6 +87,7 @@ export function ServiceRecordsTable({
   const [isPending, startTransition] = useTransition();
   const [searchInput, setSearchInput] = useState(search);
   const [navigatingId, setNavigatingId] = useState<string | null>(null);
+  const [isExporting, setIsExporting] = useState(false);
   const t = useTranslations("vehicles.services");
   const serviceType = useServiceType();
 
@@ -133,6 +136,27 @@ export function ServiceRecordsTable({
     [navigate]
   );
 
+  const handleExportPdf = useCallback(async () => {
+    setIsExporting(true);
+    try {
+      const res = await fetch(`/api/protected/vehicles/${vehicleId}/service-history-pdf`);
+      if (!res.ok) throw new Error("Failed to generate PDF");
+      const blob = await res.blob();
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = res.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] || "service-history.pdf";
+      document.body.appendChild(a);
+      a.click();
+      a.remove();
+      URL.revokeObjectURL(url);
+    } catch {
+      toast.error("Failed to export service history PDF");
+    } finally {
+      setIsExporting(false);
+    }
+  }, [vehicleId]);
+
   const startItem = (page - 1) * pageSize + 1;
   const endItem = Math.min(page * pageSize, total);
 
@@ -167,12 +191,27 @@ export function ServiceRecordsTable({
           </Select>
           {isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
         </div>
-        <Button size="sm" asChild>
-          <Link href={`/vehicles/${vehicleId}/service/new`}>
-            <Plus className="mr-1 h-3.5 w-3.5" />
-            {t("newWorkOrder")}
-          </Link>
-        </Button>
+        <div className="flex items-center gap-2">
+          <Button
+            size="sm"
+            variant="outline"
+            onClick={handleExportPdf}
+            disabled={isExporting || total === 0}
+          >
+            {isExporting ? (
+              <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <Download className="mr-1 h-3.5 w-3.5" />
+            )}
+            {t("exportServiceHistory")}
+          </Button>
+          <Button size="sm" asChild>
+            <Link href={`/vehicles/${vehicleId}/service/new`}>
+              <Plus className="mr-1 h-3.5 w-3.5" />
+              {t("newWorkOrder")}
+            </Link>
+          </Button>
+        </div>
       </div>
 
       {/* Table */}

+ 193 - 0
src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts

@@ -0,0 +1,193 @@
+import { NextResponse } from "next/server";
+import { renderToBuffer } from "@react-pdf/renderer";
+import "@/features/vehicles/Components/invoice-pdf/fonts";
+import { cookies } from "next/headers";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { db } from "@/lib/db";
+import { ServiceHistoryPDF } from "@/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF";
+import React from "react";
+import { readFile } from "fs/promises";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { getFeatures } from "@/lib/features";
+import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const ctx = await getAuthContext();
+
+    if (!ctx) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    // Load locale-based PDF translations
+    const cookieStore = await cookies();
+    const locale = cookieStore.get("locale")?.value || "en";
+    let pdfMessages: Record<string, Record<string, string>>;
+    try {
+      pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default;
+    } catch {
+      pdfMessages = (await import(`../../../../../../../messages/en/pdf.json`)).default;
+    }
+    const labels = {
+      ...pdfMessages.invoice,
+      ...pdfMessages.common,
+      ...(pdfMessages.serviceHistory || {}),
+    };
+
+    const { id } = await params;
+
+    const [vehicle, settings, org] = await Promise.all([
+      db.vehicle.findFirst({
+        where: { id, organizationId: ctx.organizationId },
+        select: {
+          id: true,
+          make: true,
+          model: true,
+          year: true,
+          vin: true,
+          licensePlate: true,
+          mileage: true,
+          customer: {
+            select: {
+              name: true,
+              email: true,
+              phone: true,
+              company: true,
+            },
+          },
+        },
+      }),
+      db.appSetting.findMany({
+        where: { organizationId: ctx.organizationId },
+      }),
+      db.organization.findUnique({
+        where: { id: ctx.organizationId },
+        select: { name: true },
+      }),
+    ]);
+
+    if (!vehicle) {
+      return NextResponse.json({ error: "Vehicle not found" }, { status: 404 });
+    }
+
+    // Fetch all service records for this vehicle, ordered by date descending
+    const records = await db.serviceRecord.findMany({
+      where: { vehicleId: id },
+      orderBy: { serviceDate: "desc" },
+      select: {
+        id: true,
+        title: true,
+        type: true,
+        status: true,
+        serviceDate: true,
+        mileage: true,
+        techName: true,
+        invoiceNumber: true,
+        cost: true,
+        totalAmount: true,
+        partItems: {
+          select: { name: true, quantity: true, unitPrice: true, total: true },
+        },
+        laborItems: {
+          select: { description: true, hours: true, rate: true, total: true },
+        },
+      },
+    });
+
+    // Build settings map
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) settingsMap[s.key] = s.value;
+
+    // Override labels for marine service type
+    const serviceType = settingsMap["workshop.serviceType"] || "automotive";
+    if (serviceType === "marine") {
+      if (pdfMessages.invoice.mileageMarine) labels.mileage = pdfMessages.invoice.mileageMarine;
+      if (pdfMessages.invoice.vinMarine) labels.vin = pdfMessages.invoice.vinMarine;
+      if (pdfMessages.invoice.plateMarine) labels.plate = pdfMessages.invoice.plateMarine;
+      if (pdfMessages.invoice.vehicleMarine) labels.vehicle = pdfMessages.invoice.vehicleMarine;
+      labels.km = "hrs";
+      labels.mi = "hrs";
+      labels.mileageCol = labels.mileageColMarine || "Engine Hrs";
+    }
+
+    // Load company logo as base64 data URI
+    let logoDataUri: string | undefined;
+    const logoPath = settingsMap["workshop.logo"];
+    if (logoPath) {
+      try {
+        const fullPath = resolveUploadPath(logoPath);
+        const logoBuffer = await readFile(fullPath);
+        const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
+        const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
+        const mime = mimeMap[ext] || "image/png";
+        logoDataUri = `data:${mime};base64,${logoBuffer.toString("base64")}`;
+      } catch {
+        // Logo file not found, skip
+      }
+    }
+
+    const invoiceSettings = {
+      currencyCode: settingsMap["workshop.currencyCode"] || "USD",
+      unitSystem: settingsMap["workshop.unitSystem"] || "imperial",
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
+    };
+
+    const templateConfig = {
+      primaryColor: settingsMap["invoice.primaryColor"] || settingsMap["invoice.template.primaryColor"] || "#d97706",
+      fontFamily: settingsMap["invoice.fontFamily"] || settingsMap["invoice.template.fontFamily"] || "Helvetica",
+      showLogo: (settingsMap["invoice.showLogo"] ?? settingsMap["invoice.template.showLogo"]) !== "false",
+      showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
+      logoSize: Number(settingsMap["invoice.logoSize"]) || 100,
+    };
+
+    // Check if Torqvoice branding should be shown
+    const features = await getFeatures(ctx.organizationId);
+    let torqvoiceLogoDataUri: string | undefined;
+    if (!features.brandingRemoved) {
+      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+    }
+
+    const element = React.createElement(ServiceHistoryPDF, {
+      vehicle,
+      records,
+      workshop: {
+        name: org?.name || "",
+        address: settingsMap["workshop.address"] || "",
+        phone: settingsMap["workshop.phone"] || "",
+        email: settingsMap["workshop.email"] || "",
+      },
+      invoiceSettings,
+      logoDataUri,
+      template: templateConfig,
+      torqvoiceLogoDataUri,
+      labels,
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    }) as any;
+    const pdfBuffer = await renderToBuffer(element);
+
+    const vehicleName = `${vehicle.year} ${vehicle.make} ${vehicle.model}`;
+    const fileName = `Service-History-${vehicleName.replace(/\s+/g, "-")}`;
+
+    const finalBuffer = pdfBuffer.buffer.slice(
+      pdfBuffer.byteOffset,
+      pdfBuffer.byteOffset + pdfBuffer.byteLength
+    ) as ArrayBuffer;
+
+    return new NextResponse(finalBuffer, {
+      headers: {
+        "Content-Type": "application/pdf",
+        "Content-Disposition": `attachment; filename="${fileName}.pdf"`,
+      },
+    });
+  } catch (error) {
+    console.error("[Service History PDF] Error:", error);
+    return NextResponse.json(
+      { error: "Failed to generate PDF" },
+      { status: 500 }
+    );
+  }
+}

+ 297 - 0
src/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF.tsx

@@ -0,0 +1,297 @@
+import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
+import { formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { createStyles, gray, getFontBold } from '../invoice-pdf/styles'
+import type { WorkshopInfo, InvoiceSettingsProps } from '../invoice-pdf/types'
+
+interface ServiceHistoryRecord {
+  id: string
+  title: string
+  type: string
+  status: string
+  serviceDate: Date
+  mileage: number | null
+  techName: string | null
+  invoiceNumber: string | null
+  cost: number
+  totalAmount: number
+  partItems: { name: string; quantity: number; unitPrice: number; total: number }[]
+  laborItems: { description: string; hours: number; rate: number; total: number }[]
+}
+
+interface ServiceHistoryPDFProps {
+  vehicle: {
+    make: string
+    model: string
+    year: number
+    vin: string | null
+    licensePlate: string | null
+    mileage: number
+    customer: {
+      name: string
+      email: string | null
+      phone: string | null
+      company: string | null
+    } | null
+  }
+  records: ServiceHistoryRecord[]
+  workshop?: WorkshopInfo
+  invoiceSettings?: InvoiceSettingsProps
+  logoDataUri?: string
+  template?: {
+    primaryColor?: string
+    fontFamily?: string
+    showLogo?: boolean
+    showCompanyName?: boolean
+    logoSize?: number
+  }
+  torqvoiceLogoDataUri?: string
+  labels?: Record<string, string>
+}
+
+function formatCurrencyPdf(amount: number, currencyCode: string): string {
+  try {
+    return new Intl.NumberFormat('en-US', {
+      style: 'currency',
+      currency: currencyCode,
+      currencyDisplay: 'narrowSymbol',
+      minimumFractionDigits: 2,
+    }).format(amount)
+  } catch {
+    return `${currencyCode} ${amount.toFixed(2)}`
+  }
+}
+
+export function ServiceHistoryPDF({
+  vehicle,
+  records,
+  workshop,
+  invoiceSettings,
+  logoDataUri,
+  template,
+  torqvoiceLogoDataUri,
+  labels = {},
+}: ServiceHistoryPDFProps) {
+  const primaryColor = template?.primaryColor || '#d97706'
+  const fontFamily = template?.fontFamily || 'Helvetica'
+  const showLogo = template?.showLogo !== false
+  const showCompanyName = template?.showCompanyName !== false
+  const styles = createStyles(primaryColor, fontFamily)
+  const fontBold = getFontBold(fontFamily)
+  const cc = invoiceSettings?.currencyCode || 'USD'
+  const df = invoiceSettings?.dateFormat || DEFAULT_DATE_FORMAT
+  const tz = invoiceSettings?.timezone || undefined
+  const unitLabel = invoiceSettings?.unitSystem === 'metric' ? (labels.km || 'km') : (labels.mi || 'mi')
+
+  const vehicleName = `${vehicle.year} ${vehicle.make} ${vehicle.model}`
+  const shopDisplayName = workshop?.name || 'Torqvoice'
+
+  const grandTotal = records.reduce((sum, r) => {
+    const display = r.totalAmount > 0 ? r.totalAmount : r.cost
+    return sum + display
+  }, 0)
+
+  const totalParts = records.reduce((sum, r) => sum + r.partItems.length, 0)
+  const totalLabor = records.reduce((sum, r) => sum + r.laborItems.length, 0)
+
+  const logoScale = (template?.logoSize || 100) / 100
+
+  return (
+    <Document>
+      <Page size="A4" style={styles.page}>
+        {/* Header */}
+        <View style={styles.header}>
+          <View>
+            {showLogo && logoDataUri && (
+              <Image
+                src={logoDataUri}
+                style={{
+                  maxWidth: 40 * logoScale,
+                  maxHeight: 40 * logoScale,
+                  marginBottom: 6,
+                  borderRadius: 4,
+                  objectFit: 'contain',
+                  objectPosition: 'left',
+                }}
+              />
+            )}
+            {showCompanyName && <Text style={styles.brandName}>{shopDisplayName}</Text>}
+            {workshop?.address && <Text style={styles.brandSub}>{workshop.address}</Text>}
+            {workshop?.phone && (
+              <Text style={styles.brandContact}>
+                {labels.tel ? labels.tel.replace('{phone}', workshop.phone) : `Tel: ${workshop.phone}`}
+              </Text>
+            )}
+            {workshop?.email && <Text style={styles.brandContact}>{workshop.email}</Text>}
+            {torqvoiceLogoDataUri && (
+              <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3, marginTop: 4 }}>
+                <Image src={torqvoiceLogoDataUri} style={{ width: 16, height: 16 }} />
+                <Text style={{ fontSize: 9, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
+              </View>
+            )}
+          </View>
+          <View>
+            <Text style={styles.invoiceTitle}>{labels.serviceHistoryTitle || 'SERVICE HISTORY'}</Text>
+            <Text style={styles.invoiceNumber}>
+              {formatDateForPdf(new Date(), df, tz)}
+            </Text>
+          </View>
+        </View>
+
+        {/* Vehicle & Customer Info */}
+        <View style={styles.infoRow}>
+          <View style={{ flex: 1 }}>
+            <View style={styles.infoBox}>
+              <Text style={styles.infoLabel}>{labels.vehicle || 'Vehicle'}</Text>
+              <Text style={styles.infoTextBold}>{vehicleName}</Text>
+              {vehicle.vin && (
+                <Text style={styles.infoTextSmall}>
+                  {labels.vin ? labels.vin.replace('{vin}', vehicle.vin) : `VIN: ${vehicle.vin}`}
+                </Text>
+              )}
+              {vehicle.licensePlate && (
+                <Text style={styles.infoTextSmall}>
+                  {labels.plate ? labels.plate.replace('{plate}', vehicle.licensePlate) : `Plate: ${vehicle.licensePlate}`}
+                </Text>
+              )}
+              <Text style={styles.infoTextSmall}>
+                {labels.mileage
+                  ? labels.mileage.replace('{mileage}', `${vehicle.mileage.toLocaleString()} ${unitLabel}`)
+                  : `Mileage: ${vehicle.mileage.toLocaleString()} ${unitLabel}`}
+              </Text>
+            </View>
+          </View>
+          {vehicle.customer && (
+            <View style={{ flex: 1 }}>
+              <View style={styles.infoBox}>
+                <Text style={styles.infoLabel}>{labels.billTo || 'Customer'}</Text>
+                <Text style={styles.infoTextBold}>{vehicle.customer.name}</Text>
+                {vehicle.customer.company && (
+                  <Text style={styles.infoTextSmall}>{vehicle.customer.company}</Text>
+                )}
+                {vehicle.customer.email && (
+                  <Text style={styles.infoTextSmall}>{vehicle.customer.email}</Text>
+                )}
+                {vehicle.customer.phone && (
+                  <Text style={styles.infoTextSmall}>{vehicle.customer.phone}</Text>
+                )}
+              </View>
+            </View>
+          )}
+        </View>
+
+        {/* Summary Stats */}
+        <View style={{ flexDirection: 'row', gap: 12, marginBottom: 16 }}>
+          <View style={{ flex: 1, padding: 8, backgroundColor: '#f3f4f6', borderRadius: 4, alignItems: 'center' }}>
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
+              {records.length}
+            </Text>
+            <Text style={{ fontSize: 8, color: gray }}>
+              {labels.totalServices || 'Total Services'}
+            </Text>
+          </View>
+          <View style={{ flex: 1, padding: 8, backgroundColor: '#f3f4f6', borderRadius: 4, alignItems: 'center' }}>
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
+              {totalParts}
+            </Text>
+            <Text style={{ fontSize: 8, color: gray }}>
+              {labels.totalPartsUsed || 'Parts Used'}
+            </Text>
+          </View>
+          <View style={{ flex: 1, padding: 8, backgroundColor: '#f3f4f6', borderRadius: 4, alignItems: 'center' }}>
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
+              {totalLabor}
+            </Text>
+            <Text style={{ fontSize: 8, color: gray }}>
+              {labels.totalLaborItems || 'Labor Items'}
+            </Text>
+          </View>
+          <View style={{ flex: 1, padding: 8, backgroundColor: '#f3f4f6', borderRadius: 4, alignItems: 'center' }}>
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
+              {formatCurrencyPdf(grandTotal, cc)}
+            </Text>
+            <Text style={{ fontSize: 8, color: gray }}>
+              {labels.grandTotal || 'Grand Total'}
+            </Text>
+          </View>
+        </View>
+
+        {/* Service Records Table */}
+        <Text style={styles.sectionTitle}>{labels.serviceRecords || 'Service Records'}</Text>
+        <View style={styles.table}>
+          {/* Table Header */}
+          <View style={styles.tableHeader}>
+            <Text style={[styles.tableHeaderCell, { width: '15%' }]}>{labels.date || 'Date'}</Text>
+            <Text style={[styles.tableHeaderCell, { width: '30%' }]}>{labels.serviceTitle || 'Service'}</Text>
+            <Text style={[styles.tableHeaderCell, { width: '12%' }]}>{labels.type || 'Type'}</Text>
+            <Text style={[styles.tableHeaderCell, { width: '12%' }]}>{labels.status || 'Status'}</Text>
+            <Text style={[styles.tableHeaderCell, { width: '13%', textAlign: 'right' }]}>{labels.mileageCol || 'Mileage'}</Text>
+            <Text style={[styles.tableHeaderCell, { width: '18%', textAlign: 'right' }]}>{labels.total || 'Total'}</Text>
+          </View>
+
+          {/* Table Rows */}
+          {records.map((record) => {
+            const displayTotal = record.totalAmount > 0 ? record.totalAmount : record.cost
+            return (
+              <View key={record.id} style={styles.tableRow} wrap={false}>
+                <Text style={[styles.tableCell, { width: '15%' }]}>
+                  {formatDateForPdf(record.serviceDate, df, tz)}
+                </Text>
+                <View style={{ width: '30%' }}>
+                  <Text style={styles.tableCellBold}>{record.title}</Text>
+                  {record.invoiceNumber && (
+                    <Text style={{ fontSize: 7, color: gray }}>{record.invoiceNumber}</Text>
+                  )}
+                </View>
+                <Text style={[styles.tableCell, { width: '12%', textTransform: 'capitalize' }]}>
+                  {record.type}
+                </Text>
+                <Text style={[styles.tableCell, { width: '12%', textTransform: 'capitalize' }]}>
+                  {record.status}
+                </Text>
+                <Text style={[styles.tableCell, { width: '13%', textAlign: 'right' }]}>
+                  {record.mileage ? record.mileage.toLocaleString() : '-'}
+                </Text>
+                <Text style={[styles.tableCellBold, { width: '18%', textAlign: 'right' }]}>
+                  {formatCurrencyPdf(displayTotal, cc)}
+                </Text>
+              </View>
+            )
+          })}
+        </View>
+
+        {/* Grand Total */}
+        <View style={styles.totalsBox}>
+          <View style={styles.totalDivider} />
+          <View style={styles.grandTotalRow}>
+            <Text style={styles.grandTotalLabel}>{labels.grandTotal || 'Grand Total'}</Text>
+            <Text style={styles.grandTotalValue}>{formatCurrencyPdf(grandTotal, cc)}</Text>
+          </View>
+        </View>
+
+        {/* Footer */}
+        {torqvoiceLogoDataUri ? (
+          <View
+            style={{
+              ...styles.footer,
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'center',
+              gap: 4,
+            }}
+          >
+            <Text style={{ fontSize: 8, color: gray }}>
+              {shopDisplayName} · {labels.serviceHistoryTitle || 'Service History'}
+            </Text>
+            <Text style={{ fontSize: 7, color: gray }}> · {labels.poweredBy || 'Powered by'}</Text>
+            <Image src={torqvoiceLogoDataUri} style={{ width: 14, height: 14 }} />
+            <Text style={{ fontSize: 7, color: gray, fontFamily: fontBold }}>Torqvoice</Text>
+          </View>
+        ) : (
+          <Text style={styles.footer}>
+            {shopDisplayName} · {labels.serviceHistoryTitle || 'Service History'} · {vehicleName}
+          </Text>
+        )}
+      </Page>
+    </Document>
+  )
+}