From 78c91e00e41172153ee111070b0f524241f965d6 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 22 May 2026 15:21:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20language=20preference=20for=20cus?= =?UTF-8?q?tomers=20to=20support=20invoice=20locali=E2=80=A6=20(#561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add language preference for customers to support invoice localization - Introduced language support for invoices, allowing customers to choose between Swedish and English. - Updated invoice PDF generation to reflect the selected language for titles, labels, and messages. - Enhanced email templates to generate content in the customer's preferred language. - Added migration to include a language column in the customers table with a default value of Swedish. - Updated tests to verify correct language usage in invoice emails and PDFs. * fix: debounce API requests in InvoicePreviewCard and update F-skatt terminology in email templates --- CLAUDE.md | 4 +- app/(dashboard)/import/page.tsx | 93 +++--- app/(dashboard)/settings/invoicing/page.tsx | 28 +- app/api/customers/[id]/route.ts | 1 + app/api/customers/route.ts | 1 + app/api/invoices/preview-pdf/route.ts | 2 +- .../[companyId]/customers/[id]/route.ts | 1 + .../companies/[companyId]/customers/route.ts | 1 + components/customers/CustomerForm.tsx | 23 ++ components/settings/InvoicePreviewCard.tsx | 78 ++--- .../components/CloudBackupCard.tsx | 239 +++++++-------- lib/api/schemas.ts | 1 + lib/email/__tests__/invoice-templates.test.ts | 144 +++++++++ lib/email/invoice-templates.ts | 204 +++++++++---- lib/invoices/pdf-template.tsx | 276 +++++++++++++----- messages/en.json | 7 + messages/sv.json | 7 + next.config.ts | 18 +- .../20260522140000_add_customer_language.sql | 16 + tests/helpers.ts | 1 + types/index.ts | 4 + 21 files changed, 807 insertions(+), 342 deletions(-) create mode 100644 lib/email/__tests__/invoice-templates.test.ts create mode 100644 supabase/migrations/20260522140000_add_customer_language.sql diff --git a/CLAUDE.md b/CLAUDE.md index cdff32d0..7c60165d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,8 +385,8 @@ Add new strings to both `messages/sv.json` and `messages/en.json` under the matc | Surface | Reason | |---|---| -| Invoice PDFs (`lib/invoices/pdf-template.tsx`) | Sent to the user's customers, who are typically Swedish | -| Customer email templates (`lib/email/invoice-templates.ts`, `reminder-templates.ts`) | Same — recipient is the customer, not the app user | +| Invoice PDFs (`lib/invoices/pdf-template.tsx`) | Customer-facing — driven by `customer.language` (`sv` default, `en` opt-in). The template's chrome translates; statutory chapter refs (ML 17 kap 24§, ML 3 kap.) stay intact in both locales. | +| Customer email templates (`lib/email/invoice-templates.ts`, `reminder-templates.ts`) | Same — `customer.language` drives the output. `reminder-templates.ts` is still Swedish-only; mirror the PDF/invoice-templates approach if you add English here. | | Year-end wizard (`app/(dashboard)/bookkeeping/year-end/page.tsx`) | Statutory bokslut terminology; English would be misleading | | Journal entry editor (`app/(dashboard)/bookkeeping/[id]/page.tsx`) | Deeply regulatory (verifikat, voucher numbers, BAS) | | INK2 / NE-bilaga / SRU (`lib/reports/ink2/**`, `lib/reports/ne-bilaga/**`, `lib/reports/sru-*`) | Skatteverket forms — field codes and labels are statutory | diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index a0fa5c2c..d9e7f0e3 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1804,9 +1804,11 @@ export default function ImportPage() {
{/* Header */}
-

{t('title')}

+

+ {view === 'export' ? t('export_title') : t('title')} +

- {t('subtitle')} + {view === 'export' ? t('export_subtitle') : t('subtitle')}

@@ -2014,48 +2016,55 @@ export default function ImportPage() { -
- {/* SIE-export */} - - - - - {t('export_sie_title')} - - {t('export_sie_description')} - - - - - {!exportPeriodId && ( -

{t('export_sie_no_period')}

- )} -
-
+
+ {/* SIE-export */} +
+
+ {/* Identity */} +
+
+ +
+
+

{t('export_sie_title')}

+

+ {t('export_sie_description')} +

+
+
- {/* Molnsynkronisering (Google Drive) */} - {hasCloudBackup && ( -
- + {/* Controls */} +
+ + +
+
- )} -
+ + {/* Molnsynkronisering (Google Drive) */} + {hasCloudBackup && ( +
+ +
+ )} +
diff --git a/app/(dashboard)/settings/invoicing/page.tsx b/app/(dashboard)/settings/invoicing/page.tsx index 13db31ff..425b9204 100644 --- a/app/(dashboard)/settings/invoicing/page.tsx +++ b/app/(dashboard)/settings/invoicing/page.tsx @@ -50,25 +50,21 @@ export default function InvoicingSettingsPage() { } return ( -
-
- - -
- -
-
- - {/* PDF settings — saves individually via toggle switches */} -
- -
+
+
+
-
-
- + + +
+
+
+ + {/* PDF settings — saves individually via toggle switches */} +
+
) diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index ae109be9..e22b7f6a 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -67,6 +67,7 @@ export const PATCH = withRouteContext( if (body.country !== undefined) updateData.country = body.country if (body.org_number !== undefined) updateData.org_number = body.org_number if (body.vat_number !== undefined) updateData.vat_number = body.vat_number + if (body.language !== undefined) updateData.language = body.language if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms if (body.notes !== undefined) updateData.notes = body.notes diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 48f418a3..4342c8ad 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -58,6 +58,7 @@ export const POST = withRouteContext( country: body.country || 'Sweden', org_number: body.org_number, vat_number: body.vat_number, + language: body.language || 'sv', default_payment_terms: body.default_payment_terms || 30, notes: body.notes, }) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 466f4465..dff24cf6 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -90,7 +90,7 @@ export async function POST(request: Request) { id: 'preview', user_id: user.id, customer_id, - invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : 'FÖRHANDSGRANSKNING', + invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : null, invoice_date: invoice_date || new Date().toISOString().split('T')[0], due_date: due_date || new Date().toISOString().split('T')[0], delivery_date: delivery_date || null, diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 62b8a5ee..6baf8a0c 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -303,6 +303,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string 'country', 'org_number', 'vat_number', + 'language', 'default_payment_terms', 'notes', 'archived_at', diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 0b269abb..f417dcb4 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -396,6 +396,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( vat_number: body.vat_number ?? null, vat_number_validated: vatValidated, vat_number_validated_at: vatValidatedAt, + language: body.language ?? 'sv', default_payment_terms: body.default_payment_terms ?? 30, notes: body.notes ?? null, }) diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 6a059da2..f33a1de2 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -52,6 +52,7 @@ export default function CustomerForm({ .regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid')) .optional() .or(z.literal('')), + language: z.enum(['sv', 'en']).optional(), default_payment_terms: z.number().min(1).optional(), notes: z.string().optional(), }), [t]) @@ -78,6 +79,7 @@ export default function CustomerForm({ org_number: initialData?.org_number || '', vat_number: initialData?.vat_number || '', personal_number: initialData?.personal_number || '', + language: initialData?.language || 'sv', default_payment_terms: initialData?.default_payment_terms || 30, notes: initialData?.notes || '', }, @@ -318,6 +320,27 @@ export default function CustomerForm({ />
+ {/* Invoice language */} +
+ + ( + + )} + /> +

{t('language_hint')}

+
+ {/* Notes */}
diff --git a/components/settings/InvoicePreviewCard.tsx b/components/settings/InvoicePreviewCard.tsx index beaa97f7..9d22cb24 100644 --- a/components/settings/InvoicePreviewCard.tsx +++ b/components/settings/InvoicePreviewCard.tsx @@ -1,8 +1,16 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Eye } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' import { Skeleton } from '@/components/ui/skeleton' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' @@ -13,47 +21,44 @@ interface InvoicePreviewCardProps { settings: CompanySettings } -/** - * Live invoice PDF preview for the invoicing settings page. - * - * Re-fetches the preview PDF whenever the persisted `settings` change - * (debounced 500ms so rapid toggles don't hammer the endpoint). Reads - * the first customer in the company as a dummy recipient — the preview - * endpoint requires a real `customer_id` and `items` payload. - */ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) { const t = useTranslations('settings_invoicing_preview') const locale = useLocale() as ErrorLocale const { company } = useCompany() + const [open, setOpen] = useState(false) const [blobUrl, setBlobUrl] = useState(null) - const [isLoading, setIsLoading] = useState(true) + const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [noCustomers, setNoCustomers] = useState(false) const currentUrlRef = useRef(null) - // Resolve translated sample-line description once per render so the - // effect dependency stays referentially stable across renders. const sampleItemDescription = t('sample_item_description') - // Debounced refresh whenever `settings` (identity) changes. useEffect(() => { - if (!company?.id) return + if (!open || !company?.id) return + const companyId = company.id let cancelled = false const controller = new AbortController() - const timer = setTimeout(async () => { + // Debounce so rapid settings toggles (PDF print options on the invoicing + // page) don't burst-fire requests at /api/invoices/preview-pdf while the + // dialog is open. AbortController still cancels any in-flight fetch. + const timer = setTimeout(() => { + run() + }, 500) + + async function run() { setIsLoading(true) setError(null) setNoCustomers(false) try { - // Pick any customer for the company — preview endpoint requires one. const supabase = createClient() const { data: customer, error: customerError } = await supabase .from('customers') .select('id') - .eq('company_id', company.id) + .eq('company_id', companyId) .limit(1) .maybeSingle() @@ -95,9 +100,6 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) { if (cancelled) return const url = URL.createObjectURL(blob) - - // Revoke the previous blob before swapping in the new one so we - // never leak object URLs. if (currentUrlRef.current) URL.revokeObjectURL(currentUrlRef.current) currentUrlRef.current = url setBlobUrl(url) @@ -108,16 +110,15 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) { setError(getErrorMessage(err, { locale, context: 'invoice' })) setIsLoading(false) } - }, 500) + } return () => { cancelled = true - controller.abort() clearTimeout(timer) + controller.abort() } - }, [settings, company?.id, sampleItemDescription, locale]) + }, [open, settings, company?.id, sampleItemDescription, locale]) - // Final cleanup: revoke the in-flight blob URL when the component unmounts. useEffect(() => { return () => { if (currentUrlRef.current) { @@ -128,26 +129,33 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) { }, []) return ( - - - {t('title')} - - + + + + + + + {t('title')} + + {isLoading && (
- +

{t('loading')}

)} {!isLoading && noCustomers && ( -
+

{t('no_customers')}

)} {!isLoading && error && ( -
+

{t('error')}: {error}

)} @@ -156,10 +164,10 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {