diff --git a/CLAUDE.md b/CLAUDE.md index 1b1963bc..cdff32d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,6 +352,54 @@ Never create a NUL/nul file: \gnubok\NUL --- +## i18n + +The app is Swedish-first and bilingual (Swedish + English) for UI chrome. Locale is per-user on `user_preferences.locale` (`'sv' | 'en'`, default `'sv'`), resolved server-side via next-intl. The user picker lives at `/settings/account`. + +**Pattern for new UI:** + +```tsx +// Server component +import { getTranslations } from 'next-intl/server' +const t = await getTranslations('namespace') + +// Client component +'use client' +import { useTranslations } from 'next-intl' +const t = useTranslations('namespace') + +// In JSX + +``` + +Add new strings to both `messages/sv.json` and `messages/en.json` under the matching namespace (`common`, `nav`, `auth`, `settings`, `empty`, etc.). Never ship an English key without a Swedish counterpart — Swedish is the default and the fallback. + +**Locale-aware formatters:** +- `formatCurrency(amount)` — stays SEK with sv-SE conventions in BOTH locales (Swedish accounting standard, not a UI string). +- `formatDate(date)` — ISO `yyyy-MM-dd`, locale-independent. +- `formatDateLong(date, locale)` — accepts locale. In client components use `useFormat()` (`lib/hooks/use-format.ts`) which pulls the active locale. + +**Error messages:** `getErrorMessage(err, { locale, context })` from `lib/errors/get-error-message.ts` is bilingual on the primary maps (Postgres codes, HTTP statuses, context fallbacks, generic fallback). The structured error envelope (`{ error: { code, message, message_en } }`) already carries both; the function picks the right one from `locale`. Pass `useLocale()` / `getLocale()` as the locale arg. + +**Stays Swedish — do NOT translate:** + +| 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 | +| 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 | +| SIE export (`lib/reports/sie-export.ts`) | SIE format is Swedish-only by spec (#KONTO, #VER, etc.) | +| BAS chart names (`lib/bookkeeping/bas-data/**`) | Standardized Swedish account names per BAS 2026 | +| VAT declaration ruta labels (`lib/reports/vat-declaration*.ts`) | Momsdeklaration field labels are Skatteverket form labels | +| Salary AGI / KU (`lib/salary/agi*`, `lib/salary/ku*`) | Skatteverket-bound forms | +| Bookkeeping engine domain errors ("Verifikationen balanserar inte", "Bokföringen är låst") | Regulatory concepts; English equivalents would be ambiguous | + +Anything in the table above stays Swedish in BOTH locales. If you find yourself reaching for `t()` inside one of these files, stop and reconsider. + +--- + ## Design Context ### Users diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d6bc1f9f..37864143 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -10,7 +11,7 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, Mail, ArrowLeft, KeyRound } from 'lucide-react' import Image from 'next/image' -import { getErrorMessage } from '@/lib/errors/get-error-message' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdAuth } from '@/components/auth/BankIdAuth' import { getBranding } from '@/lib/branding/service' @@ -43,6 +44,9 @@ function LoginPageContent() { const callbackError = searchParams.get('error') const supabase = createClient() const bankIdEnabled = isBankIdEnabled() + const tAuth = useTranslations('auth') + const tCommon = useTranslations('common') + const errorLocale = useLocale() as ErrorLocale // Reset cooldown timer useEffect(() => { @@ -72,8 +76,8 @@ function LoginPageContent() { if (result.error) { toast({ - title: 'Inloggning misslyckades', - description: 'Kunde inte slutföra BankID-inloggningen.', + title: tAuth('login_failed_title'), + description: tAuth('login_failed_bankid'), variant: 'destructive', }) return @@ -89,8 +93,8 @@ function LoginPageContent() { if (error) { console.error('[login] BankID verifyOtp failed', error) toast({ - title: 'Inloggning misslyckades', - description: 'Kunde inte slutfora BankID-inloggningen.', + title: tAuth('login_failed_title'), + description: tAuth('login_failed_bankid'), variant: 'destructive', }) return @@ -126,8 +130,8 @@ function LoginPageContent() { } catch (error) { console.error('[login] BankID complete error', error) toast({ - title: 'Inloggning misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('login_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } @@ -150,10 +154,10 @@ function LoginPageContent() { if (error) { toast({ - title: 'Inloggning misslyckades', + title: tAuth('login_failed_title'), description: error.message === 'Invalid login credentials' - ? 'Fel e-post eller lösenord.' - : getErrorMessage(error, { context: 'auth' }), + ? tAuth('login_invalid_credentials') + : getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -195,8 +199,8 @@ function LoginPageContent() { router.refresh() } catch (error) { toast({ - title: 'Inloggning misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('login_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -218,8 +222,8 @@ function LoginPageContent() { if (error) { toast({ - title: 'Kunde inte skicka återställningslänk', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('reset_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -229,13 +233,13 @@ function LoginPageContent() { setResetCooldownUntil(Date.now() + 60_000) setIsEmailSent(true) toast({ - title: 'Återställningslänk skickad!', - description: 'Kolla din inkorg för att återställa lösenordet.', + title: tAuth('reset_sent_title'), + description: tAuth('reset_sent_body'), }) } catch (error) { toast({ - title: 'Kunde inte skicka återställningslänk', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('reset_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -255,17 +259,23 @@ function LoginPageContent() {
-

