Selaa lähdekoodia

Email a document as a link instead of a PDF attachment (#350)

* Send a document as a link instead of an attachment, and match the three share dialogs

A workshop that wants to know whether its invoice was read has to send the
link. A PDF in the mail is read in the mail client, the link never gets
clicked, and the viewed counter on the document stays at zero, which is the
one thing that would say who to chase.

Invoices, quotes and inspections now all take attachPdf on their email
action. Unset follows a new workshop setting, "Attach the PDF to emails",
which starts on so nothing changes for anyone who does not go looking. Both
send dialogs, the share one and the email one, offer it per send. Sending
link-only mints a share token for a document that has never been shared,
otherwise the mail would be an empty envelope.

The quote mail carried no link at all before, so it gained one. The three
bodies are now built by one helper, which also escapes the sender's own note
rather than pasting it into the HTML.

Two things noticed while in here. The inspection share dialog listed SMS
before email and passed its own message that repeated the raw URL, so that
mail showed a bare link where the invoice showed a tidy one. And the send
button appeared only once a channel was ticked, which shoved the dialog
around; it now sits there disabled.

* Keep the attach option in place and dim it rather than popping it in

It appeared on the email tick and pushed the rest of the dialog down, the
same jump the send button had. It now sits under the email checkbox from the
start, disabled until email is the channel. Indented under the box that
governs it, so being disabled reads as a dependency rather than a puzzle.

* Say in the setting that it is only the default

The hint described the two modes but not that it is a default, so it read as
if the choice were made once for the whole workshop. It now names the three
documents it covers, says what the view counter is for, and points at the
share dialog for changing a single send.
Bernt Christian Egeland 3 viikkoa sitten
vanhempi
commit
8765a34217
37 muutettua tiedostoa jossa 677 lisäystä ja 188 poistoa
  1. 3 1
      messages/de/common.json
  2. 4 1
      messages/de/settings.json
  3. 3 1
      messages/en/common.json
  4. 4 1
      messages/en/settings.json
  5. 3 1
      messages/es/common.json
  6. 4 1
      messages/es/settings.json
  7. 3 1
      messages/fr/common.json
  8. 4 1
      messages/fr/settings.json
  9. 3 1
      messages/it/common.json
  10. 4 1
      messages/it/settings.json
  11. 3 1
      messages/lt/common.json
  12. 4 1
      messages/lt/settings.json
  13. 3 1
      messages/nb/common.json
  14. 4 1
      messages/nb/settings.json
  15. 3 1
      messages/nl/common.json
  16. 4 1
      messages/nl/settings.json
  17. 3 1
      messages/pl/common.json
  18. 4 1
      messages/pl/settings.json
  19. 3 1
      messages/pt-BR/common.json
  20. 4 1
      messages/pt-BR/settings.json
  21. 3 1
      messages/ru/common.json
  22. 4 1
      messages/ru/settings.json
  23. 3 1
      messages/tr/common.json
  24. 4 1
      messages/tr/settings.json
  25. 101 0
      src/__tests__/features/email/document-email.test.ts
  26. 16 0
      src/app/(authenticated)/settings/invoice/invoice-settings.tsx
  27. 158 111
      src/features/email/Actions/emailActions.ts
  28. 33 0
      src/features/email/Actions/emailPreferenceActions.ts
  29. 71 0
      src/features/email/Components/AttachPdfOption.tsx
  30. 13 2
      src/features/email/Components/SendEmailDialog.tsx
  31. 87 0
      src/features/email/Lib/documentEmail.ts
  32. 53 36
      src/features/inspections/Components/InspectionShareDialog.tsx
  33. 7 2
      src/features/quotes/Components/QuotePageClient.tsx
  34. 24 6
      src/features/quotes/Components/QuoteShareDialog.tsx
  35. 4 0
      src/features/settings/Schema/settingsSchema.ts
  36. 24 6
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  37. 2 1
      src/features/vehicles/Components/service-page/ServicePageClient.tsx

+ 3 - 1
messages/de/common.json

@@ -100,5 +100,7 @@
     "title": "PDF-Vorschau",
     "preview": "Vorschau",
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
-  }
+  },
+  "attachPdf": "PDF anhängen",
+  "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet."
 }

+ 4 - 1
messages/de/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Jede behält das Design, das Logo, die Werkstattangaben, den Kunden, das Fahrzeug und die Befunde, mit denen sie jetzt gedruckt wird. Das kann nicht rückgängig gemacht werden; die Rechnungen bleiben genau so bearbeitbar oder für die Bearbeitung gesperrt wie heute.",
     "freezeProgress": "{done} von {total} gesperrt",
     "freezeDone": "{count, plural, one {# Rechnung} other {# Rechnungen}} gesperrt",
-    "freezeFailed": "Rechnungen konnten nicht gesperrt werden"
+    "freezeFailed": "Rechnungen konnten nicht gesperrt werden",
+    "sectionSending": "Versand",
+    "attachPdfLabel": "PDF an E-Mails anhängen",
+    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern."
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",

+ 3 - 1
messages/en/common.json

@@ -100,5 +100,7 @@
     "title": "PDF preview",
     "preview": "Preview",
     "failed": "Could not generate the PDF preview."
-  }
+  },
+  "attachPdf": "Attach PDF",
+  "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it."
 }

+ 4 - 1
messages/en/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Each one keeps the design, logo, workshop details, customer, vehicle and findings it prints with right now. This cannot be undone, and the invoices stay editable or locked for editing exactly as they are today.",
     "freezeProgress": "Locked {done} of {total}",
     "freezeDone": "{count, plural, one {# invoice} other {# invoices}} locked",
-    "freezeFailed": "Could not lock the invoices"
+    "freezeFailed": "Could not lock the invoices",
+    "sectionSending": "Sending",
+    "attachPdfLabel": "Attach the PDF to emails",
+    "attachPdfHint": "The default for every invoice, quote and inspection you send by email. On, the customer gets the PDF attached. Off, they get a link to the document instead, and the view counter tells you when they opened it, so you can chase the ones nobody has looked at. You can change it for a single send in the share dialog."
   },
   "layoutEditor": {
     "helpText": "Drag to reorder. Use the width toggle to place sections side-by-side. Consecutive half-width sections (L/R) will render in two columns.",

+ 3 - 1
messages/es/common.json

@@ -100,5 +100,7 @@
     "title": "Vista previa del PDF",
     "preview": "Vista previa",
     "failed": "No se pudo generar la vista previa del PDF."
-  }
+  },
+  "attachPdf": "Adjuntar PDF",
+  "attachPdfHint": "Desactívalo para enviar un enlace en su lugar y ver cuándo lo abre el cliente."
 }

+ 4 - 1
messages/es/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Cada una conserva el diseño, el logotipo, los datos del taller, el cliente, el vehículo y los hallazgos con los que se imprime ahora. No se puede deshacer, y las facturas siguen editables o bloqueadas para edición exactamente como hoy.",
     "freezeProgress": "Bloqueadas {done} de {total}",
     "freezeDone": "{count, plural, one {# factura bloqueada} other {# facturas bloqueadas}}",
