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

Let workshops name their organisation number (#360)

* Let a workshop name its organisation number

Every country calls the registration number something else. A new
Company setting prints the workshop's own caption next to the number on
invoices, quotes, shared copies and the designer preview, falling back
to the translated default when empty.

* Name the organisation number label plainly and edit it from the designer
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
f5b1e4d15c

+ 5 - 0
messages/de/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Meiers Autowerkstatt",
     "orgNumber": "Organisationsnummer",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Bezeichnung der Organisationsnummer",
+    "orgNumberLabelPlaceholder": "Org.-Nr.",
+    "orgNumberLabelHint": "Wird auf Rechnungen und Angeboten neben der Nummer gedruckt. Leer lassen für die Standardbezeichnung in Ihrer Sprache, oder eintragen, wie sie bei Ihnen heißt, z. B. USt-IdNr., HRB oder UID.",
     "phone": "Telefon",
     "phonePlaceholder": "(555) 123-4567",
     "email": "E-Mail",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Zahlungseinstellungen",
     "titleText": "Titeltext",
     "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
+    "orgNumberLabel": "Bezeichnung der Organisationsnummer",
+    "orgNumberLabelHint": "Wird auf Rechnungen und Angeboten neben Ihrer Organisationsnummer gedruckt. Leer lassen für die Standardbezeichnung in Ihrer Sprache, oder eintragen, wie sie bei Ihnen heißt, z. B. USt-IdNr., HRB oder UID. Gilt auch in den Unternehmenseinstellungen.",
     "placeholder": "Platzhalter",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",

+ 5 - 0
messages/en/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Joe's Auto Repair",
     "orgNumber": "Organization Number",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Label for Organization number",
+    "orgNumberLabelPlaceholder": "Org. Number",
+    "orgNumberLabelHint": "Printed next to the number on invoices and quotes. Leave empty for the default in your language, or write what it is called where you are, such as ABN, VAT no. or CVR.",
     "phone": "Phone",
     "phonePlaceholder": "(555) 123-4567",
     "email": "Email",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Payment settings",
     "titleText": "Title text",
     "titleTextHint": "What this document calls itself. Left empty, it prints its own name in the reader's language.",
+    "orgNumberLabel": "Organization number label",
+    "orgNumberLabelHint": "Printed next to your organization number on invoices and quotes. Leave empty for the default in your language, or write what it is called where you are, such as ABN, VAT no. or CVR. Shared with company settings.",
     "placeholder": "Placeholder",
     "leaveTitle": "Leave without saving?",
     "leaveBody": "Your changes to this design have not been saved. They will be lost.",

+ 5 - 0
messages/es/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Taller Mecánico Lopez",
     "orgNumber": "Número de organización",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Etiqueta del número de organización",
+    "orgNumberLabelPlaceholder": "Núm. de org.",
+    "orgNumberLabelHint": "Se imprime junto al número en facturas y presupuestos. Déjalo vacío para usar el texto por defecto en tu idioma, o escribe cómo se llama en tu país, por ejemplo NIF, CIF o RUT.",
     "phone": "Teléfono",
     "phonePlaceholder": "(555) 123-4567",
     "email": "Correo electrónico",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Ajustes de pago",
     "titleText": "Texto del título",
     "titleTextHint": "Cómo se llama este documento. Si lo dejas vacío, imprime su propio nombre en el idioma del lector.",
+    "orgNumberLabel": "Etiqueta del número de organización",
+    "orgNumberLabelHint": "Se imprime junto al número de organización en facturas y presupuestos. Déjalo vacío para usar el texto por defecto en tu idioma, o escribe cómo se llama en tu país, por ejemplo NIF, CIF o RUT. Es el mismo valor que en los ajustes de la empresa.",
     "placeholder": "Marcador",
     "leaveTitle": "¿Salir sin guardar?",
     "leaveBody": "Los cambios de este diseño no se han guardado y se perderán.",

+ 5 - 0
messages/fr/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Garage Auto Martin",
     "orgNumber": "Numéro d'organisation",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Libellé du numéro d'organisation",
+    "orgNumberLabelPlaceholder": "N° d'org.",
+    "orgNumberLabelHint": "Imprimé à côté du numéro sur les factures et les devis. Laissez vide pour le libellé par défaut dans votre langue, ou indiquez son nom chez vous, par exemple SIRET, N° TVA ou BCE.",
     "phone": "Téléphone",
     "phonePlaceholder": "(555) 123-4567",
     "email": "E-mail",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Paramètres de paiement",
     "titleText": "Texte du titre",
     "titleTextHint": "Le nom que porte ce document. Laissé vide, il imprime son propre nom dans la langue du lecteur.",
+    "orgNumberLabel": "Libellé du numéro d'organisation",
+    "orgNumberLabelHint": "Imprimé à côté du numéro d'organisation sur les factures et les devis. Laissez vide pour le libellé par défaut dans votre langue, ou indiquez son nom chez vous, par exemple SIRET, N° TVA ou BCE. Partagé avec les paramètres de l'entreprise.",
     "placeholder": "Exemple",
     "leaveTitle": "Quitter sans enregistrer ?",
     "leaveBody": "Les modifications de cette maquette ne sont pas enregistrées et seront perdues.",

+ 5 - 0
messages/it/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Autofficina di Mario",
     "orgNumber": "Numero organizzazione",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Etichetta del numero di organizzazione",
+    "orgNumberLabelPlaceholder": "N. org.",
+    "orgNumberLabelHint": "Stampata accanto al numero su fatture e preventivi. Lascia vuoto per l'etichetta predefinita nella tua lingua, oppure scrivi come si chiama nel tuo paese, ad esempio P.IVA, C.F. o REA.",
     "phone": "Telefono",
     "phonePlaceholder": "(555) 123-4567",
     "email": "Email",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Impostazioni di pagamento",
     "titleText": "Testo del titolo",
     "titleTextHint": "Come si chiama questo documento. Se lasciato vuoto, stampa il proprio nome nella lingua del lettore.",
+    "orgNumberLabel": "Etichetta del numero di organizzazione",
+    "orgNumberLabelHint": "Stampata accanto al numero di organizzazione su fatture e preventivi. Lascia vuoto per l'etichetta predefinita nella tua lingua, oppure scrivi come si chiama nel tuo paese, ad esempio P.IVA, C.F. o REA. Condivisa con le impostazioni dell'azienda.",
     "placeholder": "Segnaposto",
     "leaveTitle": "Uscire senza salvare?",
     "leaveBody": "Le modifiche a questo design non sono state salvate e andranno perse.",

+ 5 - 0
messages/lt/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Jono autoservisas",
     "orgNumber": "Įmonės kodas",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Organizacijos numerio pavadinimas",
+    "orgNumberLabelPlaceholder": "Įmonės kodas",
+    "orgNumberLabelHint": "Spausdinamas šalia numerio sąskaitose ir pasiūlymuose. Palikite tuščią, kad būtų naudojamas numatytasis pavadinimas jūsų kalba, arba įrašykite, kaip jis vadinamas jūsų šalyje, pvz., PVM kodas, ABN arba CVR.",
     "phone": "Telefonas",
     "phonePlaceholder": "+370 600 12345",
     "email": "El. paštas",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Mokėjimų nustatymai",
     "titleText": "Pavadinimo tekstas",
     "titleTextHint": "Kaip vadinasi šis dokumentas. Palikus tuščią, spausdinamas jo paties pavadinimas skaitytojo kalba.",
+    "orgNumberLabel": "Organizacijos numerio pavadinimas",
+    "orgNumberLabelHint": "Spausdinamas šalia organizacijos numerio sąskaitose ir pasiūlymuose. Palikite tuščią, kad būtų naudojamas numatytasis pavadinimas jūsų kalba, arba įrašykite, kaip jis vadinamas jūsų šalyje, pvz., PVM kodas, ABN arba CVR. Bendras su įmonės nustatymais.",
     "placeholder": "Vietaženklis",
     "leaveTitle": "Išeiti neišsaugojus?",
     "leaveBody": "Šio dizaino pakeitimai neišsaugoti ir bus prarasti.",

+ 5 - 0
messages/nb/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Hansens Bilverksted",
     "orgNumber": "Organisasjonsnummer",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Betegnelse på organisasjonsnummer",
+    "orgNumberLabelPlaceholder": "Org.nummer",
+    "orgNumberLabelHint": "Skrives ut ved siden av nummeret på fakturaer og tilbud. La stå tomt for standarden på ditt språk, eller skriv hva det heter der du er, for eksempel ABN, VAT no. eller CVR.",
     "phone": "Telefon",
     "phonePlaceholder": "123 45 678",
     "email": "E-post",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Betalingsinnstillinger",
     "titleText": "Titteltekst",
     "titleTextHint": "Hva dette dokumentet kaller seg. Står feltet tomt, skrives dokumentets eget navn på leserens språk.",
+    "orgNumberLabel": "Betegnelse på organisasjonsnummer",
+    "orgNumberLabelHint": "Skrives ut ved siden av organisasjonsnummeret på fakturaer og tilbud. La stå tomt for standarden på ditt språk, eller skriv hva det heter der du er, for eksempel ABN, VAT no. eller CVR. Samme verdi som i firmainnstillingene.",
     "placeholder": "Plassholder",
     "leaveTitle": "Forlate uten å lagre?",
     "leaveBody": "Endringene i dette designet er ikke lagret. De går tapt.",

+ 5 - 0
messages/nl/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Jansens Autogarage",
     "orgNumber": "Organisatienummer",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Naam van het organisatienummer",
+    "orgNumberLabelPlaceholder": "Org.nr.",
+    "orgNumberLabelHint": "Wordt naast het nummer afgedrukt op facturen en offertes. Laat leeg voor de standaard in uw taal, of vul in hoe het bij u heet, bijvoorbeeld KvK-nummer, btw-nummer of ondernemingsnummer.",
     "phone": "Telefoon",
     "phonePlaceholder": "+31 20 123 4567",
     "email": "E-mail",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Betaalinstellingen",
     "titleText": "Titeltekst",
     "titleTextHint": "Hoe dit document zichzelf noemt. Laat je het leeg, dan drukt het zijn eigen naam af in de taal van de lezer.",
+    "orgNumberLabel": "Naam van het organisatienummer",
+    "orgNumberLabelHint": "Wordt naast het organisatienummer afgedrukt op facturen en offertes. Laat leeg voor de standaard in uw taal, of vul in hoe het bij u heet, bijvoorbeeld KvK-nummer, btw-nummer of ondernemingsnummer. Gedeeld met de bedrijfsinstellingen.",
     "placeholder": "Voorbeeld",
     "leaveTitle": "Weggaan zonder opslaan?",
     "leaveBody": "Je wijzigingen aan dit ontwerp zijn niet opgeslagen en gaan verloren.",

+ 5 - 0
messages/pl/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Warsztat Samochodowy Kowalski",
     "orgNumber": "Numer organizacji",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Nazwa numeru organizacji",
+    "orgNumberLabelPlaceholder": "Nr org.",
+    "orgNumberLabelHint": "Drukowana obok numeru na fakturach i ofertach. Zostaw puste, aby użyć domyślnej nazwy w Twoim języku, lub wpisz, jak nazywa się w Twoim kraju, np. NIP, REGON lub KRS.",
     "phone": "Telefon",
     "phonePlaceholder": "+48 123 456 789",
     "email": "E-mail",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Ustawienia płatności",
     "titleText": "Tekst tytułu",
     "titleTextHint": "Jak nazywa się ten dokument. Pozostawione puste, drukuje własną nazwę w języku odbiorcy.",
+    "orgNumberLabel": "Nazwa numeru organizacji",
+    "orgNumberLabelHint": "Drukowana obok numeru organizacji na fakturach i ofertach. Zostaw puste, aby użyć domyślnej nazwy w Twoim języku, lub wpisz, jak nazywa się w Twoim kraju, np. NIP, REGON lub KRS. Wspólna z ustawieniami firmy.",
     "placeholder": "Symbol zastępczy",
     "leaveTitle": "Wyjść bez zapisywania?",
     "leaveBody": "Zmiany w tym projekcie nie zostały zapisane i zostaną utracone.",

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

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Auto Mecanica Silva",
     "orgNumber": "Número da organização",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Rótulo do número da organização",
+    "orgNumberLabelPlaceholder": "Nº da org.",
+    "orgNumberLabelHint": "Impresso ao lado do número em faturas e orçamentos. Deixe vazio para usar o padrão no seu idioma, ou escreva como ele se chama no seu país, por exemplo CNPJ, NIF ou RUT.",
     "phone": "Telefone",
     "phonePlaceholder": "(11) 91234-5678",
     "email": "E-mail",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Configurações de pagamento",
     "titleText": "Texto do título",
     "titleTextHint": "Como este documento se chama. Deixado vazio, imprime o próprio nome no idioma do leitor.",
+    "orgNumberLabel": "Rótulo do número da organização",
+    "orgNumberLabelHint": "Impresso ao lado do número da organização em faturas e orçamentos. Deixe vazio para usar o padrão no seu idioma, ou escreva como ele se chama no seu país, por exemplo CNPJ, NIF ou RUT. Compartilhado com as configurações da empresa.",
     "placeholder": "Exemplo",
     "leaveTitle": "Sair sem salvar?",
     "leaveBody": "Suas alterações neste design não foram salvas e serão perdidas.",

+ 5 - 0
messages/ru/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Автосервис Ивана",
     "orgNumber": "Номер организации",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Название номера организации",
+    "orgNumberLabelPlaceholder": "Номер организации",
+    "orgNumberLabelHint": "Печатается рядом с номером в счетах и предложениях. Оставьте пустым, чтобы использовать стандартное название на вашем языке, или укажите, как он называется у вас, например ИНН, ОГРН или УНП.",
     "phone": "Телефон",
     "phonePlaceholder": "+7 (999) 123-45-67",
     "email": "Эл. почта",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Настройки оплаты",
     "titleText": "Текст заголовка",
     "titleTextHint": "Как называется этот документ. Если оставить пустым, печатается его собственное название на языке читателя.",
+    "orgNumberLabel": "Название номера организации",
+    "orgNumberLabelHint": "Печатается рядом с номером организации в счетах и предложениях. Оставьте пустым, чтобы использовать стандартное название на вашем языке, или укажите, как он называется у вас, например ИНН, ОГРН или УНП. Общее значение с настройками компании.",
     "placeholder": "Образец",
     "leaveTitle": "Выйти без сохранения?",
     "leaveBody": "Изменения в этом оформлении не сохранены и будут потеряны.",

+ 5 - 0
messages/tr/settings.json

@@ -168,6 +168,9 @@
     "shopNamePlaceholder": "Ali'nin Oto Tamir",
     "orgNumber": "Kuruluş Numarası",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Organizasyon numarası etiketi",
+    "orgNumberLabelPlaceholder": "Org. No.",
+    "orgNumberLabelHint": "Faturalarda ve tekliflerde numaranın yanına yazdırılır. Dilinizdeki varsayılan için boş bırakın veya bulunduğunuz yerde ne dendiğini yazın, örneğin Vergi No., MERSİS No. veya VKN.",
     "phone": "Telefon",
     "phonePlaceholder": "(555) 123-4567",
     "email": "E-posta",
@@ -1974,6 +1977,8 @@
     "paymentTermsLink": "Ödeme ayarları",
     "titleText": "Başlık metni",
     "titleTextHint": "Bu belgenin kendine verdiği ad. Boş bırakılırsa kendi adını okuyucunun dilinde yazdırır.",
+    "orgNumberLabel": "Organizasyon numarası etiketi",
+    "orgNumberLabelHint": "Faturalarda ve tekliflerde organizasyon numaranızın yanına yazdırılır. Dilinizdeki varsayılan için boş bırakın veya bulunduğunuz yerde ne dendiğini yazın, örneğin Vergi No., MERSİS No. veya VKN. Şirket ayarlarıyla ortaktır.",
     "placeholder": "Örnek",
     "leaveTitle": "Kaydetmeden çıkılsın mı?",
     "leaveBody": "Bu tasarımdaki değişiklikler kaydedilmedi ve kaybolacak.",

+ 27 - 0
src/__tests__/features/invoice-designer/org-number-label.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { withOrgNumberLabel } from '@/features/invoice-designer/Lib/labelOverrides'
+import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
+
+describe('organisation number label', () => {
+  it('leaves the translated caption alone when the workshop has not named it', () => {
+    const labels = { orgNumberLabel: 'Org. Number', org: 'Org: {org}' }
+    expect(withOrgNumberLabel(labels, '')).toEqual(labels)
+    expect(withOrgNumberLabel(labels, '   ')).toEqual(labels)
+    expect(withOrgNumberLabel(labels, undefined)).toEqual(labels)
+  })
+
+  it("uses the workshop's own caption in both places the number is printed", () => {
+    const labels = withOrgNumberLabel({ orgNumberLabel: 'Org. Number', org: 'Org: {org}' }, ' ABN ')
+    expect(labels.orgNumberLabel).toBe('ABN')
+    expect(labels.org).toBe('ABN: {org}')
+  })
+
+  it('reaches the printed labels through the settings, for invoices and quotes', async () => {
+    const invoice = await loadPrintLabels('en', { 'workshop.orgNumberLabel': 'Company no.' })
+    const quote = await loadPrintLabels('nb', { 'workshop.orgNumberLabel': 'CVR' }, 'quote')
+    expect(invoice.orgNumberLabel).toBe('Company no.')
+    expect(quote.orgNumberLabel).toBe('CVR')
+    const plain = await loadPrintLabels('en', {})
+    expect(plain.orgNumberLabel).toBe('Org. Number')
+  })
+})

+ 17 - 0
src/app/(authenticated)/settings/company/company-settings.tsx

@@ -36,6 +36,9 @@ export function CompanySettings({
   const [workshopPhone, setWorkshopPhone] = useState(settings[SETTING_KEYS.WORKSHOP_PHONE] || '')
   const [workshopEmail, setWorkshopEmail] = useState(settings[SETTING_KEYS.WORKSHOP_EMAIL] || '')
   const [orgNumber, setOrgNumber] = useState(settings[SETTING_KEYS.INVOICE_ORG_NUMBER] || '')
+  const [orgNumberLabel, setOrgNumberLabel] = useState(
+    settings[SETTING_KEYS.ORG_NUMBER_LABEL] || ''
+  )
   const [logoUrl, setLogoUrl] = useState(settings[SETTING_KEYS.COMPANY_LOGO] || '')
   const [uploadingLogo, setUploadingLogo] = useState(false)
   const [showCreateOrg, setShowCreateOrg] = useState(false)
@@ -52,6 +55,7 @@ export function CompanySettings({
         [SETTING_KEYS.WORKSHOP_PHONE]: workshopPhone,
         [SETTING_KEYS.WORKSHOP_EMAIL]: workshopEmail,
         [SETTING_KEYS.INVOICE_ORG_NUMBER]: orgNumber,
+        [SETTING_KEYS.ORG_NUMBER_LABEL]: orgNumberLabel.trim(),
       }),
     ])
     setSaving(false)
@@ -190,6 +194,19 @@ export function CompanySettings({
                 onChange={(e) => setOrgNumber(e.target.value)}
               />
             </div>
+            <div className="space-y-1">
+              <Label htmlFor="orgNumberLabel" className="text-xs">
+                {t('company.orgNumberLabel')}
+              </Label>
+              <Input
+                id="orgNumberLabel"
+                placeholder={t('company.orgNumberLabelPlaceholder')}
+                value={orgNumberLabel}
+                maxLength={40}
+                onChange={(e) => setOrgNumberLabel(e.target.value)}
+              />
+              <p className="text-xs text-muted-foreground">{t('company.orgNumberLabelHint')}</p>
+            </div>
             <div className="space-y-1">
               <Label htmlFor="workshopPhone" className="text-xs">
                 {t('company.phone')}

+ 1 - 0
src/app/(designer)/invoice-designer/page.tsx

@@ -118,6 +118,7 @@ export default async function InvoiceDesignerPage({
           email: settings[SETTING_KEYS.WORKSHOP_EMAIL] || '',
           slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN] || '',
           orgNumber: settings[SETTING_KEYS.INVOICE_ORG_NUMBER] || '',
+          orgNumberLabel: settings[SETTING_KEYS.ORG_NUMBER_LABEL] || '',
           paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] || '',
           logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] || '',
         }}

