feat: add language preference for customers to support invoice locali… (#561)
* 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
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -1804,9 +1804,11 @@ export default function ImportPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{view === 'export' ? t('export_title') : t('title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('subtitle')}
|
||||
{view === 'export' ? t('export_subtitle') : t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2014,48 +2016,55 @@ export default function ImportPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="mt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 items-start">
|
||||
{/* SIE-export */}
|
||||
<Card id="sie-export" className="scroll-mt-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
{t('export_sie_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('export_sie_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FiscalYearSelector
|
||||
value={exportPeriodId}
|
||||
onChange={setExportPeriodId}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
label={t('export_sie_period_label')}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (exportPeriodId) {
|
||||
window.open(`/api/reports/sie-export?period_id=${exportPeriodId}`, '_blank')
|
||||
}
|
||||
}}
|
||||
disabled={!exportPeriodId || isSandbox}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('export_sie_button')}
|
||||
</Button>
|
||||
{!exportPeriodId && (
|
||||
<p className="text-xs text-muted-foreground">{t('export_sie_no_period')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
{/* SIE-export */}
|
||||
<div id="sie-export" className="scroll-mt-24 rounded-lg border border-border bg-card p-6">
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('export_sie_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('export_sie_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Molnsynkronisering (Google Drive) */}
|
||||
{hasCloudBackup && (
|
||||
<div id="cloud-backup" className="scroll-mt-24">
|
||||
<CloudBackupCard />
|
||||
{/* Controls */}
|
||||
<div className="space-y-4">
|
||||
<FiscalYearSelector
|
||||
value={exportPeriodId}
|
||||
onChange={setExportPeriodId}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
label={t('export_sie_period_label')}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (exportPeriodId) {
|
||||
window.open(`/api/reports/sie-export?period_id=${exportPeriodId}`, '_blank')
|
||||
}
|
||||
}}
|
||||
disabled={!exportPeriodId || isSandbox}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('export_sie_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Molnsynkronisering (Google Drive) */}
|
||||
{hasCloudBackup && (
|
||||
<div id="cloud-backup" className="scroll-mt-24">
|
||||
<CloudBackupCard />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
|
||||
@@ -50,25 +50,21 @@ export default function InvoicingSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<BankDetailsForm settings={settings} />
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* PDF settings — saves individually via toggle switches */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-end">
|
||||
<InvoicePreviewCard settings={settings} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:sticky lg:top-8">
|
||||
<InvoicePreviewCard settings={settings} />
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<BankDetailsForm settings={settings} />
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* PDF settings — saves individually via toggle switches */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invoice language */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('language_label')}</Label>
|
||||
<Controller
|
||||
name="language"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value ?? 'sv'} onValueChange={(v) => { if (v) field.onChange(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sv">{t('language_sv')}</SelectItem>
|
||||
<SelectItem value="en">{t('language_en')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('language_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">{t('notes_label')}</Label>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [noCustomers, setNoCustomers] = useState(false)
|
||||
const currentUrlRef = useRef<string | null>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t('preview_button')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2" aria-live="polite" aria-busy="true">
|
||||
<Skeleton className="h-[600px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[70vh] w-full rounded-lg" />
|
||||
<p className="text-xs text-muted-foreground">{t('loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && noCustomers && (
|
||||
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('no_customers')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
|
||||
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
|
||||
<p className="text-sm text-destructive">{t('error')}: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,10 +164,10 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
|
||||
<iframe
|
||||
src={blobUrl}
|
||||
title={t('iframe_title')}
|
||||
className="w-full h-[600px] rounded-lg border border-border"
|
||||
className="w-full h-[70vh] rounded-lg border border-border"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -141,115 +140,125 @@ export default function CloudBackupCard() {
|
||||
}, [loadStatus, toast])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="h-4 w-4 text-muted-foreground" />
|
||||
Google Drive
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar…</p>
|
||||
) : status?.connected ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Ansluten som <span className="font-medium">{status.account_email}</span>
|
||||
</p>
|
||||
{status.connected_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kopplat {formatDate(status.connected_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.last_sync ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
|
||||
<p>
|
||||
Senaste synk: <span className="font-medium">{status.last_sync.file_name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(status.last_sync.at)} · {formatMb(status.last_sync.file_size_bytes)}
|
||||
</p>
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Öppna i Drive
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen synk än — kör “Synka nu” för att ladda upp första arkivet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ScheduleSection
|
||||
schedule={status.schedule}
|
||||
onUpdated={loadStatus}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Synkar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Synka nu
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar bort…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unplug className="mr-2 h-4 w-4" />
|
||||
Koppla bort
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen
|
||||
Drive. gnubok får bara tillgång till filer som appen själv skapar (scope
|
||||
<span className="font-mono text-xs"> drive.file</span>).
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<Cloud className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">Google Drive</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
Säkerhetskopia till din egen Drive.
|
||||
</p>
|
||||
<Button onClick={handleConnect} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Omdirigerar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
Koppla Google Drive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar…</p>
|
||||
) : status?.connected ? (
|
||||
<>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">Konto</dt>
|
||||
<dd className="min-w-0 truncate font-medium">{status.account_email}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">Senaste synk</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
{status.last_sync ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
|
||||
>
|
||||
{formatDate(status.last_sync.at)}
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatMb(status.last_sync.file_size_bytes)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Aldrig</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<ScheduleSection
|
||||
schedule={status.schedule}
|
||||
onUpdated={loadStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<Button onClick={handleSync} disabled={isSyncing} className="w-full sm:w-auto">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Synkar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Synka nu
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar bort…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unplug className="mr-2 h-4 w-4" />
|
||||
Koppla bort
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen Drive.
|
||||
gnubok får bara tillgång till filer som appen själv skapar (scope{' '}
|
||||
<span className="font-mono text-xs">drive.file</span>).
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Omdirigerar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
Koppla Google Drive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,14 +352,14 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background p-3 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
|
||||
Automatisk synkronisering
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kör en daglig säkerhetsbackup till din Drive.
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Daglig säkerhetsbackup till din Drive.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -364,7 +373,7 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
|
||||
Tid (din lokala tid)
|
||||
Tid (lokal)
|
||||
</Label>
|
||||
<select
|
||||
id="auto-sync-hour"
|
||||
@@ -387,7 +396,7 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Senaste automatiska synk: {formatDate(schedule.last_auto_sync_at)}{' '}
|
||||
{schedule.last_auto_sync_status === 'success' ? (
|
||||
<span className="text-emerald-600">· lyckades</span>
|
||||
<span className="text-success">· lyckades</span>
|
||||
) : schedule.last_auto_sync_status === 'error' ? (
|
||||
<span className="text-destructive">
|
||||
· misslyckades
|
||||
|
||||
@@ -266,6 +266,7 @@ export const CreateCustomerSchema = z.object({
|
||||
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
|
||||
.optional()
|
||||
.nullable(),
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject,
|
||||
} from '../invoice-templates'
|
||||
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
|
||||
|
||||
const company = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
bank_name: 'SEB',
|
||||
clearing_number: '5000',
|
||||
account_number: '1234567',
|
||||
iban: 'SE45 5000 0000 0583 9825 7466',
|
||||
bic: 'ESSESESS',
|
||||
org_number: '556677-8899',
|
||||
vat_number: 'SE556677889901',
|
||||
f_skatt: true,
|
||||
})
|
||||
|
||||
const invoice = makeInvoice({
|
||||
invoice_number: '1042',
|
||||
invoice_date: '2026-05-22',
|
||||
due_date: '2026-06-21',
|
||||
currency: 'SEK',
|
||||
total: 12500,
|
||||
})
|
||||
|
||||
describe('invoice email templates', () => {
|
||||
describe('Swedish customer (default)', () => {
|
||||
const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
|
||||
const data = { invoice, customer, company }
|
||||
|
||||
it('uses Swedish chrome in HTML', () => {
|
||||
const html = generateInvoiceEmailHtml(data)
|
||||
expect(html).toContain('<html lang="sv">')
|
||||
expect(html).toContain('Faktura från Acme AB')
|
||||
expect(html).toContain('Att betala:')
|
||||
expect(html).toContain('Betalningsinformation')
|
||||
expect(html).toContain('Hej Erik,')
|
||||
expect(html).toContain('Med vänliga hälsningar,')
|
||||
expect(html).toContain('Innehar F-skattsedel')
|
||||
})
|
||||
|
||||
it('renders the total with explicit SEK code, not "kr"', () => {
|
||||
const html = generateInvoiceEmailHtml(data)
|
||||
// sv-SE digit grouping: "12 500,00 SEK"
|
||||
expect(html).toMatch(/12[\s\u00a0]500,00 SEK/)
|
||||
expect(html).not.toContain('kr')
|
||||
})
|
||||
|
||||
it('uses Swedish subject', () => {
|
||||
expect(generateInvoiceEmailSubject(data)).toBe('Faktura 1042 från Acme AB')
|
||||
})
|
||||
|
||||
it('uses Swedish plain text body', () => {
|
||||
const text = generateInvoiceEmailText(data)
|
||||
expect(text).toContain('Hej Erik,')
|
||||
expect(text).toContain('Att betala:')
|
||||
expect(text).toContain('Förfallodatum:')
|
||||
expect(text).not.toContain('kr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('English customer', () => {
|
||||
const customer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
|
||||
const data = { invoice, customer, company }
|
||||
|
||||
it('uses English chrome in HTML', () => {
|
||||
const html = generateInvoiceEmailHtml(data)
|
||||
expect(html).toContain('<html lang="en">')
|
||||
expect(html).toContain('Invoice from Acme AB')
|
||||
expect(html).toContain('Total due:')
|
||||
expect(html).toContain('Payment information')
|
||||
expect(html).toContain('Hi Jane,')
|
||||
expect(html).toContain('Kind regards,')
|
||||
// F-skatt is statutory and stays Swedish in both locales.
|
||||
expect(html).toContain('Innehar F-skattsedel')
|
||||
})
|
||||
|
||||
it('renders the total with explicit SEK code in English digit grouping', () => {
|
||||
const html = generateInvoiceEmailHtml(data)
|
||||
// en-US digit grouping: "12,500.00 SEK"
|
||||
expect(html).toContain('12,500.00 SEK')
|
||||
expect(html).not.toContain('kr')
|
||||
})
|
||||
|
||||
it('uses English subject', () => {
|
||||
expect(generateInvoiceEmailSubject(data)).toBe('Invoice 1042 from Acme AB')
|
||||
})
|
||||
|
||||
it('uses English plain text body', () => {
|
||||
const text = generateInvoiceEmailText(data)
|
||||
expect(text).toContain('Hi Jane,')
|
||||
expect(text).toContain('Total due:')
|
||||
expect(text).toContain('Due date:')
|
||||
expect(text).toContain('Thank you for your business')
|
||||
expect(text).not.toContain('kr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credit note', () => {
|
||||
const creditInvoice = makeInvoice({
|
||||
invoice_number: '1043',
|
||||
invoice_date: '2026-05-22',
|
||||
due_date: '2026-05-22',
|
||||
currency: 'SEK',
|
||||
total: -5000,
|
||||
credited_invoice_id: 'inv-orig',
|
||||
})
|
||||
|
||||
it('translates the credit-note body in English', () => {
|
||||
const customer = makeCustomer({ language: 'en' })
|
||||
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company })
|
||||
expect(html).toContain('Credit note')
|
||||
expect(html).toContain('Attached you will find a credit note')
|
||||
})
|
||||
|
||||
it('keeps the credit-note body in Swedish for sv customers', () => {
|
||||
const customer = makeCustomer({ language: 'sv' })
|
||||
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company })
|
||||
expect(html).toContain('Kreditfaktura')
|
||||
expect(html).toContain('Bifogat hittar du en kreditfaktura')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-SEK currency', () => {
|
||||
const eurInvoice = makeInvoice({
|
||||
invoice_number: '1044',
|
||||
currency: 'EUR',
|
||||
total: 1000,
|
||||
})
|
||||
|
||||
it('writes EUR code with the chosen locale grouping', () => {
|
||||
const enCustomer = makeCustomer({ language: 'en' })
|
||||
const enHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: enCustomer, company })
|
||||
expect(enHtml).toContain('1,000.00 EUR')
|
||||
|
||||
const svCustomer = makeCustomer({ language: 'sv' })
|
||||
const svHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: svCustomer, company })
|
||||
expect(svHtml).toMatch(/1[\s\u00a0]000,00 EUR/)
|
||||
})
|
||||
})
|
||||
})
|
||||
+145
-59
@@ -1,12 +1,97 @@
|
||||
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
|
||||
function getDocumentLabel(invoice: Invoice): string {
|
||||
if (invoice.credited_invoice_id) return 'Kreditfaktura'
|
||||
type EmailLang = 'sv' | 'en'
|
||||
|
||||
// Customer-facing labels. Statutory chapter references stay intact in both
|
||||
// locales. lib/utils.ts formatCurrency() keeps the Swedish "kr" symbol for
|
||||
// in-app financial UI per the accounting standard; here we want the ISO code
|
||||
// so a non-Swedish recipient understands the unit.
|
||||
const LABELS = {
|
||||
sv: {
|
||||
docInvoice: 'Faktura',
|
||||
docCreditNote: 'Kreditfaktura',
|
||||
docProforma: 'Proformafaktura',
|
||||
docDeliveryNote: 'Följesedel',
|
||||
htmlLang: 'sv',
|
||||
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
|
||||
documentNumber: (doc: string) => `${doc}nummer:`,
|
||||
documentDate: (doc: string) => `${doc}datum:`,
|
||||
dueDate: 'Förfallodatum:',
|
||||
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
|
||||
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
|
||||
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
|
||||
toPay: 'Att betala:',
|
||||
paymentHeading: 'Betalningsinformation',
|
||||
bank: 'Bank:',
|
||||
account: 'Kontonummer:',
|
||||
iban: 'IBAN:',
|
||||
bic: 'BIC/SWIFT:',
|
||||
message: 'Meddelande:',
|
||||
questions: 'Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.',
|
||||
sincerely: 'Med vänliga hälsningar,',
|
||||
orgNo: 'Org.nr:',
|
||||
vat: 'VAT:',
|
||||
fSkatt: 'Innehar F-skattsedel',
|
||||
documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`,
|
||||
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`,
|
||||
},
|
||||
en: {
|
||||
docInvoice: 'Invoice',
|
||||
docCreditNote: 'Credit note',
|
||||
docProforma: 'Proforma invoice',
|
||||
docDeliveryNote: 'Delivery note',
|
||||
htmlLang: 'en',
|
||||
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
|
||||
documentNumber: (doc: string) => `${doc} number:`,
|
||||
documentDate: (doc: string) => `${doc} date:`,
|
||||
dueDate: 'Due date:',
|
||||
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
|
||||
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
|
||||
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
|
||||
toPay: 'Total due:',
|
||||
paymentHeading: 'Payment information',
|
||||
bank: 'Bank:',
|
||||
account: 'Account number:',
|
||||
iban: 'IBAN:',
|
||||
bic: 'BIC/SWIFT:',
|
||||
message: 'Reference:',
|
||||
questions: 'Questions about the invoice? Reply directly to this email and we will help you.',
|
||||
sincerely: 'Kind regards,',
|
||||
orgNo: 'Reg. no.:',
|
||||
vat: 'VAT:',
|
||||
// Statutory Swedish phrase — kept verbatim in both locales. F-skatt is a
|
||||
// Swedish tax-authority designation; translating it has no legal standing.
|
||||
fSkatt: 'Innehar F-skattsedel',
|
||||
documentSummary: (doc: string) => `${doc} summary:`,
|
||||
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`,
|
||||
},
|
||||
} as const
|
||||
|
||||
function resolveLang(customer: Customer): EmailLang {
|
||||
return customer.language === 'en' ? 'en' : 'sv'
|
||||
}
|
||||
|
||||
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
|
||||
const L = LABELS[lang]
|
||||
if (invoice.credited_invoice_id) return L.docCreditNote
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
if (docType === 'proforma') return 'Proformafaktura'
|
||||
if (docType === 'delivery_note') return 'Följesedel'
|
||||
return 'Faktura'
|
||||
if (docType === 'proforma') return L.docProforma
|
||||
if (docType === 'delivery_note') return L.docDeliveryNote
|
||||
return L.docInvoice
|
||||
}
|
||||
|
||||
// Currency for the customer-facing total — explicit ISO code so a non-Swedish
|
||||
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
|
||||
// English locale for digit grouping when the email is in English so the comma
|
||||
// thousands separator matches reader expectation.
|
||||
function formatCurrencyForCustomer(amount: number, currency: string, lang: EmailLang): string {
|
||||
const formatted = new Intl.NumberFormat(lang === 'en' ? 'en-US' : 'sv-SE', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
return `${formatted} ${currency}`
|
||||
}
|
||||
|
||||
export interface InvoiceEmailData {
|
||||
@@ -21,16 +106,19 @@ export interface InvoiceEmailData {
|
||||
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
const { invoice, customer, company } = data
|
||||
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
const lang = resolveLang(customer)
|
||||
const L = LABELS[lang]
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<html lang="${L.htmlLang}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -41,23 +129,20 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<!-- Header -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: #111;">
|
||||
${documentType} från ${getCompanyPrimaryName(company)}
|
||||
${L.documentFrom(documentType, getCompanyPrimaryName(company))}
|
||||
</h1>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${documentType}nummer: ${invoice.invoice_number}
|
||||
${L.documentNumber(documentType)} ${invoice.invoice_number}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p style="margin: 0 0 15px 0;">
|
||||
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
|
||||
${L.greeting(firstName)}
|
||||
</p>
|
||||
<p style="margin: 0;">
|
||||
${isCreditNote
|
||||
? `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.`
|
||||
: `Tack för ditt förtroende! Bifogat hittar du din faktura.`
|
||||
}
|
||||
${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,15 +150,15 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}nummer:</td>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(documentType)}</td>
|
||||
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}datum:</td>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(documentType)}</td>
|
||||
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
|
||||
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
|
||||
${formatDate(invoice.due_date)}
|
||||
</td>
|
||||
@@ -82,9 +167,9 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
|
||||
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
|
||||
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : '#111'};">
|
||||
${formatCurrency(invoice.total, invoice.currency)}
|
||||
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -94,35 +179,35 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
${!hidePayment ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
|
||||
Betalningsinformation
|
||||
${L.paymentHeading}
|
||||
</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${company.bank_name ? `
|
||||
<tr>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">Bank:</td>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">${L.bank}</td>
|
||||
<td style="padding: 6px 0;">${company.bank_name}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${company.clearing_number && company.account_number ? `
|
||||
<tr>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">Kontonummer:</td>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.account}</td>
|
||||
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${company.iban ? `
|
||||
<tr>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">IBAN:</td>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.iban}</td>
|
||||
<td style="padding: 6px 0;">${company.iban}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${company.bic ? `
|
||||
<tr>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">BIC/SWIFT:</td>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.bic}</td>
|
||||
<td style="padding: 6px 0;">${company.bic}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
<tr>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
|
||||
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.message}</td>
|
||||
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -132,17 +217,17 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<!-- Footer -->
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
|
||||
Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.
|
||||
${L.questions}
|
||||
</p>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
Med vänliga hälsningar,<br>
|
||||
${L.sincerely}<br>
|
||||
<strong>${getCompanyPrimaryName(company)}</strong>
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
||||
Org.nr: ${company.org_number}
|
||||
${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}
|
||||
${company.f_skatt ? ' | Innehar F-skattsedel' : ''}
|
||||
${L.orgNo} ${company.org_number}
|
||||
${company.vat_number ? ` | ${L.vat} ${company.vat_number}` : ''}
|
||||
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
|
||||
</p>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -158,51 +243,50 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
const { invoice, customer, company } = data
|
||||
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
const lang = resolveLang(customer)
|
||||
const L = LABELS[lang]
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
||||
|
||||
let text = `${documentType} från ${getCompanyPrimaryName(company)}\n`
|
||||
text += `${documentType}nummer: ${invoice.invoice_number}\n\n`
|
||||
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
|
||||
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
|
||||
|
||||
text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n`
|
||||
text += `${L.greeting(firstName)}\n\n`
|
||||
|
||||
if (isCreditNote) {
|
||||
text += `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.\n\n`
|
||||
} else {
|
||||
text += `Tack för ditt förtroende! Bifogat hittar du din faktura.\n\n`
|
||||
}
|
||||
text += `${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}\n\n`
|
||||
|
||||
text += `${documentType}sammanfattning:\n`
|
||||
text += `${L.documentSummary(documentType)}\n`
|
||||
text += `---\n`
|
||||
text += `${documentType}nummer: ${invoice.invoice_number}\n`
|
||||
text += `${documentType}datum: ${formatDate(invoice.invoice_date)}\n`
|
||||
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
|
||||
text += `Att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
|
||||
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
|
||||
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
|
||||
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
|
||||
text += `${L.toPay} ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}\n`
|
||||
text += `---\n\n`
|
||||
|
||||
if (!hidePayment) {
|
||||
text += `Betalningsinformation:\n`
|
||||
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
|
||||
text += `${L.paymentHeading}:\n`
|
||||
if (company.bank_name) text += `${L.bank} ${company.bank_name}\n`
|
||||
if (company.clearing_number && company.account_number) {
|
||||
text += `Kontonummer: ${company.clearing_number}-${company.account_number}\n`
|
||||
text += `${L.account} ${company.clearing_number}-${company.account_number}\n`
|
||||
}
|
||||
if (company.iban) text += `IBAN: ${company.iban}\n`
|
||||
if (company.bic) text += `BIC/SWIFT: ${company.bic}\n`
|
||||
text += `Meddelande: ${invoice.invoice_number}\n\n`
|
||||
if (company.iban) text += `${L.iban} ${company.iban}\n`
|
||||
if (company.bic) text += `${L.bic} ${company.bic}\n`
|
||||
text += `${L.message} ${invoice.invoice_number}\n\n`
|
||||
}
|
||||
|
||||
text += `Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.\n\n`
|
||||
text += `Med vänliga hälsningar,\n`
|
||||
text += `${L.questions}\n\n`
|
||||
text += `${L.sincerely}\n`
|
||||
text += `${getCompanyDisplayName(company)}\n`
|
||||
|
||||
if (company.org_number) {
|
||||
text += `\nOrg.nr: ${company.org_number}`
|
||||
if (company.vat_number) text += ` | VAT: ${company.vat_number}`
|
||||
if (company.f_skatt) text += ` | Innehar F-skattsedel`
|
||||
text += `\n${L.orgNo} ${company.org_number}`
|
||||
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
|
||||
if (company.f_skatt) text += ` | ${L.fSkatt}`
|
||||
text += `\n`
|
||||
}
|
||||
|
||||
@@ -213,8 +297,10 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
* Generate email subject for an invoice
|
||||
*/
|
||||
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
|
||||
const { invoice, company } = data
|
||||
const documentType = getDocumentLabel(invoice)
|
||||
const { invoice, customer, company } = data
|
||||
const lang = resolveLang(customer)
|
||||
const L = LABELS[lang]
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
|
||||
return `${documentType} ${invoice.invoice_number} från ${getCompanyPrimaryName(company)}`
|
||||
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
|
||||
}
|
||||
|
||||
+205
-71
@@ -10,6 +10,131 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy
|
||||
import { generateOcrReference } from '@/lib/bankgiro/luhn'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
|
||||
type PdfLang = 'sv' | 'en'
|
||||
|
||||
// Customer-facing labels. Statutory chapter references (ML 17 kap 24§, ML 3 kap.)
|
||||
// stay intact in both locales — they identify the law, not the language.
|
||||
const LABELS = {
|
||||
sv: {
|
||||
// Document titles
|
||||
titleInvoice: 'FAKTURA',
|
||||
titleCreditNote: 'KREDITFAKTURA',
|
||||
titleProforma: 'PROFORMAFAKTURA',
|
||||
titleDeliveryNote: 'FÖLJESEDEL',
|
||||
titlePreview: 'FÖRHANDSGRANSKNING',
|
||||
// Status banners
|
||||
cancelledTitle: 'MAKULERAD – inte en giltig faktura',
|
||||
cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`,
|
||||
cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.',
|
||||
draftTitle: 'UTKAST – inte en giltig faktura',
|
||||
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
|
||||
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
|
||||
// Credit note reference
|
||||
creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`,
|
||||
// Sections
|
||||
invoiceInfoHeading: 'Fakturainformation',
|
||||
billedToHeading: 'Faktureras till',
|
||||
itemsHeading: 'Specifikation',
|
||||
// Invoice details
|
||||
invoiceDate: 'Fakturadatum:',
|
||||
dueDate: 'Förfallodatum:',
|
||||
deliveryDate: 'Leveransdatum:',
|
||||
yourReference: 'Er referens:',
|
||||
ourReference: 'Vår referens:',
|
||||
// Customer box
|
||||
orgNo: 'Org.nr:',
|
||||
vat: 'VAT:',
|
||||
// Table columns
|
||||
colDescription: 'Beskrivning',
|
||||
colQty: 'Antal',
|
||||
colUnit: 'Enhet',
|
||||
colUnitPrice: 'à-pris',
|
||||
colVat: 'Moms',
|
||||
colTotal: 'Summa',
|
||||
// Totals
|
||||
subtotal: 'Delsumma:',
|
||||
net: (rate: number) => `Netto ${rate}%:`,
|
||||
vatRow: (rate: number) => `Moms ${rate}%:`,
|
||||
rounding: 'Öresavrundning:',
|
||||
toCredit: 'Att kreditera:',
|
||||
toPay: 'Att betala:',
|
||||
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
|
||||
totalInSek: 'Totalt i SEK:',
|
||||
// Proforma / exempt
|
||||
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
|
||||
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
|
||||
// Payment
|
||||
paymentHeading: 'Betalningsinformation',
|
||||
bank: 'Bank:',
|
||||
account: 'Kontonummer:',
|
||||
bankgiro: 'Bankgiro:',
|
||||
plusgiro: 'Plusgiro:',
|
||||
swish: 'Swish:',
|
||||
iban: 'IBAN:',
|
||||
bic: 'BIC/SWIFT:',
|
||||
ocr: 'OCR/Referens:',
|
||||
// Footer
|
||||
orgNoLong: 'Org.nr:',
|
||||
vatRegNo: 'Momsreg.nr:',
|
||||
fSkatt: 'Godkänd för F-skatt',
|
||||
},
|
||||
en: {
|
||||
titleInvoice: 'INVOICE',
|
||||
titleCreditNote: 'CREDIT NOTE',
|
||||
titleProforma: 'PROFORMA INVOICE',
|
||||
titleDeliveryNote: 'DELIVERY NOTE',
|
||||
titlePreview: 'PREVIEW',
|
||||
cancelledTitle: 'VOID — not a valid invoice',
|
||||
cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§ — Swedish VAT Act), but this document is not a valid invoice.`,
|
||||
cancelledNoNumber: 'This draft has been voided and is not a valid invoice.',
|
||||
draftTitle: 'DRAFT — not a valid invoice',
|
||||
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
|
||||
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
|
||||
creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`,
|
||||
invoiceInfoHeading: 'Invoice information',
|
||||
billedToHeading: 'Billed to',
|
||||
itemsHeading: 'Items',
|
||||
invoiceDate: 'Invoice date:',
|
||||
dueDate: 'Due date:',
|
||||
deliveryDate: 'Delivery date:',
|
||||
yourReference: 'Your reference:',
|
||||
ourReference: 'Our reference:',
|
||||
orgNo: 'Reg. no.:',
|
||||
vat: 'VAT:',
|
||||
colDescription: 'Description',
|
||||
colQty: 'Qty',
|
||||
colUnit: 'Unit',
|
||||
colUnitPrice: 'Unit price',
|
||||
colVat: 'VAT',
|
||||
colTotal: 'Amount',
|
||||
subtotal: 'Subtotal:',
|
||||
net: (rate: number) => `Net ${rate}%:`,
|
||||
vatRow: (rate: number) => `VAT ${rate}%:`,
|
||||
rounding: 'Rounding:',
|
||||
toCredit: 'To credit:',
|
||||
toPay: 'Total due:',
|
||||
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
|
||||
totalInSek: 'Total in SEK:',
|
||||
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
|
||||
exemptNotice: 'Exempt from VAT (ML 3 kap. — Swedish VAT Act).',
|
||||
paymentHeading: 'Payment information',
|
||||
bank: 'Bank:',
|
||||
account: 'Account number:',
|
||||
bankgiro: 'Bankgiro:',
|
||||
plusgiro: 'Plusgiro:',
|
||||
swish: 'Swish:',
|
||||
iban: 'IBAN:',
|
||||
bic: 'BIC/SWIFT:',
|
||||
ocr: 'Reference:',
|
||||
orgNoLong: 'Reg. no.:',
|
||||
vatRegNo: 'VAT reg. no.:',
|
||||
// Statutory Swedish phrase — kept verbatim in both locales. Peppol SE-R-005
|
||||
// and Skatteverket's F-skatt notation expect "Godkänd för F-skatt"; an
|
||||
// English translation has no legal standing.
|
||||
fSkatt: 'Godkänd för F-skatt',
|
||||
},
|
||||
} as const
|
||||
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
@@ -278,19 +403,24 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
// Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK"
|
||||
// instead of the Swedish symbol "kr". Decimal style + appended code works for any
|
||||
// currency (SEK/EUR/USD) and avoids Intl's locale-specific symbol quirks.
|
||||
function formatCurrency(amount: number, currency: string = 'SEK', language: PdfLang = 'sv'): string {
|
||||
const formatted = new Intl.NumberFormat(language === 'en' ? 'en-US' : 'sv-SE', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
return `${formatted} ${currency}`
|
||||
}
|
||||
|
||||
// Format date
|
||||
// Format date as ISO yyyy-MM-dd in both locales — universally unambiguous and
|
||||
// matches the project's formatDate() convention (lib/utils.ts).
|
||||
// Input is already a YYYY-MM-DD string from the DB, so slice avoids the
|
||||
// new Date() + local-getter timezone hazard.
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString('sv-SE')
|
||||
return date.slice(0, 10)
|
||||
}
|
||||
|
||||
// Format org number
|
||||
@@ -302,12 +432,13 @@ function formatOrgNumber(orgNumber: string): string {
|
||||
return orgNumber
|
||||
}
|
||||
|
||||
function getDocumentTitle(invoice: Invoice): string {
|
||||
if (invoice.credited_invoice_id) return 'KREDITFAKTURA'
|
||||
function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
|
||||
const L = LABELS[lang]
|
||||
if (invoice.credited_invoice_id) return L.titleCreditNote
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
if (docType === 'proforma') return 'PROFORMAFAKTURA'
|
||||
if (docType === 'delivery_note') return 'FÖLJESEDEL'
|
||||
return 'FAKTURA'
|
||||
if (docType === 'proforma') return L.titleProforma
|
||||
if (docType === 'delivery_note') return L.titleDeliveryNote
|
||||
return L.titleInvoice
|
||||
}
|
||||
|
||||
interface InvoicePDFProps {
|
||||
@@ -317,9 +448,12 @@ interface InvoicePDFProps {
|
||||
company: CompanySettings
|
||||
originalInvoiceNumber?: string
|
||||
isPreview?: boolean
|
||||
language?: PdfLang
|
||||
}
|
||||
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview }: InvoicePDFProps) {
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language }: InvoicePDFProps) {
|
||||
const lang: PdfLang = language ?? customer.language ?? 'sv'
|
||||
const L = LABELS[lang]
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
|
||||
// Check if items have mixed VAT rates
|
||||
@@ -354,20 +488,20 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
of a non-cancelled invoice that somehow lacks a number. */}
|
||||
{invoice.status === 'cancelled' ? (
|
||||
<View style={styles.cancelledBanner}>
|
||||
<Text style={styles.cancelledBannerTitle}>MAKULERAD – inte en giltig faktura</Text>
|
||||
<Text style={styles.cancelledBannerTitle}>{L.cancelledTitle}</Text>
|
||||
<Text style={styles.cancelledBannerText}>
|
||||
{invoice.invoice_number
|
||||
? `Faktura ${invoice.invoice_number} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`
|
||||
: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.'}
|
||||
? L.cancelledWithNumber(invoice.invoice_number)
|
||||
: L.cancelledNoNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && (
|
||||
<View style={styles.draftBanner}>
|
||||
<Text style={styles.draftBannerTitle}>UTKAST – inte en giltig faktura</Text>
|
||||
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
|
||||
<Text style={styles.draftBannerText}>
|
||||
{invoice.invoice_number
|
||||
? 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.'
|
||||
: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.'}
|
||||
? L.draftWithNumber
|
||||
: L.draftNoNumber}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -385,9 +519,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
|
||||
{getDocumentTitle(invoice)}
|
||||
{getDocumentTitle(invoice, lang)}
|
||||
</Text>
|
||||
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? 'FÖRHANDSGRANSKNING'}</Text>
|
||||
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? L.titlePreview}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -395,7 +529,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{isCreditNote && originalInvoiceNumber && (
|
||||
<View style={styles.creditNoteBox}>
|
||||
<Text style={styles.creditNoteText}>
|
||||
Denna kreditfaktura avser och krediterar faktura nr {originalInvoiceNumber}
|
||||
{L.creditNoteRef(originalInvoiceNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -404,24 +538,24 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<View style={styles.twoColumn}>
|
||||
{/* Invoice details */}
|
||||
<View style={styles.column}>
|
||||
<Text style={styles.sectionTitle}>Fakturainformation</Text>
|
||||
<Text style={styles.sectionTitle}>{L.invoiceInfoHeading}</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Fakturadatum:</Text>
|
||||
<Text style={styles.label}>{L.invoiceDate}</Text>
|
||||
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Förfallodatum:</Text>
|
||||
<Text style={styles.label}>{L.dueDate}</Text>
|
||||
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
{invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Leveransdatum:</Text>
|
||||
<Text style={styles.label}>{L.deliveryDate}</Text>
|
||||
<Text style={styles.value}>{formatDate(invoice.delivery_date)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.your_reference && (
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={styles.label}>Er referens:</Text>
|
||||
<Text style={styles.label}>{L.yourReference}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
|
||||
{invoice.your_reference.split(',').map((ref, i) => (
|
||||
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
|
||||
@@ -433,7 +567,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)}
|
||||
{invoice.our_reference && (
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={styles.label}>Vår referens:</Text>
|
||||
<Text style={styles.label}>{L.ourReference}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
|
||||
{invoice.our_reference.split(',').map((ref, i) => (
|
||||
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
|
||||
@@ -447,7 +581,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
|
||||
{/* Customer */}
|
||||
<View style={styles.column}>
|
||||
<Text style={styles.sectionTitle}>Faktureras till</Text>
|
||||
<Text style={styles.sectionTitle}>{L.billedToHeading}</Text>
|
||||
<View style={styles.customerBox}>
|
||||
<Text style={styles.customerName}>{customer.name}</Text>
|
||||
{customer.address_line1 && <Text>{customer.address_line1}</Text>}
|
||||
@@ -459,30 +593,30 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text>{customer.country}</Text>
|
||||
)}
|
||||
{customer.org_number && (
|
||||
<Text style={{ marginTop: 6 }}>Org.nr: {customer.org_number}</Text>
|
||||
<Text style={{ marginTop: 6 }}>{L.orgNo} {customer.org_number}</Text>
|
||||
)}
|
||||
{customer.vat_number && <Text>VAT: {customer.vat_number}</Text>}
|
||||
{customer.vat_number && <Text>{L.vat} {customer.vat_number}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Items table */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Specifikation</Text>
|
||||
<Text style={styles.sectionTitle}>{L.itemsHeading}</Text>
|
||||
<View style={styles.table}>
|
||||
{/* Table header */}
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.colDescription, styles.tableHeaderText]}>Beskrivning</Text>
|
||||
<Text style={[styles.colQty, styles.tableHeaderText]}>Antal</Text>
|
||||
<Text style={[styles.colUnit, styles.tableHeaderText]}>Enhet</Text>
|
||||
<Text style={[styles.colDescription, styles.tableHeaderText]}>{L.colDescription}</Text>
|
||||
<Text style={[styles.colQty, styles.tableHeaderText]}>{L.colQty}</Text>
|
||||
<Text style={[styles.colUnit, styles.tableHeaderText]}>{L.colUnit}</Text>
|
||||
{!isDeliveryNote && (
|
||||
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
|
||||
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={[styles.colVat, styles.tableHeaderText]}>Moms</Text>
|
||||
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && (
|
||||
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
|
||||
<Text style={[styles.colTotal, styles.tableHeaderText]}>{L.colTotal}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -493,13 +627,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.colQty}>{item.quantity}</Text>
|
||||
<Text style={styles.colUnit}>{item.unit}</Text>
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
|
||||
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
|
||||
)}
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
|
||||
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
@@ -510,8 +644,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{!isDeliveryNote && (
|
||||
<View style={styles.totalsSection}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Delsumma:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
|
||||
<Text style={styles.totalLabel}>{L.subtotal}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency, lang)}</Text>
|
||||
</View>
|
||||
{vatByRate.size > 1 ? (
|
||||
Array.from(vatByRate.entries())
|
||||
@@ -519,21 +653,21 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
.map(([rate, group]) => (
|
||||
<View key={rate}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Netto {rate}%:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(group.base, invoice.currency)}</Text>
|
||||
<Text style={styles.totalLabel}>{L.net(rate)}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(group.base, invoice.currency, lang)}</Text>
|
||||
</View>
|
||||
{group.vat > 0 && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms {rate}%:</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency)}</Text>
|
||||
<Text style={styles.totalLabel}>{L.vatRow(rate)}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency, lang)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 0)}%):</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
|
||||
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(() => {
|
||||
@@ -542,13 +676,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<>
|
||||
{rounding.applies && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
|
||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>{L.rounding}</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK', lang)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency, lang)}</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
@@ -557,13 +691,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>Moms i SEK (kurs {invoice.exchange_rate}):</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.vat_amount_sek, 'SEK')}</Text>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.vatInSek(invoice.exchange_rate ?? '')}</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.vat_amount_sek, 'SEK', lang)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>Totalt i SEK:</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.totalInSek}</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK', lang)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -574,7 +708,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{isProforma && (
|
||||
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
|
||||
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
|
||||
Detta är en proformafaktura och utgör ingen betalningsanmodan.
|
||||
{L.proformaNotice}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -582,16 +716,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
|
||||
{!isCreditNote && !isProforma && !isDeliveryNote && (
|
||||
<View style={styles.paymentSection}>
|
||||
<Text style={styles.paymentTitle}>Betalningsinformation</Text>
|
||||
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
|
||||
{company.bank_name && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Bank:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.bank}</Text>
|
||||
<Text style={styles.paymentValue}>{company.bank_name}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(company.clearing_number || company.account_number) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Kontonummer:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.account}</Text>
|
||||
<Text style={styles.paymentValue}>
|
||||
{company.clearing_number}-{company.account_number}
|
||||
</Text>
|
||||
@@ -599,41 +733,41 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)}
|
||||
{company.bankgiro && (company.invoice_show_bankgiro ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Bankgiro:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.bankgiro}</Text>
|
||||
<Text style={styles.paymentValue}>{company.bankgiro}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.plusgiro && (company.invoice_show_plusgiro ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Plusgiro:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.plusgiro}</Text>
|
||||
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.swish && (company.invoice_show_swish ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Swish:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.swish}</Text>
|
||||
<Text style={styles.paymentValue}>{company.swish}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.iban && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>IBAN:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.iban}</Text>
|
||||
<Text style={styles.paymentValue}>{company.iban}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.bic && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>BIC/SWIFT:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.bic}</Text>
|
||||
<Text style={styles.paymentValue}>{company.bic}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.paymentRow, { marginTop: 8 }]}>
|
||||
<Text style={styles.paymentLabel}>Förfallodatum:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.dueDate}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
{(company.invoice_show_ocr ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
|
||||
<Text style={styles.paymentLabel}>{L.ocr}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'}</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -648,7 +782,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)}
|
||||
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
|
||||
<View style={styles.reverseChargeBox}>
|
||||
<Text style={styles.reverseChargeText}>Undantag från skatteplikt, ML 3 kap.</Text>
|
||||
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -681,9 +815,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
: null,
|
||||
company.address_line1,
|
||||
(company.postal_code || company.city) ? `${company.postal_code ?? ''} ${company.city ?? ''}`.trim() : null,
|
||||
company.org_number ? `Org.nr: ${formatOrgNumber(company.org_number)}` : null,
|
||||
company.vat_number ? `Momsreg.nr: ${company.vat_number}` : null,
|
||||
company.f_skatt ? 'Godkänd för F-skatt' : null,
|
||||
company.org_number ? `${L.orgNoLong} ${formatOrgNumber(company.org_number)}` : null,
|
||||
company.vat_number ? `${L.vatRegNo} ${company.vat_number}` : null,
|
||||
company.f_skatt ? L.fSkatt : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -522,6 +522,10 @@
|
||||
"vat_failed_default": "The VAT number could not be verified",
|
||||
"vat_error_title": "Could not verify VAT number",
|
||||
"payment_terms_label": "Payment terms (days)",
|
||||
"language_label": "Invoice language",
|
||||
"language_sv": "Swedish",
|
||||
"language_en": "English",
|
||||
"language_hint": "Invoices and emails to this customer are sent in the chosen language. Does not affect how the invoice is booked.",
|
||||
"notes_label": "Notes",
|
||||
"notes_placeholder": "Internal notes about the customer...",
|
||||
"submit_save": "Save customer",
|
||||
@@ -945,6 +949,7 @@
|
||||
},
|
||||
"settings_invoicing_preview": {
|
||||
"title": "Preview",
|
||||
"preview_button": "Preview invoice",
|
||||
"loading": "Generating preview...",
|
||||
"error": "Could not load preview",
|
||||
"no_customers": "Add a customer to see a preview of your invoice.",
|
||||
@@ -3146,6 +3151,8 @@
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"subtitle": "Import bank transactions or bookkeeping data into your company",
|
||||
"export_title": "Export",
|
||||
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
|
||||
"tab_import": "Import",
|
||||
"tab_export": "Export",
|
||||
"sandbox_disabled": "Import is not available in the sandbox. Create an account to import data.",
|
||||
|
||||
@@ -522,6 +522,10 @@
|
||||
"vat_failed_default": "VAT-numret kunde inte verifieras",
|
||||
"vat_error_title": "Kunde inte verifiera VAT-nummer",
|
||||
"payment_terms_label": "Betalningsvillkor (dagar)",
|
||||
"language_label": "Fakturaspråk",
|
||||
"language_sv": "Svenska",
|
||||
"language_en": "Engelska",
|
||||
"language_hint": "Fakturor och e-post till denna kund skickas på det valda språket. Påverkar inte hur fakturan bokförs.",
|
||||
"notes_label": "Anteckningar",
|
||||
"notes_placeholder": "Interna anteckningar om kunden...",
|
||||
"submit_save": "Spara kund",
|
||||
@@ -945,6 +949,7 @@
|
||||
},
|
||||
"settings_invoicing_preview": {
|
||||
"title": "Förhandsvisning",
|
||||
"preview_button": "Förhandsvisa faktura",
|
||||
"loading": "Genererar förhandsvisning...",
|
||||
"error": "Kunde inte ladda förhandsvisning",
|
||||
"no_customers": "Lägg till en kund för att se en förhandsvisning av din faktura.",
|
||||
@@ -3146,6 +3151,8 @@
|
||||
"import": {
|
||||
"title": "Importera",
|
||||
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
|
||||
"export_title": "Exportera",
|
||||
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
|
||||
"tab_import": "Importera",
|
||||
"tab_export": "Exportera",
|
||||
"sandbox_disabled": "Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.",
|
||||
|
||||
+17
-1
@@ -17,7 +17,7 @@ const cspDirectives = [
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
`frame-src 'self' ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`,
|
||||
`frame-src 'self' blob: ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`,
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
@@ -82,6 +82,22 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Document inline-preview proxy must be embeddable in same-origin
|
||||
// iframes (used by the verifikat document preview Sheet).
|
||||
// Overrides the strict catch-all above for this single endpoint.
|
||||
{
|
||||
source: "/api/documents/:id/inline",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors 'self'",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Add language preference per customer.
|
||||
--
|
||||
-- Drives the locale of the customer-facing invoice PDF and email when this
|
||||
-- customer is invoiced. Defaults to Swedish, matching the existing behavior.
|
||||
-- Adding more locales is a follow-up migration so we never end up with an
|
||||
-- orphan value the templates don't have translations for.
|
||||
--
|
||||
-- Per-customer (not per-invoice) because the user shouldn't have to pick a
|
||||
-- language every time they bill the same recipient — set it once on the
|
||||
-- customer record and every future invoice + email honors it.
|
||||
|
||||
ALTER TABLE public.customers
|
||||
ADD COLUMN language TEXT NOT NULL DEFAULT 'sv'
|
||||
CHECK (language IN ('sv', 'en'));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -406,6 +406,7 @@ export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: '2024-01-01T00:00:00Z',
|
||||
personal_number: null,
|
||||
language: 'sv',
|
||||
default_payment_terms: 30,
|
||||
notes: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
|
||||
@@ -479,6 +479,9 @@ export interface Customer {
|
||||
vat_number_validated_at: string | null
|
||||
personal_number: string | null
|
||||
|
||||
// Language for customer-facing invoice PDF and email
|
||||
language: 'sv' | 'en'
|
||||
|
||||
// Payment
|
||||
default_payment_terms: number // Days
|
||||
|
||||
@@ -830,6 +833,7 @@ export interface CreateCustomerInput {
|
||||
org_number?: string
|
||||
vat_number?: string
|
||||
personal_number?: string
|
||||
language?: 'sv' | 'en'
|
||||
default_payment_terms?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user