Kolla din e-post

+

{tAuth('email_sent_title')}

- Vi har skickat en {showResetPassword ? 'återställningslänk' : 'inloggningslänk'} till{' '} - {email} + {showResetPassword + ? tAuth.rich('email_sent_body_reset', { + email, + strong: (chunks) => {chunks}, + }) + : tAuth.rich('email_sent_body_login', { + email, + strong: (chunks) => {chunks}, + })}

- Klicka på länken i e-posten för att {showResetPassword ? 'återställa ditt lösenord' : 'logga in'}. - Länken är giltig i 1 timme. + {showResetPassword ? tAuth('email_sent_hint_reset') : tAuth('email_sent_hint_login')}

@@ -278,7 +288,7 @@ function LoginPageContent() { }} > - Tillbaka + {tCommon('back')} @@ -296,22 +306,22 @@ function LoginPageContent() { -

Återställ lösenord

+

{tAuth('reset_title')}

- Ange din e-postadress så skickar vi en återställningslänk + {tAuth('reset_subtitle')}

- + setEmail(e.target.value)} required @@ -323,12 +333,12 @@ function LoginPageContent() { {isLoading ? ( <> - Skickar... + {tAuth('reset_sending')} ) : resetCooldownUntil ? ( - `Vänta ${resetCooldownRemaining}s` + tAuth('reset_cooldown', { seconds: resetCooldownRemaining }) ) : ( - 'Skicka återställningslänk' + tAuth('reset_button') )} @@ -340,7 +350,7 @@ function LoginPageContent() { onClick={() => setShowResetPassword(false)} > - Tillbaka till inloggning + {tAuth('back_to_login')}
@@ -360,7 +370,7 @@ function LoginPageContent() { priority />

- Logga in för att hantera din ekonomi + {tAuth('login_subtitle')}

@@ -368,16 +378,16 @@ function LoginPageContent() { {callbackError === 'auth_error' && (

- Återställningslänken fungerade inte + {tAuth('callback_error_title')}

- Länken har gått ut eller använts redan.{' '} + {tAuth('callback_error_body')}{' '} .

@@ -388,10 +398,10 @@ function LoginPageContent() { {bankIdNoAccount ? (

- Hej {bankIdNoAccount.givenName}! + {tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}

- Vi hittade inget konto kopplat till ditt BankID. Logga in med e-post nedan och koppla sedan BankID i installningar. + {tAuth('bankid_no_account_body')}

@@ -413,7 +423,7 @@ function LoginPageContent() {
- eller logga in med e-post + {tAuth('or_email_divider')}
@@ -421,30 +431,22 @@ function LoginPageContent() { {bankIdUnavailable && (

- Har du inget lösenord? + {tAuth('bankid_unavailable_title')}

- Om du skapade ditt konto med BankID kan du använda{' '} - {' '} - för att få en inloggningslänk via e-post. + {tAuth('bankid_unavailable_body')}

)}
- + setEmail(e.target.value)} required @@ -454,13 +456,13 @@ function LoginPageContent() {
- +
setPassword(e.target.value)} required @@ -480,10 +482,10 @@ function LoginPageContent() { {isLoading ? ( <> - Loggar in... + {tAuth('logging_in')} ) : ( - 'Logga in' + tAuth('login_button') )} @@ -493,7 +495,7 @@ function LoginPageContent() {
- eller + {tAuth('or_divider')}
@@ -503,19 +505,19 @@ function LoginPageContent() { asChild > - Skapa konto + {tAuth('no_account')}

- Genom att logga in godkänner du våra{' '} + {tAuth('terms_prefix')}{' '} - villkor + {tAuth('terms_link')} {' '} - och{' '} + {tAuth('terms_and')}{' '} - integritetspolicy + {tAuth('privacy_link')} .

diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 876ef442..6fa1ea07 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -11,6 +12,8 @@ import { Loader2, ShieldCheck, LogOut } from 'lucide-react' import { SupportLink } from '@/components/ui/support-link' export default function MfaVerifyPage() { + const t = useTranslations('mfa') + const tCommon = useTranslations('common') const [code, setCode] = useState('') const [isLoading, setIsLoading] = useState(false) const [factorId, setFactorId] = useState(null) @@ -29,7 +32,6 @@ export default function MfaVerifyPage() { if (verifiedFactor) { setFactorId(verifiedFactor.id) } else { - // No MFA factor enrolled — shouldn't be here router.push('/') } } @@ -38,7 +40,6 @@ export default function MfaVerifyPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Lockout countdown timer useEffect(() => { if (!lockoutUntil) return const tick = () => { @@ -64,8 +65,8 @@ export default function MfaVerifyPage() { if (challengeError) { toast({ - title: 'Verifiering misslyckades', - description: 'Kunde inte starta verifiering. Försök igen.', + title: t('verify_failed_title'), + description: t('verify_challenge_failed_description'), variant: 'destructive', }) setIsLoading(false) @@ -82,7 +83,6 @@ export default function MfaVerifyPage() { const attempts = failedAttempts + 1 setFailedAttempts(attempts) - // Exponential backoff after 3 failed attempts: 5s, 15s, 30s if (attempts >= 3) { const delays = [5_000, 15_000, 30_000] const delay = delays[Math.min(attempts - 3, delays.length - 1)] @@ -90,8 +90,8 @@ export default function MfaVerifyPage() { } toast({ - title: 'Fel kod', - description: 'Kontrollera koden och försök igen.', + title: t('wrong_code_title'), + description: t('wrong_code_description'), variant: 'destructive', }) setCode('') @@ -100,7 +100,6 @@ export default function MfaVerifyPage() { return } - // Check for pending invite token const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) const inviteToken = cookieMatch?.[1] @@ -127,8 +126,8 @@ export default function MfaVerifyPage() { router.refresh() } catch { toast({ - title: 'Verifiering misslyckades', - description: 'Ett oväntat fel uppstod. Försök igen.', + title: t('verify_failed_title'), + description: t('unexpected_error'), variant: 'destructive', }) } finally { @@ -150,16 +149,16 @@ export default function MfaVerifyPage() { -

Tvåfaktorsverifiering

+

{t('verify_title')}

- Ange den 6-siffriga koden från din autentiseringsapp + {t('verify_subtitle_full')}

- + - Verifierar... + {t('verifying')} ) : lockoutUntil ? ( - `Vänta ${lockoutRemaining}s` + t('wait_seconds', { seconds: lockoutRemaining }) ) : ( - 'Verifiera' + t('verify_button') )} @@ -201,13 +200,13 @@ export default function MfaVerifyPage() { onClick={handleLogout} > - Logga ut + {tCommon('logout')}

- Förlorat din autentiseringsapp?{' '} - - Kontakta support + {t('lost_authenticator')}{' '} + + {t('contact_support')}

diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 01cad102..1c595ff8 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import Link from 'next/link' +import { useLocale, useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -10,7 +11,7 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, Mail, ArrowLeft } from 'lucide-react' import Image from 'next/image' -import { getErrorMessage } from '@/lib/errors/get-error-message' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdAuth } from '@/components/auth/BankIdAuth' import type { BankIdResult } from '@/components/auth/BankIdAuth' @@ -46,6 +47,8 @@ function RegisterPageContent() { const router = useRouter() const supabase = createClient() const bankIdEnabled = isBankIdEnabled() + const t = useTranslations('register') + const errorLocale = useLocale() as ErrorLocale // When arriving from an invite link, fetch the invite info to pre-fill // and lock the email field so the user registers with the correct address. @@ -74,8 +77,8 @@ function RegisterPageContent() { if (result.error) { toast({ - title: 'BankID misslyckades', - description: 'Kunde inte verifiera din identitet.', + title: t('bankid_failed_title'), + description: t('bankid_failed_description'), variant: 'destructive', }) return @@ -108,21 +111,21 @@ function RegisterPageContent() { if (!res.ok) { if (json.error === 'already_linked') { toast({ - title: 'BankID redan kopplat', - description: 'Detta BankID ar redan kopplat till ett konto. Forsok logga in istallet.', + title: t('bankid_already_linked_title'), + description: t('bankid_already_linked_description'), variant: 'destructive', }) } else if (json.error === 'account_exists') { toast({ - title: 'Kontot finns redan', - description: 'Ett konto med den har e-postadressen finns redan. Logga in och koppla BankID i installningarna.', + title: t('account_exists_title'), + description: t('account_exists_description'), variant: 'destructive', }) router.push('/login') } else { toast({ - title: 'Registrering misslyckades', - description: json.message || json.error || 'Ett ovantat fel uppstod.', + title: t('register_failed_title'), + description: json.message || json.error || t('register_failed_default'), variant: 'destructive', }) } @@ -138,8 +141,8 @@ function RegisterPageContent() { if (error) { console.error('[register] BankID verifyOtp failed', error) toast({ - title: 'Kunde inte slutfora registreringen', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_complete'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -150,8 +153,8 @@ function RegisterPageContent() { } catch (error) { console.error('[register] BankID signup error', error) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -178,8 +181,8 @@ function RegisterPageContent() { if (!isStrongPassword(passwordValue)) { toast({ - title: 'Lösenordet är för svagt', - description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + title: t('weak_password_title'), + description: t('weak_password_description'), variant: 'destructive', }) setIsLoading(false) @@ -188,8 +191,8 @@ function RegisterPageContent() { if (passwordValue !== confirmValue) { toast({ - title: 'Lösenorden matchar inte', - description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + title: t('password_mismatch_title'), + description: t('password_mismatch_description'), variant: 'destructive', }) setIsLoading(false) @@ -224,8 +227,8 @@ function RegisterPageContent() { fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)), }) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -298,8 +301,8 @@ function RegisterPageContent() { constructor: error?.constructor?.name, }) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -318,23 +321,23 @@ function RegisterPageContent() {
-

Kontot finns redan

+

{t('duplicate_title')}

- Det finns redan ett konto kopplat till{' '} + {t('duplicate_body_prefix')}{' '} {duplicateEmail}.

- Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via "Glömt lösenord?" på inloggningssidan. + {t('duplicate_hint')}

@@ -362,24 +365,25 @@ function RegisterPageContent() {
-

Bekräfta din e-post

+

{t('confirm_email_title')}

- Vi har skickat en bekräftelselänk till{' '} - {email} + {t.rich('confirm_email_body', { + email, + strong: (chunks) => {chunks}, + })}

- Klicka på länken i e-posten för att aktivera ditt konto. - Länken är giltig i 24 timmar. + {t('confirm_email_hint')}

@@ -400,7 +404,7 @@ function RegisterPageContent() { priority />

- Skapa ett konto för att komma igång + {t('subtitle')}

@@ -415,7 +419,7 @@ function RegisterPageContent() {
- eller skapa konto med e-post + {t('or_email_divider')}
@@ -424,7 +428,7 @@ function RegisterPageContent() { {bankIdUnavailable && !bankIdUser && (

- Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare. + {t('bankid_unavailable_body')}

)} @@ -436,17 +440,17 @@ function RegisterPageContent() { {bankIdUser.givenName} {bankIdUser.surname}

- Verifierad med BankID + {t('bankid_verified')}

- + setBankIdEmail(e.target.value)} required @@ -454,17 +458,17 @@ function RegisterPageContent() { className="h-11" />

- Anvands for inloggning och notifieringar. + {t('bankid_email_hint')}

) : (
- + setEmail(e.target.value)} required @@ -499,18 +503,18 @@ function RegisterPageContent() { /> {inviteEmail && (

- Inbjudan skickades till denna adress. + {t('invite_email_hint')}

)}
- + setPassword(e.target.value)} required @@ -520,13 +524,13 @@ function RegisterPageContent() { />
- + setConfirmPassword(e.target.value)} required @@ -539,10 +543,10 @@ function RegisterPageContent() { {isLoading ? ( <> - Skapar konto... + {t('creating')} ) : ( - 'Skapa konto' + t('create_account') )} @@ -550,23 +554,23 @@ function RegisterPageContent() {

- Har du redan ett konto?{' '} + {t('already_have_account')}{' '} - Logga in + {t('sign_in')}

- Genom att skapa konto godkänner du våra{' '} + {t('terms_prefix')}{' '} - villkor + {t('terms_link')} {' '} - och{' '} + {t('terms_and')}{' '} - integritetspolicy + {t('privacy_link')} .

diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 597a5c87..aa589f05 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -9,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, KeyRound } from 'lucide-react' export default function ResetPasswordPage() { + const t = useTranslations('reset_password') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -27,8 +29,8 @@ export default function ResetPasswordPage() { if (!strong) { toast({ - title: 'Lösenordet är för svagt', - description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + title: t('weak_title'), + description: t('weak_description'), variant: 'destructive', }) setIsLoading(false) @@ -37,8 +39,8 @@ export default function ResetPasswordPage() { if (password !== confirmPassword) { toast({ - title: 'Lösenorden matchar inte', - description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + title: t('mismatch_title'), + description: t('weak_description'), variant: 'destructive', }) setIsLoading(false) @@ -60,24 +62,24 @@ export default function ResetPasswordPage() { if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string } toast({ - title: 'Kunde inte uppdatera lösenord', - description: body.error || 'Försök igen senare.', + title: t('save_failed_title'), + description: body.error || t('save_failed_description'), variant: 'destructive', }) return } toast({ - title: 'Lösenord uppdaterat', - description: 'Ditt lösenord har ändrats.', + title: t('saved_title'), + description: t('saved_description'), }) router.push('/') router.refresh() } catch { toast({ - title: 'Något gick fel', - description: 'Försök igen senare.', + title: t('save_failed_title'), + description: t('save_failed_description'), variant: 'destructive', }) } finally { @@ -94,21 +96,21 @@ export default function ResetPasswordPage() { -

Nytt lösenord

+

{t('title')}

- Ange ditt nya lösenord nedan + {t('subtitle')}

- + setPassword(e.target.value)} required @@ -118,12 +120,12 @@ export default function ResetPasswordPage() { />
- + setConfirmPassword(e.target.value)} required @@ -136,10 +138,10 @@ export default function ResetPasswordPage() { {isLoading ? ( <> - Sparar... + {t('submitting')} ) : ( - 'Spara nytt lösenord' + t('submit') )} diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx index 26768dad..11b6d568 100644 --- a/app/(dashboard)/assets/page.tsx +++ b/app/(dashboard)/assets/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -21,23 +22,23 @@ import { formatCurrency, formatDate } from '@/lib/utils' import type { Asset, AssetCategory } from '@/types' import { CreateAssetDialog } from '@/components/bookkeeping/assets/CreateAssetDialog' -const CATEGORY_LABELS: Record = { - immaterial: 'Immateriell', - building: 'Byggnad', - land_improvement: 'Markanläggning', - machinery: 'Maskin', - equipment: 'Inventarier', - vehicle: 'Fordon', - computer: 'Dator', - other_tangible: 'Övriga materiella', +const CATEGORY_LABEL_KEYS: Record = { + immaterial: 'category_immaterial', + building: 'category_building', + land_improvement: 'category_land_improvement', + machinery: 'category_machinery', + equipment: 'category_equipment', + vehicle: 'category_vehicle', + computer: 'category_computer', + other_tangible: 'category_other_tangible', } export default function AssetsPage() { + const t = useTranslations('assets') const [assets, setAssets] = useState(null) const [error, setError] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) - // Bumped on create to re-trigger the effect. const [reloadKey, setReloadKey] = useState(0) useEffect(() => { @@ -46,7 +47,7 @@ export default function AssetsPage() { .then(async (res) => { if (cancelled) return if (!res.ok) { - setError('Kunde inte ladda tillgångar') + setError(t('load_failed')) return } const { data } = (await res.json()) as { data: Asset[] } @@ -55,12 +56,12 @@ export default function AssetsPage() { setAssets(data) }) .catch(() => { - if (!cancelled) setError('Kunde inte ladda tillgångar') + if (!cancelled) setError(t('load_failed')) }) return () => { cancelled = true } - }, [reloadKey]) + }, [reloadKey, t]) const handleCreated = useCallback(() => { setDialogOpen(false) @@ -70,10 +71,10 @@ export default function AssetsPage() { return (
setDialogOpen(true)}> - Ny tillgång + {t('new_asset')} } /> @@ -96,9 +97,9 @@ export default function AssetsPage() { {assets !== null && assets.length === 0 && ( setDialogOpen(true)} /> )} @@ -109,12 +110,12 @@ export default function AssetsPage() { - Namn - Kategori - Anskaffat - Anskaffningsvärde - Avskrivningstid - Status + {t('th_name')} + {t('th_category')} + {t('th_acquired')} + {t('th_acquisition_cost')} + {t('th_useful_life')} + {t('th_status')} @@ -123,7 +124,7 @@ export default function AssetsPage() { return ( {asset.name} - {CATEGORY_LABELS[asset.category]} + {t(CATEGORY_LABEL_KEYS[asset.category])} {formatDate(asset.acquisition_date)} @@ -131,13 +132,13 @@ export default function AssetsPage() { {formatCurrency(Number(asset.acquisition_cost))} - {years} år ({asset.useful_life_months} mån) + {t('useful_life_format', { years, months: asset.useful_life_months })} {asset.disposed_at ? ( - Avyttrad + {t('status_disposed')} ) : ( - Aktiv + {t('status_active')} )} diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 9e9389e0..81843510 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, use } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AccountNumber } from '@/components/ui/account-number' @@ -11,7 +12,7 @@ import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Penc import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatDate } from '@/lib/utils' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' -import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' +import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' @@ -24,6 +25,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const router = useRouter() const { canWrite } = useCanWrite() const { toast } = useToast() + const t = useTranslations('journal_detail') + const sourceTypeLabels = useSourceTypeLabels() const [entry, setEntry] = useState(null) const [chain, setChain] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -45,7 +48,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const res = await fetch(`/api/bookkeeping/journal-entries/${id}/chain`) if (!res.ok) { const { error: msg } = await res.json() - setError(msg || 'Kunde inte hämta verifikation') + setError(msg || t('error_load_failed')) return } const { data } = await res.json() @@ -53,11 +56,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i setChain(data.chain) setIsLastInSeries(data.is_last_in_series ?? false) } catch { - setError('Kunde inte hämta verifikation') + setError(t('error_load_failed')) } finally { setIsLoading(false) } - }, [id]) + }, [id, t]) const saveNotes = useCallback(async (value: string) => { setSavingNotes(true) @@ -71,14 +74,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i setEntry(prev => prev ? { ...prev, notes: value || null } : prev) setEditingNotes(false) } else { - toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + toast({ title: t('toast_save_note_failed'), variant: 'destructive' }) } } catch { - toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + toast({ title: t('toast_save_note_failed'), variant: 'destructive' }) } finally { setSavingNotes(false) } - }, [id, toast]) + }, [id, toast, t]) const handleCommit = useCallback(async () => { setIsCommitting(true) @@ -88,19 +91,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i if (res.ok) { const posted = result.data toast({ - title: 'Verifikat bokfört', - description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`, + title: t('toast_posted_title'), + description: t('toast_posted_description', { voucher: `${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''}` }), }) await fetchData() } else { - toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) } } catch { - toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' }) + toast({ title: t('toast_post_failed_generic'), variant: 'destructive' }) } finally { setIsCommitting(false) } - }, [id, toast, fetchData]) + }, [id, toast, fetchData, t]) const handleDelete = useCallback(async () => { setIsDeleting(true) @@ -110,23 +113,23 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i if (res.ok) { const wasDraft = result.data?.was_draft === true toast({ - title: wasDraft ? 'Utkast raderat' : 'Verifikat raderat', + title: wasDraft ? t('toast_delete_draft_title') : t('toast_delete_entry_title'), description: wasDraft - ? 'Utkastet har tagits bort.' - : `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`, + ? t('toast_delete_draft_description') + : t('toast_delete_entry_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }), }) router.push('/bookkeeping') } else { - toast({ title: 'Kunde inte radera', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + toast({ title: t('toast_delete_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) setShowDeleteConfirm(false) } } catch { - toast({ title: 'Kunde inte radera verifikat', variant: 'destructive' }) + toast({ title: t('toast_delete_failed_generic'), variant: 'destructive' }) setShowDeleteConfirm(false) } finally { setIsDeleting(false) } - }, [id, router, toast]) + }, [id, router, toast, t]) useEffect(() => { fetchData() @@ -136,7 +139,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i return (
-

Laddar verifikation...

+

{t('loading')}

) } @@ -149,11 +152,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Tillbaka till bokföring + {t('back')} -

{error || 'Verifikation hittades inte'}

+

{error || t('error_not_found')}

@@ -190,7 +193,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Tillbaka till bokföring + {t('back')} {/* Header */} @@ -213,10 +216,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={handleCommit} disabled={!canWrite || isCommitting} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite ? : isCommitting && } - Bokför + {t('post')} )} {(entry.status === 'draft' || isLastInSeries) && ( @@ -226,10 +229,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => setShowDeleteConfirm(true)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite && } - {entry.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'} + {entry.status === 'draft' ? t('delete_draft') : t('delete_entry')} )} {canCorrect && ( @@ -239,10 +242,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => setShowCorrection(true)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite && } - Skapa ändringsverifikation + {t('create_correction')} )} {entry.status === 'posted' && ( @@ -252,10 +255,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite ? : } - Kopiera verifikat + {t('copy_entry')} )} @@ -266,26 +269,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
- Verifikationsdetaljer + {t('details_title')}
- Datum + {t('field_date')} {formatDate(entry.entry_date)}
{entry.committed_at && (
- Bokförd + {t('field_posted_at')} {new Date(entry.committed_at).toLocaleDateString('sv-SE')}
)}
- Typ + {t('field_type')} {sourceTypeLabels[entry.source_type] || entry.source_type}
{entry.source_voucher_series && entry.source_voucher_number != null && (
- Ursprungligt verifikat + {t('field_source_voucher')} {entry.source_voucher_series}{entry.source_voucher_number} @@ -296,14 +299,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
- Anteckning + {t('field_note')} {!editingNotes && canWrite && ( @@ -314,7 +317,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i