-    "freezeFailed": "No se pudieron bloquear las facturas"
+    "freezeFailed": "No se pudieron bloquear las facturas",
+    "sectionSending": "Envío",
+    "attachPdfLabel": "Adjuntar el PDF a los correos",
+    "attachPdfHint": "El valor predeterminado para cada factura, presupuesto e inspección que envíes por correo. Activado: el cliente recibe el PDF adjunto. Desactivado: recibe un enlace al documento y el contador de visitas te dice cuándo lo abrió, para que puedas insistir con los que nadie ha mirado. Puedes cambiarlo para un envío concreto en el diálogo de compartir."
   },
   "layoutEditor": {
     "helpText": "Arrastra para reordenar. Usa el selector de ancho para colocar secciones lado a lado. Las secciones consecutivas de medio ancho (I/D) se mostrarán en dos columnas.",

+ 3 - 1
messages/fr/common.json

@@ -100,5 +100,7 @@
     "title": "Aperçu du PDF",
     "preview": "Aperçu",
     "failed": "Impossible de générer l'aperçu du PDF."
-  }
+  },
+  "attachPdf": "Joindre le PDF",
+  "attachPdfHint": "Désactivez pour envoyer un lien à la place, afin de voir quand le client l'ouvre."
 }

+ 4 - 1
messages/fr/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Chacune conserve le design, le logo, les coordonnées de l'atelier, le client, le véhicule et les constats avec lesquels elle s'imprime maintenant. Irréversible ; les factures restent modifiables ou verrouillées en modification exactement comme aujourd'hui.",
     "freezeProgress": "{done} sur {total} verrouillées",
     "freezeDone": "{count, plural, one {# facture verrouillée} other {# factures verrouillées}}",
-    "freezeFailed": "Impossible de verrouiller les factures"
+    "freezeFailed": "Impossible de verrouiller les factures",
+    "sectionSending": "Envoi",
+    "attachPdfLabel": "Joindre le PDF aux e-mails",
+    "attachPdfHint": "La valeur par défaut pour chaque facture, devis et inspection que vous envoyez par e-mail. Activé : le client reçoit le PDF en pièce jointe. Désactivé : il reçoit un lien vers le document, et le compteur de consultations indique quand il l'a ouvert, ce qui permet de relancer ceux que personne n'a regardés. Vous pouvez le changer pour un envoi précis dans la fenêtre de partage."
   },
   "layoutEditor": {
     "helpText": "Glissez pour réorganiser. Utilisez le sélecteur de largeur pour placer les sections côte à côte. Les sections consécutives en demi-largeur (G/D) s'afficheront en deux colonnes.",

+ 3 - 1
messages/it/common.json

@@ -100,5 +100,7 @@
     "title": "Anteprima PDF",
     "preview": "Anteprima",
     "failed": "Impossibile generare l'anteprima del PDF."
-  }
+  },
+  "attachPdf": "Allega PDF",
+  "attachPdfHint": "Disattiva per inviare invece un link, così vedi quando il cliente lo apre."
 }

+ 4 - 1
messages/it/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Ciascuna conserva il design, il logo, i dati dell'officina, il cliente, il veicolo e i rilievi con cui si stampa adesso. Non si può annullare, e le fatture restano modificabili o bloccate in modifica esattamente come oggi.",
     "freezeProgress": "Bloccate {done} di {total}",
     "freezeDone": "{count, plural, one {# fattura bloccata} other {# fatture bloccate}}",
-    "freezeFailed": "Impossibile bloccare le fatture"
+    "freezeFailed": "Impossibile bloccare le fatture",
+    "sectionSending": "Invio",
+    "attachPdfLabel": "Allega il PDF alle email",
+    "attachPdfHint": "L'impostazione predefinita per ogni fattura, preventivo e controllo che invii via email. Attivo: il cliente riceve il PDF in allegato. Disattivo: riceve invece un link al documento e il contatore delle visualizzazioni indica quando lo ha aperto, così puoi sollecitare quelli che nessuno ha guardato. Puoi cambiarlo per un singolo invio nella finestra di condivisione."
   },
   "layoutEditor": {
     "helpText": "Trascina per riordinare. Usa il selettore di larghezza per posizionare le sezioni affiancate. Le sezioni consecutive a mezza larghezza (S/D) verranno visualizzate in due colonne.",

+ 3 - 1
messages/lt/common.json

@@ -100,5 +100,7 @@
     "title": "PDF peržiūra",
     "preview": "Peržiūra",
     "failed": "Nepavyko sugeneruoti PDF peržiūros."
-  }
+  },
+  "attachPdf": "Pridėti PDF",
+  "attachPdfHint": "Išjunkite, kad būtų siunčiama nuoroda, tada matysite, kada klientas ją atidarė."
 }

+ 4 - 1
messages/lt/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Kiekviena išlaiko dizainą, logotipą, dirbtuvės duomenis, klientą, transporto priemonę ir pastebėjimus, su kuriais spausdinama dabar. To atšaukti negalima; sąskaitos lieka redaguojamos arba užrakintos redagavimui lygiai kaip šiandien.",
     "freezeProgress": "Užrakinta {done} iš {total}",
     "freezeDone": "Užrakinta {count, plural, one {# sąskaita} few {# sąskaitos} other {# sąskaitų}}",
-    "freezeFailed": "Nepavyko užrakinti sąskaitų"
+    "freezeFailed": "Nepavyko užrakinti sąskaitų",
+    "sectionSending": "Siuntimas",
+    "attachPdfLabel": "Prie el. laiškų pridėti PDF",
+    "attachPdfHint": "Numatytoji nuostata kiekvienai sąskaitai, pasiūlymui ir patikrai, siunčiamai el. paštu. Įjungta: klientas gauna PDF kaip priedą. Išjungta: jis gauna nuorodą į dokumentą, o peržiūrų skaitiklis parodo, kada jis buvo atidarytas, todėl galite priminti tiems, kurie dar nepažiūrėjo. Atskiram siuntimui tai galite pakeisti bendrinimo lange."
   },
   "layoutEditor": {
     "helpText": "Vilkite, kad pertvarkytumėte. Naudokite pločio perjungiklį, kad išdėstytumėte sekcijas greta. Iš eilės einančios pusės pločio sekcijos (K/D) bus vaizduojamos dviejuose stulpeliuose.",

+ 3 - 1
messages/nb/common.json

@@ -100,5 +100,7 @@
     "title": "PDF-forhåndsvisning",
     "preview": "Forhåndsvisning",
     "failed": "Kunne ikke generere PDF-forhåndsvisningen."
-  }
+  },
+  "attachPdf": "Legg ved PDF",
+  "attachPdfHint": "Slå av for å sende en lenke i stedet, så ser du når kunden åpner den."
 }

+ 4 - 1
messages/nb/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Hver av dem beholder designet, logoen, verkstedopplysningene, kunden, kjøretøyet og funnene den skrives ut med akkurat nå. Dette kan ikke angres, og fakturaene forblir redigerbare eller låst for redigering nøyaktig som i dag.",
     "freezeProgress": "Låst {done} av {total}",
     "freezeDone": "{count, plural, one {# faktura} other {# fakturaer}} låst",
-    "freezeFailed": "Kunne ikke låse fakturaene"
+    "freezeFailed": "Kunne ikke låse fakturaene",
+    "sectionSending": "Utsending",
+    "attachPdfLabel": "Legg ved PDF i e-post",
+    "attachPdfHint": "Standard for alle fakturaer, tilbud og kontroller du sender på e-post. På: kunden får PDF-en vedlagt. Av: kunden får en lenke til dokumentet i stedet, og visningstelleren viser når det ble åpnet, så du kan følge opp dem ingen har sett på. Du kan endre det for én enkelt utsending i delingsdialogen."
   },
   "layoutEditor": {
     "helpText": "Dra for å sortere. Bruk bredde-veksler for å plassere seksjoner side om side. Påfølgende halvbredde-seksjoner (V/H) vises i to kolonner.",

+ 3 - 1
messages/nl/common.json

@@ -100,5 +100,7 @@
     "title": "PDF-voorbeeld",
     "preview": "Voorbeeld",
     "failed": "Kon het PDF-voorbeeld niet genereren."
-  }
+  },
+  "attachPdf": "Pdf toevoegen",
+  "attachPdfHint": "Zet dit uit om in plaats daarvan een link te sturen, zodat je ziet wanneer de klant hem opent."
 }

