Procházet zdrojové kódy

Keep tire hotel documents off invoices and clean up on unlink (#294)

Bernt Christian Egeland před 2 měsíci
rodič
revize
bbeb1161b1

+ 2 - 1
messages/de/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Noch nichts angehängt. Fotografieren Sie eine beschädigte Felge, solange die Reifen vor Ihnen liegen.",
     "captionPlaceholder": "Was ist darauf zu sehen?",
     "onInvoice": "Auf der Rechnung",
-    "invoiceHint": "Für die Rechnung markierte Dateien werden beim Abrechnen auf den Auftrag oder die Rechnung kopiert.",
+    "invoiceHint": "Für die Rechnung markierte Fotos werden beim Abrechnen auf den Auftrag oder die Rechnung kopiert. Dokumente bleiben hier beim Satz.",
+    "documentStays": "Bleibt beim Satz, wird nicht auf Rechnungen kopiert",
     "deleteTitle": "Diese Datei entfernen?",
     "deleteBody": "{name} wird vom Satz entfernt. Bereits auf eine Rechnung kopierte Dateien bleiben dort.",
     "failed": "Konnte nicht gespeichert werden",

+ 2 - 1
messages/en/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Nothing attached yet. Photograph a kerbed rim or damage while the tires are in front of you.",
     "captionPlaceholder": "What does this show?",
     "onInvoice": "On the invoice",
-    "invoiceHint": "Files marked for the invoice are copied onto the work order or invoice when this set is billed.",
+    "invoiceHint": "Photos marked for the invoice are copied onto the work order or invoice when this set is billed. Documents stay here with the set.",
+    "documentStays": "Stays with the set, not copied to invoices",
     "deleteTitle": "Remove this file?",
     "deleteBody": "{name} is removed from this set. Anything already copied onto an invoice stays there.",
     "failed": "Could not save that",

+ 2 - 1
messages/es/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Todavía no hay nada adjunto. Fotografía una llanta rozada mientras tienes los neumáticos delante.",
     "captionPlaceholder": "¿Qué se ve aquí?",
     "onInvoice": "En la factura",
-    "invoiceHint": "Los archivos marcados para la factura se copian a la orden o a la factura al facturar el juego.",
+    "invoiceHint": "Las fotos marcadas para la factura se copian a la orden o a la factura al facturar el juego. Los documentos se quedan aquí con el juego.",
+    "documentStays": "Se queda con el juego, no se copia a las facturas",
     "deleteTitle": "¿Quitar este archivo?",
     "deleteBody": "{name} se quita del juego. Lo que ya se copió a una factura se mantiene.",
     "failed": "No se pudo guardar",

+ 2 - 1
messages/fr/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Rien de joint pour l’instant. Photographiez une jante frottée pendant que les pneus sont devant vous.",
     "captionPlaceholder": "Que montre cette image ?",
     "onInvoice": "Sur la facture",
-    "invoiceHint": "Les fichiers marqués pour la facture sont copiés sur l’ordre ou la facture au moment de facturer.",
+    "invoiceHint": "Les photos marquées pour la facture sont copiées sur l’ordre ou la facture au moment de facturer. Les documents restent ici avec le jeu.",
+    "documentStays": "Reste avec le jeu, non copié sur les factures",
     "deleteTitle": "Retirer ce fichier ?",
     "deleteBody": "{name} est retiré du jeu. Ce qui a déjà été copié sur une facture y reste.",
     "failed": "Impossible d’enregistrer",

+ 2 - 1
messages/it/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Ancora nulla in allegato. Fotografa un cerchio segnato mentre hai gli pneumatici davanti.",
     "captionPlaceholder": "Che cosa mostra?",
     "onInvoice": "In fattura",
-    "invoiceHint": "I file marcati per la fattura vengono copiati sull’ordine o sulla fattura quando il treno viene fatturato.",
+    "invoiceHint": "Le foto marcate per la fattura vengono copiate sull’ordine o sulla fattura quando il treno viene fatturato. I documenti restano qui con il treno.",
+    "documentStays": "Resta con il treno, non viene copiato sulle fatture",
     "deleteTitle": "Rimuovere questo file?",
     "deleteBody": "{name} viene rimosso dal treno. Ciò che è già stato copiato su una fattura resta lì.",
     "failed": "Impossibile salvare",

+ 2 - 1
messages/lt/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Kol kas nieko nepridėta. Nufotografuokite subraižytą ratlankį, kol padangos prieš jus.",
     "captionPlaceholder": "Kas čia matyti?",
     "onInvoice": "Sąskaitoje",
-    "invoiceHint": "Sąskaitai pažymėti failai nukopijuojami į darbo užsakymą ar sąskaitą apmokestinant komplektą.",
+    "invoiceHint": "Sąskaitai pažymėtos nuotraukos nukopijuojamos į darbo užsakymą ar sąskaitą apmokestinant komplektą. Dokumentai lieka čia, prie komplekto.",
+    "documentStays": "Lieka prie komplekto, į sąskaitas nekopijuojamas",
     "deleteTitle": "Pašalinti šį failą?",
     "deleteBody": "{name} pašalinamas iš komplekto. Tai, kas jau nukopijuota į sąskaitą, lieka.",
     "failed": "Nepavyko išsaugoti",

+ 2 - 1
messages/nb/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Ingenting lagt ved ennå. Ta bilde av en skadet felg mens dekkene står foran deg.",
     "captionPlaceholder": "Hva viser dette?",
     "onInvoice": "På fakturaen",
-    "invoiceHint": "Filer merket for faktura kopieres til arbeidsordren eller fakturaen når settet faktureres.",
+    "invoiceHint": "Bilder merket for faktura kopieres til arbeidsordren eller fakturaen når settet faktureres. Dokumenter blir liggende her på settet.",
+    "documentStays": "Blir på settet, kopieres ikke til fakturaer",
     "deleteTitle": "Fjerne denne filen?",
     "deleteBody": "{name} fjernes fra settet. Det som allerede er kopiert til en faktura, blir stående.",
     "failed": "Kunne ikke lagre",

+ 2 - 1
messages/nl/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Nog niets toegevoegd. Fotografeer een beschadigde velg terwijl de banden voor u liggen.",
     "captionPlaceholder": "Wat is hierop te zien?",
     "onInvoice": "Op de factuur",
-    "invoiceHint": "Bestanden gemarkeerd voor de factuur worden bij het factureren naar de werkorder of factuur gekopieerd.",
+    "invoiceHint": "Foto's gemarkeerd voor de factuur worden bij het factureren naar de werkorder of factuur gekopieerd. Documenten blijven hier bij de set.",
+    "documentStays": "Blijft bij de set, wordt niet naar facturen gekopieerd",
     "deleteTitle": "Dit bestand verwijderen?",
     "deleteBody": "{name} wordt van de set verwijderd. Wat al naar een factuur is gekopieerd, blijft staan.",
     "failed": "Kon dat niet opslaan",

+ 2 - 1
messages/pl/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Nic jeszcze nie dołączono. Zrób zdjęcie otartej felgi, póki opony masz przed sobą.",
     "captionPlaceholder": "Co widać na zdjęciu?",
     "onInvoice": "Na fakturze",
-    "invoiceHint": "Pliki oznaczone do faktury są kopiowane na zlecenie lub fakturę przy rozliczaniu kompletu.",
+    "invoiceHint": "Zdjęcia oznaczone do faktury są kopiowane na zlecenie lub fakturę przy rozliczaniu kompletu. Dokumenty zostają tutaj przy komplecie.",
+    "documentStays": "Zostaje przy komplecie, nie trafia na faktury",
     "deleteTitle": "Usunąć ten plik?",
     "deleteBody": "{name} zostanie usunięty z kompletu. To, co już trafiło na fakturę, tam zostaje.",
     "failed": "Nie udało się zapisać",

+ 2 - 1
messages/pt-BR/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Nada anexado ainda. Fotografe uma roda raspada enquanto os pneus estão na sua frente.",
     "captionPlaceholder": "O que aparece aqui?",
     "onInvoice": "Na fatura",
-    "invoiceHint": "Arquivos marcados para a fatura são copiados para a ordem ou a fatura quando o jogo é cobrado.",
+    "invoiceHint": "As fotos marcadas para a fatura são copiadas para a ordem ou a fatura quando o jogo é cobrado. Os documentos ficam aqui com o jogo.",
+    "documentStays": "Fica com o jogo, não é copiado para faturas",
     "deleteTitle": "Remover este arquivo?",
     "deleteBody": "{name} sai deste jogo. O que já foi copiado para uma fatura continua lá.",
     "failed": "Não foi possível salvar",

+ 2 - 1
messages/ru/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Пока ничего не прикреплено. Сфотографируйте задетый диск, пока шины перед вами.",
     "captionPlaceholder": "Что на снимке?",
     "onInvoice": "В счёте",
-    "invoiceHint": "Файлы, отмеченные для счёта, копируются в заказ-наряд или счёт при выставлении за комплект.",
+    "invoiceHint": "Фото, отмеченные для счёта, копируются в заказ-наряд или счёт при выставлении за комплект. Документы остаются здесь, при комплекте.",
+    "documentStays": "Остаётся при комплекте, в счета не копируется",
     "deleteTitle": "Удалить этот файл?",
     "deleteBody": "{name} убирается из комплекта. То, что уже скопировано в счёт, остаётся там.",
     "failed": "Не удалось сохранить",

+ 2 - 1
messages/tr/tireHotel.json

@@ -464,7 +464,8 @@
     "none": "Henüz ek yok. Lastikler önünüzdeyken çizilmiş bir janta fotoğraf çekin.",
     "captionPlaceholder": "Bu ne gösteriyor?",
     "onInvoice": "Faturada",
-    "invoiceHint": "Fatura için işaretlenen dosyalar, takım faturalanırken iş emrine veya faturaya kopyalanır.",
+    "invoiceHint": "Fatura için işaretlenen fotoğraflar, takım faturalanırken iş emrine veya faturaya kopyalanır. Belgeler burada, takımda kalır.",
+    "documentStays": "Takımda kalır, faturalara kopyalanmaz",
     "deleteTitle": "Bu dosya kaldırılsın mı?",
     "deleteBody": "{name} bu takımdan kaldırılır. Faturaya kopyalanmış olanlar orada kalır.",
     "failed": "Kaydedilemedi",

+ 68 - 0
src/__tests__/features/tire-hotel/copy-set-files.test.ts

@@ -0,0 +1,68 @@
+/**
+ * Tests for copySetFilesToJob — what billing a tire set puts on the job.
+ *
+ * The rule it exists to enforce: only photos travel. A PDF stored with a set
+ * (a storage agreement, an old invoice) is merged page for page into the
+ * job's invoice PDF, so copying one silently turns a tire swap invoice into
+ * two invoices stapled together — which is exactly the bug that prompted it.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import { copySetFilesToJob, type SetFile } from '@/features/tire-hotel/Lib/copySetFilesToJob'
+
+const photo = (name: string): SetFile => ({
+  fileName: name,
+  fileUrl: `/api/protected/files/org/tire-hotel/${name}`,
+  fileType: 'image/jpeg',
+  fileSize: 1024,
+  description: 'kerbing on the near side',
+})
+
+const pdf = (name: string): SetFile => ({
+  fileName: name,
+  fileUrl: `/api/protected/files/org/tire-hotel/${name}`,
+  fileType: 'application/pdf',
+  fileSize: 2048,
+  description: null,
+})
+
+function makeTx() {
+  const createMany = vi.fn().mockResolvedValue({ count: 0 })
+  return { tx: { serviceAttachment: { createMany } }, createMany }
+}
+
+describe('copying a set’s files onto a job', () => {
+  it('copies photos with the tire hotel category, included on the invoice', async () => {
+    const { tx, createMany } = makeTx()
+    await copySetFilesToJob(tx as never, 'svc-1', [photo('rim-front-left.jpg')])
+
+    expect(createMany).toHaveBeenCalledTimes(1)
+    expect(createMany.mock.calls[0][0].data).toEqual([
+      expect.objectContaining({
+        serviceRecordId: 'svc-1',
+        fileName: 'rim-front-left.jpg',
+        category: 'tire_hotel',
+        includeInInvoice: true,
+      }),
+    ])
+  })
+
+  it('leaves PDFs with the set', async () => {
+    const { tx, createMany } = makeTx()
+    await copySetFilesToJob(tx as never, 'svc-1', [
+      pdf('storage-agreement.pdf'),
+      photo('rim-front-left.jpg'),
+      pdf('2026-1048.pdf'),
+    ])
+
+    const rows = createMany.mock.calls[0][0].data as { fileName: string }[]
+    expect(rows.map((r) => r.fileName)).toEqual(['rim-front-left.jpg'])
+  })
+
+  it('writes nothing when the set only holds documents', async () => {
+    const { tx, createMany } = makeTx()
+    await copySetFilesToJob(tx as never, 'svc-1', [pdf('storage-agreement.pdf')])
+
+    expect(createMany).not.toHaveBeenCalled()
+  })
+})

+ 3 - 0
src/features/tire-hotel/Actions/attachmentActions.ts

@@ -96,6 +96,9 @@ export async function addTireSetAttachments(input: unknown) {
           fileType: file.fileType,
           fileSize: file.fileSize,
           description: file.description || null,
+          // Photos are taken to be shown and default onto the invoice;
+          // documents are the set's own paperwork and never travel to a job.
+          includeInInvoice: file.fileType.startsWith('image/'),
           sortOrder: start + index,
           tireSetId: set.id,
           organizationId,

+ 27 - 50
src/features/tire-hotel/Actions/tireJobActions.ts

@@ -3,7 +3,6 @@
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
 import { db } from '@/lib/db'
-import type { Prisma } from '@/generated/prisma/client'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { calculateTotals } from '@/lib/tax'
@@ -12,6 +11,7 @@ import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceReco
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { addTireLineToRecord } from '../Lib/addTireLine'
+import { copySetFilesToJob } from '../Lib/copySetFilesToJob'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { matchStock, parseTireSize, formatTireSize, sizesMatch } from '../Lib/tireMatching'
 import { TREATMENT_TYPES, billableTreatments, parseTreatmentPrices } from '../Lib/treatments'
@@ -45,8 +45,8 @@ const fromSetSchema = z.object({
   /// a schedule of its own. The period is not a rule, it is what prints on
   /// the line so the customer can see what they paid for.
   includeStorage: z.boolean().default(false),
-  /// Copies the set's photos and documents onto the job, so they reach the
-  /// invoice PDF the way any other attachment does.
+  /// Copies the set's photos onto the job, so they reach the invoice PDF the
+  /// way any other attachment does. Documents stay with the set.
   includeAttachments: z.boolean().default(true),
   /// Bills the customer directly instead of raising a job on a vehicle, for
   /// the customer who only ever stores tires and never brings the car in.
@@ -80,9 +80,10 @@ async function loadSet(organizationId: string, tireSetId: string) {
       customer: { select: { id: true, taxExempt: true } },
       treatments: { select: { type: true, status: true } },
       // Copied onto the job when the set is billed, so the invoice carries the
-      // photos the technician took.
+      // photos the technician took. Photos only: a PDF held against the set
+      // would be appended to the invoice page for page.
       attachments: {
-        where: { includeInInvoice: true },
+        where: { includeInInvoice: true, fileType: { startsWith: 'image/' } },
         orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
         select: {
           fileName: true,
@@ -224,41 +225,6 @@ async function storageLine(
   }
 }
 
-/**
- * Copies the set's files onto a job, so the invoice shows what the technician
- * saw.
- *
- * Pointing at the same files rather than duplicating the bytes: a photo of a
- * kerbed rim is one photo, and copying it would double the disk for every
- * season a set is billed. Removing it from the set later leaves the invoice
- * intact, which is the right way round for a document a customer may hold.
- */
-async function copyAttachments(
-  tx: Prisma.TransactionClient,
-  serviceRecordId: string,
-  files: {
-    fileName: string
-    fileUrl: string
-    fileType: string
-    fileSize: number
-    description: string | null
-  }[]
-) {
-  if (files.length === 0) return
-  await tx.serviceAttachment.createMany({
-    data: files.map((file) => ({
-      serviceRecordId,
-      fileName: file.fileName,
-      fileUrl: file.fileUrl,
-      fileType: file.fileType,
-      fileSize: file.fileSize,
-      description: file.description,
-      category: 'tire_hotel',
-      includeInInvoice: true,
-    })),
-  })
-}
-
 async function billablePrep(
   organizationId: string,
   treatments: { type: string; status: string }[]
@@ -584,7 +550,7 @@ export async function createWorkOrderFromTireSet(input: unknown) {
         // Independent of the lines: a job can carry the photos without
         // carrying a charge, which is what an inspection amounts to.
         if (data.includeAttachments) {
-          await copyAttachments(tx, record.id, set.attachments)
+          await copySetFilesToJob(tx, record.id, set.attachments)
         }
 
         if (labor.length > 0) {
@@ -751,7 +717,7 @@ export async function addTireSetToWorkOrder(input: unknown) {
         // Independent of the lines: a job can carry the photos without
         // carrying a charge, which is what an inspection amounts to.
         if (data.includeAttachments) {
-          await copyAttachments(tx, record.id, set.attachments)
+          await copySetFilesToJob(tx, record.id, set.attachments)
         }
 
         if (labor.length > 0) {
@@ -806,10 +772,16 @@ export async function addTireSetToWorkOrder(input: unknown) {
 /**
  * Takes the tire set off a job.
  *
- * Only the link goes. The parts and labour lines stay, because they are what
- * is being charged and someone may have edited them since; deciding on the
- * operator's behalf that a billed line should vanish would be a worse
- * surprise than leaving it visible for them to remove.
+ * The link goes, and so do the files that were copied over from the set: they
+ * only ever pointed at the set's own uploads, so removing the rows loses
+ * nothing, and leaving them would keep the set's photos on an invoice that is
+ * no longer about those tires. Only the rows are deleted, never the bytes on
+ * disk, which the set still owns.
+ *
+ * The parts and labour lines stay, because they are what is being charged and
+ * someone may have edited them since; deciding on the operator's behalf that
+ * a billed line should vanish would be a worse surprise than leaving it
+ * visible for them to remove.
  *
  * The shelf written into the job notes also stays. It is free text by now and
  * may have been added to, so rewriting it is not this action's business.
@@ -829,10 +801,15 @@ export async function unlinkTireSetFromWorkOrder(serviceRecordId: string) {
       if (!record) throw new Error('Work order not found')
       if (!record.tireSet) throw new Error('This job is not linked to a tire set')
 
-      await db.serviceRecord.update({
-        where: { id: record.id },
-        data: { tireSetId: null },
-      })
+      await db.$transaction([
+        db.serviceRecord.update({
+          where: { id: record.id },
+          data: { tireSetId: null },
+        }),
+        db.serviceAttachment.deleteMany({
+          where: { serviceRecordId: record.id, category: 'tire_hotel' },
+        }),
+      ])
 
       revalidatePath(`/tire-hotel/${record.tireSet.id}`)
       if (record.vehicleId) {

+ 16 - 8
src/features/tire-hotel/Components/TireAttachmentsCard.tsx

@@ -224,14 +224,22 @@ export function TireAttachmentsCard({
                     disabled={busyId === file.id}
                   />
                   <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
-                    <label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
-                      <Checkbox
-                        checked={file.includeInInvoice}
-                        onCheckedChange={(value) => toggleInvoice(file.id, value === true)}
-                        disabled={busyId === file.id}
-                      />
-                      {t('files.onInvoice')}
-                    </label>
+                    {isImage ? (
+                      <label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
+                        <Checkbox
+                          checked={file.includeInInvoice}
+                          onCheckedChange={(value) => toggleInvoice(file.id, value === true)}
+                          disabled={busyId === file.id}
+                        />
+                        {t('files.onInvoice')}
+                      </label>
+                    ) : (
+                      // Only photos travel onto a job when the set is billed,
+                      // so a document must not offer a choice it cannot keep.
+                      <span className="text-xs text-muted-foreground">
+                        {t('files.documentStays')}
+                      </span>
+                    )}
                     <span className="text-xs text-muted-foreground">
                       {formatDate(new Date(file.createdAt))}
                       {file.uploadedBy ? ` · ${file.uploadedBy.name}` : ''}

+ 43 - 0
src/features/tire-hotel/Lib/copySetFilesToJob.ts

@@ -0,0 +1,43 @@
+import type { Prisma } from '@/generated/prisma/client'
+
+export interface SetFile {
+  fileName: string
+  fileUrl: string
+  fileType: string
+  fileSize: number
+  description: string | null
+}
+
+/**
+ * Copies the set's photos onto a job, so the invoice shows what the technician
+ * saw.
+ *
+ * Photos only. A PDF stored with a set (a storage agreement, an old invoice)
+ * would be appended to the job's invoice page for page, which is never what
+ * billing a tire swap means.
+ *
+ * Pointing at the same files rather than duplicating the bytes: a photo of a
+ * kerbed rim is one photo, and copying it would double the disk for every
+ * season a set is billed. Removing it from the set later leaves the invoice
+ * intact, which is the right way round for a document a customer may hold.
+ */
+export async function copySetFilesToJob(
+  tx: Prisma.TransactionClient,
+  serviceRecordId: string,
+  files: SetFile[]
+) {
+  const photos = files.filter((file) => file.fileType.startsWith('image/'))
+  if (photos.length === 0) return
+  await tx.serviceAttachment.createMany({
+    data: photos.map((file) => ({
+      serviceRecordId,
+      fileName: file.fileName,
+      fileUrl: file.fileUrl,
+      fileType: file.fileType,
+      fileSize: file.fileSize,
+      description: file.description,
+      category: 'tire_hotel',
+      includeInInvoice: true,
+    })),
+  })
+}

+ 10 - 6
src/features/vehicles/Actions/serviceActions.ts

@@ -1106,12 +1106,16 @@ export async function deleteServiceAttachment(attachmentId: string) {
       })
       if (!attachment) throw new Error('Attachment not found')
 
-      // Delete file from disk
-      const filePath = resolveUploadPath(attachment.fileUrl)
-      try {
-        await unlink(filePath)
-      } catch (err) {
-        console.warn(`[deleteServiceAttachment] Failed to delete file "${filePath}":`, err)
+      // Delete file from disk. Not for tire hotel copies: those rows point at
+      // the tire set's own uploads, so only the reference goes and the set
+      // keeps its file.
+      if (attachment.category !== 'tire_hotel') {
+        const filePath = resolveUploadPath(attachment.fileUrl)
+        try {
+          await unlink(filePath)
+        } catch (err) {
+          console.warn(`[deleteServiceAttachment] Failed to delete file "${filePath}":`, err)
+        }
       }
 
       await db.serviceAttachment.delete({ where: { id: attachmentId } })

+ 14 - 2
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -224,14 +224,26 @@ export async function ServiceRecordPage({
     (record.attachments as ((typeof record.attachments)[number] & {
       includeInInvoice: boolean
     })[]) || []
+  // Files copied over from a linked tire set (category tire_hotel) are shown
+  // in the same tabs by file type. They end up on the invoice like any other
+  // attachment, and an attachment that changes the invoice must never be
+  // invisible on the page.
   const imageAttachmentsForManager = allAttachments
-    .filter((a) => a.category === 'image')
+    .filter(
+      (a) =>
+        a.category === 'image' || (a.category === 'tire_hotel' && a.fileType.startsWith('image/'))
+    )
     .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
   const videoAttachments = allAttachments
     .filter((a) => a.category === 'video')
     .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
   const documentAttachments = allAttachments
-    .filter((a) => a.category === 'document' || a.category === 'diagnostic')
+    .filter(
+      (a) =>
+        a.category === 'document' ||
+        a.category === 'diagnostic' ||
+        (a.category === 'tire_hotel' && !a.fileType.startsWith('image/'))
+    )
     .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
 
   // Fetch notification history for this service record