+ 1 - 0
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -117,6 +117,7 @@ export default async function PublicQuotePage({
             'quote.layoutConfig',
             'workshop.serviceType',
             'workshop.taxLabel',
+            'workshop.orgNumberLabel',
           ],
         },
       },

+ 2 - 0
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -1,3 +1,4 @@
+import { withOrgNumberLabel } from '@/features/invoice-designer/Lib/labelOverrides'
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
@@ -91,6 +92,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     if (customTaxLabel) {
       labels.tax = `${customTaxLabel} ({rate}%)`
     }
+    Object.assign(labels, withOrgNumberLabel(labels, settingsMap['workshop.orgNumberLabel']))
 
     let logoDataUri: string | undefined
     const logoPath = documentLogoPath(settingsMap, 'quote')

+ 2 - 0
src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts

@@ -1,3 +1,4 @@
+import { withOrgNumberLabel } from '@/features/invoice-designer/Lib/labelOverrides'
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
@@ -96,6 +97,7 @@ export async function GET(
     if (customTaxLabel) {
       labels.tax = `${customTaxLabel} ({rate}%)`
     }
+    Object.assign(labels, withOrgNumberLabel(labels, settingsMap['workshop.orgNumberLabel']))
 
     let logoDataUri: string | undefined
     const logoPath = documentLogoPath(settingsMap, 'quote')

+ 31 - 0
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -323,6 +323,9 @@ export function DesignerInspector({
   sloganSet,
   paymentTermsSet,
   documentTitleDefault,
+  orgNumberLabel,
+  orgNumberLabelDefault,
+  onOrgNumberLabel,
 }: {
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
@@ -346,6 +349,11 @@ export function DesignerInspector({
   paymentTermsSet: boolean
   /** What the title strip prints when the workshop has not renamed it. */
   documentTitleDefault: string
+  /** What the organisation number is called on paper, empty for the default. */
+  orgNumberLabel: string
+  /** The default caption in the reader's language, shown as the placeholder. */
+  orgNumberLabelDefault: string
+  onOrgNumberLabel: (value: string) => void
 }) {
   const t = useTranslations('settings.designer')
   const tSection = useTranslations('settings.layoutEditor.sections')
@@ -360,6 +368,25 @@ export function DesignerInspector({
   }
   /** A section's name, or its id spaced out when nothing has named it. */
   const sectionName = (id: string) => (tSection.has(id) ? tSection(id) : id.replace(/_/g, ' '))
+  /**
+   * The caption printed before the organisation number. Offered wherever the
+   * number is looked at, in the header and in the payment panel, and again
+   * with the sheet's own properties, so it is found from whichever side
+   * somebody approaches it. One value: it is the company's, not the block's.
+   */
+  const orgNumberLabelGroup = (
+    <Group title={t('orgNumberLabel')}>
+      <input
+        type="text"
+        maxLength={40}
+        value={orgNumberLabel}
+        placeholder={orgNumberLabelDefault}
+        onChange={(e) => onOrgNumberLabel(e.target.value)}
+        className="h-7 w-full rounded-md border border-[#e3e5e9] px-2 text-[12px]"
+      />
+      <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('orgNumberLabelHint')}</p>
+    </Group>
+  )
   /** The field row being dragged to a new spot in the list, if any. */
   const [dragFieldId, setDragFieldId] = useState<string | null>(null)
   const section = selected ? layout.sections.find((s) => s.id === selected) : undefined
@@ -676,6 +703,7 @@ export function DesignerInspector({
               </p>
             </Group>
           )}
+          {section.id === 'bank_account' && orgNumberLabelGroup}
 
           {section.id === 'header' && (
             <Group title={t('logo')}>
@@ -712,6 +740,7 @@ export function DesignerInspector({
               <p className="text-[11.5px] leading-snug text-[#8a8f97]">{t('logoHint')}</p>
             </Group>
           )}
+          {section.id === 'header' && orgNumberLabelGroup}
 
           {/* The footer prints the same logo when it is switched on below, so
               it offers the same swap rather than sending somebody to the
@@ -1096,6 +1125,8 @@ export function DesignerInspector({
           />
         </Group>
 
+        {orgNumberLabelGroup}
+
         <Group title={t('page')}>
           <Slider
             label={t('margins')}

+ 34 - 8
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -3,6 +3,7 @@
 import { useCallback, useEffect, useMemo, useState } from 'react'
 import { Eye, EyeOff, Trash2 } from 'lucide-react'
 import { useMessages, useTranslations } from 'next-intl'
+import { withOrgNumberLabel } from '../Lib/labelOverrides'
 import { useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { useConfirm } from '@/components/confirm-dialog'
@@ -34,6 +35,7 @@ import {
   saveQuoteLayoutConfig,
 } from '@/features/settings/Actions/invoiceLayoutActions'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import {
   deleteDocumentDesign,
   getDocumentDesignUsage,
@@ -207,9 +209,26 @@ export function InvoiceDesigner({
   // What this document actually prints: its own mark when it has one, the
   // company logo otherwise. The same fallback the print routes apply, so the
   // canvas cannot promise a picture the paper will not carry.
+  // What the organisation number is called on paper ("ABN", "CVR", "VAT
+  // no."). A company fact rather than a design choice, so it is one value
+  // for both documents and is written to the company setting on save, the
+  // same place the company page writes it. Edited here all the same, because
+  // the number is looked at where it prints.
+  const [orgNumberLabel, setOrgNumberLabelState] = useState(companyWorkshop.orgNumberLabel ?? '')
+  const setOrgNumberLabel = useCallback(
+    (next: string) => {
+      setOrgNumberLabelState(next)
+      setDirty((prev) => ({ ...prev, [docType]: true }))
+    },
+    [docType]
+  )
   const workshop = useMemo(
-    () => ({ ...companyWorkshop, logoUrl: template.logoUrl || companyWorkshop.logoUrl || '' }),
-    [companyWorkshop, template.logoUrl]
+    () => ({
+      ...companyWorkshop,
+      logoUrl: template.logoUrl || companyWorkshop.logoUrl || '',
+      orgNumberLabel,
+    }),
+    [companyWorkshop, template.logoUrl, orgNumberLabel]
   )
 
   /**
@@ -220,12 +239,15 @@ export function InvoiceDesigner({
    */
   const printLabels = useMemo<PrintLabels>(() => {
     const pdf = messages.pdf ?? {}
-    return {
-      ...(pdf.invoice ?? {}),
-      ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
-      ...(pdf.common ?? {}),
-    }
-  }, [messages, docType])
+    return withOrgNumberLabel(
+      {
+        ...(pdf.invoice ?? {}),
+        ...(docType === 'quote' ? (pdf.quote ?? {}) : {}),
+        ...(pdf.common ?? {}),
+      },
+      workshop.orgNumberLabel
+    )
+  }, [messages, docType, workshop.orgNumberLabel])
   const L = useCallback(
     (key: string, fallback: string) => printLabels[key] || fallback,
     [printLabels]
@@ -614,6 +636,7 @@ export function InvoiceDesigner({
           [`${prefix}.headerStyle`]: template.headerStyle,
           [`${prefix}.logoSize`]: String(template.logoSize),
           [`${prefix}.logo`]: template.logoUrl,
+          [SETTING_KEYS.ORG_NUMBER_LABEL]: orgNumberLabel.trim(),
         }),
       ])
       setDirty((prev) => ({ ...prev, [docType]: false }))
@@ -1011,6 +1034,9 @@ export function InvoiceDesigner({
           sloganSet={!!companyWorkshop.slogan?.trim()}
           paymentTermsSet={!!companyWorkshop.paymentTerms?.trim()}
           documentTitleDefault={data.meta.title}
+          orgNumberLabel={orgNumberLabel}
+          orgNumberLabelDefault={messages.pdf?.invoice?.orgNumberLabel ?? 'Org. Number'}
+          onOrgNumberLabel={setOrgNumberLabel}
           onLogo={(url) => setTemplate({ logoUrl: url })}
         />
       </div>

+ 2 - 0
src/features/invoice-designer/Components/types.ts

@@ -47,6 +47,8 @@ export interface DesignerWorkshop {
   email: string
   slogan: string
   orgNumber: string
+  /** The workshop's own caption for the number; empty means the translated one. */
+  orgNumberLabel?: string
   /** Payment terms from payment settings. Empty prints nothing on the sheet. */
   paymentTerms: string
   logoUrl: string

+ 18 - 0
src/features/invoice-designer/Lib/labelOverrides.ts

@@ -0,0 +1,18 @@
+/**
+ * The workshop's own caption for its registration number.
+ *
+ * Every country calls the number something else: an ABN in Australia, a
+ * company number in the UK, a CVR in Denmark, "Org. nr." in Norway. The
+ * translations carry one caption per language, which cannot be right for
+ * everyone, so a workshop may name it. Applied wherever the printed labels
+ * are resolved, so the sheet, the shared copy, the PDF and the designer's
+ * preview all say the same thing.
+ */
+export function withOrgNumberLabel<T extends Record<string, string>>(
+  labels: T,
+  custom: string | null | undefined
+): T {
+  const caption = custom?.trim()
+  if (!caption) return labels
+  return { ...labels, orgNumberLabel: caption, org: `${caption}: {org}` }
+}

+ 3 - 1
src/features/invoice-designer/Pdf/printLabels.ts

@@ -4,6 +4,8 @@
  * routes have always applied.
  */
 
+import { withOrgNumberLabel } from '../Lib/labelOverrides'
+
 type PdfMessages = Record<string, Record<string, string>>
 
 /** Which document's wording wins where the invoice and the quote differ. */
@@ -51,5 +53,5 @@ export async function loadPrintLabels(
     labels.tax = `${customTaxLabel} ({rate}%)`
   }
 
-  return labels
+  return withOrgNumberLabel(labels, settingsMap['workshop.orgNumberLabel'])
 }

+ 10 - 2
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -316,7 +316,11 @@ async function assembleLive(
     logoDataUri,
     paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
     designSource,
-    labelSettings: { 'workshop.serviceType': serviceType, 'workshop.taxLabel': taxLabel ?? '' },
+    labelSettings: {
+      'workshop.serviceType': serviceType,
+      'workshop.taxLabel': taxLabel ?? '',
+      'workshop.orgNumberLabel': settingsMap['workshop.orgNumberLabel'] ?? '',
+    },
   }
 }
 
@@ -384,7 +388,11 @@ function assembleFrozen(
     logoDataUri,
     paymentSummary: paymentSummaryOf(record, invoiceSettings.dateFormat, invoiceSettings.timezone),
     designSource,
-    labelSettings: { 'workshop.serviceType': serviceType, 'workshop.taxLabel': taxLabel ?? '' },
+    labelSettings: {
+      'workshop.serviceType': serviceType,
+      'workshop.taxLabel': taxLabel ?? '',
+      'workshop.orgNumberLabel': settingsMap['workshop.orgNumberLabel'] ?? '',
+    },
   }
 }
 

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

@@ -12,6 +12,7 @@ export const SETTING_KEYS = {
   TAX_ENABLED: 'workshop.taxEnabled',
   TAX_INCLUSIVE: 'workshop.taxInclusive',
   TAX_LABEL: 'workshop.taxLabel',
+  ORG_NUMBER_LABEL: 'workshop.orgNumberLabel',
   INVOICE_PREFIX: 'workshop.invoicePrefix',
   INVOICE_START_NUMBER: 'workshop.invoiceStartNumber',
   CURRENCY_SYMBOL: 'workshop.currencySymbol',
@@ -227,6 +228,7 @@ export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]
 export const workshopSettingsSchema = z.object({
   [SETTING_KEYS.WORKSHOP_ADDRESS]: z.string().optional(),
   [SETTING_KEYS.WORKSHOP_SLOGAN]: z.string().max(160).optional(),
+  [SETTING_KEYS.ORG_NUMBER_LABEL]: z.string().max(40).optional(),
   [SETTING_KEYS.WORKSHOP_PHONE]: z.string().optional(),
   [SETTING_KEYS.TIMEZONE_DETECTED]: z.string().max(64).optional(),
   [SETTING_KEYS.INSPECTION_DURATION_MINUTES]: z.string().regex(/^\d+$/).optional(),