+ 4 - 1
messages/nl/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Elke factuur behoudt het ontwerp, het logo, de werkplaatsgegevens, de klant, het voertuig en de bevindingen waarmee ze nu wordt afgedrukt. Dit kan niet ongedaan worden gemaakt; de facturen blijven precies zo bewerkbaar of voor bewerking vergrendeld als vandaag.",
     "freezeProgress": "{done} van {total} vergrendeld",
     "freezeDone": "{count, plural, one {# factuur} other {# facturen}} vergrendeld",
-    "freezeFailed": "Kon de facturen niet vergrendelen"
+    "freezeFailed": "Kon de facturen niet vergrendelen",
+    "sectionSending": "Verzenden",
+    "attachPdfLabel": "De pdf aan e-mails toevoegen",
+    "attachPdfHint": "De standaard voor elke factuur, offerte en inspectie die je per e-mail verstuurt. Aan: de klant krijgt de pdf als bijlage. Uit: hij krijgt een link naar het document, en de weergaveteller laat zien wanneer het is geopend, zodat je kunt nabellen bij wie nog niet gekeken heeft. Voor één verzending kun je het aanpassen in het deelvenster."
   },
   "layoutEditor": {
     "helpText": "Sleep om te herordenen. Gebruik de breedte-schakelaar om secties naast elkaar te plaatsen. Opeenvolgende halve breedte secties (L/R) worden in twee kolommen weergegeven.",

+ 3 - 1
messages/pl/common.json

@@ -100,5 +100,7 @@
     "title": "Podgląd PDF",
     "preview": "Podgląd",
     "failed": "Nie udało się wygenerować podglądu PDF."
-  }
+  },
+  "attachPdf": "Dołącz PDF",
+  "attachPdfHint": "Wyłącz, aby wysłać zamiast tego link i widzieć, kiedy klient go otworzy."
 }

+ 4 - 1
messages/pl/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Każda zachowa projekt, logo, dane warsztatu, klienta, pojazd i ustalenia, z którymi drukuje się teraz. Nie da się tego cofnąć; faktury pozostaną edytowalne lub zablokowane do edycji dokładnie tak jak dziś.",
     "freezeProgress": "Zablokowano {done} z {total}",
     "freezeDone": "Zablokowano {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}}",
-    "freezeFailed": "Nie udało się zablokować faktur"
+    "freezeFailed": "Nie udało się zablokować faktur",
+    "sectionSending": "Wysyłka",
+    "attachPdfLabel": "Dołączaj PDF do wiadomości e-mail",
+    "attachPdfHint": "Domyślne ustawienie dla każdej faktury, oferty i przeglądu wysyłanych e-mailem. Włączone: klient dostaje PDF w załączniku. Wyłączone: dostaje link do dokumentu, a licznik wyświetleń pokazuje, kiedy go otworzył, więc możesz przypomnieć się tym, którzy jeszcze nie zajrzeli. Dla pojedynczej wysyłki zmienisz to w oknie udostępniania."
   },
   "layoutEditor": {
     "helpText": "Przeciągnij, aby zmienić kolejność. Użyj przełącznika szerokości, aby umieścić sekcje obok siebie. Kolejne sekcje o połowie szerokości (L/P) będą wyświetlane w dwóch kolumnach.",

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

@@ -100,5 +100,7 @@
     "title": "Pré-visualização do PDF",
     "preview": "Pré-visualização",
     "failed": "Não foi possível gerar a pré-visualização do PDF."
-  }
+  },
+  "attachPdf": "Anexar PDF",
+  "attachPdfHint": "Desligue para enviar um link, assim você vê quando o cliente abre."
 }

+ 4 - 1
messages/pt-BR/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Cada uma mantém o design, o logo, os dados da oficina, o cliente, o veículo e as constatações com que é impressa agora. Não pode ser desfeito, e as faturas continuam editáveis ou travadas para edição exatamente como hoje.",
     "freezeProgress": "Travadas {done} de {total}",
     "freezeDone": "{count, plural, one {# fatura travada} other {# faturas travadas}}",
-    "freezeFailed": "Não foi possível travar as faturas"
+    "freezeFailed": "Não foi possível travar as faturas",
+    "sectionSending": "Envio",
+    "attachPdfLabel": "Anexar o PDF aos e-mails",
+    "attachPdfHint": "O padrão para cada fatura, orçamento e inspeção que você envia por e-mail. Ligado: o cliente recebe o PDF em anexo. Desligado: ele recebe um link para o documento, e o contador de visualizações mostra quando foi aberto, para você cobrar quem ainda não olhou. Você pode mudar isso em um envio específico na janela de compartilhamento."
   },
   "layoutEditor": {
     "helpText": "Arraste para reordenar. Use o alternador de largura para posicionar seções lado a lado. Seções consecutivas de meia largura (E/D) serão exibidas em duas colunas.",

+ 3 - 1
messages/ru/common.json

@@ -100,5 +100,7 @@
     "title": "Предпросмотр PDF",
     "preview": "Предпросмотр",
     "failed": "Не удалось создать предпросмотр PDF."
-  }
+  },
+  "attachPdf": "Прикрепить PDF",
+  "attachPdfHint": "Отключите, чтобы отправить ссылку вместо файла и видеть, когда клиент её открыл."
 }

+ 4 - 1
messages/ru/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Каждый сохранит дизайн, логотип, реквизиты мастерской, клиента, автомобиль и замечания, с которыми печатается сейчас. Отменить нельзя; счета останутся редактируемыми или заблокированными для правки ровно как сегодня.",
     "freezeProgress": "Зафиксировано {done} из {total}",
     "freezeDone": "Зафиксировано {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}}",
-    "freezeFailed": "Не удалось зафиксировать счета"
+    "freezeFailed": "Не удалось зафиксировать счета",
+    "sectionSending": "Отправка",
+    "attachPdfLabel": "Прикреплять PDF к письмам",
+    "attachPdfHint": "Значение по умолчанию для каждого счёта, коммерческого предложения и осмотра, отправляемых по почте. Включено: клиент получает PDF во вложении. Выключено: он получает ссылку на документ, а счётчик просмотров показывает, когда её открыли, чтобы вы могли напомнить тем, кто ещё не посмотрел. Для отдельной отправки это можно изменить в окне общего доступа."
   },
   "layoutEditor": {
     "helpText": "Перетаскивайте для изменения порядка. Используйте переключатель ширины для размещения разделов бок о бок. Последовательные разделы половинной ширины (Л/П) будут отображаться в два столбца.",

+ 3 - 1
messages/tr/common.json

@@ -100,5 +100,7 @@
     "title": "PDF önizleme",
     "preview": "Önizleme",
     "failed": "PDF önizlemesi oluşturulamadı."
-  }
+  },
+  "attachPdf": "PDF ekle",
+  "attachPdfHint": "Bunun yerine bağlantı göndermek için kapatın; böylece müşterinin ne zaman açtığını görürsünüz."
 }

+ 4 - 1
messages/tr/settings.json

@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Her biri şu anda yazdırıldığı tasarımı, logoyu, atölye bilgilerini, müşteriyi, aracı ve bulguları korur. Geri alınamaz; faturalar bugünkü gibi düzenlenebilir ya da düzenlemeye kilitli kalır.",
     "freezeProgress": "{total} faturadan {done} tanesi kilitlendi",
     "freezeDone": "{count, plural, one {# fatura} other {# fatura}} kilitlendi",
-    "freezeFailed": "Faturalar kilitlenemedi"
+    "freezeFailed": "Faturalar kilitlenemedi",
+    "sectionSending": "Gönderim",
+    "attachPdfLabel": "E-postalara PDF ekle",
+    "attachPdfHint": "E-postayla gönderdiğiniz her fatura, teklif ve kontrol için varsayılan. Açık: müşteri PDF'i ek olarak alır. Kapalı: bunun yerine belgeye bir bağlantı alır ve görüntülenme sayacı ne zaman açtığını gösterir, böylece kimsenin bakmadıklarını takip edebilirsiniz. Tek bir gönderim için bunu paylaşım penceresinde değiştirebilirsiniz."
   },
   "layoutEditor": {
     "helpText": "Sıralamak için sürükleyin. Bölümleri yan yana yerleştirmek için genişlik değiştiriciyi kullanın. Ardışık yarım genişlik bölümleri (S/S) iki sütun halinde görüntülenir.",

+ 101 - 0
src/__tests__/features/email/document-email.test.ts

@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest'
+import {
+  attachPdfDefault,
+  buildDocumentEmailHtml,
+  resolveAttachPdf,
+} from '@/features/email/Lib/documentEmail'
+
+/**
+ * A workshop that wants to know whether its invoice was read has to send the
+ * link, because a PDF in the mail is read without ever touching the link and
+ * the viewed counter stays at zero.
+ */
+describe('attachPdfDefault', () => {
+  it('attaches when the workshop has never chosen', () => {
+    expect(attachPdfDefault({})).toBe(true)
+  })
+
+  it('attaches when the setting says so', () => {
+    expect(attachPdfDefault({ 'email.attachPdf': 'true' })).toBe(true)
+  })
+
+  it('sends the link when the setting says so', () => {
+    expect(attachPdfDefault({ 'email.attachPdf': 'false' })).toBe(false)
+  })
+})
+
+describe('resolveAttachPdf', () => {
+  it('follows the setting when the send does not say', () => {
+    expect(resolveAttachPdf({ 'email.attachPdf': 'false' }, undefined)).toBe(false)
+    expect(resolveAttachPdf({}, undefined)).toBe(true)
+  })
+
+  it('lets one send overrule the setting either way', () => {
+    expect(resolveAttachPdf({ 'email.attachPdf': 'false' }, true)).toBe(true)
+    expect(resolveAttachPdf({ 'email.attachPdf': 'true' }, false)).toBe(false)
+  })
+})
+
+describe('buildDocumentEmailHtml', () => {
+  const base = {
+    heading: 'Invoice INV-1042',
+    subject: 'invoice',
+    linkLabel: 'View Invoice Online',
+    fromName: 'Bergen Bil',
+  }
+
+  it('says the document is attached when it is', () => {
+    const html = buildDocumentEmailHtml({ ...base, attached: true, link: null })
+    expect(html).toContain('Please find the invoice attached.')
+  })
+
+  it('points at the link when the PDF is not attached', () => {
+    const html = buildDocumentEmailHtml({
+      ...base,
+      attached: false,
+      link: 'https://workshop.example.com/share/invoice/org/token',
+    })
+    expect(html).not.toContain('attached')
+    expect(html).toContain('href="https://workshop.example.com/share/invoice/org/token"')
+    expect(html).toContain('View Invoice Online')
+  })
+
+  it('carries the link alongside the attachment, as it always did', () => {
+    const html = buildDocumentEmailHtml({
+      ...base,
+      attached: true,
+      link: 'https://workshop.example.com/share/invoice/org/token',
+    })
+    expect(html).toContain('attached')
+    expect(html).toContain('View Invoice Online')
+  })
+
+  it('still reads sensibly with neither an attachment nor a link', () => {
+    const html = buildDocumentEmailHtml({ ...base, attached: false, link: null })
+    expect(html).toContain('The invoice is ready.')
+    expect(html).not.toContain('<a href')
+  })
+
+  it('includes the sender note and the phone when there is one', () => {
+    const html = buildDocumentEmailHtml({
+      ...base,
+      attached: true,
+      link: null,
+      message: 'Ready for pickup on Friday',
+      phone: '+47 55 00 00 00',
+    })
+    expect(html).toContain('Ready for pickup on Friday')
+    expect(html).toContain('+47 55 00 00 00')
+  })
+
+  it('escapes what the sender typed rather than pasting it into the mail', () => {
+    const html = buildDocumentEmailHtml({
+      ...base,
+      attached: true,
+      link: null,
+      message: '<script>alert(1)</script>',
+    })
+    expect(html).not.toContain('<script>')
+    expect(html).toContain('&lt;script&gt;')
+  })
+})

+ 16 - 0
src/app/(authenticated)/settings/invoice/invoice-settings.tsx

@@ -123,6 +123,7 @@ export function InvoiceSettings({
   const [quoteLockTrigger, setQuoteLockTrigger] = useState(
     settings[SETTING_KEYS.QUOTE_LOCK_TRIGGER] || 'accepted'
   )
+  const [attachPdf, setAttachPdf] = useState(settings[SETTING_KEYS.EMAIL_ATTACH_PDF] !== 'false')
 
   const handleSaveGeneral = async () => {
     setSaving(true)
@@ -139,6 +140,7 @@ export function InvoiceSettings({
       [SETTING_KEYS.INVOICE_LOCK_TRIGGER]: invoiceLockTrigger,
       [SETTING_KEYS.QUOTE_LOCK_ENABLED]: quoteLockEnabled ? 'true' : 'false',
       [SETTING_KEYS.QUOTE_LOCK_TRIGGER]: quoteLockTrigger,
+      [SETTING_KEYS.EMAIL_ATTACH_PDF]: attachPdf ? 'true' : 'false',
     })
     setSaving(false)
     router.refresh()
@@ -355,6 +357,20 @@ export function InvoiceSettings({
 
             <Separator />
 
+            <div className="space-y-3">
+              <h3 className="text-sm font-semibold">{t('invoice.sectionSending')}</h3>
+              <Label
+                htmlFor="attachPdf"
+                className="flex items-center justify-between gap-3 font-normal"
+              >
+                <span className="text-sm">{t('invoice.attachPdfLabel')}</span>
+                <Switch id="attachPdf" checked={attachPdf} onCheckedChange={setAttachPdf} />
+              </Label>
+              <p className="text-xs text-muted-foreground">{t('invoice.attachPdfHint')}</p>
+            </div>
+
+            <Separator />
+
             <div className="space-y-3">
               <h3 className="text-sm font-semibold">{t('invoice.sectionCustomers')}</h3>
               <div className="flex items-center justify-between gap-4">

+ 158 - 111
src/features/email/Actions/emailActions.ts

@@ -27,6 +27,8 @@ import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/a
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getAppBaseUrl } from '@/lib/app-url'
+import { randomUUID } from 'crypto'
+import { buildDocumentEmailHtml, resolveAttachPdf } from '@/features/email/Lib/documentEmail'
 
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
@@ -113,6 +115,8 @@ export async function sendQuoteEmail(input: {
   quoteId: string
   recipientEmail: string
   message?: string
+  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  attachPdf?: boolean
 }) {
   return withAuth(
     async ({ organizationId }) => {
@@ -140,6 +144,7 @@ export async function sendQuoteEmail(input: {
       if (settings['workshop.emailEnabled'] === 'false') {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
+      const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       const logoDataUri = await loadLogoDataUri(documentLogoPath(settings, 'quote'))
       const currencyCode = settings['workshop.currencyCode'] || 'USD'
@@ -172,48 +177,62 @@ export async function sendQuoteEmail(input: {
         quoteLayoutConfig = undefined
       }
 
-      // Generate PDF
-      const element = React.createElement(QuotePDF, {
-        data: quote,
-        workshop: {
-          name: settings['workshop.name'] || '',
-          address: settings['workshop.address'] || '',
-          phone: settings['workshop.phone'] || '',
-          email: settings['workshop.email'] || '',
-          slogan: settings['workshop.slogan'] || undefined,
-        },
-        currencyCode,
-        currencyFormat,
-        logoDataUri,
-        template,
-        layoutConfig: quoteLayoutConfig,
-      }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
-      const pdfBuffer = await renderToBuffer(element)
+      let pdfBuffer: Buffer | null = null
+      if (attachPdf) {
+        const element = React.createElement(QuotePDF, {
+          data: quote,
+          workshop: {
+            name: settings['workshop.name'] || '',
+            address: settings['workshop.address'] || '',
+            phone: settings['workshop.phone'] || '',
+            email: settings['workshop.email'] || '',
+            slogan: settings['workshop.slogan'] || undefined,
+          },
+          currencyCode,
+          currencyFormat,
+          logoDataUri,
+          template,
+          layoutConfig: quoteLayoutConfig,
+        }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
+        pdfBuffer = Buffer.from(await renderToBuffer(element))
+      }
       const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`
 
+      // The quote mail carried no link at all before, which left a link-only
+      // send with nothing in it. One is minted for a quote never shared.
+      const token = quote.publicToken ?? (attachPdf ? null : randomUUID())
+      if (token && token !== quote.publicToken) {
+        await db.quote.update({
+          where: { id: quoteId },
+          data: { publicToken: token, sharedAt: new Date() },
+        })
+      }
+      const publicLink = token ? `${getAppBaseUrl()}/share/quote/${organizationId}/${token}` : null
+
       const from = await getOrgFromAddress(organizationId)
 
       await sendOrgMail(organizationId, {
         from,
         to: recipientEmail,
         subject: `Quote ${quoteNum} - ${quote.title}`,
-        html: `
-        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
-          <h2>Quote ${quoteNum}</h2>
-          <p>Please find your quote attached.</p>
-          ${message ? `<p>${message}</p>` : ''}
-          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
-          <p style="color: #666; font-size: 14px;">
-            ${fromName}${settings['workshop.phone'] ? ` · ${settings['workshop.phone']}` : ''}
-          </p>
-        </div>
-      `,
-        attachments: [
-          {
-            filename: `${quoteNum}.pdf`,
-            content: Buffer.from(pdfBuffer),
-          },
-        ],
+        html: buildDocumentEmailHtml({
+          heading: `Quote ${quoteNum}`,
+          subject: 'quote',
+          link: publicLink,
+          linkLabel: 'View Quote Online',
+          attached: attachPdf,
+          message,
+          fromName,
+          phone: settings['workshop.phone'],
+        }),
+        attachments: pdfBuffer
+          ? [
+              {
+                filename: `${quoteNum}.pdf`,
+                content: pdfBuffer,
+              },
+            ]
+          : undefined,
       })
 
       // Stamps sentAt and moves a draft to "sent" (accepted and converted
@@ -275,6 +294,8 @@ export async function sendInvoiceEmail(input: {
   serviceRecordId: string
   recipientEmail: string
   message?: string
+  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  attachPdf?: boolean
 }) {
   return withAuth(
     async ({ organizationId }) => {
@@ -293,6 +314,7 @@ export async function sendInvoiceEmail(input: {
       if (settings['workshop.emailEnabled'] === 'false') {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
+      const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       // Issued before it is rendered, so the copy that goes out and the copy
       // the workshop can print in five years are the same one.
@@ -301,26 +323,37 @@ export async function sendInvoiceEmail(input: {
       if (!assembly) throw new Error('Service record not found')
       const { record } = assembly
 
-      const locale = await resolveCustomerLocale(organizationId, null)
-      const labels = await loadPrintLabels(locale, assembly.labelSettings)
       const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
 
-      // Generate PDF
-      const element = React.createElement(InvoicePDF, {
-        data: assembly.data,
-        workshop: assembly.workshop,
-        invoiceSettings: assembly.invoiceSettings,
-        paymentSummary: assembly.paymentSummary,
-        logoDataUri: assembly.logoDataUri,
-        template: assembly.template,
-        labels,
-      }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
-      const pdfBuffer = await renderToBuffer(element)
+      let pdfBuffer: Buffer | null = null
+      if (attachPdf) {
+        const locale = await resolveCustomerLocale(organizationId, null)
+        const labels = await loadPrintLabels(locale, assembly.labelSettings)
+        const element = React.createElement(InvoicePDF, {
+          data: assembly.data,
+          workshop: assembly.workshop,
+          invoiceSettings: assembly.invoiceSettings,
+          paymentSummary: assembly.paymentSummary,
+          logoDataUri: assembly.logoDataUri,
+          template: assembly.template,
+          labels,
+        }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
+        pdfBuffer = Buffer.from(await renderToBuffer(element))
+      }
       const invoiceNum = invoiceNumberOf(record)
 
-      // Build public invoice link if token exists
-      const publicLink = owned.publicToken
-        ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${owned.publicToken}`
+      // Without the PDF the link is the whole mail, so one is minted here for
+      // a document that has never been shared. sharedAt goes with it, the way
+      // the share dialog sets it; sentAt is markInvoiceSent's job below.
+      const token = owned.publicToken ?? (attachPdf ? null : randomUUID())
+      if (token && token !== owned.publicToken) {
+        await db.serviceRecord.update({
+          where: { id: serviceRecordId },
+          data: { publicToken: token, sharedAt: new Date() },
+        })
+      }
+      const publicLink = token
+        ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${token}`
         : null
 
       const from = await getOrgFromAddress(organizationId)
@@ -329,24 +362,24 @@ export async function sendInvoiceEmail(input: {
         from,
         to: recipientEmail,
         subject: `Invoice ${invoiceNum} - ${record.title}`,
-        html: `
-        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
-          <h2>Invoice ${invoiceNum}</h2>
-          <p>Please find your invoice attached.</p>
-          ${message ? `<p>${message}</p>` : ''}
-          ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Invoice Online</a></p>` : ''}
-          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
-          <p style="color: #666; font-size: 14px;">
-            ${fromName}${settings['workshop.phone'] ? ` · ${settings['workshop.phone']}` : ''}
-          </p>
-        </div>
-      `,
-        attachments: [
-          {
-            filename: `${invoiceNum}.pdf`,
-            content: Buffer.from(pdfBuffer),
-          },
-        ],
+        html: buildDocumentEmailHtml({
+          heading: `Invoice ${invoiceNum}`,
+          subject: 'invoice',
+          link: publicLink,
+          linkLabel: 'View Invoice Online',
+          attached: attachPdf,
+          message,
+          fromName,
+          phone: settings['workshop.phone'],
+        }),
+        attachments: pdfBuffer
+          ? [
+              {
+                filename: `${invoiceNum}.pdf`,
+                content: pdfBuffer,
+              },
+            ]
+          : undefined,
       })
 
       await markInvoiceSent(serviceRecordId, organizationId, { alreadyIssued: true })
@@ -375,6 +408,8 @@ export async function sendInspectionEmail(input: {
   inspectionId: string
   recipientEmail: string
   message?: string
+  /** Send the PDF, or the share link instead. Unset follows the setting. */
+  attachPdf?: boolean
 }) {
   return withAuth(
     async ({ organizationId }) => {
@@ -413,16 +448,11 @@ export async function sendInspectionEmail(input: {
       if (settings['workshop.emailEnabled'] === 'false') {
         throw new Error('Email sending is disabled. Enable it in Settings.')
       }
+      const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       const logoDataUri = await loadLogoDataUri(settings['workshop.logo'])
       const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
 
-      const features = await getFeatures(organizationId)
-      let torqvoiceLogoDataUri: string | undefined
-      if (!features.brandingRemoved) {
-        torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
-      }
-
       const template = {
         primaryColor: settings['invoice.primaryColor'] || '#d97706',
         backgroundColor: settings['invoice.backgroundColor'] || undefined,
@@ -439,28 +469,45 @@ export async function sendInspectionEmail(input: {
         headerStyle: settings['invoice.headerStyle'] || 'standard',
       }
 
-      const element = React.createElement(InspectionPDF, {
-        data: inspection,
-        workshop: {
-          name: org?.name || '',
-          address: settings['workshop.address'] || '',
-          phone: settings['workshop.phone'] || '',
-          email: settings['workshop.email'] || '',
-        },
-        logoDataUri,
-        torqvoiceLogoDataUri,
-        dateFormat: settings['workshop.dateFormat'] || undefined,
-        timezone: settings['workshop.timezone'] || undefined,
-        template,
-      }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
-      const pdfBuffer = await renderToBuffer(element)
-
       const vehicleName = `${inspection.vehicle.year} ${inspection.vehicle.make} ${inspection.vehicle.model}`
       const fileName = `Inspection-${vehicleName}.pdf`
 
-      // Build public link if token exists
-      const publicLink = inspection.publicToken
-        ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${inspection.publicToken}`
+      let pdfBuffer: Buffer | null = null
+      if (attachPdf) {
+        const features = await getFeatures(organizationId)
+        let torqvoiceLogoDataUri: string | undefined
+        if (!features.brandingRemoved) {
+          torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
+        }
+
+        const element = React.createElement(InspectionPDF, {
+          data: inspection,
+          workshop: {
+            name: org?.name || '',
+            address: settings['workshop.address'] || '',
+            phone: settings['workshop.phone'] || '',
+            email: settings['workshop.email'] || '',
+          },
+          logoDataUri,
+          torqvoiceLogoDataUri,
+          dateFormat: settings['workshop.dateFormat'] || undefined,
+          timezone: settings['workshop.timezone'] || undefined,
+          template,
+        }) as any // eslint-disable-line @typescript-eslint/no-explicit-any
+        pdfBuffer = Buffer.from(await renderToBuffer(element))
+      }
+
+      // Without the PDF the link is the whole mail, so one is minted for an
+      // inspection that has never been shared.
+      const token = inspection.publicToken ?? (attachPdf ? null : randomUUID())
+      if (token && token !== inspection.publicToken) {
+        await db.inspection.update({
+          where: { id: inspectionId },
+          data: { publicToken: token },
+        })
+      }
+      const publicLink = token
+        ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${token}`
         : null
 
       const from = await getOrgFromAddress(organizationId)
@@ -469,24 +516,24 @@ export async function sendInspectionEmail(input: {
         from,
         to: recipientEmail,
         subject: `Vehicle Inspection - ${vehicleName}`,
-        html: `
-        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
-          <h2>Vehicle Inspection Report</h2>
-          <p>Please find the inspection report for your ${vehicleName} attached.</p>
-          ${message ? `<p>${message}</p>` : ''}
-          ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Inspection Online</a></p>` : ''}
-          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
-          <p style="color: #666; font-size: 14px;">
-            ${fromName}${settings['workshop.phone'] ? ` · ${settings['workshop.phone']}` : ''}
-          </p>
-        </div>
-      `,
-        attachments: [
-          {
-            filename: fileName,
-            content: Buffer.from(pdfBuffer),
-          },
-        ],
+        html: buildDocumentEmailHtml({
+          heading: 'Vehicle Inspection Report',
+          subject: `inspection report for your ${vehicleName}`,
+          link: publicLink,
+          linkLabel: 'View Inspection Online',
+          attached: attachPdf,
+          message,
+          fromName,
+          phone: settings['workshop.phone'],
+        }),
+        attachments: pdfBuffer
+          ? [
+              {
+                filename: fileName,
+                content: pdfBuffer,
+              },
+            ]
+          : undefined,
       })
 
       return { sent: true, inspectionId, recipientEmail }

+ 33 - 0
src/features/email/Actions/emailPreferenceActions.ts

@@ -0,0 +1,33 @@
+'use server'
+
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { attachPdfDefault } from '@/features/email/Lib/documentEmail'
+
+/**
+ * What the send dialogs start with: the workshop's own answer to "attach the
+ * PDF, or send the link". Read when a dialog opens rather than threaded down
+ * through every page that can send a document.
+ */
+export async function getAttachPdfDefault() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const setting = await db.appSetting.findFirst({
+        where: { organizationId, key: SETTING_KEYS.EMAIL_ATTACH_PDF },
+        select: { value: true },
+      })
+      return {
+        attachPdf: attachPdfDefault(
+          setting ? { [SETTING_KEYS.EMAIL_ATTACH_PDF]: setting.value } : {}
+        ),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}

+ 71 - 0
src/features/email/Components/AttachPdfOption.tsx

@@ -0,0 +1,71 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { getAttachPdfDefault } from '@/features/email/Actions/emailPreferenceActions'
+
+/**
+ * The workshop's default answer to "attach the PDF", fetched when the dialog
+ * opens so no page has to thread the setting down to it. Attaching until the
+ * answer arrives: it is the old behaviour and the common one.
+ */
+export function useAttachPdf(open: boolean) {
+  const [attachPdf, setAttachPdf] = useState(true)
+
+  useEffect(() => {
+    if (!open) return
+    let cancelled = false
+    getAttachPdfDefault().then((result) => {
+      if (!cancelled && result.success && result.data) setAttachPdf(result.data.attachPdf)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [open])
+
+  return [attachPdf, setAttachPdf] as const
+}
+
+/**
+ * Sits under the email option in every send dialog. Off means the customer
+ * gets the link instead, which is the only send that can be seen to have been
+ * opened.
+ *
+ * Stays in place and goes dim when email is not the channel, rather than
+ * appearing on the tick: a control that pops into existence moves everything
+ * under it, including the row the cursor is already travelling towards. It
+ * reads clearly enough disabled because it sits indented under the checkbox
+ * that governs it.
+ */
+export function AttachPdfOption({
+  id,
+  checked,
+  onCheckedChange,
+  disabled = false,
+}: {
+  id: string
+  checked: boolean
+  onCheckedChange: (checked: boolean) => void
+  disabled?: boolean
+}) {
+  const t = useTranslations('common')
+
+  return (
+    <div className={`space-y-1 ${disabled ? 'opacity-50' : ''}`}>
+      <div className="flex items-center gap-2">
+        <Checkbox
+          id={id}
+          checked={checked}
+          onCheckedChange={(v) => onCheckedChange(v === true)}
+          disabled={disabled}
+        />
+        <Label htmlFor={id} className="text-sm">
+          {t('attachPdf')}
+        </Label>
+      </div>
+      <p className="text-xs text-muted-foreground">{t('attachPdfHint')}</p>
+    </div>
+  )
+}

+ 13 - 2
src/features/email/Components/SendEmailDialog.tsx

@@ -8,13 +8,18 @@ import { Textarea } from '@/components/ui/textarea'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Loader2, Mail, Send } from 'lucide-react'
 import { useGlassModal } from '@/components/glass-modal'
+import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 interface SendEmailDialogProps {
   open: boolean
   onOpenChange: (open: boolean) => void
   defaultEmail?: string
   entityLabel: string
-  onSend: (email: string, message?: string) => Promise<{ success: boolean; error?: string }>
+  onSend: (
+    email: string,
+    message?: string,
+    attachPdf?: boolean
+  ) => Promise<{ success: boolean; error?: string }>
 }
 
 export function SendEmailDialog({
@@ -28,12 +33,13 @@ export function SendEmailDialog({
   const [sending, setSending] = useState(false)
   const [email, setEmail] = useState(defaultEmail)
   const [message, setMessage] = useState('')
+  const [attachPdf, setAttachPdf] = useAttachPdf(open)
 
   const handleSend = async (e: React.FormEvent) => {
     e.preventDefault()
     if (!email) return
     setSending(true)
-    const result = await onSend(email, message || undefined)
+    const result = await onSend(email, message || undefined, attachPdf)
     if (result.success) {
       modal.open('success', 'Email Sent', `${entityLabel} has been sent to ${email}`)
       onOpenChange(false)
@@ -75,6 +81,11 @@ export function SendEmailDialog({
               rows={3}
             />
           </div>
+          <AttachPdfOption
+            id="attach-pdf-send"
+            checked={attachPdf}
+            onCheckedChange={setAttachPdf}
+          />
           <div className="flex justify-end gap-2">
             <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
               Cancel

+ 87 - 0
src/features/email/Lib/documentEmail.ts

@@ -0,0 +1,87 @@
+/**
+ * How a document leaves the building by email.
+ *
+ * Two ways to send an invoice, a quote or an inspection report. With the PDF
+ * attached, which is what a customer who prints things wants. Or as a link to
+ * the shared copy, which is what a workshop that chases unpaid invoices wants:
+ * the link is the only thing that can be seen to have been opened, so the
+ * viewed counter on the document finally means something. A mail carrying the
+ * PDF is read in the mail client and the link never gets clicked.
+ *
+ * The workshop picks a default in settings and either send can override it.
+ */
+
+/** Attaching is the old behaviour, so an unset setting keeps it. */
+export function attachPdfDefault(settings: Record<string, string>): boolean {
+  return settings['email.attachPdf'] !== 'false'
+}
+
+/** What this particular send does: the caller's choice, else the workshop's. */
+export function resolveAttachPdf(
+  settings: Record<string, string>,
+  override: boolean | undefined
+): boolean {
+  return override ?? attachPdfDefault(settings)
+}
+
+export interface DocumentEmailBody {
+  /** Heading line, e.g. "Invoice INV-1042". */
+  heading: string
+  /** What the document is about, e.g. "your Volvo V70". Optional. */
+  subject?: string
+  /** The public link, when the document has one. */
+  link?: string | null
+  /** Wording of the link, e.g. "View Invoice Online". */
+  linkLabel: string
+  /** Whether the PDF rides along, which decides the opening line. */
+  attached: boolean
+  /** The sender's own note. */
+  message?: string
+  /** Workshop name, and phone when it has one. */
+  fromName: string
+  phone?: string
+}
+
+const html = (value: string) =>
+  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+
+/**
+ * One body for all three documents, so a link-only mail cannot end up saying
+ * "please find it attached" in one place and not another.
+ *
+ * A mail with neither an attachment nor a link would be an empty envelope, so
+ * the callers make sure a link exists before they turn the attachment off;
+ * should one slip through anyway, the opening line still reads sensibly.
+ */
+export function buildDocumentEmailHtml(body: DocumentEmailBody): string {
+  const { heading, subject, link, linkLabel, attached, message, fromName, phone } = body
+
+  const intro = attached
+    ? subject
+      ? `Please find the ${subject} attached.`
+      : 'Please find it attached.'
+    : link
+      ? subject
+        ? `The ${subject} is ready. Use the link below to view it.`
+        : 'It is ready. Use the link below to view it.'
+      : subject
+        ? `The ${subject} is ready.`
+        : 'It is ready.'
+
+  const linkBlock = link
+    ? `<p><a href="${html(link)}" style="color: #2563eb;">${html(linkLabel)}</a></p>`
+    : ''
+
+  return `
+        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
+          <h2>${html(heading)}</h2>
+          <p>${html(intro)}</p>
+          ${message ? `<p>${html(message)}</p>` : ''}
+          ${linkBlock}
+          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
+          <p style="color: #666; font-size: 14px;">
+            ${html(fromName)}${phone ? ` · ${html(phone)}` : ''}
+          </p>
+        </div>
+      `
+}

+ 53 - 36
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -24,6 +24,7 @@ import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsAc
 import { sendInspectionEmail } from '@/features/email/Actions/emailActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
+import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 interface InspectionShareDialogProps {
   open: boolean
@@ -58,6 +59,7 @@ export function InspectionShareDialog({
   const [token, setToken] = useState(publicToken)
   const [notifySms, setNotifySms] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
+  const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [sending, setSending] = useState(false)
 
   const hasPhone = !!customer?.phone
@@ -107,6 +109,16 @@ export function InspectionShareDialog({
 
     const results: string[] = []
 
+    if (notifyEmail && hasEmail) {
+      const res = await sendInspectionEmail({
+        inspectionId,
+        recipientEmail: customer.email!,
+        attachPdf,
+      })
+      if (res.success) results.push('Email sent')
+      else toast.error(res.error || 'Failed to send email')
+    }
+
     if (notifySms && hasPhone) {
       const tplResult = await getSmsTemplates()
       const tplData = tplResult.success && tplResult.data ? tplResult.data : null
@@ -129,16 +141,6 @@ export function InspectionShareDialog({
       else toast.error(res.error || 'Failed to send SMS')
     }
 
-    if (notifyEmail && hasEmail) {
-      const res = await sendInspectionEmail({
-        inspectionId,
-        recipientEmail: customer.email!,
-        message: `Your vehicle inspection report is ready. View it here: ${shareUrl}`,
-      })
-      if (res.success) results.push('Email sent')
-      else toast.error(res.error || 'Failed to send email')
-    }
-
     if (results.length > 0) {
       toast.success(results.join(' & '))
       setNotifySms(false)
@@ -147,7 +149,7 @@ export function InspectionShareDialog({
     setSending(false)
   }
 
-  const canNotify = shareUrl && customer && (notifySms || notifyEmail)
+  const canNotify = shareUrl && customer && (notifyEmail || notifySms)
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
@@ -177,28 +179,10 @@ export function InspectionShareDialog({
               </div>
 
               {/* Notify customer */}
-              {customer && (smsEnabled || emailEnabled) && (
+              {customer && (emailEnabled || smsEnabled) && (
                 <div className="space-y-3 rounded-lg border p-3">
                   <p className="text-sm font-medium">Notify {customer.name}</p>
                   <div className="space-y-2">
-                    {smsEnabled && (
-                      <div className="flex items-center gap-2">
-                        <Checkbox
-                          id="notify-sms-inspection"
-                          checked={notifySms}
-                          onCheckedChange={(v) => setNotifySms(v === true)}
-                          disabled={!hasPhone}
-                        />
-                        <Label
-                          htmlFor="notify-sms-inspection"
-                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
-                        >
-                          <MessageSquare className="h-3.5 w-3.5" />
-                          SMS
-                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
-                        </Label>
-                      </div>
-                    )}
                     {emailEnabled && (
                       <div className="flex items-center gap-2">
                         <Checkbox
@@ -217,13 +201,46 @@ export function InspectionShareDialog({
                         </Label>
                       </div>
                     )}
+                    {emailEnabled && (
+                      <div className="pl-6">
+                        <AttachPdfOption
+                          id="attach-pdf-inspection"
+                          checked={attachPdf}
+                          onCheckedChange={setAttachPdf}
+                          disabled={!notifyEmail || !hasEmail}
+                        />
+                      </div>
+                    )}
+                    {smsEnabled && (
+                      <div className="flex items-center gap-2">
+                        <Checkbox
+                          id="notify-sms-inspection"
+                          checked={notifySms}
+                          onCheckedChange={(v) => setNotifySms(v === true)}
+                          disabled={!hasPhone}
+                        />
+                        <Label
+                          htmlFor="notify-sms-inspection"
+                          className={`flex items-center gap-1.5 text-sm ${!hasPhone ? 'text-muted-foreground/50' : ''}`}
+                        >
+                          <MessageSquare className="h-3.5 w-3.5" />
+                          SMS
+                          {!hasPhone && <span className="text-xs">(no phone on file)</span>}
+                        </Label>
+                      </div>
+                    )}
                   </div>
-                  {canNotify && (
-                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
-                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                      Send Notification
-                    </Button>
-                  )}
+                  {/* Always here, disabled until a channel is ticked: it used to
+                      appear on the first tick and shove the dialog around. */}
+                  <Button
+                    size="sm"
+                    onClick={handleNotify}
+                    disabled={sending || !canNotify}
+                    className="w-full"
+                  >
+                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                    Send Notification
+                  </Button>
                 </div>
               )}
 

+ 7 - 2
src/features/quotes/Components/QuotePageClient.tsx

@@ -481,8 +481,13 @@ export function QuotePageClient({
         onOpenChange={state.setShowEmailDialog}
         defaultEmail={quote.customer?.email || ''}
         entityLabel={t('page.entityLabel')}
-        onSend={async (email, message) => {
-          const result = await sendQuoteEmail({ quoteId: quote.id, recipientEmail: email, message })
+        onSend={async (email, message, attachPdf) => {
+          const result = await sendQuoteEmail({
+            quoteId: quote.id,
+            recipientEmail: email,
+            message,
+            attachPdf,
+          })
           if (result.success) handleQuoteSent()
           return result
         }}

+ 24 - 6
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -17,6 +17,7 @@ import { sendQuoteEmail } from '@/features/email/Actions/emailActions'
 import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
+import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 interface QuoteShareDialogProps {
   open: boolean
@@ -51,6 +52,7 @@ export function QuoteShareDialog({
   const [generatingLink, setGeneratingLink] = useState(false)
   const [copied, setCopied] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
+  const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [notifySms, setNotifySms] = useState(false)
   const [sending, setSending] = useState(false)
   const t = useTranslations('quotes')
@@ -97,6 +99,7 @@ export function QuoteShareDialog({
       const res = await sendQuoteEmail({
         quoteId,
         recipientEmail: customer.email!,
+        attachPdf,
       })
       if (res.success) results.push(t('shareDialog.emailSent'))
       else toast.error(res.error || t('shareDialog.failedEmail'))
@@ -190,6 +193,16 @@ export function QuoteShareDialog({
                         </Label>
                       </div>
                     )}
+                    {emailEnabled && (
+                      <div className="pl-6">
+                        <AttachPdfOption
+                          id="attach-pdf-quote"
+                          checked={attachPdf}
+                          onCheckedChange={setAttachPdf}
+                          disabled={!notifyEmail || !hasEmail}
+                        />
+                      </div>
+                    )}
                     {smsEnabled && (
                       <div className="flex items-center gap-2">
                         <Checkbox
@@ -209,12 +222,17 @@ export function QuoteShareDialog({
                       </div>
                     )}
                   </div>
-                  {canNotify && (
-                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
-                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                      {t('shareDialog.sendNotification')}
-                    </Button>
-                  )}
+                  {/* Always here, disabled until a channel is ticked: it used to
+                      appear on the first tick and shove the dialog around. */}
+                  <Button
+                    size="sm"
+                    onClick={handleNotify}
+                    disabled={sending || !canNotify}
+                    className="w-full"
+                  >
+                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                    {t('shareDialog.sendNotification')}
+                  </Button>
                 </div>
               )}
 

+ 4 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -37,6 +37,10 @@ export const SETTING_KEYS = {
   QUOTE_VALID_DAYS: 'workshop.quoteValidDays',
   EMAIL_FROM_NAME: 'email.fromName',
   EMAIL_ENABLED: 'email.enabled',
+  /// Whether a document emailed from Torqvoice carries its PDF. Off sends the
+  /// share link instead, which is the only way to learn whether the customer
+  /// opened it.
+  EMAIL_ATTACH_PDF: 'email.attachPdf',
   INVOICE_TEMPLATE: 'invoice.template',
   INVOICE_PRIMARY_COLOR: 'invoice.primaryColor',
   /// Sheet color behind the document. Empty means the paper stays white.

+ 24 - 6
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -14,6 +14,7 @@ import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { sendSmsToCustomer, getSmsTemplates } from '@/features/sms/Actions/smsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
+import { AttachPdfOption, useAttachPdf } from '@/features/email/Components/AttachPdfOption'
 
 interface ShareDialogProps {
   open: boolean
@@ -50,6 +51,7 @@ export function ShareDialog({
   const [revoking, setRevoking] = useState(false)
   const [copied, setCopied] = useState(false)
   const [notifyEmail, setNotifyEmail] = useState(false)
+  const [attachPdf, setAttachPdf] = useAttachPdf(open)
   const [notifySms, setNotifySms] = useState(false)
   const [sending, setSending] = useState(false)
 
@@ -101,6 +103,7 @@ export function ShareDialog({
       const res = await sendInvoiceEmail({
         serviceRecordId: recordId,
         recipientEmail: customer.email!,
+        attachPdf,
       })
       if (res.success) results.push(t('emailSent'))
       else toast.error(res.error || t('failedEmail'))
@@ -194,6 +197,16 @@ export function ShareDialog({
                         </Label>
                       </div>
                     )}
+                    {emailEnabled && (
+                      <div className="pl-6">
+                        <AttachPdfOption
+                          id="attach-pdf-invoice"
+                          checked={attachPdf}
+                          onCheckedChange={setAttachPdf}
+                          disabled={!notifyEmail || !hasEmail}
+                        />
+                      </div>
+                    )}
                     {smsEnabled && (
                       <div className="flex items-center gap-2">
                         <Checkbox
@@ -213,12 +226,17 @@ export function ShareDialog({
                       </div>
                     )}
                   </div>
-                  {canNotify && (
-                    <Button size="sm" onClick={handleNotify} disabled={sending} className="w-full">
-                      {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
-                      {t('sendNotification')}
-                    </Button>
-                  )}
+                  {/* Always here, disabled until a channel is ticked: it used to
+                      appear on the first tick and shove the dialog around. */}
+                  <Button
+                    size="sm"
+                    onClick={handleNotify}
+                    disabled={sending || !canNotify}
+                    className="w-full"
+                  >
+                    {sending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
+                    {t('sendNotification')}
+                  </Button>
                 </div>
               )}
 

+ 2 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -589,11 +589,12 @@ export function ServicePageClient({
         onOpenChange={actions.setShowEmailDialog}
         defaultEmail={customer?.email || ''}
         entityLabel={t('invoice.entityLabel')}
-        onSend={async (email, message) => {
+        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"