feat: add user locale preference to user_preferences table (#555)

* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
+48
View File
@@ -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
<button>{t('save')}</button>
```
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
+65 -63
View File
@@ -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() {
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kolla din e-post</h1>
<h1 className="text-2xl font-medium tracking-tight">{tAuth('email_sent_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en {showResetPassword ? 'återställningslänk' : 'inloggningslänk'} till{' '}
<span className="font-medium text-foreground">{email}</span>
{showResetPassword
? tAuth.rich('email_sent_body_reset', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})
: tAuth.rich('email_sent_body_login', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})}
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
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')}
</p>
</div>
@@ -278,7 +288,7 @@ function LoginPageContent() {
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{tCommon('back')}
</Button>
</div>
</div>
@@ -296,22 +306,22 @@ function LoginPageContent() {
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Återställ lösenord</h1>
<h1 className="text-2xl font-medium tracking-tight">{tAuth('reset_title')}</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange din e-postadress så skickar vi en återställningslänk
{tAuth('reset_subtitle')}
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
placeholder={tAuth('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -323,12 +333,12 @@ function LoginPageContent() {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
{tAuth('reset_sending')}
</>
) : resetCooldownUntil ? (
`Vänta ${resetCooldownRemaining}s`
tAuth('reset_cooldown', { seconds: resetCooldownRemaining })
) : (
'Skicka återställningslänk'
tAuth('reset_button')
)}
</Button>
</form>
@@ -340,7 +350,7 @@ function LoginPageContent() {
onClick={() => setShowResetPassword(false)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till inloggning
{tAuth('back_to_login')}
</Button>
</div>
</div>
@@ -360,7 +370,7 @@ function LoginPageContent() {
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Logga in för att hantera din ekonomi
{tAuth('login_subtitle')}
</p>
</div>
@@ -368,16 +378,16 @@ function LoginPageContent() {
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm font-medium text-destructive">
Återställningslänken fungerade inte
{tAuth('callback_error_title')}
</p>
<p className="mt-1 text-sm text-destructive/90">
Länken har gått ut eller använts redan.{' '}
{tAuth('callback_error_body')}{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
Begär en ny återställningslänk
{tAuth('request_new_reset_link')}
</button>
.
</p>
@@ -388,10 +398,10 @@ function LoginPageContent() {
{bankIdNoAccount ? (
<div className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Hej {bankIdNoAccount.givenName}!
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
</p>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
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')}
</p>
<p className="mt-2">
<button
@@ -399,7 +409,7 @@ function LoginPageContent() {
onClick={() => setBankIdNoAccount(null)}
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 dark:text-amber-400"
>
Eller skapa ett nytt konto
{tAuth('bankid_no_account_create')}
</button>
</p>
</div>
@@ -413,7 +423,7 @@ function LoginPageContent() {
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">eller logga in med e-post</span>
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_email_divider')}</span>
</div>
</div>
</>
@@ -421,30 +431,22 @@ function LoginPageContent() {
{bankIdUnavailable && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
Har du inget lösenord?
{tAuth('bankid_unavailable_title')}
</p>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Om du skapade ditt konto med BankID kan du använda{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
&quot;Glömt lösenord?&quot;
</button>{' '}
för att få en inloggningslänk via e-post.
{tAuth('bankid_unavailable_body')}
</p>
</div>
)}
<form onSubmit={handlePasswordLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
placeholder={tAuth('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -454,13 +456,13 @@ function LoginPageContent() {
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Lösenord</Label>
<Label htmlFor="password">{tAuth('password_label')}</Label>
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
Glömt lösenord?
{tAuth('forgot_password')}
</button>
</div>
<Input
@@ -468,7 +470,7 @@ function LoginPageContent() {
name="password"
type="password"
autoComplete="current-password"
placeholder="Ditt lösenord"
placeholder={tAuth('password_placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -480,10 +482,10 @@ function LoginPageContent() {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loggar in...
{tAuth('logging_in')}
</>
) : (
'Logga in'
tAuth('login_button')
)}
</Button>
</form>
@@ -493,7 +495,7 @@ function LoginPageContent() {
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">eller</span>
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_divider')}</span>
</div>
</div>
@@ -503,19 +505,19 @@ function LoginPageContent() {
asChild
>
<Link href="/register">
Skapa konto
{tAuth('no_account')}
</Link>
</Button>
</div>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
Genom att logga in godkänner du våra{' '}
{tAuth('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
{tAuth('terms_link')}
</a>{' '}
och{' '}
{tAuth('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
{tAuth('privacy_link')}
</a>
.
</p>
+19 -20
View File
@@ -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<string | null>(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() {
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Tvåfaktorsverifiering</h1>
<h1 className="text-2xl font-medium tracking-tight">{t('verify_title')}</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange den 6-siffriga koden från din autentiseringsapp
{t('verify_subtitle_full')}
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleVerify} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="code">Verifieringskod</Label>
<Label htmlFor="code">{t('verify_code_label')}</Label>
<Input
ref={inputRef}
id="code"
@@ -184,12 +183,12 @@ export default function MfaVerifyPage() {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifierar...
{t('verifying')}
</>
) : lockoutUntil ? (
`Vänta ${lockoutRemaining}s`
t('wait_seconds', { seconds: lockoutRemaining })
) : (
'Verifiera'
t('verify_button')
)}
</Button>
</form>
@@ -201,13 +200,13 @@ export default function MfaVerifyPage() {
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
{tCommon('logout')}
</Button>
<p className="text-xs text-muted-foreground text-center mt-4">
Förlorat din autentiseringsapp?{' '}
<SupportLink variant="muted" subject="MFA-problem — kan inte logga in" className="inline">
Kontakta support
{t('lost_authenticator')}{' '}
<SupportLink variant="muted" subject="MFA — cannot sign in" className="inline">
{t('contact_support')}
</SupportLink>
</p>
</div>
+61 -57
View File
@@ -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() {
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kontot finns redan</h1>
<h1 className="text-2xl font-medium tracking-tight">{t('duplicate_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Det finns redan ett konto kopplat till{' '}
{t('duplicate_body_prefix')}{' '}
<span className="font-medium text-foreground">{duplicateEmail}</span>.
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via &quot;Glömt lösenord?&quot; på inloggningssidan.
{t('duplicate_hint')}
</p>
</div>
<div className="space-y-2">
<Button className="w-full" asChild>
<Link href={`/login?email=${encodeURIComponent(duplicateEmail)}`}>
Logga in
{t('sign_in')}
</Link>
</Button>
<Button
@@ -343,7 +346,7 @@ function RegisterPageContent() {
onClick={() => setDuplicateEmail(null)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
</div>
</div>
@@ -362,24 +365,25 @@ function RegisterPageContent() {
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Bekräfta din e-post</h1>
<h1 className="text-2xl font-medium tracking-tight">{t('confirm_email_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en bekräftelselänk till{' '}
<span className="font-medium text-foreground">{email}</span>
{t.rich('confirm_email_body', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})}
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Klicka på länken i e-posten för att aktivera ditt konto.
Länken är giltig i 24 timmar.
{t('confirm_email_hint')}
</p>
</div>
<Button variant="ghost" className="w-full text-muted-foreground" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till inloggning
{t('back_to_login')}
</Link>
</Button>
</div>
@@ -400,7 +404,7 @@ function RegisterPageContent() {
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Skapa ett konto för att komma igång
{t('subtitle')}
</p>
</div>
@@ -415,7 +419,7 @@ function RegisterPageContent() {
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">eller skapa konto med e-post</span>
<span className="bg-card px-2 text-muted-foreground">{t('or_email_divider')}</span>
</div>
</div>
</>
@@ -424,7 +428,7 @@ function RegisterPageContent() {
{bankIdUnavailable && !bankIdUser && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare.
{t('bankid_unavailable_body')}
</p>
</div>
)}
@@ -436,17 +440,17 @@ function RegisterPageContent() {
{bankIdUser.givenName} {bankIdUser.surname}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Verifierad med BankID
{t('bankid_verified')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bankid_email">E-postadress</Label>
<Label htmlFor="bankid_email">{t('email_label')}</Label>
<Input
id="bankid_email"
name="bankid_email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
placeholder={t('email_placeholder')}
value={bankIdEmail}
onChange={(e) => setBankIdEmail(e.target.value)}
required
@@ -454,17 +458,17 @@ function RegisterPageContent() {
className="h-11"
/>
<p className="text-xs text-muted-foreground">
Anvands for inloggning och notifieringar.
{t('bankid_email_hint')}
</p>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar konto...
{t('creating')}
</>
) : (
'Skapa konto'
t('create_account')
)}
</Button>
<Button
@@ -477,19 +481,19 @@ function RegisterPageContent() {
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
</form>
) : (
<form onSubmit={handleRegister} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
placeholder={t('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -499,18 +503,18 @@ function RegisterPageContent() {
/>
{inviteEmail && (
<p className="text-xs text-muted-foreground">
Inbjudan skickades till denna adress.
{t('invite_email_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
<Label htmlFor="password">{t('password_label')}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken, Aa1!"
placeholder={t('password_placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -520,13 +524,13 @@ function RegisterPageContent() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">Bekräfta lösenord</Label>
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -539,10 +543,10 @@ function RegisterPageContent() {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar konto...
{t('creating')}
</>
) : (
'Skapa konto'
t('create_account')
)}
</Button>
</form>
@@ -550,23 +554,23 @@ function RegisterPageContent() {
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
Har du redan ett konto?{' '}
{t('already_have_account')}{' '}
<Link
href="/login"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary transition-colors"
>
Logga in
{t('sign_in')}
</Link>
</p>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
Genom att skapa konto godkänner du våra{' '}
{t('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
{t('terms_link')}
</a>{' '}
och{' '}
{t('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
{t('privacy_link')}
</a>
.
</p>
+20 -18
View File
@@ -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() {
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Nytt lösenord</h1>
<h1 className="text-2xl font-medium tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange ditt nya lösenord nedan
{t('subtitle')}
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="password">Nytt lösenord</Label>
<Label htmlFor="password">{t('new_password_label')}</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken, Aa1!"
placeholder={t('new_password_placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -118,12 +120,12 @@ export default function ResetPasswordPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">Bekräfta lösenord</Label>
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -136,10 +138,10 @@ export default function ResetPasswordPage() {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('submitting')}
</>
) : (
'Spara nytt lösenord'
t('submit')
)}
</Button>
</form>
+29 -28
View File
@@ -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<AssetCategory, string> = {
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<AssetCategory, string> = {
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<Asset[] | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-8">
<PageHeader
title="Anläggningstillgångar"
title={t('title')}
action={
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 h-4 w-4" /> Ny tillgång
<Plus className="mr-1 h-4 w-4" /> {t('new_asset')}
</Button>
}
/>
@@ -96,9 +97,9 @@ export default function AssetsPage() {
{assets !== null && assets.length === 0 && (
<EmptyState
icon={Package}
title="Inga tillgångar än"
description="Lägg till anläggningstillgångar (datorer, möbler, fordon, maskiner) så räknar bokslutet planenliga avskrivningar automatiskt."
actionLabel="Ny tillgång"
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('new_asset')}
onAction={() => setDialogOpen(true)}
/>
)}
@@ -109,12 +110,12 @@ export default function AssetsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Anskaffat</TableHead>
<TableHead className="text-right">Anskaffningsvärde</TableHead>
<TableHead>Avskrivningstid</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_category')}</TableHead>
<TableHead>{t('th_acquired')}</TableHead>
<TableHead className="text-right">{t('th_acquisition_cost')}</TableHead>
<TableHead>{t('th_useful_life')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -123,7 +124,7 @@ export default function AssetsPage() {
return (
<TableRow key={asset.id}>
<TableCell className="font-medium">{asset.name}</TableCell>
<TableCell className="text-sm">{CATEGORY_LABELS[asset.category]}</TableCell>
<TableCell className="text-sm">{t(CATEGORY_LABEL_KEYS[asset.category])}</TableCell>
<TableCell className="tabular-nums">
{formatDate(asset.acquisition_date)}
</TableCell>
@@ -131,13 +132,13 @@ export default function AssetsPage() {
{formatCurrency(Number(asset.acquisition_cost))}
</TableCell>
<TableCell className="text-sm">
{years} år ({asset.useful_life_months} mån)
{t('useful_life_format', { years, months: asset.useful_life_months })}
</TableCell>
<TableCell>
{asset.disposed_at ? (
<Badge variant="secondary">Avyttrad</Badge>
<Badge variant="secondary">{t('status_disposed')}</Badge>
) : (
<Badge variant="success">Aktiv</Badge>
<Badge variant="success">{t('status_active')}</Badge>
)}
</TableCell>
</TableRow>
+67 -66
View File
@@ -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<JournalEntry | null>(null)
const [chain, setChain] = useState<JournalEntry[]>([])
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 (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Laddar verifikation...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
)
}
@@ -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"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till bokföring
{t('back')}
</Link>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">{error || 'Verifikation hittades inte'}</p>
<p className="text-sm text-muted-foreground">{error || t('error_not_found')}</p>
</CardContent>
</Card>
</div>
@@ -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"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till bokföring
{t('back')}
</Link>
{/* 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 ? <Lock className="mr-2 h-4 w-4" /> : isCommitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
{t('post')}
</Button>
)}
{(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 && <Lock className="mr-2 h-4 w-4" />}
{entry.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'}
{entry.status === 'draft' ? t('delete_draft') : t('delete_entry')}
</Button>
)}
{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 && <Lock className="mr-2 h-4 w-4" />}
Skapa ändringsverifikation
{t('create_correction')}
</Button>
)}
{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 ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
Kopiera verifikat
{t('copy_entry')}
</Button>
)}
</div>
@@ -266,26 +269,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Verifikationsdetaljer</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t('details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Datum</span>
<span className="text-muted-foreground">{t('field_date')}</span>
<span>{formatDate(entry.entry_date)}</span>
</div>
{entry.committed_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Bokförd</span>
<span className="text-muted-foreground">{t('field_posted_at')}</span>
<span>{new Date(entry.committed_at).toLocaleDateString('sv-SE')}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Typ</span>
<span className="text-muted-foreground">{t('field_type')}</span>
<span>{sourceTypeLabels[entry.source_type] || entry.source_type}</span>
</div>
{entry.source_voucher_series && entry.source_voucher_number != null && (
<div className="flex justify-between">
<span className="text-muted-foreground">Ursprungligt verifikat</span>
<span className="text-muted-foreground">{t('field_source_voucher')}</span>
<span className="font-mono tabular-nums">
{entry.source_voucher_series}{entry.source_voucher_number}
</span>
@@ -296,14 +299,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<div className="flex items-center justify-between mb-1">
<span className="text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
Anteckning
{t('field_note')}
</span>
{!editingNotes && canWrite && (
<Button
variant="ghost"
size="icon"
onClick={() => { setNotesValue(entry.notes || ''); setEditingNotes(true) }}
aria-label="Redigera anteckning"
aria-label={t('edit_note_aria')}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -314,7 +317,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<Textarea
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
placeholder="Intern anteckning..."
placeholder={t('note_placeholder')}
className="resize-none text-sm"
rows={3}
maxLength={2000}
@@ -342,7 +345,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</div>
) : (
<p className="text-sm text-muted-foreground italic">
{entry.notes || 'Ingen anteckning'}
{entry.notes || t('no_note')}
</p>
)}
</div>
@@ -351,23 +354,23 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Summering</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t('summary_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Debet</span>
<span className="text-muted-foreground">{t('summary_debit')}</span>
<span className="tabular-nums font-medium">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Kredit</span>
<span className="text-muted-foreground">{t('summary_credit')}</span>
<span className="tabular-nums font-medium">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Antal rader</span>
<span className="text-muted-foreground">{t('summary_lines')}</span>
<span>{lines.length}</span>
</div>
</CardContent>
@@ -375,19 +378,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Underlag</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<div className="flex items-center gap-2">
{attachmentCount > 0 ? (
<>
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span>{attachmentCount} {attachmentCount === 1 ? 'dokument' : 'dokument'}</span>
<span>{t('attachments_count', { count: attachmentCount })}</span>
</>
) : (
<>
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
<span className="text-muted-foreground">Inga underlag bifogade</span>
<span className="text-muted-foreground">{t('no_attachments')}</span>
</>
)}
</div>
@@ -400,11 +403,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<Card>
<CardContent className="p-4">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
Valutaomräkning
{t('currency_title')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div className="flex justify-between sm:block">
<span className="text-muted-foreground">Kurs</span>
<span className="text-muted-foreground">{t('currency_rate')}</span>
<span className="tabular-nums sm:block">
{foreignExchangeRate
? `1 ${foreignCurrency} = ${foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK`
@@ -412,7 +415,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</span>
</div>
<div className="flex justify-between sm:block">
<span className="text-muted-foreground">Ursprungsbelopp</span>
<span className="text-muted-foreground">{t('currency_original_amount')}</span>
<span className="tabular-nums sm:block">
{foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency}
</span>
@@ -425,7 +428,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{/* Lines table */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Kontorader</CardTitle>
<CardTitle className="text-sm font-medium">{t('lines_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
@@ -433,10 +436,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-48">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
<th className="py-2 w-48">{t('col_account')}</th>
<th className="py-2">{t('col_description')}</th>
<th className="py-2 w-28 text-right">{t('col_debit')}</th>
<th className="py-2 w-28 text-right">{t('col_credit')}</th>
</tr>
</thead>
<tbody>
@@ -476,7 +479,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">Summa</td>
<td colSpan={2} className="py-2">{t('sum')}</td>
<td className="py-2 text-right tabular-nums">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
@@ -526,7 +529,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
)
})}
<div className="flex justify-between font-semibold text-sm pt-1">
<span>Summa</span>
<span>{t('sum')}</span>
<div className="flex gap-3 tabular-nums">
<span>D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
<span>K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
@@ -539,7 +542,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{/* Attachments */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Underlag</CardTitle>
<CardTitle className="text-sm font-medium">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent>
<JournalEntryAttachments
@@ -553,7 +556,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{chain.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Ändringshistorik</CardTitle>
<CardTitle className="text-sm font-medium">{t('history_title')}</CardTitle>
</CardHeader>
<CardContent>
<CorrectionChain currentEntryId={id} chain={fullChain} />
@@ -580,22 +583,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
onOpenChange={setShowDeleteConfirm}
onConfirm={handleDelete}
isSubmitting={isDeleting}
title={entry?.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'}
title={entry?.status === 'draft' ? t('delete_draft') : t('delete_entry')}
warningText={
entry?.status === 'draft'
? 'Utkastet har aldrig bokförts och kan tas bort utan att påverka verifikationsserien. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.'
: `Verifikat ${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''} raderas permanent. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.`
? t('delete_warning_draft')
: t('delete_warning_entry', { voucher: `${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''}` })
}
confirmLabel="Radera permanent"
confirmLabel={t('delete_confirm_label')}
>
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium mb-1">Permanent radering</p>
<p className="font-medium mb-1">{t('delete_dialog_heading')}</p>
<p className="text-muted-foreground">
{entry?.status === 'draft'
? 'Utkastet och dess kontorader tas bort. Kopplade fakturor och transaktioner påverkas inte — de stannar kvar som obokförda. Underlag (kvitton, dokument) behålls men avlänkas.'
: 'Verifikatet och dess kontorader tas bort. Kopplade transaktioner och fakturor behåller sina uppgifter men markeras som ej bokförda. Underlag (kvitton, dokument) behålls men avlänkas.'}
{entry?.status === 'draft' ? t('delete_dialog_draft_body') : t('delete_dialog_entry_body')}
</p>
</div>
</div>
+14 -14
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
@@ -46,6 +47,7 @@ export default function BookkeepingPage() {
const [copyPrefill, setCopyPrefill] = useState<CopyPrefill | null>(null)
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(null)
const t = useTranslations('bookkeeping')
// React to copy_from in URL: switch tab, fetch source entry, then clean URL.
// useSearchParams keeps this reactive even when navigation happens within the
@@ -64,8 +66,8 @@ export default function BookkeepingPage() {
.then(({ data, error }: { data?: JournalEntry; error?: string }) => {
if (error || !data) {
toast({
title: 'Kunde inte kopiera verifikat',
description: error || 'Källverifikatet hittades inte.',
title: t('copy_failed_title'),
description: error || t('copy_source_missing'),
variant: 'destructive',
})
return
@@ -93,8 +95,8 @@ export default function BookkeepingPage() {
})
.catch(() => {
toast({
title: 'Kunde inte kopiera verifikat',
description: 'Källverifikatet kunde inte hämtas.',
title: t('copy_failed_title'),
description: t('copy_fetch_failed'),
variant: 'destructive',
})
})
@@ -132,12 +134,12 @@ export default function BookkeepingPage() {
return (
<div className="space-y-8">
<PageHeader
title="Bokföring"
title={t('title')}
action={
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
Årsbokslut
{t('year_end')}
</Link>
</Button>
}
@@ -145,16 +147,16 @@ export default function BookkeepingPage() {
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="journal">Verifikationer</TabsTrigger>
<TabsTrigger value="journal">{t('tab_journal')}</TabsTrigger>
<TabsTrigger value="new-entry">
Ny verifikation
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-muted-foreground tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</TabsTrigger>
<TabsTrigger value="accounts">Kontoplan</TabsTrigger>
<TabsTrigger value="accounts">{t('tab_accounts')}</TabsTrigger>
</TabsList>
<TabsContent value="journal" className="space-y-4">
@@ -166,7 +168,7 @@ export default function BookkeepingPage() {
{isLoadingCopy ? (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Laddar källverifikat...</span>
<span className="text-sm">{t('loading_source_voucher')}</span>
</div>
) : (
<>
@@ -175,12 +177,10 @@ export default function BookkeepingPage() {
<Copy className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">
Kopia av verifikat {copyPrefill.sourceVoucherLabel || '(okänt nummer)'}
{t('copy_banner_title', { label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label') })}
</p>
<p className="text-muted-foreground mt-0.5">
Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer.
Detta är <strong>inte</strong> en rättelse eller storno av originalet — använd
&quot;Skapa ändringsverifikation&quot; om du vill korrigera källverifikatet.
{t('copy_banner_body')}
</p>
</div>
</div>
+44 -38
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { use } from 'react'
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 { Badge } from '@/components/ui/badge'
@@ -30,11 +31,11 @@ import { cn, formatDate } from '@/lib/utils'
import { invoiceNumberDisplay } from '@/lib/invoices/display'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
const CUSTOMER_TYPE_KEY: Record<CustomerType, string> = {
individual: 'type_individual',
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
@@ -68,6 +69,7 @@ export default function CustomerDetailPage({
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('customer_detail')
const [customer, setCustomer] = useState<CustomerWithRelations | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -89,8 +91,8 @@ export default function CustomerDetailPage({
setCustomer(data)
} catch {
toast({
title: 'Kunde inte ladda kund',
description: 'Kunden hittades inte.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
router.push('/customers')
@@ -113,15 +115,15 @@ export default function CustomerDetailPage({
}
toast({
title: 'Kund uppdaterad',
title: t('updated_title'),
description: data.name,
})
setIsEditOpen(false)
fetchCustomer()
} catch {
toast({
title: 'Kunde inte uppdatera kund',
description: 'Försök igen.',
title: t('update_failed_title'),
description: t('retry'),
variant: 'destructive',
})
} finally {
@@ -132,9 +134,9 @@ export default function CustomerDetailPage({
async function handleDelete() {
if (!customer) return
const ok = await confirmAction({
title: `Ta bort ${customer.name}`,
description: 'Kunden och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
title: t('delete_confirm_title', { name: customer.name }),
description: t('delete_confirm_description'),
confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
})
if (!ok) return
@@ -149,14 +151,14 @@ export default function CustomerDetailPage({
}
toast({
title: 'Kund borttagen',
title: t('deleted_title'),
description: customer.name,
})
router.push('/customers')
} catch {
toast({
title: 'Kunde inte ta bort kund',
description: 'Försök igen.',
title: t('delete_failed_title'),
description: t('retry'),
variant: 'destructive',
})
}
@@ -194,7 +196,7 @@ export default function CustomerDetailPage({
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till kunder
{t('back')}
</Link>
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
@@ -202,7 +204,7 @@ export default function CustomerDetailPage({
</div>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{customer.name}</h1>
<Badge variant="secondary">{customerTypeLabels[customer.customer_type]}</Badge>
<Badge variant="secondary">{t(CUSTOMER_TYPE_KEY[customer.customer_type])}</Badge>
</div>
</div>
</div>
@@ -213,10 +215,10 @@ export default function CustomerDetailPage({
size="sm"
onClick={() => setIsEditOpen(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
Redigera
{t('edit')}
</Button>
<Button
variant="outline"
@@ -224,10 +226,10 @@ export default function CustomerDetailPage({
onClick={handleDelete}
className="text-destructive hover:text-destructive"
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Trash2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
Ta bort
{t('delete')}
</Button>
</div>
</div>
@@ -237,7 +239,7 @@ export default function CustomerDetailPage({
{/* Contact */}
<Card>
<CardHeader>
<CardTitle className="text-base">Kontaktuppgifter</CardTitle>
<CardTitle className="text-base">{t('section_contact')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{customer.email && (
@@ -268,7 +270,7 @@ export default function CustomerDetailPage({
</div>
)}
{!customer.email && !customer.phone && !customer.address_line1 && !customer.city && (
<p className="text-sm text-muted-foreground">Inga kontaktuppgifter</p>
<p className="text-sm text-muted-foreground">{t('no_contact_info')}</p>
)}
</CardContent>
</Card>
@@ -276,30 +278,30 @@ export default function CustomerDetailPage({
{/* Business details */}
<Card>
<CardHeader>
<CardTitle className="text-base">Företagsuppgifter</CardTitle>
<CardTitle className="text-base">{t('section_business')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{customer.org_number && (
<div className="text-sm">
<span className="text-muted-foreground">Org.nr: </span>
<span className="text-muted-foreground">{t('label_org_number')} </span>
{customer.org_number}
</div>
)}
{customer.vat_number && (
<div className="text-sm flex items-center gap-2">
<span className="text-muted-foreground">VAT: </span>
<span className="text-muted-foreground">{t('label_vat')} </span>
{customer.vat_number}
{customer.vat_number_validated && (
<Badge variant="success" className="text-xs">Verifierad</Badge>
<Badge variant="success" className="text-xs">{t('verified')}</Badge>
)}
</div>
)}
<div className="text-sm">
<span className="text-muted-foreground">Betalningsvillkor: </span>
{customer.default_payment_terms || 30} dagar
<span className="text-muted-foreground">{t('label_payment_terms')} </span>
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
</div>
{!customer.org_number && !customer.vat_number && (
<p className="text-sm text-muted-foreground">Inga företagsuppgifter</p>
<p className="text-sm text-muted-foreground">{t('no_business_info')}</p>
)}
</CardContent>
</Card>
@@ -307,12 +309,12 @@ export default function CustomerDetailPage({
{/* Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Översikt</CardTitle>
<CardTitle className="text-base">{t('section_summary')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span>{customer.invoices?.length || 0} fakturor</span>
<span>{t('invoice_count', { count: customer.invoices?.length || 0 })}</span>
</div>
</CardContent>
</Card>
@@ -322,7 +324,7 @@ export default function CustomerDetailPage({
{customer.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Anteckningar</CardTitle>
<CardTitle className="text-base">{t('section_notes')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{customer.notes}</p>
@@ -335,7 +337,7 @@ export default function CustomerDetailPage({
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Receipt className="h-4 w-4" />
Fakturor
{t('section_invoices')}
{customer.invoices?.length > 0 && (
<Badge variant="secondary">{customer.invoices.length}</Badge>
)}
@@ -359,7 +361,11 @@ export default function CustomerDetailPage({
{formatCurrency(invoice.total, invoice.currency)}
</span>
<Badge variant={invoice.payment_status === 'paid' ? 'success' : 'secondary'}>
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Förfallen' : 'Obetald'}
{invoice.payment_status === 'paid'
? t('invoice_status_paid')
: invoice.payment_status === 'overdue'
? t('invoice_status_overdue')
: t('invoice_status_unpaid')}
</Badge>
</div>
</Link>
@@ -367,7 +373,7 @@ export default function CustomerDetailPage({
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
Inga fakturor kopplade till denna kund
{t('no_invoices')}
</p>
)}
</CardContent>
@@ -379,7 +385,7 @@ export default function CustomerDetailPage({
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Redigera kund</DialogTitle>
<DialogTitle>{t('edit_dialog_title')}</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleUpdate}
+24 -21
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -8,7 +9,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { Plus, Search, Users, Lock } from 'lucide-react'
import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
@@ -18,11 +19,11 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
const CUSTOMER_TYPE_LABEL_KEYS: Record<CustomerType, string> = {
individual: 'type_individual',
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
function getInitials(name: string): string {
@@ -44,6 +45,8 @@ export default function CustomersPage() {
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('customers')
const errorLocale = useLocale() as ErrorLocale
async function fetchCustomers() {
if (!company) return
@@ -56,8 +59,8 @@ export default function CustomersPage() {
if (error) {
toast({
title: 'Kunde inte ladda kunder',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
@@ -83,14 +86,14 @@ export default function CustomersPage() {
if (!response.ok) {
toast({
title: 'Kunde inte skapa kund',
description: getErrorMessage(result, { context: 'customer' }),
title: t('create_failed_title'),
description: getErrorMessage(result, { context: 'customer', locale: errorLocale }),
variant: 'destructive',
})
} else {
toast({
title: 'Kund skapad',
description: `${data.name} har lagts till`,
title: t('created_title'),
description: t('created_description', { name: data.name }),
})
setCustomers([...customers, result.data])
setIsDialogOpen(false)
@@ -108,25 +111,25 @@ export default function CustomersPage() {
return (
<div className="space-y-8">
<PageHeader
title="Kunder"
title={t('title')}
action={
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
Ny kund
{t('new_customer')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till kund</DialogTitle>
<DialogTitle>{t('add_customer')}</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleCreateCustomer}
@@ -141,7 +144,7 @@ export default function CustomersPage() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök kunder"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@@ -169,8 +172,8 @@ export default function CustomersPage() {
{searchTerm ? (
<EmptyState
icon={Users}
title="Inga träffar"
description={`Inga kunder matchar "${searchTerm}".`}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
@@ -190,7 +193,7 @@ export default function CustomersPage() {
<div className="min-w-0">
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">{customer.name}</CardTitle>
<Badge variant="secondary" className="mt-1">
{customerTypeLabels[customer.customer_type]}
{t(CUSTOMER_TYPE_LABEL_KEYS[customer.customer_type])}
</Badge>
</div>
</div>
@@ -204,7 +207,7 @@ export default function CustomersPage() {
<div className="flex items-center gap-2">
<span>{customer.org_number}</span>
{customer.vat_number_validated && (
<Badge variant="success" className="text-xs">Verifierad</Badge>
<Badge variant="success" className="text-xs">{t('verified')}</Badge>
)}
</div>
)}
+26 -24
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
@@ -15,6 +16,7 @@ const supabase = createClient()
export default function DeadlinesPage() {
const { company } = useCompany()
const t = useTranslations('deadlines')
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
@@ -49,14 +51,14 @@ export default function DeadlinesPage() {
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
title: 'Kunde inte ladda deadlines',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchData()
@@ -78,15 +80,15 @@ export default function DeadlinesPage() {
}
toast({
title: 'Deadline skapad',
description: 'Din deadline har sparats',
title: t('created_title'),
description: t('created_description'),
})
fetchData()
} catch (error) {
toast({
title: 'Kunde inte skapa deadline',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
throw error
@@ -113,9 +115,9 @@ export default function DeadlinesPage() {
if (newCompleted) {
toast({
title: `"${deadline.title}" markerad som klar`,
title: t('marked_done', { title: deadline.title }),
action: (
<ToastAction altText="Ångra" onClick={async () => {
<ToastAction altText={t('undo')} onClick={async () => {
try {
await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
@@ -124,22 +126,22 @@ export default function DeadlinesPage() {
})
} catch {
toast({
title: 'Kunde inte ångra',
title: t('undo_failed'),
variant: 'destructive',
})
}
}}>
Ångra
{t('undo')}
</ToastAction>
),
})
} else {
toast({ title: `"${deadline.title}" markerad som ej klar` })
toast({ title: t('marked_not_done', { title: deadline.title }) })
}
} catch (error) {
toast({
title: 'Kunde inte uppdatera status',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('toggle_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
@@ -159,15 +161,15 @@ export default function DeadlinesPage() {
}
toast({
title: 'Deadline uppdaterad',
description: 'Dina ändringar har sparats',
title: t('updated_title'),
description: t('updated_description'),
})
fetchData()
} catch (error) {
toast({
title: 'Kunde inte spara ändringar',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('update_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
@@ -184,12 +186,12 @@ export default function DeadlinesPage() {
throw new Error(result.error || 'Failed to delete deadline')
}
toast({ title: 'Deadline borttagen' })
toast({ title: t('deleted_title') })
fetchData()
} catch (error) {
toast({
title: 'Kunde inte ta bort deadline',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('delete_failed_title'),
description: error instanceof Error ? error.message : t('retry'),
variant: 'destructive',
})
}
@@ -198,7 +200,7 @@ export default function DeadlinesPage() {
if (isLoading) {
return (
<div className="space-y-8">
<PageHeader title="Deadlines" />
<PageHeader title={t('title')} />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-lg border p-4 animate-pulse">
@@ -223,7 +225,7 @@ export default function DeadlinesPage() {
return (
<div className="space-y-6">
<PageHeader title="Deadlines" />
<PageHeader title={t('title')} />
{/* Overdue invoices alert */}
{overdueInvoices.count > 0 && (
@@ -232,7 +234,7 @@ export default function DeadlinesPage() {
<div className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
<p className="text-sm">
<span className="font-medium">{overdueInvoices.count} förfallna fakturor</span>
<span className="font-medium">{t('overdue_invoices', { count: overdueInvoices.count })}</span>
<span className="text-muted-foreground ml-1.5">
{overdueInvoices.total.toLocaleString('sv-SE')} kr
</span>
@@ -1,6 +1,13 @@
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import {
extensionNameKey,
extensionDescriptionKey,
extensionLongDescriptionKey,
sectorNameKey,
} from '@/lib/extensions/i18n'
import type { SectorSlug } from '@/lib/extensions/types'
import CategoryBadge from '@/components/extensions/CategoryBadge'
import { WORKSPACES } from '@/lib/extensions/_generated/workspace-map'
@@ -19,14 +26,29 @@ export default async function ExtensionDetailPage({
const sector = getSector(sectorSlug as SectorSlug)
const t = await getTranslations('extensions')
const nameKey = extensionNameKey(definition.slug)
const descriptionKey = extensionDescriptionKey(definition.slug)
const longDescriptionKey = extensionLongDescriptionKey(definition.slug)
const extensionName = nameKey ? t(nameKey) : definition.name
const extensionDescription = descriptionKey ? t(descriptionKey) : definition.description
const extensionLongDescription = longDescriptionKey ? t(longDescriptionKey) : definition.longDescription
const sectorLabel = (() => {
if (!sector) return sectorSlug
const key = sectorNameKey(sector.slug)
return key ? t(key) : sector.name
})()
const Icon = resolveIcon(definition.icon)
const hasWorkspace = `${sectorSlug}/${extensionSlug}` in WORKSPACES
const dataPatternLabels: Record<string, string> = {
core: 'Använder bokföringsdata',
manual: 'Manuell inmatning',
both: 'Bokföringsdata + manuell inmatning',
core: t('data_pattern_core'),
manual: t('data_pattern_manual'),
both: t('data_pattern_both'),
}
return (
@@ -34,17 +56,17 @@ export default async function ExtensionDetailPage({
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
<Link href="/extensions" className="hover:text-foreground transition-colors">
Tillägg
{t('breadcrumb')}
</Link>
<span>/</span>
<Link
href={`/extensions/${sectorSlug}`}
className="hover:text-foreground transition-colors"
>
{sector?.name ?? sectorSlug}
{sectorLabel}
</Link>
<span>/</span>
<span className="text-foreground">{definition.name}</span>
<span className="text-foreground">{extensionName}</span>
</nav>
{/* Header */}
@@ -54,8 +76,8 @@ export default async function ExtensionDetailPage({
<Icon className="h-7 w-7 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">{definition.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{definition.description}</p>
<h1 className="text-xl font-semibold tracking-tight">{extensionName}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{extensionDescription}</p>
<div className="mt-2">
<CategoryBadge category={definition.category} />
</div>
@@ -64,7 +86,7 @@ export default async function ExtensionDetailPage({
{hasWorkspace && (
<Button asChild>
<Link href={`/e/${sectorSlug}/${extensionSlug}`}>
Öppna
{t('open')}
</Link>
</Button>
)}
@@ -73,20 +95,20 @@ export default async function ExtensionDetailPage({
{/* Details */}
<div className="space-y-6">
<div>
<h2 className="text-sm font-semibold mb-2">Beskrivning</h2>
<h2 className="text-sm font-semibold mb-2">{t('description_heading')}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{definition.longDescription}
{extensionLongDescription}
</p>
</div>
<div>
<h2 className="text-sm font-semibold mb-2">Datakälla</h2>
<h2 className="text-sm font-semibold mb-2">{t('data_source_heading')}</h2>
<p className="text-sm text-muted-foreground">
{dataPatternLabels[definition.dataPattern]}
</p>
{definition.readsCoreTables && definition.readsCoreTables.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Läser från: {definition.readsCoreTables.join(', ')}
{t('reads_from', { tables: definition.readsCoreTables.join(', ') })}
</p>
)}
</div>
+14 -5
View File
@@ -1,6 +1,8 @@
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { sectorNameKey, sectorDescriptionKey } from '@/lib/extensions/i18n'
import type { SectorSlug } from '@/lib/extensions/types'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import Link from 'next/link'
@@ -15,7 +17,14 @@ export default async function SectorExtensionsPage({
if (!sector) notFound()
const t = await getTranslations('extensions')
const nameKey = sectorNameKey(sector.slug)
const descriptionKey = sectorDescriptionKey(sector.slug)
const sectorName = nameKey ? t(nameKey) : sector.name
const sectorDescription = descriptionKey ? t(descriptionKey) : sector.description
const Icon = resolveIcon(sector.icon)
return (
@@ -23,10 +32,10 @@ export default async function SectorExtensionsPage({
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
<Link href="/extensions" className="hover:text-foreground transition-colors">
Tillägg
{t('breadcrumb')}
</Link>
<span>/</span>
<span className="text-foreground">{sector.name}</span>
<span className="text-foreground">{sectorName}</span>
</nav>
{/* Header */}
@@ -35,8 +44,8 @@ export default async function SectorExtensionsPage({
<Icon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">{sector.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{sector.description}</p>
<h1 className="text-xl font-semibold tracking-tight">{sectorName}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{sectorDescription}</p>
</div>
</div>
+14 -5
View File
@@ -1,17 +1,26 @@
import { getTranslations } from 'next-intl/server'
import { SECTORS } from '@/lib/extensions/sectors'
import { sectorNameKey } from '@/lib/extensions/i18n'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import SectorCard from '@/components/extensions/SectorCard'
export default function ExtensionsPage() {
export default async function ExtensionsPage() {
const t = await getTranslations('extensions')
const generalSector = SECTORS.find(s => s.slug === 'general')
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
const generalSectorName = (() => {
if (!generalSector) return ''
const key = sectorNameKey(generalSector.slug)
return key ? t(key) : generalSector.name
})()
return (
<div>
<div className="mb-8">
<h1 className="text-xl font-semibold tracking-tight">Tillägg</h1>
<h1 className="text-xl font-semibold tracking-tight">{t('page_title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
Utöka ditt bokföringssystem med verktyg och branschspecifika funktioner.
{t('page_description')}
</p>
</div>
@@ -19,7 +28,7 @@ export default function ExtensionsPage() {
{generalSector && (
<section className="mb-8">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
{generalSector.name}
{generalSectorName}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{generalSector.extensions.map(ext => (
@@ -32,7 +41,7 @@ export default function ExtensionsPage() {
{/* Industry sectors */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Branschverktyg
{t('industry_tools')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{industrySectors.map(sector => (
+22 -19
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@@ -209,15 +210,16 @@ const glossaryTerms: GlossaryTerm[] = [
]
const categoryConfig = {
skatt: { label: 'Skatt', icon: Calculator, color: 'bg-orange-500/10 text-orange-600' },
moms: { label: 'Moms', icon: Receipt, color: 'bg-blue-500/10 text-blue-600' },
faktura: { label: 'Faktura', icon: FileText, color: 'bg-success/10 text-success' },
bokföring: { label: 'Bokföring', icon: BookOpen, color: 'bg-purple-500/10 text-purple-600' },
bank: { label: 'Bank', icon: Building2, color: 'bg-pink-500/10 text-pink-600' },
företag: { label: 'Företag', icon: Building2, color: 'bg-cyan-500/10 text-cyan-600' },
skatt: { labelKey: 'category_skatt', icon: Calculator, color: 'bg-orange-500/10 text-orange-600' },
moms: { labelKey: 'category_moms', icon: Receipt, color: 'bg-blue-500/10 text-blue-600' },
faktura: { labelKey: 'category_faktura', icon: FileText, color: 'bg-success/10 text-success' },
bokföring: { labelKey: 'category_bokforing', icon: BookOpen, color: 'bg-purple-500/10 text-purple-600' },
bank: { labelKey: 'category_bank', icon: Building2, color: 'bg-pink-500/10 text-pink-600' },
företag: { labelKey: 'category_foretag', icon: Building2, color: 'bg-cyan-500/10 text-cyan-600' },
}
function TermCard({ term, isExpanded, onToggle }: { term: GlossaryTerm; isExpanded: boolean; onToggle: () => void }) {
const t = useTranslations('help')
const config = categoryConfig[term.category]
const CategoryIcon = config.icon
@@ -265,7 +267,7 @@ function TermCard({ term, isExpanded, onToggle }: { term: GlossaryTerm; isExpand
{term.relatedTerms && term.relatedTerms.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Relaterat:</span>
<span className="text-xs text-muted-foreground">{t('related_label')}</span>
{term.relatedTerms.map((related) => (
<Badge key={related} variant="outline" className="text-xs">
{related}
@@ -276,7 +278,7 @@ function TermCard({ term, isExpanded, onToggle }: { term: GlossaryTerm; isExpand
{term.skatteverketUrl && (
<HelpLink href={term.skatteverketUrl}>
Läs mer på Skatteverket
{t('read_more_skv')}
<ExternalLink className="h-3 w-3" />
</HelpLink>
)}
@@ -288,6 +290,7 @@ function TermCard({ term, isExpanded, onToggle }: { term: GlossaryTerm; isExpand
}
export default function HelpPage() {
const t = useTranslations('help')
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [expandedTerms, setExpandedTerms] = useState<Set<string>>(new Set())
@@ -329,8 +332,8 @@ export default function HelpPage() {
return (
<div className="space-y-8">
<PageHeader
title="Hjälp & Ordlista"
description="Förklaringar av skatte- och bokföringstermer på ren svenska."
title={t('title')}
description={t('subtitle')}
/>
{/* Search */}
@@ -338,7 +341,7 @@ export default function HelpPage() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Sök efter term..."
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -356,7 +359,7 @@ export default function HelpPage() {
: 'bg-secondary text-muted-foreground hover:text-foreground'
)}
>
Alla
{t('filter_all')}
</button>
{Object.entries(categoryConfig).map(([key, config]) => (
<button
@@ -369,7 +372,7 @@ export default function HelpPage() {
: 'bg-secondary text-muted-foreground hover:text-foreground'
)}
>
{config.label}
{t(config.labelKey)}
</button>
))}
</div>
@@ -381,7 +384,7 @@ export default function HelpPage() {
<CardContent className="py-12 text-center">
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
Inga termer hittades för &quot;{searchQuery}&quot;
{t('no_results', { query: searchQuery })}
</p>
</CardContent>
</Card>
@@ -400,8 +403,8 @@ export default function HelpPage() {
{/* Document templates */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Dokument & Mallar</CardTitle>
<CardDescription>Lagstadgade mallar för din bokföring — ladda ner, fyll i och spara</CardDescription>
<CardTitle className="text-lg">{t('templates_title')}</CardTitle>
<CardDescription>{t('templates_subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
@@ -438,7 +441,7 @@ export default function HelpPage() {
{/* External resources */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Externa resurser</CardTitle>
<CardTitle className="text-lg">{t('external_resources_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
@@ -475,10 +478,10 @@ export default function HelpPage() {
<CardHeader>
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-muted-foreground" />
<CardTitle>Behöver du mer hjälp?</CardTitle>
<CardTitle>{t('support_title')}</CardTitle>
</div>
<CardDescription>
Hittar du inte svaret? Kontakta oss så hjälper vi dig.
{t('support_subtitle')}
</CardDescription>
</CardHeader>
<CardContent>
+26 -18
View File
@@ -2,6 +2,7 @@
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
@@ -1729,6 +1730,7 @@ export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null)
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
const t = useTranslations('import')
// Fetch authenticated user ID and sandbox status
useEffect(() => {
@@ -1769,9 +1771,9 @@ export default function ImportPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Importera</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
Importera banktransaktioner eller bokföringsdata till ditt företag
{t('subtitle')}
</p>
</div>
@@ -1781,7 +1783,7 @@ export default function ImportPage() {
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.
{t('sandbox_disabled')}
</p>
</div>
)}
@@ -1807,13 +1809,13 @@ export default function ImportPage() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5">
<h3 className="text-[15px] font-semibold leading-tight">Koppla bank</h3>
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
<span className="text-[11px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full leading-none">
Rekommenderat
{t('psd2_recommended')}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.
{t('psd2_description')}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
@@ -1840,9 +1842,9 @@ export default function ImportPage() {
<ArrowRightLeft className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Hämta från annat system</h3>
<h3 className="text-[15px] font-semibold leading-tight">{t('migration_title')}</h3>
<p className="text-sm mt-1.5 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
Inget ändras i ditt befintliga system.
{t('migration_description')}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
@@ -1882,9 +1884,9 @@ export default function ImportPage() {
<ArrowLeftRight className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Banktransaktioner</h3>
<h3 className="text-[15px] font-semibold leading-tight">{t('bankfile_title')}</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera kontoutdrag från din bank. Stöder de flesta svenska banker.
{t('bankfile_description')}
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['CSV', 'OFX', 'SEB', 'Swedbank', 'Nordea'].map(fmt => (
@@ -1915,14 +1917,20 @@ export default function ImportPage() {
<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">Importera CSV/Excel-data</h3>
<h3 className="text-[15px] font-semibold leading-tight">{t('csv_data_title')}</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera ingående balanser, kunder eller leverantörer.
{t('csv_data_description')}
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['XLSX', 'CSV', 'Ingående balanser', 'Kunder', 'Leverantörer'].map(fmt => (
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{fmt}
{[
{ key: 'XLSX', label: 'XLSX' },
{ key: 'CSV', label: 'CSV' },
{ key: 'opening_balances', label: t('csv_chip_opening_balances') },
{ key: 'customers', label: t('csv_chip_customers') },
{ key: 'suppliers', label: t('csv_chip_suppliers') },
].map(chip => (
<span key={chip.key} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{chip.label}
</span>
))}
</div>
@@ -1948,9 +1956,9 @@ export default function ImportPage() {
<FileText className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Bokföringsdata (SIE)</h3>
<h3 className="text-[15px] font-semibold leading-tight">{t('sie_title')}</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera verifikationer och kontoplan från ett annat bokföringsprogram.
{t('sie_description')}
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['SIE4', '.se', '.si'].map(fmt => (
@@ -1969,7 +1977,7 @@ export default function ImportPage() {
{mode !== null && (
<Button variant="ghost" size="sm" onClick={() => setMode(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till val
{t('back_to_choices')}
</Button>
)}
+49 -47
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, use } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -27,6 +28,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_credit')
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -53,8 +55,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
if (error || !data) {
toast({
title: 'Kunde inte ladda faktura',
description: 'Fakturan hittades inte.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
router.push('/invoices')
@@ -64,8 +66,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
// Check if invoice can be credited
if (!['sent', 'paid', 'overdue'].includes(data.status)) {
toast({
title: 'Kan inte krediteras',
description: 'Endast skickade, betalda eller förfallna fakturor kan krediteras',
title: t('cannot_credit_title'),
description: t('cannot_credit_description'),
variant: 'destructive',
})
router.push(`/invoices/${id}`)
@@ -74,8 +76,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
if (data.status === 'credited') {
toast({
title: 'Redan krediterad',
description: 'Denna faktura har redan krediterats',
title: t('already_credited_title'),
description: t('already_credited_description'),
variant: 'destructive',
})
router.push(`/invoices/${id}`)
@@ -88,7 +90,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
}
setInvoice(data as InvoiceWithRelations)
setReason(`Krediterar faktura ${data.invoice_number}`)
setReason(t('reason_default', { number: data.invoice_number ?? '' }))
setIsLoading(false)
}
@@ -109,21 +111,21 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create credit note')
throw new Error(data.error || t('create_failed_fallback'))
}
const { data: creditNote } = await response.json()
toast({
title: 'Kreditfaktura skapad',
description: `Kreditfaktura ${creditNote.invoice_number} har skapats`,
title: t('created_toast_title'),
description: t('created_toast_description', { number: creditNote.invoice_number }),
})
router.push(`/invoices/${creditNote.id}`)
} catch (error) {
toast({
title: 'Kunde inte skapa kreditfaktura',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('create_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
variant: 'destructive',
})
}
@@ -149,13 +151,13 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<div className="space-y-6 max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Skapa kreditfaktura</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
Krediterar faktura {invoice.invoice_number}
{t('subtitle', { number: invoice.invoice_number ?? '' })}
</p>
</div>
</div>
@@ -165,11 +167,9 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<CardContent className="flex items-start gap-4 pt-6">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">Oåterkallelig åtgärd</p>
<p className="font-medium text-destructive">{t('warning_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
En kreditfaktura makulerar den ursprungliga fakturan helt.
Alla belopp blir negativa, en bokföringsverifikation skapas, och den ursprungliga fakturan markeras som krediterad.
Denna åtgärd kan inte ångras.
{t('warning_description')}
</p>
</div>
</CardContent>
@@ -178,27 +178,27 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Original invoice info */}
<Card>
<CardHeader>
<CardTitle>Ursprunglig faktura</CardTitle>
<CardTitle>{t('original_card_title')}</CardTitle>
<CardDescription>
Kreditfakturan baseras på denna faktura
{t('original_card_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturanummer:</span>
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span className="ml-2 font-medium">{invoice.invoice_number}</span>
</div>
<div>
<span className="text-muted-foreground">Datum:</span>
<span className="text-muted-foreground">{t('date_label')}</span>
<span className="ml-2">{formatDate(invoice.invoice_date)}</span>
</div>
<div>
<span className="text-muted-foreground">Kund:</span>
<span className="text-muted-foreground">{t('customer_label')}</span>
<span className="ml-2">{customer.name}</span>
</div>
<div>
<span className="text-muted-foreground">Momsbehandling:</span>
<span className="text-muted-foreground">{t('vat_treatment_label')}</span>
<span className="ml-2">{getVatTreatmentLabel(invoice.vat_treatment)}</span>
</div>
</div>
@@ -208,20 +208,20 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Credit note preview */}
<Card>
<CardHeader>
<CardTitle>Kreditfaktura förhandsgranskning</CardTitle>
<CardTitle>{t('preview_card_title')}</CardTitle>
<CardDescription>
Kreditfakturanummer: KR-{invoice.invoice_number}
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header */}
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
<div className="col-span-2 text-right">à-pris</div>
<div className="col-span-2 text-right">Summa</div>
<div className="col-span-5">{t('th_description')}</div>
<div className="col-span-2 text-right">{t('th_quantity')}</div>
<div className="col-span-1 text-center">{t('th_unit')}</div>
<div className="col-span-2 text-right">{t('th_unit_price')}</div>
<div className="col-span-2 text-right">{t('th_amount')}</div>
</div>
{/* Items (negated) */}
@@ -246,27 +246,27 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Totals (negated) */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span className="text-muted-foreground">{t('subtotal')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.subtotal), invoice.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
<span className="text-muted-foreground">{t('vat_at_rate', { rate: invoice.vat_rate })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.vat_amount), invoice.currency)}
</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{t('total')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total), invoice.currency)}
</span>
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total_sek))}
</span>
@@ -280,19 +280,19 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Reason */}
<Card>
<CardHeader>
<CardTitle>Anledning</CardTitle>
<CardTitle>{t('reason_card_title')}</CardTitle>
<CardDescription>
Ange anledning till kreditering (visas på kreditfakturan)
{t('reason_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="reason">Anledning</Label>
<Label htmlFor="reason">{t('reason_label')}</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="T.ex. Felaktig fakturering, returnerade varor..."
placeholder={t('reason_placeholder')}
rows={3}
/>
</div>
@@ -302,9 +302,11 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Confirmation */}
<Card>
<CardHeader>
<CardTitle>Bekräfta</CardTitle>
<CardTitle>{t('confirm_card_title')}</CardTitle>
<CardDescription>
Skriv fakturanumret <span className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span> för att bekräfta
{t('confirm_card_description_1')}
<span className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{t('confirm_card_description_2')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -323,7 +325,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
{/* Actions */}
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => router.back()}>
Avbryt
{t('cancel')}
</Button>
<Button
variant="destructive"
@@ -334,20 +336,20 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
confirmText !== invoice.invoice_number ||
!canWrite
}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
{t('creating')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Skapa kreditfaktura
{t('create_credit_note')}
</>
) : (
'Skapa kreditfaktura'
t('create_credit_note')
)}
</Button>
</div>
+127 -126
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -44,20 +45,14 @@ import {
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
draft: { label: 'Utkast', variant: 'secondary' },
sent: { label: 'Skickad', variant: 'default' },
paid: { label: 'Betald', variant: 'success' },
partially_paid: { label: 'Delbetalad', variant: 'warning' },
overdue: { label: 'Förfallen', variant: 'destructive' },
cancelled: { label: 'Makulerad', variant: 'secondary' },
credited: { label: 'Krediterad', variant: 'secondary' },
}
const reminderLevelLabels: Record<1 | 2 | 3, string> = {
1: 'Vänlig påminnelse',
2: 'Andra påminnelsen',
3: 'Slutlig påminnelse'
const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
draft: 'secondary',
sent: 'default',
paid: 'success',
partially_paid: 'warning',
overdue: 'destructive',
cancelled: 'secondary',
credited: 'secondary',
}
interface InvoiceWithRelations extends Invoice {
@@ -76,6 +71,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_detail')
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
@@ -93,6 +89,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [isDeleting, setIsDeleting] = useState(false)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
useEffect(() => {
fetchInvoice()
}, [id])
@@ -112,8 +111,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (error || !data) {
toast({
title: 'Kunde inte ladda faktura',
description: 'Fakturan hittades inte.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
router.push('/invoices')
@@ -203,7 +202,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte markera som skickad')
throw new Error(data.error || t('mark_sent_failed_fallback'))
}
} else if (status === 'cancelled') {
// Only drafts and proformas can be cancelled directly — sent/overdue/paid
@@ -211,7 +210,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (invoice.status !== 'draft') {
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
if (docType !== 'proforma') {
throw new Error('Bokförda fakturor kan inte makuleras. Skapa en kreditfaktura istället.')
throw new Error(t('cancel_posted_error'))
}
}
const { error } = await supabase
@@ -228,14 +227,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
toast({
title: 'Uppdaterad',
description: `Fakturan är nu markerad som ${statusConfig[status].label.toLowerCase()}`,
title: t('status_update_toast_title'),
description: t('status_update_toast_description', { status: statusLabel(status).toLowerCase() }),
})
fetchInvoice()
} catch (error) {
toast({
title: 'Statusuppdatering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('status_update_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -260,19 +259,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte konvertera proformafakturan')
throw new Error(data.error || t('convert_failed_fallback'))
}
toast({
title: 'Konverterad till faktura',
description: `Faktura ${data.data.invoice_number} har skapats`,
title: t('converted_toast_title'),
description: t('converted_toast_description', { number: data.data.invoice_number }),
})
router.push(`/invoices/${data.data.id}`)
} catch (error) {
toast({
title: 'Konvertering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('convert_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -289,7 +288,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const response = await fetch(`/api/invoices/${invoice.id}/pdf`)
if (!response.ok) {
throw new Error('Kunde inte generera PDF')
throw new Error(t('pdf_generate_failed'))
}
const blob = await response.blob()
@@ -303,15 +302,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
document.body.removeChild(a)
toast({
title: 'PDF nedladdad',
title: t('pdf_downloaded_title'),
description: invoice.invoice_number
? `Faktura ${invoice.invoice_number} har laddats ner`
: 'Utkastet har laddats ner',
? t('pdf_downloaded_with_number', { number: invoice.invoice_number })
: t('pdf_downloaded_draft'),
})
} catch (error) {
toast({
title: 'Kunde inte ladda ner PDF',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('pdf_download_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -331,21 +330,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte makulera fakturan')
throw new Error(data.error || t('cancel_failed_fallback'))
}
toast({
title: 'Faktura makulerad',
title: t('cancelled_toast_title'),
description: invoice.invoice_number
? `Faktura ${invoice.invoice_number} har makulerats. Numret behålls i serien.`
: 'Utkastet har makulerats.',
? t('cancelled_with_number', { number: invoice.invoice_number })
: t('cancelled_draft'),
})
router.push('/invoices')
} catch (error) {
toast({
title: 'Kunde inte makulera fakturan',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('cancel_failed_title'),
description: error instanceof Error ? error.message : t('fallback_try_again'),
variant: 'destructive',
})
}
@@ -366,7 +365,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
return null
}
const status = statusConfig[invoice.status]
const statusVariant = statusVariantMap[invoice.status]
const customer = invoice.customer
const customerHasEmail = !!customer.email
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
@@ -378,25 +377,25 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-primary/10 text-primary">Proforma</Badge>
<Badge variant="secondary" className="bg-primary/10 text-primary">{t('badge_proforma')}</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="bg-success/10 text-success">Följesedel</Badge>
<Badge variant="secondary" className="bg-success/10 text-success">{t('badge_delivery_note')}</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
<Badge variant={statusVariant as 'default' | 'secondary' | 'destructive'}>
{statusLabel(invoice.status)}
</Badge>
</div>
<p className="text-muted-foreground">
Skapad {formatDate(invoice.created_at)}
{invoice.sent_at && ` • Skickad ${formatDate(invoice.sent_at)}`}
{t('created_at', { date: formatDate(invoice.created_at) })}
{invoice.sent_at && t('sent_at_suffix', { date: formatDate(invoice.sent_at) })}
</p>
</div>
</div>
@@ -407,7 +406,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Button
onClick={convertToInvoice}
disabled={isConverting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -416,7 +415,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Konvertera till faktura
{t('convert_to_invoice')}
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && (
@@ -424,20 +423,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Button
onClick={() => openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Skicka via e-post
{t('send_via_email')}
</Button>
) : (
<Button
variant="secondary"
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Skickad manuellt
{t('mark_sent_manually')}
</Button>
)
)}
@@ -446,20 +445,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
variant="secondary"
onClick={() => updateStatus('sent')}
disabled={isUpdating || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera som skickad
{t('mark_as_sent')}
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera som betald
{t('mark_as_paid')}
</Button>
)}
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
@@ -468,7 +467,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner PDF
{t('download_pdf')}
</Button>
</div>
</div>
@@ -477,16 +476,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Customer info */}
<Card className="lg:col-span-2 lg:row-start-1">
<CardHeader>
<CardTitle>Kund</CardTitle>
<CardTitle>{t('customer_card_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium text-lg">{customer.name}</p>
{customer.org_number && (
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
<p className="text-muted-foreground">{t('org_number_label', { value: customer.org_number })}</p>
)}
{customer.vat_number && (
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
<p className="text-muted-foreground">{t('vat_number_label', { value: customer.vat_number })}</p>
)}
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
{customer.email && (
@@ -513,17 +512,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Invoice items */}
<Card className="lg:col-span-2 lg:row-start-2">
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
<CardTitle>{t('items_card_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header — desktop */}
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
<div className="col-span-2 text-right">à-pris</div>
<div className="col-span-2 text-right">Summa</div>
<div className="col-span-5">{t('th_description')}</div>
<div className="col-span-2 text-right">{t('th_quantity')}</div>
<div className="col-span-1 text-center">{t('th_unit')}</div>
<div className="col-span-2 text-right">{t('th_unit_price')}</div>
<div className="col-span-2 text-right">{t('th_amount')}</div>
</div>
{/* Items — desktop */}
@@ -563,7 +562,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(() => {
@@ -580,7 +579,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (entries.length === 0) {
return (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, invoice.currency)}</span>
</div>
)
@@ -588,7 +587,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
return entries.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(vat, invoice.currency)}</span>
</div>
))
@@ -600,12 +599,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<>
{rounding.applies && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>Öresavrundning</span>
<span>{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
</>
@@ -613,7 +612,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span>{formatCurrency(invoice.total_sek)}</span>
</div>
)}
@@ -626,12 +625,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{(invoice.notes || invoice.reverse_charge_text) && (
<Card className="lg:col-span-2 lg:row-start-3">
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
<CardTitle>{t('notes_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.reverse_charge_text && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
<p className="text-sm font-medium">{t('reverse_charge_label')}</p>
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
</div>
)}
@@ -645,35 +644,35 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Invoice details */}
<Card>
<CardHeader>
<CardTitle>Detaljer</CardTitle>
<CardTitle>{t('details_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturanummer</span>
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span className={cn('font-medium', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturadatum</span>
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
<span>{formatDate(invoice.invoice_date)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Förfallodatum</span>
<span className="text-muted-foreground">{t('due_date_label')}</span>
<span>{formatDate(invoice.due_date)}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">Valuta</span>
<span className="text-muted-foreground">{t('currency_label')}</span>
<span>{invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Momsbehandling</span>
<span className="text-muted-foreground">{t('vat_treatment_label')}</span>
<span className="text-right text-sm">
{getVatTreatmentLabel(invoice.vat_treatment)}
</span>
</div>
{invoice.your_reference && (
<div className="space-y-1">
<span className="text-muted-foreground">Er referens</span>
<span className="text-muted-foreground">{t('your_reference_label')}</span>
<div className="flex flex-wrap gap-1">
{invoice.your_reference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -685,7 +684,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{invoice.our_reference && (
<div className="space-y-1">
<span className="text-muted-foreground">Vår referens</span>
<span className="text-muted-foreground">{t('our_reference_label')}</span>
<div className="flex flex-wrap gap-1">
{invoice.our_reference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -699,13 +698,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<>
<Separator />
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">Bokföring</span>
<span className="text-muted-foreground text-sm">{t('bookkeeping_label')}</span>
<div className="flex flex-col items-end gap-1.5">
<Link
href={`/bookkeeping/${invoice.journal_entry_id}`}
className="text-sm hover:underline tabular-nums"
>
Visa verifikation
{t('view_voucher')}
</Link>
{canWrite && (
<CorrectionAffordance
@@ -719,7 +718,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isLoading}
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{isLoading ? 'Hämtar…' : 'Något fel? Skapa ändringsverifikation'}
{isLoading ? t('correction_loading') : t('correction_prompt')}
</button>
)}
</CorrectionAffordance>
@@ -737,12 +736,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardHeader>
<CardTitle className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
Betald
{t('paid_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Betalning mottagen {formatDate(invoice.paid_at)}
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
</p>
{invoice.paid_amount && (
<p className="text-lg font-bold mt-2">
@@ -759,11 +758,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Påminnelser
{t('reminders_card_title')}
</CardTitle>
{reminders.length === 0 && (
<CardDescription>
Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning
{t('reminders_description')}
</CardDescription>
)}
</CardHeader>
@@ -781,26 +780,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
variant={reminder.reminder_level === 3 ? 'destructive' : reminder.reminder_level === 2 ? 'default' : 'secondary'}
className="text-xs"
>
Nivå {reminder.reminder_level}
{t('reminder_level_label', { level: reminder.reminder_level })}
</Badge>
<span className="text-sm font-medium">
{reminderLevelLabels[reminder.reminder_level as 1 | 2 | 3]}
{reminderLevelLabel(reminder.reminder_level as 1 | 2 | 3)}
</span>
</div>
<p className="text-xs text-muted-foreground">
Skickad {formatDate(reminder.sent_at)} till {reminder.email_to}
{t('reminder_sent_to', { date: formatDate(reminder.sent_at), email: reminder.email_to })}
</p>
{reminder.response_type && (
<div className="flex items-center gap-1 mt-1">
{reminder.response_type === 'marked_paid' ? (
<>
<CheckCircle className="h-3 w-3 text-success" />
<span className="text-xs text-success">Kunden markerat som betald</span>
<span className="text-xs text-success">{t('reminder_marked_paid')}</span>
</>
) : (
<>
<MessageSquare className="h-3 w-3 text-orange-600" />
<span className="text-xs text-orange-600">Kunden har invändningar</span>
<span className="text-xs text-orange-600">{t('reminder_objection')}</span>
</>
)}
</div>
@@ -811,7 +810,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
) : (
<p className="text-sm text-muted-foreground">
Inga påminnelser har skickats ännu.
{t('reminders_empty')}
</p>
)}
</CardContent>
@@ -824,17 +823,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<ReceiptText className="h-5 w-5" />
Krediterad
{t('credited_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Denna faktura har krediterats
{t('credited_description')}
</p>
<Link href={`/invoices/${creditNote.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se kreditfaktura {creditNote.invoice_number}
{t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
</Button>
</Link>
</CardContent>
@@ -847,17 +846,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ReceiptText className="h-5 w-5" />
Kreditfaktura
{t('credit_note_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Denna kreditfaktura krediterar
{t('credit_note_description')}
</p>
<Link href={`/invoices/${originalInvoice.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se faktura {originalInvoice.invoice_number}
{t('see_original_invoice', { number: originalInvoice.invoice_number ?? '' })}
</Button>
</Link>
</CardContent>
@@ -870,17 +869,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-600">
<FileText className="h-5 w-5" />
Konverterad
{t('converted_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Konverterad från proformafaktura
{t('converted_description')}
</p>
<Link href={`/invoices/${convertedFromInvoice.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se proforma {convertedFromInvoice.invoice_number}
{t('see_proforma', { number: convertedFromInvoice.invoice_number ?? '' })}
</Button>
</Link>
</CardContent>
@@ -891,7 +890,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
<Card>
<CardHeader>
<CardTitle>Åtgärder</CardTitle>
<CardTitle>{t('actions_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isProforma && (
@@ -906,7 +905,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Konvertera till faktura
{t('convert_to_invoice')}
</Button>
<Button
variant="outline"
@@ -915,7 +914,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isUpdating}
>
<XCircle className="mr-2 h-4 w-4" />
Makulera
{t('cancel_action')}
</Button>
</>
)}
@@ -928,7 +927,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onClick={() => openSendDialog('email')}
>
<Mail className="mr-2 h-4 w-4" />
Skicka via e-post
{t('send_via_email')}
</Button>
<Button
variant="ghost"
@@ -936,10 +935,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
Skickad manuellt
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
Använd om du redan skickat fakturan på annat sätt (post, annat system)
{t('send_manual_hint_with_email')}
</p>
</>
) : (
@@ -948,7 +947,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2 dark:bg-yellow-950/30 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
{t('no_customer_email_warning')}
</p>
</div>
)}
@@ -957,10 +956,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
Skickad manuellt
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
Markerar fakturan som skickad och skapar bokföringsverifikation
{t('send_manual_hint_no_email')}
</p>
</>
)}
@@ -971,7 +970,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
Makulera utkast
{t('delete_draft')}
</Button>
</>
)}
@@ -983,12 +982,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isUpdating}
>
<CheckCircle className="mr-2 h-4 w-4" />
Markera som betald
{t('mark_as_paid')}
</Button>
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
Skapa kreditfaktura
{t('create_credit_note')}
</Button>
</Link>
</>
@@ -997,7 +996,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
Skapa kreditfaktura
{t('create_credit_note')}
</Button>
</Link>
)}
@@ -1012,29 +1011,31 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Makulera fakturautkast</DialogTitle>
<DialogTitle>{t('delete_dialog_title')}</DialogTitle>
<DialogDescription>
{invoice.invoice_number ? (
<>
Fakturan markeras som makulerad och sparas i fakturalistan med status <strong>Makulerad</strong>.
{t('delete_dialog_desc_with_number_1')}
<strong>{t('delete_dialog_status_makulerad')}</strong>
{t('delete_dialog_desc_with_number_2')}
<span className="mt-2 block text-muted-foreground">
Fakturanumret {invoice.invoice_number} behålls för att hålla nummerserien obruten enligt ML 17 kap 24§.
{t('delete_dialog_number_kept', { number: invoice.invoice_number })}
</span>
</>
) : (
<>
Utkastet markeras som makulerat. Detta kan inte ångras.
{t('delete_dialog_desc_no_number')}
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)} disabled={isDeleting}>
Avbryt
{t('delete_dialog_cancel')}
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Makulera
{t('delete_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1047,8 +1048,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onSuccess={() => {
fetchInvoice()
toast({
title: 'Betald',
description: `Faktura ${invoice.invoice_number} har markerats som betald och bokförts`,
title: t('paid_toast_title'),
description: t('paid_toast_description', { number: invoice.invoice_number ?? '' }),
})
}}
/>
+124 -98
View File
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -31,29 +32,6 @@ import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPr
import { useCompany } from '@/contexts/CompanyContext'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType } from '@/types'
const itemSchema = z.object({
description: z.string().min(1, 'Beskrivning krävs'),
quantity: z.number().min(0.01, 'Minst 0.01'),
unit: z.string().min(1, 'Enhet krävs'),
unit_price: z.number().min(0, 'Pris måste vara positivt'),
vat_rate: z.number().min(0).max(25),
})
const schema = z.object({
customer_id: z.string().min(1, 'Välj en kund'),
invoice_date: z.string().min(1, 'Fakturadatum krävs'),
due_date: z.string().min(1, 'Förfallodatum krävs'),
delivery_date: z.string().optional(),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, 'Minst en rad krävs'),
})
type FormData = z.infer<typeof schema>
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -67,6 +45,31 @@ export default function NewInvoicePage() {
const { canWrite } = useCanWrite()
const { company } = useCompany()
const supabase = createClient()
const t = useTranslations('invoice_editor')
const schema = useMemo(() => {
const itemSchema = z.object({
description: z.string().min(1, t('validation_description_required')),
quantity: z.number().min(0.01, t('validation_quantity_min')),
unit: z.string().min(1, t('validation_unit_required')),
unit_price: z.number().min(0, t('validation_price_positive')),
vat_rate: z.number().min(0).max(25),
})
return z.object({
customer_id: z.string().min(1, t('validation_customer_required')),
invoice_date: z.string().min(1, t('validation_invoice_date_required')),
due_date: z.string().min(1, t('validation_due_date_required')),
delivery_date: z.string().optional(),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
}, [t])
type FormData = z.infer<typeof schema>
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -249,8 +252,8 @@ export default function NewInvoicePage() {
if (error) {
toast({
title: 'Kunde inte ladda kunder',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_customers_failed_title'),
description: t('load_customers_failed_description'),
variant: 'destructive',
})
} else {
@@ -272,14 +275,14 @@ export default function NewInvoicePage() {
if (!response.ok) {
toast({
title: 'Kunde inte skapa kund',
title: t('create_customer_failed_title'),
description: getErrorMessage(result, { context: 'customer' }),
variant: 'destructive',
})
} else {
toast({
title: 'Kund skapad',
description: `${data.name} har lagts till`,
title: t('customer_created_title'),
description: t('customer_created_description', { name: data.name }),
})
pendingCustomerRef.current = result.data
setCustomers(prev => [...prev, result.data])
@@ -347,6 +350,12 @@ export default function NewInvoicePage() {
}
}
function getDocLabel(type: InvoiceDocumentType): string {
if (type === 'proforma') return t('doc_label_proforma')
if (type === 'delivery_note') return t('doc_label_delivery_note')
return t('doc_label_invoice')
}
function handleLogoPromptClose() {
setShowLogoPrompt(false)
// Resume the post-create flow that was deferred by the logo prompt.
@@ -374,10 +383,10 @@ export default function NewInvoicePage() {
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const docLabel = watchDocumentType === 'proforma' ? 'Proformafaktura' : watchDocumentType === 'delivery_note' ? 'Följesedel' : 'Faktura'
const docLabel = getDocLabel(watchDocumentType)
toast({
title: `${docLabel} skapad`,
description: `${docLabel} ${result.data.invoice_number} har skapats`,
title: t('doc_created_title', { docLabel }),
description: t('doc_created_description', { docLabel, number: result.data.invoice_number }),
})
setShowReview(false)
@@ -396,7 +405,7 @@ export default function NewInvoicePage() {
}
} catch (error) {
toast({
title: 'Kunde inte skapa faktura',
title: t('create_invoice_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
@@ -420,12 +429,12 @@ export default function NewInvoicePage() {
}
toast({
title: 'Faktura skickad',
description: `Fakturan har skickats till ${selectedCustomer?.email}`,
title: t('invoice_sent_title'),
description: t('invoice_sent_description', { email: selectedCustomer?.email ?? '' }),
})
} catch (error) {
toast({
title: 'Kunde inte skicka faktura',
title: t('send_invoice_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
@@ -468,7 +477,7 @@ export default function NewInvoicePage() {
window.open(url, '_blank')
} catch (error) {
toast({
title: 'Kunde inte generera PDF',
title: t('preview_pdf_failed'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
@@ -485,33 +494,42 @@ export default function NewInvoicePage() {
)
}
const titleText = watchDocumentType === 'proforma'
? t('title_proforma')
: watchDocumentType === 'delivery_note'
? t('title_delivery_note')
: t('title_invoice')
const subtitleText = watchDocumentType === 'proforma'
? t('subtitle_proforma')
: watchDocumentType === 'delivery_note'
? t('subtitle_delivery_note')
: t('subtitle_invoice')
return (
<div className="space-y-8">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'}
{titleText}
{numberPreview && (
<span className="ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl">
({numberPreview})
</span>
)}
</h1>
<p className="text-muted-foreground">
{watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'}
</p>
<p className="text-muted-foreground">{subtitleText}</p>
</div>
</div>
{hasBankDetails === false && (
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-sm">
<Landmark className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">Betalningsuppgifter saknas — du behöver lägga till dem innan du skapar en faktura.</p>
<p className="text-muted-foreground">{t('bank_missing_warning')}</p>
<Button variant="link" size="sm" className="ml-auto shrink-0 px-0" onClick={() => setShowBankSetup(true)}>
Lägg till nu
{t('bank_add_now')}
</Button>
</div>
)}
@@ -523,8 +541,8 @@ export default function NewInvoicePage() {
{/* Customer selection */}
<Card>
<CardHeader>
<CardTitle>Kund<RequiredMark /></CardTitle>
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
<CardTitle>{t('customer_card_title')}<RequiredMark /></CardTitle>
<CardDescription>{t('customer_card_description')}</CardDescription>
</CardHeader>
<CardContent>
<Controller
@@ -533,7 +551,7 @@ export default function NewInvoicePage() {
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kund" />
<SelectValue placeholder={t('select_customer_placeholder')} />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
@@ -553,7 +571,7 @@ export default function NewInvoicePage() {
onClick={() => setIsCreateCustomerOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Skapa kund
{t('create_customer')}
</Button>
{errors.customer_id && (
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
@@ -565,8 +583,8 @@ export default function NewInvoicePage() {
{/* Invoice items */}
<Card>
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
<CardTitle>{t('items_card_title')}</CardTitle>
<CardDescription>{t('items_card_description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -581,9 +599,9 @@ export default function NewInvoicePage() {
{/* Description + mobile delete button */}
<div className="flex items-start gap-2 md:contents">
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('description_label')}</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
placeholder={t('description_placeholder')}
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
@@ -607,7 +625,7 @@ export default function NewInvoicePage() {
{/* Antal, Enhet, à-pris */}
<div className="grid grid-cols-3 gap-2 md:contents">
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Antal</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('quantity_label')}</Label>
<Input
type="number"
step="0.01"
@@ -617,7 +635,7 @@ export default function NewInvoicePage() {
/>
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Enhet</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_label')}</Label>
<Controller
name={`items.${index}.unit`}
control={control}
@@ -638,7 +656,7 @@ export default function NewInvoicePage() {
/>
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_price_label')}</Label>
<Input
type="number"
step="any"
@@ -651,7 +669,7 @@ export default function NewInvoicePage() {
{/* Moms */}
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('vat_label')}</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
@@ -691,7 +709,7 @@ export default function NewInvoicePage() {
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">Rad {index + 1}</span>
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
</div>
</div>
@@ -707,7 +725,7 @@ export default function NewInvoicePage() {
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
{t('add_row')}
</Button>
</div>
</CardContent>
@@ -716,12 +734,12 @@ export default function NewInvoicePage() {
{/* Notes */}
<Card>
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
<CardDescription>Valfritt meddelande på fakturan</CardDescription>
<CardTitle>{t('notes_card_title')}</CardTitle>
<CardDescription>{t('notes_card_description')}</CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</CardContent>
@@ -733,11 +751,11 @@ export default function NewInvoicePage() {
{/* Invoice details */}
<Card>
<CardHeader>
<CardTitle>Fakturadetaljer</CardTitle>
<CardTitle>{t('details_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Dokumenttyp</Label>
<Label>{t('document_type_label')}</Label>
<Controller
name="document_type"
control={control}
@@ -747,9 +765,9 @@ export default function NewInvoicePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">Faktura</SelectItem>
<SelectItem value="proforma">Proformafaktura</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
<SelectItem value="invoice">{t('doctype_invoice')}</SelectItem>
<SelectItem value="proforma">{t('doctype_proforma')}</SelectItem>
<SelectItem value="delivery_note">{t('doctype_delivery_note')}</SelectItem>
</SelectContent>
</Select>
)}
@@ -757,7 +775,7 @@ export default function NewInvoicePage() {
</div>
<div className="space-y-2">
<Label>Valuta</Label>
<Label>{t('currency_label')}</Label>
<Controller
name="currency"
control={control}
@@ -779,26 +797,26 @@ export default function NewInvoicePage() {
</div>
<div className="space-y-2">
<Label>Fakturadatum<RequiredMark /></Label>
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
<Input type="date" {...register('invoice_date')} aria-required="true" />
</div>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Label>{t('due_date_label')}<RequiredMark /></Label>
<Input type="date" {...register('due_date')} aria-required="true" />
</div>
{watchDocumentType === 'invoice' && (
<div className="space-y-2">
<Label>Leveransdatum</Label>
<Input type="date" {...register('delivery_date')} placeholder="Om det skiljer sig från fakturadatum" />
<Label>{t('delivery_date_label')}</Label>
<Input type="date" {...register('delivery_date')} placeholder={t('delivery_date_placeholder')} />
</div>
)}
<Separator />
<div className="space-y-2">
<Label>Er referens</Label>
<Label>{t('your_reference_label')}</Label>
<Controller
name="your_reference"
control={control}
@@ -806,14 +824,14 @@ export default function NewInvoicePage() {
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Kontaktperson hos kund"
placeholder={t('your_reference_placeholder')}
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Vår referens</Label>
<Label>{t('our_reference_label')}</Label>
<Controller
name="our_reference"
control={control}
@@ -821,7 +839,7 @@ export default function NewInvoicePage() {
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Ditt namn"
placeholder={t('our_reference_placeholder')}
/>
)}
/>
@@ -832,11 +850,11 @@ export default function NewInvoicePage() {
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Summering</CardTitle>
<CardTitle>{t('summary_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span className="text-muted-foreground">{t('subtotal_label')}</span>
<span>{formatCurrency(subtotal, watchCurrency)}</span>
</div>
{Array.from(vatByRate.entries())
@@ -845,13 +863,13 @@ export default function NewInvoicePage() {
<div key={rate}>
{vatByRate.size > 1 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Netto {rate}%</span>
<span className="text-muted-foreground">{t('net_at_rate', { rate })}</span>
<span>{formatCurrency(group.base, watchCurrency)}</span>
</div>
)}
{group.vat > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(group.vat, watchCurrency)}</span>
</div>
)}
@@ -859,13 +877,13 @@ export default function NewInvoicePage() {
))}
{vatByRate.size === 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label_short')}</span>
<span>{formatCurrency(0, watchCurrency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{t('total_label')}</span>
<span>{formatCurrency(total, watchCurrency)}</span>
</div>
</CardContent>
@@ -877,10 +895,10 @@ export default function NewInvoicePage() {
className="w-full hidden md:block"
size="lg"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
{t('review_and_create')}
</Button>
</div>
</div>
@@ -889,16 +907,16 @@ export default function NewInvoicePage() {
<div className="md:hidden fixed left-0 right-0 z-40 bg-card/98 backdrop-blur-sm border-t border-border/40 px-5 py-3" style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}>
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
<div>
<p className="text-xs text-muted-foreground">Totalt</p>
<p className="text-xs text-muted-foreground">{t('total_label')}</p>
<p className="text-lg font-bold tabular-nums">{formatCurrency(total, watchCurrency)}</p>
</div>
<Button
type="submit"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
{t('review_and_create')}
</Button>
</div>
</div>
@@ -910,15 +928,23 @@ export default function NewInvoicePage() {
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title={watchDocumentType === 'proforma' ? 'Granska proformafaktura' : watchDocumentType === 'delivery_note' ? 'Granska följesedel' : 'Granska faktura'}
title={watchDocumentType === 'proforma'
? t('review_dialog_title_proforma')
: watchDocumentType === 'delivery_note'
? t('review_dialog_title_delivery_note')
: t('review_dialog_title_invoice')}
warningText={watchDocumentType === 'invoice'
? accountingMethod === 'cash'
? 'En faktura skapas och tilldelas ett fakturanummer. Verifikationen bokförs först när fakturan markeras som betald (kontantmetoden).'
: 'En faktura skapas och tilldelas ett fakturanummer. När den skickas eller markeras som skickad bokförs en verifikation, som inte kan redigeras direkt men kan korrigeras via en kreditnota.'
? t('review_warning_invoice_cash')
: t('review_warning_invoice_accrual')
: watchDocumentType === 'proforma'
? 'En proformafaktura skapas. Ingen verifikation bokförs. Proforman kan senare konverteras till en riktig faktura.'
: 'En följesedel skapas utan priser. Ingen verifikation bokförs.'}
confirmLabel={watchDocumentType === 'proforma' ? 'Skapa proformafaktura' : watchDocumentType === 'delivery_note' ? 'Skapa följesedel' : 'Bekräfta & skapa'}
? t('review_warning_proforma')
: t('review_warning_delivery_note')}
confirmLabel={watchDocumentType === 'proforma'
? t('confirm_create_proforma')
: watchDocumentType === 'delivery_note'
? t('confirm_create_delivery_note')
: t('confirm_create_invoice')}
extraActions={
<Button
variant="outline"
@@ -930,7 +956,7 @@ export default function NewInvoicePage() {
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{isPreviewing ? 'Genererar...' : 'Förhandsgranska PDF'}
{isPreviewing ? t('preview_pdf_generating') : t('preview_pdf')}
</Button>
}
>
@@ -959,7 +985,7 @@ export default function NewInvoicePage() {
<Dialog open={isCreateCustomerOpen} onOpenChange={setIsCreateCustomerOpen}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till kund</DialogTitle>
<DialogTitle>{t('create_customer_dialog_title')}</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleCreateCustomer}
@@ -992,9 +1018,9 @@ export default function NewInvoicePage() {
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Skicka fakturan nu?</DialogTitle>
<DialogTitle>{t('send_now_dialog_title')}</DialogTitle>
<DialogDescription>
Fakturan skapades. Vill du skicka den till {selectedCustomer?.email} direkt?
{t('send_now_dialog_description', { email: selectedCustomer?.email ?? '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:gap-0">
@@ -1006,7 +1032,7 @@ export default function NewInvoicePage() {
}}
disabled={isSending}
>
Skicka senare
{t('send_later')}
</Button>
<Button onClick={handleSendNow} disabled={isSending}>
{isSending ? (
@@ -1014,7 +1040,7 @@ export default function NewInvoicePage() {
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isSending ? 'Skickar...' : 'Skicka nu'}
{isSending ? t('send_now_sending') : t('send_now')}
</Button>
</DialogFooter>
</DialogContent>
+69 -64
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -21,35 +22,40 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
draft: { label: 'Utkast', variant: 'secondary' },
sent: { label: 'Skickad', variant: 'default' },
paid: { label: 'Betald', variant: 'success' },
partially_paid: { label: 'Delbetalad', variant: 'warning' },
overdue: { label: 'Förfallen', variant: 'destructive' },
cancelled: { label: 'Makulerad', variant: 'secondary' },
credited: { label: 'Krediterad', variant: 'secondary' },
type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
const STATUS_CONFIG: Record<InvoiceStatus, { labelKey: string; variant: InvoiceStatusVariant }> = {
draft: { labelKey: 'status_draft', variant: 'secondary' },
sent: { labelKey: 'status_sent', variant: 'default' },
paid: { labelKey: 'status_paid', variant: 'success' },
partially_paid: { labelKey: 'status_partially_paid', variant: 'warning' },
overdue: { labelKey: 'status_overdue', variant: 'destructive' },
cancelled: { labelKey: 'status_cancelled', variant: 'secondary' },
credited: { labelKey: 'status_credited', variant: 'secondary' },
}
function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
if (status === 'paid' || status === 'cancelled' || status === 'credited' || status === 'draft') return null
function useRelativeTimeLabel() {
const t = useTranslations('invoices')
return function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
if (status === 'paid' || status === 'cancelled' || status === 'credited' || status === 'draft') return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { text: `${Math.abs(diffDays)} dagar försenad`, color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: 'Förfaller idag', color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: `${diffDays} dagar kvar`, color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: `${diffDays} dagar kvar`, color: 'text-muted-foreground' }
if (diffDays < 0) {
return { text: t('due_days_overdue', { days: Math.abs(diffDays) }), color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: t('due_today'), color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: t('due_days_left', { days: diffDays }), color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: t('due_days_left', { days: diffDays }), color: 'text-muted-foreground' }
}
return null
}
return null
}
export default function InvoicesPage() {
@@ -62,6 +68,8 @@ export default function InvoicesPage() {
const [activeTab, setActiveTab] = useState('all')
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoices')
const getRelativeTimeLabel = useRelativeTimeLabel()
async function fetchInvoices() {
if (!company) return
@@ -81,8 +89,8 @@ export default function InvoicesPage() {
if (invoicesResult.error) {
toast({
title: 'Kunde inte ladda fakturor',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
@@ -103,9 +111,6 @@ export default function InvoicesPage() {
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
// Cancelled invoices are kept in the table for compliance but hidden from
// the default 'Alla' view; they only show up when the user explicitly picks
// the 'Makulerade' tab.
const matchesTab =
(activeTab === 'all' && invoice.status !== 'cancelled') ||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
@@ -136,29 +141,29 @@ export default function InvoicesPage() {
return (
<div className="space-y-8">
<PageHeader
title="Fakturor"
title={t('title')}
action={
<div className="flex gap-2">
<Link href="/invoices/recurring">
<Button variant="secondary">
<Repeat className="mr-2 h-4 w-4" />
Återkommande
{t('recurring')}
</Button>
</Link>
{canWrite ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny faktura
{t('new_invoice')}
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
title={t('viewer_disabled_tooltip')}
>
<Lock className="mr-2 h-4 w-4" />
Ny faktura
{t('new_invoice')}
</Button>
)}
</div>
@@ -168,17 +173,17 @@ export default function InvoicesPage() {
{/* Inline summary */}
{!isLoading && invoices.length > 0 && (
<p className="text-sm text-muted-foreground tabular-nums">
{invoices.length} {invoices.length === 1 ? 'faktura' : 'fakturor'}
{invoices.length === 1 ? t('summary_one', { count: invoices.length }) : t('summary_other', { count: invoices.length })}
{stats.unpaid > 0 && (
<>
{' · '}
<span className="text-foreground">{stats.unpaid} obetalda</span>
<span className="text-foreground">{t('summary_unpaid', { count: stats.unpaid })}</span>
{' · '}
{formatCurrency(stats.unpaidAmount)} att få in
{t('summary_to_collect', { amount: formatCurrency(stats.unpaidAmount) })}
{stats.overdue > 0 && (
<>
{' · '}
<span className="text-destructive">{stats.overdue} förfallna</span>
<span className="text-destructive">{t('summary_overdue', { count: stats.overdue })}</span>
</>
)}
</>
@@ -191,7 +196,7 @@ export default function InvoicesPage() {
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök fakturor"
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@@ -203,27 +208,27 @@ export default function InvoicesPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="unpaid">Obetalda</SelectItem>
<SelectItem value="paid">Betalda</SelectItem>
<SelectItem value="draft">Utkast</SelectItem>
<SelectItem value="proforma">Proforma</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
<SelectItem value="cancelled">Makulerade</SelectItem>
<SelectItem value="all">{t('tab_all')}</SelectItem>
<SelectItem value="unpaid">{t('tab_unpaid')}</SelectItem>
<SelectItem value="paid">{t('tab_paid')}</SelectItem>
<SelectItem value="draft">{t('tab_draft')}</SelectItem>
<SelectItem value="proforma">{t('tab_proforma')}</SelectItem>
<SelectItem value="delivery_note">{t('tab_delivery_note')}</SelectItem>
<SelectItem value="credit">{t('tab_credit')}</SelectItem>
<SelectItem value="cancelled">{t('tab_cancelled')}</SelectItem>
</SelectContent>
</Select>
{/* Desktop: tab bar */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="hidden sm:block">
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="unpaid">Obetalda</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
<TabsTrigger value="draft">Utkast</TabsTrigger>
<TabsTrigger value="proforma">Proforma</TabsTrigger>
<TabsTrigger value="delivery_note">Följesedel</TabsTrigger>
<TabsTrigger value="credit">Kredit</TabsTrigger>
<TabsTrigger value="cancelled">Makulerade</TabsTrigger>
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="unpaid">{t('tab_unpaid')}</TabsTrigger>
<TabsTrigger value="paid">{t('tab_paid')}</TabsTrigger>
<TabsTrigger value="draft">{t('tab_draft')}</TabsTrigger>
<TabsTrigger value="proforma">{t('tab_proforma')}</TabsTrigger>
<TabsTrigger value="delivery_note">{t('tab_delivery_note')}</TabsTrigger>
<TabsTrigger value="credit">{t('tab_credit')}</TabsTrigger>
<TabsTrigger value="cancelled">{t('tab_cancelled')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
@@ -251,16 +256,16 @@ export default function InvoicesPage() {
{searchTerm ? (
<EmptyState
icon={Receipt}
title="Inga träffar"
description={`Inga fakturor matchar "${searchTerm}".`}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices />
) : (
<EmptyState
icon={Receipt}
title="Inga fakturor i denna kategori"
description="Prova att byta flik för att se fler fakturor."
title={t('no_category_title')}
description={t('no_category_description')}
/>
)}
</CardContent>
@@ -268,7 +273,7 @@ export default function InvoicesPage() {
) : (
<div className="space-y-3">
{filteredInvoices.map((invoice) => {
const status = statusConfig[invoice.status]
const status = STATUS_CONFIG[invoice.status]
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isProforma = docType === 'proforma'
@@ -296,21 +301,21 @@ export default function InvoicesPage() {
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{isCreditNote && (
<Badge variant="destructive" className="text-xs">
Kredit
{t('badge_credit')}
</Badge>
)}
{isProforma && (
<Badge variant="secondary" className="text-xs bg-primary/10 text-primary">
Proforma
{t('badge_proforma')}
</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="text-xs bg-success/10 text-success">
Följesedel
{t('badge_delivery_note')}
</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
{t(status.labelKey)}
</Badge>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
+59 -57
View File
@@ -1,8 +1,9 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -26,32 +27,6 @@ import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import type { Customer, Currency } from '@/types'
import { formatCurrency } from '@/lib/utils'
const itemSchema = z.object({
description: z.string().min(1, 'Beskrivning krävs'),
quantity: z.number().min(0.01, 'Minst 0.01'),
unit: z.string().min(1, 'Enhet krävs'),
unit_price: z.number(),
vat_rate: z
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.nullable()
.optional(),
})
const schema = z.object({
customer_id: z.string().uuid('Välj en kund'),
name: z.string().min(1, 'Namn krävs'),
day_of_month: z.number().int().min(1).max(31),
payment_terms_days: z.number().int().min(0).max(90),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
auto_send: z.boolean(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, 'Minst en rad krävs'),
})
type FormData = z.infer<typeof schema>
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -60,9 +35,37 @@ export default function NewRecurringSchedulePage() {
const { toast } = useToast()
const { company } = useCompany()
const supabase = createClient()
const t = useTranslations('invoice_recurring_new')
const [customers, setCustomers] = useState<Customer[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const schema = useMemo(() => {
const itemSchema = z.object({
description: z.string().min(1, t('validation_description_required')),
quantity: z.number().min(0.01, t('validation_quantity_min')),
unit: z.string().min(1, t('validation_unit_required')),
unit_price: z.number(),
vat_rate: z
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
.nullable()
.optional(),
})
return z.object({
customer_id: z.string().uuid(t('validation_customer_required')),
name: z.string().min(1, t('validation_name_required')),
day_of_month: z.number().int().min(1).max(31),
payment_terms_days: z.number().int().min(0).max(90),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
auto_send: z.boolean(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
}, [t])
type FormData = z.infer<typeof schema>
const {
register,
control,
@@ -104,13 +107,13 @@ export default function NewRecurringSchedulePage() {
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte skapa schema')
throw new Error(body.error || t('create_failed_fallback'))
}
toast({ title: 'Schema skapat' })
toast({ title: t('created_title') })
router.push('/invoices/recurring')
} catch (err) {
toast({
title: 'Kunde inte skapa schema',
title: t('create_failed_title'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -135,22 +138,22 @@ export default function NewRecurringSchedulePage() {
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Tillbaka
{t('back')}
</Link>
<PageHeader title="Nytt återkommande schema" />
<PageHeader title={t('title')} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Schema</CardTitle>
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Namn</Label>
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder="t.ex. Månadsretainer Acme AB"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
@@ -159,14 +162,14 @@ export default function NewRecurringSchedulePage() {
</div>
<div>
<Label htmlFor="customer_id">Kund</Label>
<Label htmlFor="customer_id">{t('customer_label')}</Label>
<Controller
control={control}
name="customer_id"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="customer_id">
<SelectValue placeholder="Välj kund" />
<SelectValue placeholder={t('customer_placeholder')} />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
@@ -185,7 +188,7 @@ export default function NewRecurringSchedulePage() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label htmlFor="day_of_month">Dag i månaden</Label>
<Label htmlFor="day_of_month">{t('day_label')}</Label>
<Input
id="day_of_month"
type="number"
@@ -195,11 +198,11 @@ export default function NewRecurringSchedulePage() {
{...register('day_of_month', { valueAsNumber: true })}
/>
<p className="text-xs text-muted-foreground mt-1">
29-31 körs sista dagen i kortare månader.
{t('day_hint')}
</p>
</div>
<div>
<Label htmlFor="payment_terms_days">Betalningsvillkor (dagar)</Label>
<Label htmlFor="payment_terms_days">{t('payment_terms_label')}</Label>
<Input
id="payment_terms_days"
type="number"
@@ -210,7 +213,7 @@ export default function NewRecurringSchedulePage() {
/>
</div>
<div>
<Label htmlFor="currency">Valuta</Label>
<Label htmlFor="currency">{t('currency_label')}</Label>
<Controller
control={control}
name="currency"
@@ -249,11 +252,10 @@ export default function NewRecurringSchedulePage() {
/>
<div className="flex-1">
<Label htmlFor="auto_send" className="font-medium">
Skapa och skicka automatiskt
{t('auto_send_label')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
När markerad: fakturan skickas med e-post till kunden direkt vid
skapande. Annars skapas den som utkast för manuell granskning.
{t('auto_send_description')}
</p>
</div>
</div>
@@ -263,7 +265,7 @@ export default function NewRecurringSchedulePage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Rader</CardTitle>
<CardTitle className="text-base">{t('items_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{fields.map((field, index) => (
@@ -273,7 +275,7 @@ export default function NewRecurringSchedulePage() {
>
<div className="col-span-12 sm:col-span-5">
<Input
placeholder="Beskrivning"
placeholder={t('description_placeholder')}
{...register(`items.${index}.description`)}
/>
</div>
@@ -281,7 +283,7 @@ export default function NewRecurringSchedulePage() {
<Input
type="number"
step="0.01"
placeholder="Antal"
placeholder={t('quantity_placeholder')}
className="tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
@@ -310,7 +312,7 @@ export default function NewRecurringSchedulePage() {
<Input
type="number"
step="0.01"
placeholder="à-pris"
placeholder={t('unit_price_placeholder')}
className="tabular-nums"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
@@ -321,7 +323,7 @@ export default function NewRecurringSchedulePage() {
variant="ghost"
size="icon"
onClick={() => fields.length > 1 && remove(index)}
aria-label="Ta bort rad"
aria-label={t('remove_row')}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -337,31 +339,31 @@ export default function NewRecurringSchedulePage() {
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
{t('add_row')}
</Button>
<div className="pt-2 text-sm text-muted-foreground tabular-nums">
Delsumma exkl. moms: {formatCurrency(subtotal, watchCurrency)}
{t('subtotal_ex_vat', { amount: formatCurrency(subtotal, watchCurrency) })}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Övrigt</CardTitle>
<CardTitle className="text-base">{t('other_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="your_reference">Er referens</Label>
<Label htmlFor="your_reference">{t('your_reference_label')}</Label>
<Input id="your_reference" {...register('your_reference')} />
</div>
<div>
<Label htmlFor="our_reference">Vår referens</Label>
<Label htmlFor="our_reference">{t('our_reference_label')}</Label>
<Input id="our_reference" {...register('our_reference')} />
</div>
</div>
<div>
<Label htmlFor="notes">Anteckningar (skrivs på varje faktura)</Label>
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea id="notes" rows={3} {...register('notes')} />
</div>
</CardContent>
@@ -370,11 +372,11 @@ export default function NewRecurringSchedulePage() {
<div className="flex justify-end gap-2">
<Link href="/invoices/recurring">
<Button type="button" variant="secondary">
Avbryt
{t('cancel')}
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar...' : 'Skapa schema'}
{isSubmitting ? t('creating') : t('create_schedule')}
</Button>
</div>
</form>
+28 -26
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -32,6 +33,7 @@ export default function RecurringInvoicesPage() {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('invoice_recurring')
async function fetchSchedules() {
setIsLoading(true)
@@ -42,8 +44,8 @@ export default function RecurringInvoicesPage() {
setSchedules(json.data ?? [])
} catch {
toast({
title: 'Kunde inte ladda återkommande fakturor',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
}
@@ -63,46 +65,46 @@ export default function RecurringInvoicesPage() {
})
if (res.ok) {
toast({
title: next === 'paused' ? 'Schema pausat' : 'Schema återaktiverat',
title: next === 'paused' ? t('schedule_paused_title') : t('schedule_resumed_title'),
})
fetchSchedules()
} else {
toast({
title: 'Kunde inte uppdatera schema',
title: t('schedule_update_failed_title'),
variant: 'destructive',
})
}
}
async function deleteSchedule(s: ScheduleRow) {
if (!confirm(`Ta bort schemat "${s.name}"? Redan skapade fakturor påverkas inte.`)) {
if (!confirm(t('delete_confirm', { name: s.name }))) {
return
}
const res = await fetch(`/api/invoices/recurring/${s.id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Schema borttaget' })
toast({ title: t('schedule_deleted_title') })
fetchSchedules()
} else {
toast({ title: 'Kunde inte ta bort schema', variant: 'destructive' })
toast({ title: t('schedule_delete_failed_title'), variant: 'destructive' })
}
}
return (
<div className="space-y-8">
<PageHeader
title="Återkommande fakturor"
title={t('title')}
action={
canWrite ? (
<Link href="/invoices/recurring/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Nytt schema
{t('new_schedule')}
</Button>
</Link>
) : (
<Button disabled title="Du har endast läsbehörighet i detta företag">
<Button disabled title={t('viewer_disabled_tooltip')}>
<Lock className="mr-2 h-4 w-4" />
Nytt schema
{t('new_schedule')}
</Button>
)
}
@@ -111,7 +113,7 @@ export default function RecurringInvoicesPage() {
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Laddar...
{t('loading')}
</CardContent>
</Card>
) : schedules.length === 0 ? (
@@ -119,9 +121,9 @@ export default function RecurringInvoicesPage() {
<CardContent className="p-0">
<EmptyState
icon={Repeat}
title="Inga återkommande fakturor"
description="Skapa ett schema för att automatiskt fakturera kunder på en bestämd dag varje månad."
actionLabel={canWrite ? 'Nytt schema' : undefined}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_schedule') : undefined}
actionHref={canWrite ? '/invoices/recurring/new' : undefined}
/>
</CardContent>
@@ -132,13 +134,13 @@ export default function RecurringInvoicesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Kund</TableHead>
<TableHead className="tabular-nums">Dag</TableHead>
<TableHead className="tabular-nums">Nästa körning</TableHead>
<TableHead>Status</TableHead>
<TableHead className="tabular-nums text-right">Skapade</TableHead>
<TableHead className="text-right">Åtgärder</TableHead>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_customer')}</TableHead>
<TableHead className="tabular-nums">{t('th_day')}</TableHead>
<TableHead className="tabular-nums">{t('th_next_run')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead className="tabular-nums text-right">{t('th_generated')}</TableHead>
<TableHead className="text-right">{t('th_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -166,9 +168,9 @@ export default function RecurringInvoicesPage() {
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
<TableCell>
{s.status === 'active' ? (
<Badge variant="success">Aktiv</Badge>
<Badge variant="success">{t('status_active')}</Badge>
) : (
<Badge variant="secondary">Pausad</Badge>
<Badge variant="secondary">{t('status_paused')}</Badge>
)}
</TableCell>
<TableCell className="tabular-nums text-right">
@@ -186,14 +188,14 @@ export default function RecurringInvoicesPage() {
size="sm"
onClick={() => togglePause(s)}
>
{s.status === 'active' ? 'Pausa' : 'Aktivera'}
{s.status === 'active' ? t('pause') : t('resume')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSchedule(s)}
>
Ta bort
{t('delete')}
</Button>
</>
)}
+6 -4
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
@@ -13,6 +14,7 @@ import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
import type { KPIReport, KPIPreferences } from '@/types'
export default function KpiPage() {
const t = useTranslations('kpi')
const [selectedPeriod, setSelectedPeriod] = useState<string>('')
const [report, setReport] = useState<KPIReport | null>(null)
const [preferences, setPreferences] = useState<KPIPreferences>(getDefaultPreferences())
@@ -39,15 +41,15 @@ export default function KpiPage() {
setError(null)
try {
const res = await fetch(`/api/reports/kpi?period_id=${periodId}`)
if (!res.ok) throw new Error('Kunde inte hämta nyckeltal')
if (!res.ok) throw new Error(t('fetch_failed'))
const { data } = await res.json()
setReport(data)
} catch {
setError('Kunde inte hämta nyckeltal')
setError(t('fetch_failed'))
} finally {
setIsLoadingReport(false)
}
}, [])
}, [t])
useEffect(() => {
if (!selectedPeriod) return
@@ -80,7 +82,7 @@ export default function KpiPage() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Nyckeltal</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<KPISettingsDialog
preferences={preferences}
onSave={handleSavePreferences}
+56 -49
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react'
import { useTranslations } from 'next-intl'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -26,18 +27,18 @@ import type { PendingOperation, PendingOperationStatus } from '@/types'
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview'
const operationLabels: Record<string, { label: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' },
create_customer: { label: 'Ny kund', icon: Users, variant: 'secondary' },
create_invoice: { label: 'Ny faktura', icon: Receipt, variant: 'outline' },
create_transaction: { label: 'Ny transaktion', icon: ArrowLeftRight, variant: 'secondary' },
create_voucher: { label: 'Ny verifikation', icon: BookOpen, variant: 'outline' },
correct_entry: { label: 'Rättelse', icon: BookOpen, variant: 'outline' },
reverse_entry: { label: 'Makulering', icon: BookOpen, variant: 'outline' },
mark_invoice_paid: { label: 'Betald faktura', icon: Receipt, variant: 'default' },
send_invoice: { label: 'Skicka faktura', icon: Receipt, variant: 'outline' },
mark_invoice_sent: { label: 'Markera skickad', icon: Receipt, variant: 'outline' },
match_transaction_invoice: { label: 'Fakturamatchning', icon: ArrowLeftRight, variant: 'secondary' },
const OPERATION_LABEL_KEYS: Record<string, { labelKey: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
categorize_transaction: { labelKey: 'type_categorize_transaction', icon: ArrowLeftRight, variant: 'default' },
create_customer: { labelKey: 'type_create_customer', icon: Users, variant: 'secondary' },
create_invoice: { labelKey: 'type_create_invoice', icon: Receipt, variant: 'outline' },
create_transaction: { labelKey: 'type_create_transaction', icon: ArrowLeftRight, variant: 'secondary' },
create_voucher: { labelKey: 'type_create_voucher', icon: BookOpen, variant: 'outline' },
correct_entry: { labelKey: 'type_correct_entry', icon: BookOpen, variant: 'outline' },
reverse_entry: { labelKey: 'type_reverse_entry', icon: BookOpen, variant: 'outline' },
mark_invoice_paid: { labelKey: 'type_mark_invoice_paid', icon: Receipt, variant: 'default' },
send_invoice: { labelKey: 'type_send_invoice', icon: Receipt, variant: 'outline' },
mark_invoice_sent: { labelKey: 'type_mark_invoice_sent', icon: Receipt, variant: 'outline' },
match_transaction_invoice: { labelKey: 'type_match_transaction_invoice', icon: ArrowLeftRight, variant: 'secondary' },
}
// Terse per-type labels used in the bulk confirmation dialog list. Phrased so
@@ -58,10 +59,11 @@ const bulkActionDescriptions: Record<string, (count: number) => string> = {
n === 1 ? 'En kategorisering tas bort.' : `${n} kategoriseringar tas bort.`,
}
function bulkActionLabel(operationType: string, count: number): string {
function bulkActionLabel(operationType: string, count: number, t: (key: string) => string): string {
const fn = bulkActionDescriptions[operationType]
if (fn) return fn(count)
const fallback = operationLabels[operationType]?.label ?? operationType
const entry = OPERATION_LABEL_KEYS[operationType]
const fallback = entry ? t(entry.labelKey) : operationType
return `${count} × ${fallback}`
}
@@ -389,6 +391,7 @@ function OperationPreview({ op }: { op: PendingOperation }) {
type SourceFilter = 'all' | 'agent' | 'high_risk'
export default function PendingOperationsPage() {
const t = useTranslations('pending')
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
@@ -580,23 +583,23 @@ export default function PendingOperationsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Granskning"
description="Operationer som väntar på godkännande"
title={t('title')}
description={t('subtitle')}
/>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
<TabsTrigger value="pending">Väntande</TabsTrigger>
<TabsTrigger value="committed">Godkända</TabsTrigger>
<TabsTrigger value="rejected">Avvisade</TabsTrigger>
<TabsTrigger value="pending">{t('tab_pending')}</TabsTrigger>
<TabsTrigger value="committed">{t('tab_committed')}</TabsTrigger>
<TabsTrigger value="rejected">{t('tab_rejected')}</TabsTrigger>
</TabsList>
</Tabs>
<Tabs value={sourceFilter} onValueChange={(v) => setSourceFilter(v as SourceFilter)}>
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="agent">Från agent</TabsTrigger>
<TabsTrigger value="high_risk">Hög risk</TabsTrigger>
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="agent">{t('tab_agent')}</TabsTrigger>
<TabsTrigger value="high_risk">{t('tab_high_risk')}</TabsTrigger>
</TabsList>
</Tabs>
@@ -607,20 +610,21 @@ export default function PendingOperationsPage() {
id="select-all"
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={() => toggleSelectAll()}
aria-label="Markera alla"
aria-label={t('select_all_aria')}
/>
<label htmlFor="select-all" className="text-sm cursor-pointer">
{selectedCount > 0
? `${selectedCount} valda`
: `Markera alla (${bulkEligible.length})`}
? t('selected_count', { count: selectedCount })
: t('select_all_count', { count: bulkEligible.length })}
</label>
</div>
{typeCounts.length > 0 && selectedCount === 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">Snabbval:</span>
<span className="text-xs text-muted-foreground">{t('quick_pick')}</span>
{typeCounts.map(([type, count]) => {
const config = operationLabels[type] || { label: type }
const entry = OPERATION_LABEL_KEYS[type]
const label = entry ? t(entry.labelKey) : type
return (
<Button
key={type}
@@ -629,7 +633,7 @@ export default function PendingOperationsPage() {
className="h-7 px-2 text-xs"
onClick={() => selectAllOfType(type)}
>
{config.label} ({count})
{label} ({count})
</Button>
)
})}
@@ -644,7 +648,7 @@ export default function PendingOperationsPage() {
className="h-8 px-3 text-xs"
onClick={() => setSelectedIds(new Set())}
>
Avmarkera
{t('deselect')}
</Button>
)}
<Button
@@ -653,7 +657,7 @@ export default function PendingOperationsPage() {
disabled={selectedCount === 0 || isBulkCommitting}
onClick={() => setShowBulkDialog(true)}
>
Godkänn valda ({selectedCount})
{t('approve_selected', { count: selectedCount })}
</Button>
</div>
</div>
@@ -673,22 +677,25 @@ export default function PendingOperationsPage() {
</div>
<p className="font-medium">
{activeTab === 'pending'
? 'Inga väntande operationer'
? t('empty_pending_title')
: activeTab === 'committed'
? 'Inga godkända operationer'
: 'Inga avvisade operationer'}
? t('empty_committed_title')
: t('empty_rejected_title')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{activeTab === 'pending'
? 'När en operation kräver godkännande visas den här för granskning.'
: 'Operationer du har godkänt eller avvisat visas här.'}
? t('empty_pending_description')
: t('empty_finished_description')}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredOperations.map((op) => {
const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
const entry = OPERATION_LABEL_KEYS[op.operation_type]
const config = entry
? { label: t(entry.labelKey), icon: entry.icon, variant: entry.variant }
: { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const }
const isExpanded = expandedId === op.id
const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high'
const isSelected = selectedIds.has(op.id)
@@ -711,7 +718,7 @@ export default function PendingOperationsPage() {
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelected(op.id)}
aria-label="Välj operation"
aria-label={t('select_operation_aria')}
/>
</div>
)}
@@ -720,7 +727,7 @@ export default function PendingOperationsPage() {
<Badge variant={config.variant}>{config.label}</Badge>
{op.risk_level === 'high' && (
<Badge variant="outline" className="border-terracotta/40 text-terracotta">
Hög risk
{t('badge_high_risk')}
</Badge>
)}
{op.actor_type && op.actor_type !== 'user' && (
@@ -732,13 +739,13 @@ export default function PendingOperationsPage() {
{op.status === 'committed' && (
<Badge variant="success">
<CheckCircle2 className="h-3 w-3 mr-1" />
Godkänd
{t('badge_approved')}
</Badge>
)}
{op.status === 'rejected' && (
<Badge variant="destructive">
<XCircle className="h-3 w-3 mr-1" />
Avvisad
{t('badge_rejected')}
</Badge>
)}
<span className="text-xs text-muted-foreground">
@@ -759,7 +766,7 @@ export default function PendingOperationsPage() {
setShowCommitDialog(true)
}}
>
Godkänn
{t('approve')}
</Button>
<Button
size="sm"
@@ -770,7 +777,7 @@ export default function PendingOperationsPage() {
handleReject(op)
}}
>
Avvisa
{t('reject')}
</Button>
</div>
)}
@@ -795,9 +802,9 @@ export default function PendingOperationsPage() {
<ConfirmationDialog
open={showCommitDialog}
onOpenChange={setShowCommitDialog}
title={selectedOp?.title || 'Godkänn operation'}
title={selectedOp?.title || t('approve_operation_title')}
warningText={selectedOp ? singleActionWarning(selectedOp.operation_type) : ''}
confirmLabel="Godkänn"
confirmLabel={t('approve')}
isSubmitting={isCommitting}
onConfirm={handleCommit}
>
@@ -808,24 +815,24 @@ export default function PendingOperationsPage() {
<ConfirmationDialog
open={showBulkDialog}
onOpenChange={setShowBulkDialog}
title={`Godkänn ${selectedCount} operationer?`}
title={t('approve_bulk_title', { count: selectedCount })}
warningText=""
confirmLabel={`Godkänn ${selectedCount}`}
confirmLabel={t('approve_count', { count: selectedCount })}
isSubmitting={isBulkCommitting}
onConfirm={() => handleBulkCommit(Array.from(selectedIds))}
>
<div className="space-y-3 text-sm">
<p>Genom att bekräfta utförs följande:</p>
<p>{t('bulk_confirm_intro')}</p>
<ul className="space-y-1 rounded-md border bg-muted/30 px-3 py-2">
{selectedBreakdown.map(({ type, count }) => (
<li key={type} className="flex justify-between font-mono tabular-nums">
<span className="font-sans">{bulkActionLabel(type, count)}</span>
<span className="font-sans">{bulkActionLabel(type, count, t)}</span>
<span className="text-muted-foreground">{count}</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
Operationerna körs i ordning. Misslyckade hoppas över och rapporteras efteråt.
{t('bulk_confirm_footer')}
</p>
</div>
</ConfirmationDialog>
+22 -16
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from '@/components/ui/button'
@@ -43,13 +44,13 @@ interface DrillDownStep {
accountNumber?: string
}
const TAB_LABELS: Record<string, string> = {
'resultatrapport': 'Resultatrapport',
'balansrapport': 'Balansrapport',
'trial-balance': 'Saldobalans',
'income-statement': 'Resultaträkning',
'balance-sheet': 'Balansräkning',
'huvudbok': 'Huvudbok',
const TAB_LABEL_KEYS: Record<string, string> = {
'resultatrapport': 'name_resultatrapport',
'balansrapport': 'name_balansrapport',
'trial-balance': 'name_trial_balance',
'income-statement': 'name_income_statement',
'balance-sheet': 'name_balance_sheet',
'huvudbok': 'name_huvudbok',
}
export default function ReportsPage() {
@@ -57,6 +58,7 @@ export default function ReportsPage() {
const [activeTab, setActiveTab] = useState('resultatrapport')
const [isLoadingInit, setIsLoadingInit] = useState(true)
const { company } = useCompany()
const t = useTranslations('reports')
// Drill-down state: when navigating from a report to the GL for a specific account
const [glAccountFilter, setGlAccountFilter] = useState<string | null>(null)
@@ -65,11 +67,11 @@ export default function ReportsPage() {
const navigateToAccount = useCallback((accountNumber: string) => {
setDrillDownTrail((prev) => [
...prev,
{ tab: activeTab, label: TAB_LABELS[activeTab] || activeTab },
{ tab: activeTab, label: TAB_LABEL_KEYS[activeTab] ? t(TAB_LABEL_KEYS[activeTab]) : activeTab },
])
setGlAccountFilter(accountNumber)
setActiveTab('huvudbok')
}, [activeTab])
}, [activeTab, t])
const handleTabChange = useCallback((tab: string) => {
// Manual tab change clears drill-down state
@@ -94,7 +96,7 @@ export default function ReportsPage() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Rapporter</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
@@ -113,7 +115,7 @@ export default function ReportsPage() {
}}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner SIE-fil
{t('download_sie')}
</Button>
)}
</div>
@@ -159,7 +161,7 @@ export default function ReportsPage() {
</React.Fragment>
))}
<span className="font-medium">
Huvudbok {glAccountFilter && `— ${glAccountFilter}`}
{t('name_huvudbok')} {glAccountFilter && `— ${glAccountFilter}`}
</span>
</nav>
)}
@@ -444,6 +446,7 @@ function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string;
)
}
function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<IncomeStatementReport | null>(null)
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
const [monthlyLoading, setMonthlyLoading] = useState(false)
@@ -523,7 +526,7 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
onClick={() => window.open(`/api/reports/income-statement/pdf?period_id=${periodId}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner PDF
{t('download_pdf')}
</Button>
</div>
@@ -603,6 +606,7 @@ function IncomeStatementView({ periodId, onNavigateToAccount }: { periodId: stri
}
function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<BalanceSheetReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -668,7 +672,7 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
onClick={() => window.open(`/api/reports/balance-sheet/pdf?period_id=${periodId}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner PDF
{t('download_pdf')}
</Button>
</div>
@@ -727,6 +731,7 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string;
}
function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<ResultatrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -792,7 +797,7 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
onClick={() => window.open(`/api/reports/resultatrapport/pdf?period_id=${periodId}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner PDF
{t('download_pdf')}
</Button>
</div>
@@ -867,6 +872,7 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
}
function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
const t = useTranslations('reports')
const [data, setData] = useState<BalansrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -930,7 +936,7 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
onClick={() => window.open(`/api/reports/balansrapport/pdf?period_id=${periodId}`, '_blank')}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner PDF
{t('download_pdf')}
</Button>
</div>
+24 -20
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from '@/components/ui/button'
@@ -12,13 +13,14 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import type { Employee } from '@/types'
const EMPLOYMENT_LABELS: Record<string, string> = {
employee: 'Anställd',
company_owner: 'Företagsledare',
board_member: 'Styrelseledamot',
const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
employee: 'employment_employee',
company_owner: 'employment_company_owner',
board_member: 'employment_board_member',
}
export default function EmployeesPage() {
const t = useTranslations('employees')
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const { canWrite } = useCanWrite()
@@ -40,18 +42,18 @@ export default function EmployeesPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/salary" aria-label="Tillbaka till löner"><ArrowLeft className="h-4 w-4" /></Link>
<Link href="/salary" aria-label={t('back_to_payroll')}><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Anställda</h1>
<p className="text-sm text-muted-foreground mt-1">{employees.length} registrerade</p>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('registered_count', { count: employees.length })}</p>
</div>
</div>
{canWrite && (
<Button asChild>
<Link href="/salary/employees/new">
<Plus className="mr-2 h-4 w-4" />
Ny anställd
{t('new_employee')}
</Link>
</Button>
)}
@@ -68,9 +70,9 @@ export default function EmployeesPage() {
<CardContent className="p-0">
<EmptyState
icon={UserCircle}
title="Inga anställda"
description="Lägg till anställda för att kunna skapa lönekörningar och AGI-deklarationer."
actionLabel={canWrite ? 'Lägg till anställd' : undefined}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('add_employee') : undefined}
actionHref={canWrite ? '/salary/employees/new' : undefined}
/>
</CardContent>
@@ -81,12 +83,12 @@ export default function EmployeesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Personnummer</TableHead>
<TableHead>Typ</TableHead>
<TableHead className="text-right">Lön</TableHead>
<TableHead className="text-right">Sysselsättningsgrad</TableHead>
<TableHead>Skattetabell</TableHead>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_personnummer')}</TableHead>
<TableHead>{t('th_type')}</TableHead>
<TableHead className="text-right">{t('th_salary')}</TableHead>
<TableHead className="text-right">{t('th_employment_degree')}</TableHead>
<TableHead>{t('th_tax_table')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -101,18 +103,20 @@ export default function EmployeesPage() {
{emp.personnummer}
</TableCell>
<TableCell className="text-muted-foreground">
{EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type}
{EMPLOYMENT_LABEL_KEYS[emp.employment_type]
? t(EMPLOYMENT_LABEL_KEYS[emp.employment_type])
: emp.employment_type}
</TableCell>
<TableCell className="text-right tabular-nums">
{emp.salary_type === 'hourly'
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}/tim` : '—'
? emp.hourly_rate ? `${formatCurrency(emp.hourly_rate)}${t('hourly_suffix')}` : '—'
: emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{emp.employment_degree}%
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
{emp.tax_table_number ? `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` : '—'}
{emp.tax_table_number ? t('tax_table_format', { table: emp.tax_table_number, column: emp.tax_column ?? '' }) : '—'}
</TableCell>
</TableRow>
))}
+25 -23
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -14,12 +15,12 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { SalaryRun } from '@/types'
const STATUS_LABELS: Record<string, string> = {
draft: 'Utkast',
review: 'Granskning',
approved: 'Godkänd',
paid: 'Betald',
booked: 'Bokförd',
const STATUS_LABEL_KEYS: Record<string, string> = {
draft: 'status_draft',
review: 'status_review',
approved: 'status_approved',
paid: 'status_paid',
booked: 'status_booked',
}
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
@@ -35,6 +36,7 @@ export default function SalaryPage() {
const [employeeCount, setEmployeeCount] = useState(0)
const [loading, setLoading] = useState(true)
const { canWrite } = useCanWrite()
const t = useTranslations('salary')
useEffect(() => {
async function load() {
@@ -81,20 +83,20 @@ export default function SalaryPage() {
return (
<div className="space-y-8">
<PageHeader
title="Löner"
title={t('title')}
action={
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/salary/employees">
<Users className="mr-2 h-4 w-4" />
Anställda
{t('employees')}
</Link>
</Button>
{canWrite && (
<Button asChild>
<Link href="/salary/runs/new">
<Plus className="mr-2 h-4 w-4" />
Ny lönekörning
{t('new_run')}
</Link>
</Button>
)}
@@ -109,7 +111,7 @@ export default function SalaryPage() {
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Anställda</p>
<p className="text-sm text-muted-foreground">{t('employees')}</p>
<p className="text-2xl font-semibold tabular-nums">{employeeCount}</p>
</div>
</div>
@@ -120,7 +122,7 @@ export default function SalaryPage() {
<div className="flex items-center gap-3">
<HandCoins className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Bruttolöner {currentYear}</p>
<p className="text-sm text-muted-foreground">{t('gross_year', { year: currentYear })}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalGrossYTD)}</p>
</div>
</div>
@@ -131,7 +133,7 @@ export default function SalaryPage() {
<div className="flex items-center gap-3">
<CalendarDays className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Avgifter {currentYear}</p>
<p className="text-sm text-muted-foreground">{t('contributions_year', { year: currentYear })}</p>
<p className="text-2xl font-semibold tabular-nums">{formatCurrency(totalAvgifterYTD)}</p>
</div>
</div>
@@ -142,27 +144,27 @@ export default function SalaryPage() {
{/* Recent runs */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lönekörningar</CardTitle>
<CardTitle className="text-base">{t('runs_title')}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{runs.length === 0 ? (
<EmptyState
icon={HandCoins}
title="Inga lönekörningar ännu"
description="Skapa en lönekörning för att räkna ut löner, skatt och arbetsgivaravgifter."
actionLabel={canWrite ? 'Skapa lönekörning' : undefined}
title={t('empty_runs_title')}
description={t('empty_runs_description')}
actionLabel={canWrite ? t('create_run') : undefined}
actionHref={canWrite ? '/salary/runs/new' : undefined}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Utbetalningsdag</TableHead>
<TableHead className="text-right">Brutto</TableHead>
<TableHead className="text-right">Netto</TableHead>
<TableHead className="text-right">Avgifter</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('th_period')}</TableHead>
<TableHead>{t('th_payday')}</TableHead>
<TableHead className="text-right">{t('th_gross')}</TableHead>
<TableHead className="text-right">{t('th_net')}</TableHead>
<TableHead className="text-right">{t('th_contributions')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@@ -186,7 +188,7 @@ export default function SalaryPage() {
</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{STATUS_LABELS[run.status]}
{STATUS_LABEL_KEYS[run.status] ? t(STATUS_LABEL_KEYS[run.status]) : run.status}
</Badge>
</TableCell>
<TableCell className="text-right">
+74 -11
View File
@@ -2,9 +2,10 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Monitor, LogOut } from 'lucide-react'
import { Sun, Moon, Monitor, LogOut, Languages } from 'lucide-react'
import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
@@ -13,6 +14,8 @@ import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
export default function AccountSettingsPage() {
const router = useRouter()
@@ -21,6 +24,11 @@ export default function AccountSettingsPage() {
const [mounted, setMounted] = useState(false)
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
const { settings } = useSettings()
const { toast } = useToast()
const activeLocale = useLocale() as Locale
const tCommon = useTranslations('common')
const tSettings = useTranslations('settings')
const [savingLocale, setSavingLocale] = useState(false)
useEffect(() => { setMounted(true) }, [])
@@ -30,20 +38,47 @@ export default function AccountSettingsPage() {
router.push('/login')
}
async function handleLocaleChange(next: Locale) {
if (next === activeLocale || savingLocale) return
setSavingLocale(true)
try {
const res = await fetch('/api/user/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: next }),
})
if (!res.ok) throw new Error('Could not save')
toast({ title: tSettings('language_saved') })
router.refresh()
} catch {
toast({
title: tSettings('language_save_failed'),
variant: 'destructive',
})
} finally {
setSavingLocale(false)
}
}
const localeLabels: Record<Locale, string> = {
sv: tCommon('language_swedish'),
en: tCommon('language_english'),
}
return (
<div className="space-y-8">
{/* Appearance */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utseende
{tSettings('section_appearance')}
</h2>
{mounted && (
<div className="flex gap-3">
{([
{ value: 'light', label: 'Ljust', icon: Sun },
{ value: 'dark', label: 'Mörkt', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
] as const).map(({ value, label, icon: Icon }) => (
{ value: 'light', labelKey: 'theme_light', icon: Sun },
{ value: 'dark', labelKey: 'theme_dark', icon: Moon },
{ value: 'system', labelKey: 'theme_system', icon: Monitor },
] as const).map(({ value, labelKey, icon: Icon }) => (
<button
key={value}
type="button"
@@ -55,13 +90,41 @@ export default function AccountSettingsPage() {
}`}
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
{tCommon(labelKey)}
</button>
))}
</div>
)}
</section>
{/* Language */}
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{tSettings('section_language')}
</h2>
<p className="text-sm text-muted-foreground max-w-md">
{tSettings('language_description')}
</p>
<div className="flex gap-3">
{SUPPORTED_LOCALES.map((value) => (
<button
key={value}
type="button"
onClick={() => handleLocaleChange(value)}
disabled={savingLocale}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors disabled:opacity-50 ${
activeLocale === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<Languages className="h-4 w-4 text-muted-foreground" />
{localeLabels[value]}
</button>
))}
</div>
</section>
{/* Security */}
<div className="border-t border-border/8 pt-8">
<SecuritySettings />
@@ -78,17 +141,17 @@ export default function AccountSettingsPage() {
<section className="border-t border-border/8 pt-8">
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
<CardTitle>{tCommon('account_settings')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Logga ut</p>
<p className="text-sm text-muted-foreground">Logga ut från ditt konto</p>
<p className="font-medium">{tCommon('logout')}</p>
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
{tCommon('logout')}
</Button>
</div>
</CardContent>
+5 -7
View File
@@ -1,20 +1,18 @@
import { getTranslations } from 'next-intl/server'
import { BackupDownloadForm } from '@/components/settings/BackupDownloadForm'
import { getBranding } from '@/lib/branding/service'
export default function BackupSettingsPage() {
export default async function BackupSettingsPage() {
const t = await getTranslations('settings_backup')
const { appName } = getBranding()
return (
<div className="space-y-8">
<section className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Säkerhetsbackup
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground max-w-prose">
Ladda ner en egen kopia av all räkenskapsinformation — SIE-filer, kvitton,
underlag och behandlingshistorik — i en enda ZIP-fil. Säkerhetsbackupen är din
egen kopia för trygghet och portabilitet. {appName.toLowerCase()} arkiverar all
räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter
inte vårt lagkrav — den kompletterar det.
{t('intro', { appName: appName.toLowerCase() })}
</p>
</section>
+20 -18
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
@@ -13,6 +14,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
const BankingPanel = getSettingsPanel('enable-banking')
export default function BankingSettingsPage() {
const t = useTranslations('settings_banking')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
@@ -42,8 +44,8 @@ export default function BankingSettingsPage() {
if (connectionId) {
toast({
title: 'Synkroniserar transaktioner...',
description: 'Hämtar transaktioner från din bank i bakgrunden.',
title: t('sync_start_title'),
description: t('sync_start_description'),
})
const controller = new AbortController()
abortControllerRef.current = controller
@@ -62,8 +64,8 @@ export default function BankingSettingsPage() {
if (res.ok) {
if (!unmountedRef.current) {
toast({
title: 'Bank ansluten!',
description: `${data.imported ?? 0} transaktioner importerade`,
title: t('sync_success_title'),
description: t('sync_success_description', { count: data.imported ?? 0 }),
})
}
} else {
@@ -74,13 +76,13 @@ export default function BankingSettingsPage() {
if (unmountedRef.current) return
if (controller.signal.aborted) {
toast({
title: 'Synkronisering tog för lång tid',
description: 'Transaktionerna hämtas i bakgrunden. Ladda om sidan om en stund.',
title: t('sync_timeout_title'),
description: t('sync_timeout_description'),
})
} else {
toast({
title: 'Synkronisering misslyckades',
description: err instanceof Error ? err.message : 'Kunde inte hämta transaktioner',
title: t('sync_failed_title'),
description: err instanceof Error ? err.message : t('sync_failed_default'),
variant: 'destructive',
})
}
@@ -88,8 +90,8 @@ export default function BankingSettingsPage() {
})()
} else {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad.',
title: t('sync_success_title'),
description: t('sync_success_no_id_description'),
})
}
}
@@ -100,7 +102,7 @@ export default function BankingSettingsPage() {
const bankName = searchParams.get('bank_name')
const errorCode = searchParams.get('bank_error_code')
toast({
title: 'Anslutning misslyckades',
title: t('connect_failed_title'),
description: errorMsg,
variant: 'destructive',
})
@@ -109,7 +111,7 @@ export default function BankingSettingsPage() {
if (errorCode === 'access_denied') setIsAccessDenied(true)
router.replace('/settings/banking')
}
}, [searchParams, router, toast])
}, [searchParams, router, toast, t])
return (
<div className="space-y-8">
@@ -120,11 +122,11 @@ export default function BankingSettingsPage() {
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
{isAccessDenied && failedBankName && (
<p className="mt-1 text-sm text-muted-foreground">
{failedBankName} nekade åtkomst. Om du använder ett privatkonto kan du prova att ansluta med kontotypen &quot;Privatkonto&quot; i bankväljaren nedan.
{t('access_denied_hint', { bankName: failedBankName })}
</p>
)}
<p className="mt-1 text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
{t('import_fallback_text')}<Link href="/import?mode=bank" className="underline hover:text-foreground">{t('import_fallback_link')}</Link>{t('import_fallback_suffix')}
</p>
</div>
<button
@@ -134,7 +136,7 @@ export default function BankingSettingsPage() {
setIsAccessDenied(false)
}}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label="Stäng"
aria-label={t('dismiss_aria')}
>
<span className="text-lg leading-none">&times;</span>
</button>
@@ -147,14 +149,14 @@ export default function BankingSettingsPage() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
<p className="font-medium mb-1">{t('not_enabled_title')}</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
{t('not_enabled_description')}
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
Gå till Tillägg
{t('go_to_extensions')}
</Link>
</Button>
</CardContent>
+13 -13
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
@@ -13,6 +14,7 @@ import type { CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export default function BookkeepingSettingsPage() {
const t = useTranslations('settings_bookkeeping')
const { settings, isLoading, updateSettings } = useSettings()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
@@ -43,23 +45,21 @@ export default function BookkeepingSettingsPage() {
{/* Accounting method */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bokföringsmetod
{t('method_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_method">Metod</Label>
<Label htmlFor="accounting_method">{t('method_label')}</Label>
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
<option value="accrual">{t('method_accrual')}</option>
<option value="cash">{t('method_cash')}</option>
</select>
<p className="text-xs text-muted-foreground">
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
3 MSEK (BFL 5 kap. 2 §). Obetalda fordringar och skulder ska bokföras
vid räkenskapsårets utgång.
{t('method_help')}
</p>
</div>
</section>
@@ -68,10 +68,10 @@ export default function BookkeepingSettingsPage() {
<div className="border-t border-border/8 pt-8">
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Standardserie för verifikationer
{t('series_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="default_voucher_series">Serie</Label>
<Label htmlFor="default_voucher_series">{t('series_label')}</Label>
<select
id="default_voucher_series"
name="default_voucher_series"
@@ -83,7 +83,7 @@ export default function BookkeepingSettingsPage() {
))}
</select>
<p className="text-xs text-muted-foreground">
Vilken serie som förväljs vid manuell bokföring. Kan ändras per verifikation.
{t('series_help')}
</p>
</div>
</section>
@@ -103,7 +103,7 @@ export default function BookkeepingSettingsPage() {
{/* Cross-links */}
<div className="border-t border-border/8 pt-8 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Relaterat
{t('related_heading')}
</h2>
<div className="flex flex-col gap-2">
<Link
@@ -111,14 +111,14 @@ export default function BookkeepingSettingsPage() {
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Räkenskapsår och ingående balanser
{t('related_fiscal_year')}
</Link>
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Kontoplan (BAS)
{t('related_chart_of_accounts')}
</Link>
</div>
</div>
+3 -1
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
@@ -11,6 +12,7 @@ import { normaliseSwish } from '@/lib/payments/swish'
import type { CompanySettings } from '@/types'
export default function InvoicingSettingsPage() {
const t = useTranslations('settings_invoicing')
const { settings, isLoading, updateSettings } = useSettings()
const { toast } = useToast()
@@ -20,7 +22,7 @@ export default function InvoicingSettingsPage() {
const bankErrors = validateBankFields(formData)
if (bankErrors.length > 0) {
toast({
title: 'Kontrollera bankuppgifter',
title: t('bank_validation_title'),
description: bankErrors.map(e => e.message).join(', '),
variant: 'destructive',
})
+24 -22
View File
@@ -1,27 +1,29 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { PageHeader } from '@/components/ui/page-header'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
export default function SalarySettingsPage() {
const t = useTranslations('settings_salary')
return (
<div className="space-y-8">
<PageHeader title="Löneinställningar" />
<PageHeader title={t('title')} />
<Card>
<CardHeader>
<CardTitle className="text-base">Bokföring</CardTitle>
<CardTitle className="text-base">{t('accounting_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Standard verifikationsserie för löner</label>
<label className="text-sm font-medium">{t('voucher_series_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="A">
<option value="A">A — Standard</option>
<option value="L">L — Löner</option>
<option value="A">{t('voucher_series_a')}</option>
<option value="L">{t('voucher_series_l')}</option>
</select>
<p className="text-xs text-muted-foreground">
Kan ändras per lönekörning. Varje serie har obrutna verifikationsnummer per räkenskapsår.
{t('voucher_series_help')}
</p>
</div>
</CardContent>
@@ -29,39 +31,37 @@ export default function SalarySettingsPage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Skattetabeller</CardTitle>
<CardTitle className="text-base">{t('tax_tables_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<TaxTableStatus />
<p className="text-xs text-muted-foreground">
Skattetabeller och kommunala skattesatser hämtas automatiskt från Skatteverkets öppna data
vid varje lönekörning. Ingen manuell uppdatering krävs. Om Skatteverkets API är otillgängligt
används en inbäddad reservkopia tills tjänsten är uppe igen.
{t('tax_tables_help')}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
<CardTitle className="text-base">{t('vacation_heading')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Standard semesterregel</label>
<label className="text-sm font-medium">{t('vacation_rule_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="procentregeln">
<option value="procentregeln">Procentregeln (12 %)</option>
<option value="sammaloneregeln">Sammalöneregeln</option>
<option value="none">Ingen semesteravsättning</option>
<option value="procentregeln">{t('vacation_rule_percentage')}</option>
<option value="sammaloneregeln">{t('vacation_rule_same_pay')}</option>
<option value="none">{t('vacation_rule_none')}</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Semestertillägg</label>
<label className="text-sm font-medium">{t('vacation_supplement_label')}</label>
<select className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm" defaultValue="0.0043">
<option value="0.0043">0,43% (lagstadgat minimum)</option>
<option value="0.008">0,80% (vanligt kollektivavtalsbelopp)</option>
<option value="0.0043">{t('vacation_supplement_min')}</option>
<option value="0.008">{t('vacation_supplement_cba')}</option>
</select>
<p className="text-xs text-muted-foreground">
Tillämpas vid sammalöneregeln. Kan ändras per anställd.
{t('vacation_supplement_help')}
</p>
</div>
</CardContent>
@@ -69,13 +69,15 @@ export default function SalarySettingsPage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Information</CardTitle>
<CardTitle className="text-base">{t('info_heading')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground space-y-2">
<p>Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.</p>
<p>{t('info_payroll_scope')}</p>
<p>
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK
{t.rich('info_current_year', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</CardContent>
@@ -1,11 +1,13 @@
'use client'
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/use-toast'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
export default function SkatteverketSettingsPage() {
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
@@ -16,21 +18,21 @@ export default function SkatteverketSettingsPage() {
if (connected === 'true') {
toast({
title: 'Skatteverket anslutet',
description: 'Du kan nu skicka deklarationer och hämta skattekonto-saldot.',
title: t('connected_title'),
description: t('connected_description'),
})
router.replace('/settings/skatteverket')
} else if (error) {
let msg: string
try { msg = decodeURIComponent(error) } catch { msg = error }
toast({
title: 'Anslutning misslyckades',
title: t('connect_failed_title'),
description: msg,
variant: 'destructive',
})
router.replace('/settings/skatteverket')
}
}, [searchParams, router, toast])
}, [searchParams, router, toast, t])
return (
<div className="space-y-8">
+110 -105
View File
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -34,22 +35,12 @@ const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warn
reversed: 'secondary',
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
disputed: 'Tvist',
credited: 'Krediterad',
reversed: 'Makulerad',
}
export default function SupplierInvoiceDetailPage() {
const { canWrite } = useCanWrite()
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('supplier_invoice_detail')
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
@@ -67,12 +58,23 @@ export default function SupplierInvoiceDetailPage() {
>(null)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
const statusLabels = useMemo<Record<string, string>>(() => ({
registered: t('status_registered'),
approved: t('status_approved'),
paid: t('status_paid'),
partially_paid: t('status_partially_paid'),
overdue: t('status_overdue'),
disputed: t('status_disputed'),
credited: t('status_credited'),
reversed: t('status_reversed'),
}), [t])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Kunde inte ladda leverantörsfaktura', description: error, variant: 'destructive' })
toast({ title: t('load_failed_title'), description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
@@ -90,9 +92,9 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Godkännande misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
toast({ title: t('approve_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Fakturan har godkänts' })
toast({ title: t('approved_title'), description: t('approved_description') })
fetchInvoice()
}
setIsProcessing(false)
@@ -111,12 +113,12 @@ export default function SupplierInvoiceDetailPage() {
setDuplicateCandidates(result.error.details.candidates)
setIsPayDialogOpen(false)
} else {
toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
toast({ title: t('payment_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
}
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
title: result.status === 'paid' ? t('paid_title') : t('partial_payment_title'),
description: t('amount_registered_description', { amount: formatAmount(parseFloat(payAmount)) }),
})
setIsPayDialogOpen(false)
setDuplicateCandidates(null)
@@ -127,9 +129,9 @@ export default function SupplierInvoiceDetailPage() {
async function handleCredit() {
const ok = await confirmAction({
title: 'Registrera kreditfaktura',
description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.',
confirmLabel: 'Registrera kreditfaktura',
title: t('credit_confirm_title'),
description: t('credit_confirm_description'),
confirmLabel: t('credit_confirm_label'),
variant: 'warning',
})
if (!ok) return
@@ -137,9 +139,9 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kreditering misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
toast({ title: t('credit_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
toast({ title: t('credit_success_title') })
fetchInvoice()
}
setIsProcessing(false)
@@ -147,28 +149,27 @@ export default function SupplierInvoiceDetailPage() {
async function handleDelete() {
const ok = await confirmAction({
title: 'Ta bort faktura',
description: 'Fakturan och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
title: t('delete_confirm_title'),
description: t('delete_confirm_description'),
confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte ta bort faktura', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
toast({ title: t('delete_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
toast({ title: t('deleted_title') })
router.push('/supplier-invoices')
}
}
async function handleUncredit() {
const ok = await confirmAction({
title: 'Ångra kreditering',
description:
'Kreditfakturan tas bort och dess verifikation makuleras (storno). Originalfakturan återställs så att fakturanumret blir ledigt igen.',
confirmLabel: 'Ångra kreditering',
title: t('uncredit_confirm_title'),
description: t('uncredit_confirm_description'),
confirmLabel: t('uncredit_confirm_label'),
variant: 'warning',
})
if (!ok) return
@@ -177,14 +178,14 @@ export default function SupplierInvoiceDetailPage() {
const result = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte ångra kreditering',
title: t('uncredit_failed_title'),
description: getErrorMessage(result, { context: 'supplier_invoice' }),
variant: 'destructive',
})
} else {
toast({
title: 'Kreditering ångrad',
description: 'Originalfakturan är återställd och numret är ledigt.',
title: t('uncredit_success_title'),
description: t('uncredit_success_description'),
})
fetchInvoice()
}
@@ -203,9 +204,9 @@ export default function SupplierInvoiceDetailPage() {
if (!invoice) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Fakturan hittades inte</p>
<p className="text-muted-foreground">{t('not_found')}</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/supplier-invoices')}>
Tillbaka
{t('back')}
</Button>
</div>
)
@@ -219,20 +220,23 @@ export default function SupplierInvoiceDetailPage() {
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')} aria-label="Tillbaka till leverantörsfakturor">
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')} aria-label={t('back_aria')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">
Ankomst #{invoice.arrival_number}
{t('arrival_header', { number: invoice.arrival_number })}
</h1>
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
{statusLabels[invoice.status] || invoice.status}
</Badge>
</div>
<p className="text-muted-foreground text-sm sm:text-base truncate">
{invoice.supplier?.name} | Faktura {invoice.supplier_invoice_number}
{t('header_subtitle', {
supplier: invoice.supplier?.name ?? '',
number: invoice.supplier_invoice_number,
})}
</p>
</div>
</div>
@@ -244,17 +248,18 @@ export default function SupplierInvoiceDetailPage() {
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Godkänn
{t('approve')}
</Button>
<Button
variant="destructive"
size="icon"
onClick={handleDelete}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
aria-label={t('delete_confirm_label')}
>
{canWrite ? <Trash2 className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
@@ -265,20 +270,20 @@ export default function SupplierInvoiceDetailPage() {
<Button
onClick={() => setIsPayDialogOpen(true)}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CreditCard className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera betald
{t('mark_paid')}
</Button>
{invoice.status !== 'partially_paid' && (
<Button
variant="outline"
onClick={handleCredit}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <FileText className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Kreditfaktura
{t('credit_note_button')}
</Button>
)}
</>
@@ -288,10 +293,10 @@ export default function SupplierInvoiceDetailPage() {
variant="outline"
onClick={handleUncredit}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Undo2 className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Ångra kreditering
{t('uncredit_button')}
</Button>
)}
</div>
@@ -302,20 +307,20 @@ export default function SupplierInvoiceDetailPage() {
<div className="rounded-lg border bg-muted/40 p-4 flex gap-3 text-sm">
<Info className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Detta är en kreditfaktura</p>
<p className="font-medium">{t('credit_note_banner_title')}</p>
<p className="text-muted-foreground">
Den är kopplad till{' '}
{t('credit_note_banner_prefix')}{' '}
{(invoice as SupplierInvoice & { credited_original?: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original ? (
<Link
href={`/supplier-invoices/${(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.id}`}
className="text-primary hover:underline font-medium"
>
faktura {(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.supplier_invoice_number}
{t('credit_note_banner_link', { number: (invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.supplier_invoice_number })}
</Link>
) : (
<span>originalfakturan</span>
<span>{t('credit_note_banner_original_fallback')}</span>
)}
. För att ta bort kreditfakturan och frigöra fakturanumret, gå till originalet och välj &quot;Ångra kreditering&quot;.
{t('credit_note_banner_suffix')}
</p>
</div>
</div>
@@ -325,40 +330,40 @@ export default function SupplierInvoiceDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Fakturainformation</CardTitle>
<CardTitle className="text-lg">{t('invoice_info_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Ankomstnummer</span>
<span className="text-muted-foreground">{t('arrival_number_label')}</span>
<span className="font-mono">{invoice.arrival_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturanummer</span>
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span>{invoice.supplier_invoice_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturadatum</span>
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Förfallodatum</span>
<span className="text-muted-foreground">{t('due_date_label')}</span>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</div>
{invoice.delivery_date && (
<div className="flex justify-between">
<span className="text-muted-foreground">Leveransdatum</span>
<span className="text-muted-foreground">{t('delivery_date_label')}</span>
<span>{invoice.delivery_date}</span>
</div>
)}
{invoice.payment_reference && (
<div className="flex justify-between">
<span className="text-muted-foreground">OCR/referens</span>
<span className="text-muted-foreground">{t('ocr_reference_label')}</span>
<span className="font-mono">{invoice.payment_reference}</span>
</div>
)}
{invoice.reverse_charge && (
<div className="mt-2">
<Badge variant="warning">Omvänd skattskyldighet</Badge>
<Badge variant="warning">{t('reverse_charge_badge')}</Badge>
</div>
)}
</CardContent>
@@ -366,27 +371,27 @@ export default function SupplierInvoiceDetailPage() {
<Card>
<CardHeader>
<CardTitle className="text-lg">Belopp</CardTitle>
<CardTitle className="text-lg">{t('amounts_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-bold text-base pt-2 border-t">
<span>Totalt</span>
<span>{t('total_label')}</span>
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">Betalt</span>
<span className="text-muted-foreground">{t('paid_label')}</span>
<span className="font-mono text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Kvar att betala</span>
<span>{t('remaining_label')}</span>
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
</div>
</CardContent>
@@ -397,14 +402,14 @@ export default function SupplierInvoiceDetailPage() {
{invoice.supplier && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Leverantör</CardTitle>
<CardTitle className="text-lg">{t('supplier_section_title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline font-medium">
{invoice.supplier.name}
</Link>
<div className="text-muted-foreground mt-1">
{invoice.supplier.org_number && <span>Org.nr: {invoice.supplier.org_number} | </span>}
{invoice.supplier.org_number && <span>{t('org_number_inline', { number: invoice.supplier.org_number })}</span>}
{invoice.supplier.email}
</div>
</CardContent>
@@ -414,7 +419,7 @@ export default function SupplierInvoiceDetailPage() {
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Rader</CardTitle>
<CardTitle className="text-lg">{t('rows_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
@@ -422,14 +427,14 @@ export default function SupplierInvoiceDetailPage() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-16 text-right">Antal</th>
<th className="pb-2 w-16">Enhet</th>
<th className="pb-2 w-28 text-right">À-pris</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-16 text-right">{t('col_quantity')}</th>
<th className="pb-2 w-16">{t('col_unit')}</th>
<th className="pb-2 w-28 text-right">{t('col_unit_price')}</th>
<th className="pb-2 w-20">{t('col_account')}</th>
<th className="pb-2 w-16 text-right">{t('col_vat_rate')}</th>
<th className="pb-2 w-28 text-right">{t('col_amount')}</th>
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
</tr>
</thead>
<tbody>
@@ -458,8 +463,8 @@ export default function SupplierInvoiceDetailPage() {
<span className="font-mono">{formatAmount(item.line_total)} kr</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span><AccountNumber number={item.account_number} /> · {Math.round(item.vat_rate * 100)}% moms</span>
<span className="font-mono">moms {formatAmount(item.vat_amount)}</span>
<span><AccountNumber number={item.account_number} /> · {t('vat_inline', { rate: Math.round(item.vat_rate * 100) })}</span>
<span className="font-mono">{t('vat_amount_inline', { amount: formatAmount(item.vat_amount) })}</span>
</div>
</div>
))}
@@ -471,7 +476,7 @@ export default function SupplierInvoiceDetailPage() {
{payments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningshistorik</CardTitle>
<CardTitle className="text-lg">{t('payment_history_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
@@ -479,10 +484,10 @@ export default function SupplierInvoiceDetailPage() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
<th className="pb-2">{t('col_date')}</th>
<th className="pb-2 text-right">{t('col_amount_short')}</th>
<th className="pb-2">{t('col_voucher')}</th>
<th className="pb-2">{t('col_note')}</th>
</tr>
</thead>
<tbody>
@@ -529,12 +534,12 @@ export default function SupplierInvoiceDetailPage() {
{/* Journal entries (sambandskrav) */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Verifikationer (sambandskrav)</CardTitle>
<CardTitle className="text-lg">{t('vouchers_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<span className="text-muted-foreground">{t('registration_voucher')}</span>
<Link
href={`/bookkeeping/${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
@@ -543,11 +548,11 @@ export default function SupplierInvoiceDetailPage() {
</Link>
</div>
) : (
<p className="text-muted-foreground">Ingen registreringsverifikation (kontantmetoden)</p>
<p className="text-muted-foreground">{t('no_registration_voucher')}</p>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<span className="text-muted-foreground">{t('payment_voucher')}</span>
<Link
href={`/bookkeeping/${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
@@ -563,7 +568,7 @@ export default function SupplierInvoiceDetailPage() {
{invoice.notes && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Anteckningar</CardTitle>
<CardTitle className="text-lg">{t('notes_title')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
@@ -577,11 +582,11 @@ export default function SupplierInvoiceDetailPage() {
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Markera som betald</DialogTitle>
<DialogTitle>{t('pay_dialog_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="payment-date">Betalningsdatum</Label>
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
<Input
id="payment-date"
type="date"
@@ -592,7 +597,7 @@ export default function SupplierInvoiceDetailPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="payment-amount">Belopp att betala</Label>
<Label htmlFor="payment-amount">{t('payment_amount_label')}</Label>
<Input
id="payment-amount"
type="number"
@@ -601,15 +606,15 @@ export default function SupplierInvoiceDetailPage() {
onChange={(e) => setPayAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
{t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
{isProcessing ? t('processing') : t('register_payment')}
</Button>
</div>
</div>
@@ -625,13 +630,13 @@ export default function SupplierInvoiceDetailPage() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Möjlig dubbelbetalning</DialogTitle>
<DialogTitle>{t('duplicate_payment_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Vi hittade {duplicateCandidates?.length === 1 ? 'en banktransaktion' : 'banktransaktioner'} som
verkar matcha denna betalning. Länka den befintliga transaktionen istället för att skapa en ny
verifikation.
{duplicateCandidates?.length === 1
? t('duplicate_payment_description_one')
: t('duplicate_payment_description_many')}
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
{duplicateCandidates?.map((c) => (
@@ -639,7 +644,7 @@ export default function SupplierInvoiceDetailPage() {
<div className="min-w-0">
<div className="font-medium tabular-nums">{formatDate(c.date)}</div>
<div className="truncate text-xs text-muted-foreground">
{c.merchant_name || c.description || 'Banktransaktion'}
{c.merchant_name || c.description || t('bank_transaction_fallback')}
</div>
</div>
<div className="tabular-nums font-medium">
@@ -650,21 +655,21 @@ export default function SupplierInvoiceDetailPage() {
size="sm"
onClick={() => router.push(`/transactions?highlight=${c.id}`)}
>
Gå till
{t('go_to')}
</Button>
</div>
))}
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setDuplicateCandidates(null)}>
Avbryt
{t('cancel')}
</Button>
<Button
variant="outline"
onClick={() => handleMarkPaid(true)}
disabled={isProcessing}
>
{isProcessing ? 'Bearbetar...' : 'Skapa ny verifikation ändå'}
{isProcessing ? t('processing') : t('create_voucher_anyway')}
</Button>
</div>
</div>
+121 -116
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -106,6 +107,7 @@ export default function NewSupplierInvoicePage() {
const inboxItemId = searchParams.get('inbox_item_id')
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('supplier_invoice_editor')
// When opened from an invoice-inbox item, every redirect should land the
// user back in the inbox so they can pick the next document. Outside the
@@ -205,8 +207,8 @@ export default function NewSupplierInvoicePage() {
if (cancelled) return
if (!res.ok) {
toast({
title: 'Kunde inte ladda inkorgsposten',
description: json?.error || 'Posten finns inte längre eller är otillgänglig.',
title: t('inbox_load_failed_title'),
description: json?.error || t('inbox_load_failed_description'),
variant: 'destructive',
})
setIsLoadingInbox(false)
@@ -273,8 +275,8 @@ export default function NewSupplierInvoicePage() {
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte ladda inkorgsposten',
description: err instanceof Error ? err.message : 'Okänt fel.',
title: t('inbox_load_failed_title'),
description: err instanceof Error ? err.message : t('unknown_error'),
variant: 'destructive',
})
} finally {
@@ -447,7 +449,7 @@ export default function NewSupplierInvoicePage() {
async function handleCreateSupplier() {
if (!newSupplier.name.trim()) {
toast({ title: 'Namn saknas', description: 'Ange ett namn för leverantören.', variant: 'destructive' })
toast({ title: t('name_missing_title'), description: t('name_missing_description'), variant: 'destructive' })
return
}
setIsCreatingSupplier(true)
@@ -471,7 +473,7 @@ export default function NewSupplierInvoicePage() {
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte skapa leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
toast({ title: t('create_supplier_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
} else {
const created = result.data as Supplier
setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
@@ -479,7 +481,7 @@ export default function NewSupplierInvoicePage() {
setHasMatchedSupplier(true)
setShowNewSupplier(false)
setNewSupplier(EMPTY_NEW_SUPPLIER)
toast({ title: 'Leverantör skapad', description: created.name })
toast({ title: t('supplier_created_title'), description: created.name })
}
setIsCreatingSupplier(false)
@@ -581,11 +583,11 @@ export default function NewSupplierInvoicePage() {
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Leverantör saknas', description: 'Välj eller skapa en leverantör.', variant: 'destructive' })
toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fakturanummer saknas', description: 'Ange leverantörens fakturanummer.', variant: 'destructive' })
toast({ title: t('invoice_number_missing_title'), description: t('invoice_number_missing_description'), variant: 'destructive' })
return
}
@@ -632,8 +634,8 @@ export default function NewSupplierInvoicePage() {
if (data.paid_with_private_funds) {
toast({
title: 'Utlägg registrerat',
description: `Ankomstnummer: ${result.data.arrival_number}`,
title: t('expense_registered_title'),
description: t('arrival_number_label', { number: result.data.arrival_number }),
})
router.push(afterCreate())
setIsSubmitting(false)
@@ -644,13 +646,13 @@ export default function NewSupplierInvoicePage() {
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
if (!approveRes.ok) {
toast({
title: 'Varning',
description: 'Fakturan skapades men kunde inte godkännas automatiskt',
title: t('warning_title'),
description: t('auto_approve_failed_description'),
variant: 'destructive',
})
router.push(afterCreate(result.data.id))
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: result.data.arrival_number }) })
router.push(afterCreate())
}
setIsSubmitting(false)
@@ -683,18 +685,18 @@ export default function NewSupplierInvoicePage() {
if (matchRes.ok) {
toast({
title: 'Faktura registrerad och matchad',
description: `Ankomstnummer: ${arrivalNumber}. Markerad som betald.`,
title: t('invoice_registered_and_matched_title'),
description: t('invoice_registered_and_matched_description', { number: arrivalNumber }),
})
} else {
toast({
title: 'Faktura registrerad — kunde inte matcha',
title: t('invoice_registered_match_failed_title'),
description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }),
variant: 'destructive',
})
}
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${arrivalNumber}` })
toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: arrivalNumber }) })
}
router.push(afterCreate(invoiceId))
@@ -703,7 +705,7 @@ export default function NewSupplierInvoicePage() {
if (status === 409 && result.error === 'duplicate_supplier_invoice_number') {
setShowReview(false)
setConflict({
message: result.message || 'Det finns redan en faktura med detta nummer från denna leverantör.',
message: result.message || t('duplicate_default_message'),
existing: result.existing ?? null,
})
} else {
@@ -719,7 +721,7 @@ export default function NewSupplierInvoicePage() {
result: { error?: string; message?: string },
) {
toast({
title: 'Kunde inte registrera faktura',
title: t('register_invoice_failed_title'),
description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }),
variant: 'destructive',
})
@@ -738,7 +740,7 @@ export default function NewSupplierInvoicePage() {
const uncreditResult = await uncreditRes.json()
if (!uncreditRes.ok) {
toast({
title: 'Kunde inte ångra kreditering',
title: t('uncredit_failed_title'),
description: getErrorMessage(uncreditResult, { context: 'supplier_invoice', statusCode: uncreditRes.status }),
variant: 'destructive',
})
@@ -758,8 +760,8 @@ export default function NewSupplierInvoicePage() {
if (ok && result.data) {
toast({
title: 'Kreditering ångrad och faktura registrerad',
description: `Ankomstnummer: ${result.data.arrival_number}`,
title: t('uncredit_and_register_success_title'),
description: t('arrival_number_label', { number: result.data.arrival_number }),
})
reset(pendingData)
router.push(afterCreate(result.data.id))
@@ -767,8 +769,11 @@ export default function NewSupplierInvoicePage() {
}
toast({
title: 'Kreditering ångrad — men nya fakturan kunde inte registreras',
description: `Faktura ${existingNumber} är återställd och numret är ledigt. ${getErrorMessage(result, { context: 'supplier_invoice', statusCode: status })}`,
title: t('uncredit_but_register_failed_title'),
description: t('uncredit_but_register_failed_description', {
number: existingNumber,
reason: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }),
}),
variant: 'destructive',
})
}
@@ -799,7 +804,7 @@ export default function NewSupplierInvoicePage() {
if (!ok || !result.data) {
if (status === 409 && result.error === 'duplicate_supplier_invoice_number') {
setConflict({
message: result.message || 'Det finns redan en faktura med detta nummer från denna leverantör.',
message: result.message || t('duplicate_default_message'),
existing: result.existing ?? null,
})
} else {
@@ -828,12 +833,12 @@ export default function NewSupplierInvoicePage() {
if (matchRes.ok) {
toast({
title: 'Faktura registrerad och matchad',
description: `Ankomstnummer: ${arrivalNumber}. Markerad som betald.`,
title: t('invoice_registered_and_matched_title'),
description: t('invoice_registered_and_matched_description', { number: arrivalNumber }),
})
} else {
toast({
title: 'Faktura registrerad — kunde inte matcha',
title: t('invoice_registered_match_failed_title'),
description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }),
variant: 'destructive',
})
@@ -849,12 +854,12 @@ export default function NewSupplierInvoicePage() {
variant="ghost"
size="icon"
onClick={() => router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')}
aria-label={inboxItemId ? 'Tillbaka till inkorgen' : 'Tillbaka till leverantörsfakturor'}
aria-label={inboxItemId ? t('back_aria_inbox') : t('back_aria')}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Registrera leverantörsfaktura</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('page_title')}</h1>
</div>
</div>
@@ -862,7 +867,7 @@ export default function NewSupplierInvoicePage() {
<Card>
<CardContent className="py-4 flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar uppgifter från inkorgen…
{t('loading_inbox')}
</CardContent>
</Card>
)}
@@ -875,19 +880,19 @@ export default function NewSupplierInvoicePage() {
<Sparkles className="h-5 w-5 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium">
Föreslagen leverantör (från fakturan): {extractedData?.supplier?.name}
{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}
</p>
<p className="text-xs text-muted-foreground">
{extractedData?.supplier?.orgNumber
? `Org.nr ${extractedData.supplier.orgNumber}`
: 'Ingen organisationsnummer hittades'}
{' — leverantören finns inte upplagd ännu.'}
? t('ai_org_number', { orgNumber: extractedData.supplier.orgNumber })
: t('ai_no_org_number')}
{t('ai_supplier_not_in_system')}
</p>
</div>
</div>
<Button type="button" size="sm" onClick={openSupplierDialogPrefilled}>
<Plus className="mr-2 h-4 w-4" />
Skapa &amp; välj
{t('create_and_select')}
</Button>
</div>
</CardContent>
@@ -898,7 +903,7 @@ export default function NewSupplierInvoicePage() {
{/* Section 1: Faktura */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Faktura</CardTitle>
<CardTitle className="text-lg">{t('section_invoice')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Eget utlägg-toggle. När den är på bokas verifikatet direkt mot
@@ -918,16 +923,16 @@ export default function NewSupplierInvoicePage() {
)}
/>
<Label htmlFor="paid_with_private_funds" className="cursor-pointer flex-1">
<span className="text-sm font-medium">Jag har betalat detta privat</span>
<span className="text-sm font-medium">{t('paid_privately_label')}</span>
<span className="block text-[11px] text-muted-foreground font-normal mt-0.5">
Bokförs som skuld från bolaget till dig ({isEF ? '2018 Egen insättning' : '2893 Skuld till ägare'}). Återbetalas senare manuellt från företagskontot.
{isEF ? t('paid_privately_help_ef') : t('paid_privately_help_ab')}
</span>
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör<RequiredMark /></Label>
<Label>{t('supplier_label')}<RequiredMark /></Label>
<Controller
name="supplier_id"
control={control}
@@ -943,14 +948,14 @@ export default function NewSupplierInvoicePage() {
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
<SelectValue placeholder={t('supplier_placeholder')} />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
<SelectItem value="__new__" className="text-primary font-medium">
+ Lägg till ny leverantör...
{t('add_new_supplier')}
</SelectItem>
</SelectContent>
</Select>
@@ -958,12 +963,12 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer<RequiredMark /></Label>
<Label>{t('supplier_invoice_number_label')}<RequiredMark /></Label>
{(() => {
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
return (
<Input
placeholder="Fakturanr från leverantören"
placeholder={t('supplier_invoice_number_placeholder')}
{...rest}
ref={(el) => {
rhfRef(el)
@@ -979,18 +984,18 @@ export default function NewSupplierInvoicePage() {
watchedPaidPrivately ? 'sm:grid-cols-1' : 'sm:grid-cols-3',
)}>
<div className="space-y-2">
<Label>Fakturadatum<RequiredMark /></Label>
<Label>{t('invoice_date_label')}<RequiredMark /></Label>
<Input type="date" {...register('invoice_date')} />
</div>
{!watchedPaidPrivately && (
<>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Label>{t('due_date_label')}<RequiredMark /></Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input placeholder="OCR-nummer" {...register('payment_reference')} />
<Label>{t('payment_reference_label')}</Label>
<Input placeholder={t('payment_reference_placeholder')} {...register('payment_reference')} />
</div>
</>
)}
@@ -1001,7 +1006,7 @@ export default function NewSupplierInvoicePage() {
{/* Section 2: Kontering */}
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<CardTitle className="text-lg">Kontering</CardTitle>
<CardTitle className="text-lg">{t('section_accounting')}</CardTitle>
<Button
type="button"
variant="outline"
@@ -1012,7 +1017,7 @@ export default function NewSupplierInvoicePage() {
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
{t('add_row')}
</Button>
</CardHeader>
<CardContent>
@@ -1021,7 +1026,7 @@ export default function NewSupplierInvoicePage() {
normal moms) collapse to nothing so most users don't see this. */}
<div className="mb-5 pb-5 border-b grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">Valuta</Label>
<Label className="text-xs">{t('currency_label')}</Label>
<Controller
name="currency"
control={control}
@@ -1045,13 +1050,13 @@ export default function NewSupplierInvoicePage() {
{watchedCurrency !== 'SEK' && (
<div className="space-y-1.5">
<Label className="text-xs">
Växelkurs <span className="text-muted-foreground">(till SEK)</span>
{t('exchange_rate_label')} <span className="text-muted-foreground">{t('exchange_rate_to_sek')}</span>
</Label>
<Input
type="number"
step="0.0001"
inputMode="decimal"
placeholder="Hämtas från Riksbanken"
placeholder={t('exchange_rate_placeholder')}
className="h-9 text-right tabular-nums"
{...register('exchange_rate', {
onChange: () => { userTouchedRateRef.current = true },
@@ -1077,9 +1082,9 @@ export default function NewSupplierInvoicePage() {
)}
/>
<Label htmlFor="reverse_charge" className="text-xs cursor-pointer">
Omvänd skattskyldighet
{t('reverse_charge_label')}
<span className="block text-[11px] text-muted-foreground font-normal mt-0.5">
Köp inom EU eller byggtjänster — momsen redovisas av köparen.
{t('reverse_charge_help')}
</span>
</Label>
</div>
@@ -1090,11 +1095,11 @@ export default function NewSupplierInvoicePage() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-28">{t('col_account')}</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
<th className="pb-2 w-24">{t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
@@ -1120,7 +1125,7 @@ export default function NewSupplierInvoicePage() {
control={control}
render={({ field }) => (
<Input
placeholder="Beskrivning"
placeholder={t('description_placeholder')}
ref={field.ref}
value={field.value ?? ''}
onChange={field.onChange}
@@ -1170,7 +1175,7 @@ export default function NewSupplierInvoicePage() {
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)}>
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
@@ -1186,15 +1191,15 @@ export default function NewSupplierInvoicePage() {
{fields.map((field, index) => (
<div key={field.id} className="border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Rad {index + 1}</span>
<span className="text-sm font-medium text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={`Ta bort rad ${index + 1}`}>
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Konto</Label>
<Label className="text-xs text-muted-foreground">{t('col_account')}</Label>
<Controller
name={`items.${index}.account_number`}
control={control}
@@ -1204,13 +1209,13 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Beskrivning</Label>
<Label className="text-xs text-muted-foreground">{t('col_description')}</Label>
<Controller
name={`items.${index}.description`}
control={control}
render={({ field }) => (
<Input
placeholder="Beskrivning"
placeholder={t('description_placeholder')}
ref={field.ref}
value={field.value ?? ''}
onChange={field.onChange}
@@ -1221,7 +1226,7 @@ export default function NewSupplierInvoicePage() {
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Belopp (exkl.)</Label>
<Label className="text-xs text-muted-foreground">{t('col_amount_excl')}</Label>
<Controller
name={`items.${index}.amount`}
control={control}
@@ -1239,7 +1244,7 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Momssats</Label>
<Label className="text-xs text-muted-foreground">{t('col_vat_rate')}</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
@@ -1260,7 +1265,7 @@ export default function NewSupplierInvoicePage() {
</div>
</div>
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-xs text-muted-foreground">Moms</span>
<span className="text-xs text-muted-foreground">{t('col_vat')}</span>
<span className="font-mono text-sm">{formatCurrency(itemTotals[index]?.vatAmount || 0, watchedCurrency)}</span>
</div>
</div>
@@ -1270,20 +1275,20 @@ export default function NewSupplierInvoicePage() {
{/* AI totals comparison — only when extracted */}
{extractedData?.totals && (extractedData.totals.subtotal != null || extractedData.totals.total != null) && (
<div className="mt-4 pt-4 border-t flex flex-wrap gap-2 text-xs">
<span className="text-muted-foreground">Från fakturan (AI):</span>
<span className="text-muted-foreground">{t('ai_totals_label')}</span>
{extractedData.totals.subtotal != null && (
<span className="px-2 py-1 rounded bg-muted font-mono">
Netto {formatAmount(extractedData.totals.subtotal)}
{t('ai_net', { amount: formatAmount(extractedData.totals.subtotal) })}
</span>
)}
{extractedData.totals.vatAmount != null && (
<span className="px-2 py-1 rounded bg-muted font-mono">
Moms {formatAmount(extractedData.totals.vatAmount)}
{t('ai_vat', { amount: formatAmount(extractedData.totals.vatAmount) })}
</span>
)}
{extractedData.totals.total != null && (
<span className="px-2 py-1 rounded bg-muted font-mono">
Totalt {formatAmount(extractedData.totals.total)}
{t('ai_total', { amount: formatAmount(extractedData.totals.total) })}
</span>
)}
</div>
@@ -1292,17 +1297,17 @@ export default function NewSupplierInvoicePage() {
{/* Computed totals */}
<div className="mt-4 pt-4 border-t space-y-2">
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="font-mono sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">
{watchedReverseCharge ? 'Moms (omvänd, redovisas av köparen)' : 'Moms'}
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
</span>
<span className="font-mono sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
<span>Totalt</span>
<span>{t('total_label')}</span>
<span className="font-mono sm:w-32 text-right">{formatCurrency(total, watchedCurrency)}</span>
</div>
</div>
@@ -1316,19 +1321,19 @@ export default function NewSupplierInvoicePage() {
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Övrigt</CardTitle>
<CardTitle className="text-lg">{t('section_other')}</CardTitle>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</div>
</CardHeader>
{advancedOpen && (
<CardContent className="space-y-4 pt-0">
<div className="space-y-2">
<Label>Leveransdatum (ML krav)</Label>
<Label>{t('delivery_date_label')}</Label>
<Input type="date" {...register('delivery_date')} />
</div>
<div className="space-y-2">
<Label>Anteckningar</Label>
<Textarea placeholder="Interna anteckningar om denna faktura..." {...register('notes')} />
<Label>{t('notes_label')}</Label>
<Textarea placeholder={t('notes_placeholder')} {...register('notes')} />
</div>
</CardContent>
)}
@@ -1337,7 +1342,7 @@ export default function NewSupplierInvoicePage() {
{/* Submit */}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 sm:gap-4">
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')}>
Avbryt
{t('cancel')}
</Button>
{!watchedPaidPrivately && (
<Button
@@ -1346,10 +1351,10 @@ export default function NewSupplierInvoicePage() {
className="w-full sm:w-auto"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<Link2 className="mr-2 h-4 w-4" />
Registrera &amp; markera som betald
{t('register_and_mark_paid')}
</Button>
)}
<Button
@@ -1357,24 +1362,24 @@ export default function NewSupplierInvoicePage() {
disabled={isSubmitting || !canWrite}
className="w-full sm:w-auto"
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
{t('registering')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{watchedPaidPrivately ? 'Registrera utlägg' : isEF ? 'Registrera faktura' : 'Granska & registrera'}
{watchedPaidPrivately ? t('register_expense') : isEF ? t('register_invoice') : t('review_and_register')}
</>
) : watchedPaidPrivately ? (
'Registrera utlägg'
t('register_expense')
) : isEF ? (
'Registrera faktura'
t('register_invoice')
) : (
'Granska & registrera'
t('review_and_register')
)}
</Button>
</div>
@@ -1391,9 +1396,9 @@ export default function NewSupplierInvoicePage() {
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska leverantörsfaktura"
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
confirmLabel="Bekräfta & registrera"
title={t('review_dialog_title')}
warningText={t('review_dialog_warning')}
confirmLabel={t('review_dialog_confirm')}
>
<SupplierInvoiceReviewContent
supplier={selectedSupplier}
@@ -1433,19 +1438,19 @@ export default function NewSupplierInvoicePage() {
<Dialog open={showNewSupplier} onOpenChange={setShowNewSupplier}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ny leverantör</DialogTitle>
<DialogTitle>{t('new_supplier_dialog_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Namn<RequiredMark /></Label>
<Label>{t('new_supplier_name_label')}<RequiredMark /></Label>
<Input
placeholder="Leverantörens namn"
placeholder={t('new_supplier_name_placeholder')}
value={newSupplier.name}
onChange={(e) => setNewSupplier((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Typ</Label>
<Label>{t('new_supplier_type_label')}</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => setNewSupplier((p) => ({ ...p, supplier_type: v }))}
@@ -1454,15 +1459,15 @@ export default function NewSupplierInvoicePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
<SelectItem value="swedish_business">{t('supplier_type_swedish')}</SelectItem>
<SelectItem value="eu_business">{t('supplier_type_eu')}</SelectItem>
<SelectItem value="non_eu_business">{t('supplier_type_non_eu')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Organisationsnummer</Label>
<Label>{t('new_supplier_org_number_label')}</Label>
<Input
placeholder="XXXXXX-XXXX"
value={newSupplier.org_number}
@@ -1470,7 +1475,7 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label>VAT-nummer</Label>
<Label>{t('new_supplier_vat_number_label')}</Label>
<Input
placeholder="SE..."
value={newSupplier.vat_number}
@@ -1479,16 +1484,16 @@ export default function NewSupplierInvoicePage() {
</div>
</div>
<div className="space-y-2">
<Label>Adress</Label>
<Label>{t('new_supplier_address_label')}</Label>
<Input
placeholder="Gatuadress"
placeholder={t('new_supplier_address_placeholder')}
value={newSupplier.address_line1}
onChange={(e) => setNewSupplier((p) => ({ ...p, address_line1: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bankgiro</Label>
<Label>{t('new_supplier_bankgiro_label')}</Label>
<Input
placeholder="XXX-XXXX"
value={newSupplier.bankgiro}
@@ -1496,7 +1501,7 @@ export default function NewSupplierInvoicePage() {
/>
</div>
<div className="space-y-2">
<Label>Plusgiro</Label>
<Label>{t('new_supplier_plusgiro_label')}</Label>
<Input
placeholder="XXXXXX-X"
value={newSupplier.plusgiro}
@@ -1505,9 +1510,9 @@ export default function NewSupplierInvoicePage() {
</div>
</div>
<div className="space-y-2">
<Label>Standardkonto (kostnad)</Label>
<Label>{t('new_supplier_default_account_label')}</Label>
<Input
placeholder="t.ex. 5010"
placeholder={t('new_supplier_default_account_placeholder')}
value={newSupplier.default_expense_account}
onChange={(e) => setNewSupplier((p) => ({ ...p, default_expense_account: e.target.value }))}
/>
@@ -1515,16 +1520,16 @@ export default function NewSupplierInvoicePage() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewSupplier(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreateSupplier} disabled={isCreatingSupplier}>
{isCreatingSupplier ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
{t('creating')}
</>
) : (
'Skapa leverantör'
t('create_supplier_button')
)}
</Button>
</DialogFooter>
@@ -1537,7 +1542,7 @@ export default function NewSupplierInvoicePage() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Fakturanummer används redan
{t('duplicate_dialog_title')}
</DialogTitle>
<DialogDescription>{conflict?.message}</DialogDescription>
</DialogHeader>
@@ -1548,16 +1553,16 @@ export default function NewSupplierInvoicePage() {
onClick={() => router.push(`/supplier-invoices/${conflict.existing!.id}`)}
disabled={isResolvingConflict}
>
Visa befintlig faktura
{t('show_existing_invoice')}
</Button>
)}
{conflict?.existing?.status === 'credited' && (
<Button onClick={handleUncreditAndRetry} disabled={isResolvingConflict}>
{isResolvingConflict ? 'Bearbetar...' : 'Ångra kreditering & återförsök'}
{isResolvingConflict ? t('processing') : t('uncredit_and_retry')}
</Button>
)}
<Button variant="ghost" onClick={handlePickNewNumber} disabled={isResolvingConflict}>
Använd ett annat nummer
{t('use_different_number')}
</Button>
</div>
</DialogContent>
+35 -33
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
@@ -19,7 +20,7 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary',
approved: 'default',
paid: 'success',
@@ -30,18 +31,19 @@ const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warn
reversed: 'secondary',
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
disputed: 'Tvist',
credited: 'Krediterad',
reversed: 'Makulerad',
const STATUS_LABEL_KEYS: Record<string, string> = {
registered: 'status_registered',
approved: 'status_approved',
paid: 'status_paid',
partially_paid: 'status_partially_paid',
overdue: 'status_overdue',
disputed: 'status_disputed',
credited: 'status_credited',
reversed: 'status_reversed',
}
export default function SupplierInvoicesPage() {
const t = useTranslations('supplier_invoices')
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -72,22 +74,22 @@ export default function SupplierInvoicesPage() {
return (
<div className="space-y-8">
<PageHeader
title="Leverantörsfakturor"
title={t('title')}
action={
canWrite ? (
<Link href="/supplier-invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Registrera faktura
{t('register_invoice')}
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
title={t('viewer_disabled_tooltip')}
>
<Lock className="mr-2 h-4 w-4" />
Registrera faktura
{t('register_invoice')}
</Button>
)
}
@@ -96,11 +98,11 @@ export default function SupplierInvoicesPage() {
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="registered">Registrerade</TabsTrigger>
<TabsTrigger value="approved">Godkända</TabsTrigger>
<TabsTrigger value="to_pay">Att betala</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
<TabsTrigger value="registered">{t('tab_registered')}</TabsTrigger>
<TabsTrigger value="approved">{t('tab_approved')}</TabsTrigger>
<TabsTrigger value="to_pay">{t('tab_to_pay')}</TabsTrigger>
<TabsTrigger value="paid">{t('tab_paid')}</TabsTrigger>
</TabsList>
<TabsContent value={activeTab}>
@@ -128,13 +130,13 @@ export default function SupplierInvoicesPage() {
<CardContent className="p-0">
<EmptyState
icon={FileInput}
title="Inga fakturor"
title={t('empty_title')}
description={
activeTab === 'all'
? 'Registrera leverantörsfakturor för att hålla koll på inköp och betalningar.'
: 'Inga fakturor i denna kategori.'
? t('empty_description_all')
: t('empty_description_category')
}
actionLabel={activeTab === 'all' && canWrite ? 'Registrera faktura' : undefined}
actionLabel={activeTab === 'all' && canWrite ? t('register_invoice') : undefined}
actionHref={activeTab === 'all' && canWrite ? '/supplier-invoices/new' : undefined}
/>
</CardContent>
@@ -145,14 +147,14 @@ export default function SupplierInvoicesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Ankomst</TableHead>
<TableHead>Leverantör</TableHead>
<TableHead>Fakturanr</TableHead>
<TableHead>Fakturadatum</TableHead>
<TableHead>Förfaller</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="text-right">Kvar att betala</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('th_arrival')}</TableHead>
<TableHead>{t('th_supplier')}</TableHead>
<TableHead>{t('th_invoice_number')}</TableHead>
<TableHead>{t('th_invoice_date')}</TableHead>
<TableHead>{t('th_due_date')}</TableHead>
<TableHead className="text-right">{t('th_amount')}</TableHead>
<TableHead className="text-right">{t('th_remaining')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -174,8 +176,8 @@ export default function SupplierInvoicesPage() {
<TableCell className="text-right tabular-nums">{formatAmount(inv.total)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(inv.remaining_amount)}</TableCell>
<TableCell>
<Badge variant={statusVariants[inv.status] || 'secondary'}>
{statusLabels[inv.status] || inv.status}
<Badge variant={STATUS_VARIANTS[inv.status] || 'secondary'}>
{STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status}
</Badge>
</TableCell>
</TableRow>
+58 -55
View File
@@ -1,7 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -18,12 +19,6 @@ import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
@@ -33,6 +28,7 @@ export default function SupplierDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('supplier_detail')
const [supplier, setSupplier] = useState<Supplier & { stats?: { total_outstanding: number; total_paid: number; invoice_count: number } } | null>(null)
const [invoices, setInvoices] = useState<SupplierInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -40,12 +36,18 @@ export default function SupplierDetailPage() {
const [isSaving, setIsSaving] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
const supplierTypeLabels = useMemo<Record<SupplierType, string>>(() => ({
swedish_business: t('type_swedish'),
eu_business: t('type_eu'),
non_eu_business: t('type_non_eu'),
}), [t])
async function fetchSupplier() {
setIsLoading(true)
const res = await fetch(`/api/suppliers/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Kunde inte ladda leverantör', description: error, variant: 'destructive' })
toast({ title: t('load_failed_title'), description: error, variant: 'destructive' })
} else {
setSupplier(data)
}
@@ -74,9 +76,9 @@ export default function SupplierDetailPage() {
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte uppdatera leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
toast({ title: t('update_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
} else {
toast({ title: 'Sparat', description: 'Leverantören har uppdaterats' })
toast({ title: t('saved_title'), description: t('saved_description') })
setSupplier({ ...result.data, stats: supplier?.stats })
setIsEditOpen(false)
}
@@ -85,9 +87,9 @@ export default function SupplierDetailPage() {
async function handleDelete() {
const ok = await confirmAction({
title: 'Ta bort leverantör',
description: `"${supplier?.name}" och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.`,
confirmLabel: 'Ta bort',
title: t('delete_confirm_title'),
description: t('delete_confirm_description', { name: supplier?.name ?? '' }),
confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
})
if (!ok) return
@@ -95,9 +97,9 @@ export default function SupplierDetailPage() {
const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte ta bort leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
toast({ title: t('delete_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
} else {
toast({ title: 'Borttagen', description: 'Leverantören har tagits bort' })
toast({ title: t('deleted_title'), description: t('deleted_description') })
router.push('/suppliers')
}
}
@@ -116,9 +118,9 @@ export default function SupplierDetailPage() {
if (!supplier) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Leverantören hittades inte</p>
<p className="text-muted-foreground">{t('not_found')}</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/suppliers')}>
Tillbaka
{t('back')}
</Button>
</div>
)
@@ -134,26 +136,26 @@ export default function SupplierDetailPage() {
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
credited: 'Krediterad',
registered: t('status_registered'),
approved: t('status_approved'),
paid: t('status_paid'),
partially_paid: t('status_partially_paid'),
overdue: t('status_overdue'),
credited: t('status_credited'),
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')} aria-label="Tillbaka till leverantörer">
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')} aria-label={t('back_aria')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{supplier.name}</h1>
<p className="text-muted-foreground">
{supplierTypeLabels[supplier.supplier_type]}
{supplier.org_number && ` | Org.nr: ${supplier.org_number}`}
{supplier.org_number && t('org_number_inline', { number: supplier.org_number })}
</p>
</div>
</div>
@@ -162,17 +164,18 @@ export default function SupplierDetailPage() {
variant="outline"
onClick={() => setIsEditOpen(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Edit className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Redigera
{t('edit')}
</Button>
<Button
variant="destructive"
size="icon"
onClick={handleDelete}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
aria-label={t('delete_confirm_label')}
>
{canWrite ? <Trash2 className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
@@ -183,7 +186,7 @@ export default function SupplierDetailPage() {
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Utestående</CardTitle>
<CardTitle className="text-sm text-muted-foreground">{t('outstanding')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
@@ -191,7 +194,7 @@ export default function SupplierDetailPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt betalt</CardTitle>
<CardTitle className="text-sm text-muted-foreground">{t('total_paid')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
@@ -199,7 +202,7 @@ export default function SupplierDetailPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Antal fakturor</CardTitle>
<CardTitle className="text-sm text-muted-foreground">{t('invoice_count')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl font-medium tabular-nums">{supplier.stats?.invoice_count || 0}</p>
@@ -211,28 +214,28 @@ export default function SupplierDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Kontaktuppgifter</CardTitle>
<CardTitle className="text-lg">{t('contact_section_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{supplier.email && <p>E-post: {supplier.email}</p>}
{supplier.phone && <p>Telefon: {supplier.phone}</p>}
{supplier.email && <p>{t('email_inline', { email: supplier.email })}</p>}
{supplier.phone && <p>{t('phone_inline', { phone: supplier.phone })}</p>}
{supplier.address_line1 && <p>{supplier.address_line1}</p>}
{supplier.postal_code && <p>{supplier.postal_code} {supplier.city}</p>}
{supplier.vat_number && <p>VAT: {supplier.vat_number}</p>}
{supplier.vat_number && <p>{t('vat_inline', { vat: supplier.vat_number })}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningsuppgifter</CardTitle>
<CardTitle className="text-lg">{t('payment_section_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{supplier.bankgiro && <p>Bankgiro: {supplier.bankgiro}</p>}
{supplier.plusgiro && <p>Plusgiro: {supplier.plusgiro}</p>}
{supplier.iban && <p>IBAN: {supplier.iban}</p>}
{supplier.bic && <p>BIC: {supplier.bic}</p>}
<p>Betalningsvillkor: {supplier.default_payment_terms} dagar</p>
<p>Valuta: {supplier.default_currency}</p>
{supplier.default_expense_account && <p>Kostnadskonto: {supplier.default_expense_account}</p>}
{supplier.bankgiro && <p>{t('bankgiro_inline', { value: supplier.bankgiro })}</p>}
{supplier.plusgiro && <p>{t('plusgiro_inline', { value: supplier.plusgiro })}</p>}
{supplier.iban && <p>{t('iban_inline', { value: supplier.iban })}</p>}
{supplier.bic && <p>{t('bic_inline', { value: supplier.bic })}</p>}
<p>{t('payment_terms_inline', { days: supplier.default_payment_terms })}</p>
<p>{t('currency_inline', { currency: supplier.default_currency })}</p>
{supplier.default_expense_account && <p>{t('expense_account_inline', { account: supplier.default_expense_account })}</p>}
</CardContent>
</Card>
</div>
@@ -240,18 +243,18 @@ export default function SupplierDetailPage() {
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Fakturor</CardTitle>
<CardTitle className="text-lg">{t('invoices_section_title')}</CardTitle>
<Link href="/supplier-invoices/new">
<Button size="sm">
<FileText className="mr-2 h-4 w-4" />
Ny faktura
{t('new_invoice')}
</Button>
</Link>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
Inga fakturor registrerade för denna leverantör
{t('no_invoices')}
</p>
) : (
<>
@@ -260,13 +263,13 @@ export default function SupplierDetailPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Ankomst</TableHead>
<TableHead>Fakturanr</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Förfaller</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="text-right">Kvar</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('col_arrival')}</TableHead>
<TableHead>{t('col_invoice_number')}</TableHead>
<TableHead>{t('col_date')}</TableHead>
<TableHead>{t('col_due')}</TableHead>
<TableHead className="text-right">{t('col_amount')}</TableHead>
<TableHead className="text-right">{t('col_remaining')}</TableHead>
<TableHead>{t('col_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -310,7 +313,7 @@ export default function SupplierDetailPage() {
</div>
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && (
<div className="text-xs text-muted-foreground text-right">
Kvar: {formatAmount(inv.remaining_amount)} kr
{t('remaining_inline', { amount: formatAmount(inv.remaining_amount) })}
</div>
)}
</div>
@@ -327,7 +330,7 @@ export default function SupplierDetailPage() {
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Redigera leverantör</DialogTitle>
<DialogTitle>{t('edit_dialog_title')}</DialogTitle>
</DialogHeader>
<SupplierForm
onSubmit={handleUpdate}
+34 -32
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -15,17 +16,17 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
const SUPPLIER_TYPE_KEYS: Record<SupplierType, string> = {
swedish_business: 'type_swedish_business',
eu_business: 'type_eu_business',
non_eu_business: 'type_non_eu_business',
}
function getPaymentInfo(supplier: Supplier): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: 'BG', value: supplier.bankgiro }
if (supplier.plusgiro) return { label: 'PG', value: supplier.plusgiro }
if (supplier.iban) return { label: 'IBAN', value: supplier.iban }
if (supplier.bank_account) return { label: 'Bankkonto', value: supplier.bank_account }
function getPaymentInfo(supplier: Supplier, t: (key: string) => string): { label: string; value: string } | null {
if (supplier.bankgiro) return { label: t('label_bg'), value: supplier.bankgiro }
if (supplier.plusgiro) return { label: t('label_pg'), value: supplier.plusgiro }
if (supplier.iban) return { label: t('label_iban'), value: supplier.iban }
if (supplier.bank_account) return { label: t('label_bank_account'), value: supplier.bank_account }
return null
}
@@ -38,6 +39,7 @@ function formatLocation(supplier: Supplier): string | null {
export default function SuppliersPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const t = useTranslations('suppliers')
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -57,8 +59,8 @@ export default function SuppliersPage() {
if (error) {
toast({
title: 'Kunde inte ladda leverantörer',
description: 'Kontrollera din anslutning och försök igen.',
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
} else {
@@ -85,14 +87,14 @@ export default function SuppliersPage() {
if (!response.ok) {
const fieldErrors = result.errors?.map((e: { field: string; message: string }) => `${e.field}: ${e.message}`).join(', ')
toast({
title: 'Kunde inte skapa leverantör',
description: fieldErrors || result.error || 'Försök igen.',
title: t('create_failed_title'),
description: fieldErrors || result.error || t('create_failed_retry'),
variant: 'destructive',
})
} else {
toast({
title: 'Leverantör skapad',
description: `${data.name} har lagts till`,
title: t('created_title'),
description: t('created_description', { name: data.name }),
})
setSuppliers([...suppliers, result.data])
setIsDialogOpen(false)
@@ -111,28 +113,28 @@ export default function SuppliersPage() {
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Leverantörer</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
Hantera dina leverantörer och deras betalningsuppgifter
{t('subtitle')}
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
Ny leverantör
{t('new_supplier')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till leverantör</DialogTitle>
<DialogTitle>{t('add_supplier')}</DialogTitle>
</DialogHeader>
<SupplierForm
onSubmit={handleCreateSupplier}
@@ -146,7 +148,7 @@ export default function SuppliersPage() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på namn, e-post eller org.nr..."
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@@ -174,15 +176,15 @@ export default function SuppliersPage() {
{searchTerm ? (
<EmptyState
icon={Building2}
title="Inga träffar"
description={`Inga leverantörer matchar "${searchTerm}".`}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
) : (
<EmptyState
icon={Building2}
title="Inga leverantörer"
description="Lägg till din första leverantör för att börja registrera inköpsfakturor."
actionLabel={canWrite ? 'Ny leverantör' : undefined}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_supplier') : undefined}
onAction={canWrite ? () => setIsDialogOpen(true) : undefined}
/>
)}
@@ -191,7 +193,7 @@ export default function SuppliersPage() {
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSuppliers.map((supplier) => {
const payment = getPaymentInfo(supplier)
const payment = getPaymentInfo(supplier, t)
const location = formatLocation(supplier)
return (
<Link key={supplier.id} href={`/suppliers/${supplier.id}`} className="group">
@@ -202,14 +204,14 @@ export default function SuppliersPage() {
{supplier.name}
</h3>
<p className="text-xs text-muted-foreground leading-snug">
{supplierTypeLabels[supplier.supplier_type]}
{t(SUPPLIER_TYPE_KEYS[supplier.supplier_type])}
</p>
</div>
<dl className="mt-auto space-y-1.5 text-sm border-t pt-3">
{supplier.org_number && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">Org.nr</dt>
<dt className="text-xs text-muted-foreground shrink-0">{t('label_org_number')}</dt>
<dd className="tabular-nums truncate">{supplier.org_number}</dd>
</div>
)}
@@ -221,18 +223,18 @@ export default function SuppliersPage() {
)}
{supplier.email && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">E-post</dt>
<dt className="text-xs text-muted-foreground shrink-0">{t('label_email')}</dt>
<dd className="truncate">{supplier.email}</dd>
</div>
)}
{location && (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground shrink-0">Plats</dt>
<dt className="text-xs text-muted-foreground shrink-0">{t('label_location')}</dt>
<dd className="truncate">{location}</dd>
</div>
)}
{!supplier.org_number && !payment && !supplier.email && !location && (
<p className="text-xs text-muted-foreground italic">Inga kontaktuppgifter</p>
<p className="text-xs text-muted-foreground italic">{t('no_contact_info')}</p>
)}
</dl>
</CardContent>
+102 -64
View File
@@ -3,15 +3,17 @@
import { useState, useEffect, useRef } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { Landmark, X } from 'lucide-react'
import { Landmark, Search, X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
@@ -76,6 +78,7 @@ interface QuickReviewState {
export default function TransactionsPage() {
const { company } = useCompany()
const t = useTranslations('transactions')
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [mode, setMode] = useState<ViewMode>('inbox')
@@ -174,6 +177,7 @@ export default function TransactionsPage() {
// Source filter for the merged inbox. Defaults to 'all' so users see
// both sources unless they want to narrow down.
const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('all')
const [searchQuery, setSearchQuery] = useState('')
const { toast } = useToast()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
@@ -224,12 +228,30 @@ export default function TransactionsPage() {
items.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r })
}
}
return items.sort((a, b) => {
const sorted = items.sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date)
// Same date → bank first so invoice-match cards lead.
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0
})
const query = searchQuery.trim().toLowerCase()
if (!query) return sorted
return sorted.filter(item => {
if (item.source === 'bank') {
const tx = item.data
return (
tx.description?.toLowerCase().includes(query) ||
tx.date.includes(query) ||
String(tx.amount).includes(query)
)
}
const r = item.data
return (
r.transaktionstext?.toLowerCase().includes(query) ||
r.transaktionsdatum.includes(query) ||
String(r.belopp_skatteverket).includes(query)
)
})
})()
const transactionsWithMatches = transactions.filter(
(t) =>
@@ -257,7 +279,7 @@ export default function TransactionsPage() {
])
if (txError) {
toast({ title: 'Kunde inte ladda transaktioner', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive' })
setIsLoading(false)
return
}
@@ -608,7 +630,7 @@ export default function TransactionsPage() {
)
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
toast({ title: t('undone_title'), description: t('undone_description') })
} else {
const errData = await undoRes.json()
toast({
@@ -618,7 +640,7 @@ export default function TransactionsPage() {
})
}
} catch {
toast({ title: 'Kunde inte ångra', description: 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
}
}}>
Ångra
@@ -628,7 +650,7 @@ export default function TransactionsPage() {
} else if (result.journal_entry_error) {
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
} else {
toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' })
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
}
// Update transaction in state after a brief delay for animation
@@ -651,7 +673,7 @@ export default function TransactionsPage() {
return result.journal_entry_id || null
} catch {
toast({ title: 'Bokföring misslyckades', description: 'Transaktionen kunde inte bokföras. Försök igen.', variant: 'destructive' })
toast({ title: t('booking_failed_title'), description: t('booking_failed_description'), variant: 'destructive' })
setProcessingId(null)
return null
}
@@ -680,7 +702,7 @@ export default function TransactionsPage() {
return
}
toast({ title: 'Kundfaktura matchad', description: 'Fakturan markerades som betald' })
toast({ title: t('customer_invoice_matched_title'), description: t('customer_invoice_matched_description') })
setCiMatchSuggestion(null)
setExitingIds((prev) => new Set(prev).add(transactionId))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
@@ -704,7 +726,7 @@ export default function TransactionsPage() {
})
}, 350)
} catch {
toast({ title: 'Matchning misslyckades', description: 'Försök igen.', variant: 'destructive' })
toast({ title: t('match_failed_title'), description: t('match_failed_description_retry'), variant: 'destructive' })
} finally {
setCiMatchProcessing(false)
}
@@ -729,7 +751,7 @@ export default function TransactionsPage() {
return
}
toast({ title: 'Leverantörsfaktura matchad', description: 'Fakturan markerades som betald' })
toast({ title: t('supplier_invoice_matched_title'), description: t('supplier_invoice_matched_description') })
setSiMatchSuggestion(null)
setExitingIds((prev) => new Set(prev).add(transactionId))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
@@ -753,7 +775,7 @@ export default function TransactionsPage() {
})
}, 350)
} catch {
toast({ title: 'Matchning misslyckades', description: 'Försök igen.', variant: 'destructive' })
toast({ title: t('match_failed_title'), description: t('match_failed_description_retry'), variant: 'destructive' })
} finally {
setSiMatchProcessing(false)
}
@@ -842,7 +864,7 @@ export default function TransactionsPage() {
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas. Försök igen.', variant: 'destructive' })
toast({ title: t('match_failed_title'), description: t('match_failed_transaction'), variant: 'destructive' })
setIsConfirmingMatch(false)
}
}
@@ -911,7 +933,7 @@ export default function TransactionsPage() {
} catch {
toast({
title: 'Koppling misslyckades',
description: 'Verifikationen kunde inte kopplas. Försök igen.',
description: t('voucher_link_failed_description'),
variant: 'destructive',
})
setIsConfirmingMatch(false)
@@ -953,7 +975,7 @@ export default function TransactionsPage() {
toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` })
return true
} catch {
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
toast({ title: t('match_failed_title'), description: t('match_failed_with_invoice'), variant: 'destructive' })
return false
}
}
@@ -1014,7 +1036,7 @@ export default function TransactionsPage() {
} catch {
toast({
title: 'Matchning misslyckades',
description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.',
description: t('match_failed_with_invoice'),
variant: 'destructive',
})
setIsMatchingFromPicker(false)
@@ -1025,7 +1047,7 @@ export default function TransactionsPage() {
setIsCreating(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast({ title: 'Inloggning krävs', description: 'Du måste vara inloggad för att lägga till transaktioner.', variant: 'destructive' })
toast({ title: t('login_required_title'), description: t('login_required_description'), variant: 'destructive' })
setIsCreating(false)
return
}
@@ -1080,11 +1102,11 @@ export default function TransactionsPage() {
return
}
setTransactions((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Borttagen', description: 'Transaktionen har tagits bort' })
toast({ title: t('deleted_title'), description: t('deleted_description') })
} catch {
toast({
title: 'Kunde inte ta bort',
description: 'Transaktionen kunde inte tas bort. Försök igen.',
description: t('delete_failed_description'),
variant: 'destructive',
})
}
@@ -1103,7 +1125,7 @@ export default function TransactionsPage() {
}
toast({
title: 'Utkast skapat',
description: 'Granska och bokför verifikatet i Bokföring.',
description: t('review_in_bookkeeping_description'),
})
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
@@ -1454,18 +1476,28 @@ export default function TransactionsPage() {
))}
</div>
) : mode === 'inbox' ? (
inboxItems.length === 0 ? (
uncategorizedTransactions.length === 0 && skvUnmatched.length === 0 ? (
<InboxZeroState
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Source filter — only render when both sources have content
to filter between, otherwise it'd be a no-op chip row. */}
{skvUnmatched.length > 0 && uncategorizedTransactions.length > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Källa:</span>
<span className="text-muted-foreground">{t('source_label')}</span>
<button
onClick={() => setSourceFilter('all')}
className={cn(
@@ -1475,7 +1507,7 @@ export default function TransactionsPage() {
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
Alla ({uncategorizedTransactions.length + skvUnmatched.length})
{t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })}
</button>
<button
onClick={() => setSourceFilter('bank')}
@@ -1486,7 +1518,7 @@ export default function TransactionsPage() {
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
Bank ({uncategorizedTransactions.length})
{t('source_bank', { count: uncategorizedTransactions.length })}
</button>
<button
onClick={() => setSourceFilter('skatteverket')}
@@ -1498,44 +1530,50 @@ export default function TransactionsPage() {
)}
>
<Landmark className="h-3 w-3" />
Skatteverket ({skvUnmatched.length})
{t('source_skatteverket', { count: skvUnmatched.length })}
</button>
</div>
)}
<AnimatePresence mode="popLayout">
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
suggestions={categorySuggestions[item.data.id]}
templateSuggestions={templateSuggestions[item.data.id]}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(item.data.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</AnimatePresence>
{inboxItems.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
{t('no_search_results')}
</p>
) : (
<AnimatePresence mode="popLayout">
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
suggestions={categorySuggestions[item.data.id]}
templateSuggestions={templateSuggestions[item.data.id]}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(item.data.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</AnimatePresence>
)}
</div>
)
) : (
@@ -1611,7 +1649,7 @@ export default function TransactionsPage() {
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Välj mall</DialogTitle>
<DialogTitle>{t('dialog_choose_template')}</DialogTitle>
</DialogHeader>
{templatePickerTransaction && (
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
@@ -1665,7 +1703,7 @@ export default function TransactionsPage() {
>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Matcha med faktura</DialogTitle>
<DialogTitle>{t('dialog_match_invoice')}</DialogTitle>
</DialogHeader>
{invoicePickerTransaction && (
<>
@@ -1716,7 +1754,7 @@ export default function TransactionsPage() {
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till transaktion</DialogTitle>
<DialogTitle>{t('dialog_add_transaction')}</DialogTitle>
</DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
@@ -1740,7 +1778,7 @@ export default function TransactionsPage() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Matcha mot leverantörsfaktura?</DialogTitle>
<DialogTitle>{t('dialog_match_supplier_invoice')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
@@ -1798,7 +1836,7 @@ export default function TransactionsPage() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Matcha mot kundfaktura?</DialogTitle>
<DialogTitle>{t('dialog_match_customer_invoice')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
@@ -1815,7 +1853,7 @@ export default function TransactionsPage() {
{c.customer_name || 'Kund'} · {c.invoice_number ?? '—'}
</span>
{c.match_reason === 'ocr_exact' && (
<Badge variant="success">Exakt OCR-träff</Badge>
<Badge variant="success">{t('badge_exact_ocr')}</Badge>
)}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { LOCALE_COOKIE, SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
const BodySchema = z.object({
locale: z.enum(SUPPORTED_LOCALES),
})
export async function POST(request: Request) {
const { user, supabase, error } = await requireAuth()
if (error) return error
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = BodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid locale' }, { status: 400 })
}
const locale: Locale = parsed.data.locale
const { error: upsertError } = await supabase
.from('user_preferences')
.upsert({ user_id: user.id, locale }, { onConflict: 'user_id' })
if (upsertError) {
return NextResponse.json({ error: 'Could not save language preference' }, { status: 500 })
}
const response = NextResponse.json({ data: { locale } })
response.cookies.set(LOCALE_COOKIE, locale, {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
return response
}
+29 -22
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Image from 'next/image'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
@@ -22,19 +23,23 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
const STEP_INFO = [
{ title: 'Nytt företag', subtitle: 'Välj företagsform för det nya företaget.', label: 'Företagsform' },
{ title: 'Företagsuppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
{ title: 'F-skatt & räkenskapsår', subtitle: 'Skatteregistrering och räkenskapsår.', label: 'Skatt' },
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
]
type TFn = (key: string, values?: Record<string, string | number>) => string
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle'), label: t('step1_label') },
{ title: t('step2_title'), subtitle: t('step2_subtitle'), label: t('step2_label') },
{ title: t('step3_title'), subtitle: t('step3_subtitle'), label: t('step3_label') },
{ title: t('step4_title'), subtitle: t('step4_subtitle'), label: t('step4_label') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
export default function NewCompanyPage() {
@@ -64,6 +69,8 @@ function NewCompanyContent() {
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('companies_new')
const STEP_INFO = buildStepInfo(t)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
@@ -119,8 +126,8 @@ function NewCompanyContent() {
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -140,8 +147,8 @@ function NewCompanyContent() {
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -149,7 +156,7 @@ function NewCompanyContent() {
if (!teamId) {
logError('handleNext aborted: no teamId')
toast({ title: 'Fel', description: 'Kunde inte hitta team. Ladda om sidan.', variant: 'destructive' })
toast({ title: t('toast_error_title'), description: t('toast_no_team'), variant: 'destructive' })
return
}
@@ -168,8 +175,8 @@ function NewCompanyContent() {
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
toast({
title: 'Fel',
description: result.error || 'Kunde inte skapa företag. Försök igen.',
title: t('toast_error_title'),
description: result.error || t('toast_create_failed'),
variant: 'destructive',
})
return
@@ -177,14 +184,14 @@ function NewCompanyContent() {
console.log(LOG, 'created company', result.companyId)
toast({
title: 'Företag skapat!',
description: 'Du har nu bytt till det nya företaget.',
title: t('toast_company_created'),
description: t('toast_switched_to_new'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
toast({ title: t('toast_error_title'), description: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
+41 -30
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
@@ -25,6 +26,7 @@ export default function InvitePage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('invite')
const token = params.token as string
const [isLoading, setIsLoading] = useState(true)
@@ -48,20 +50,20 @@ export default function InvitePage() {
const data = await inviteRes.json()
if (!inviteRes.ok) {
setError(data.error || 'Inbjudan är ogiltig.')
setError(data.error || t('invalid_invite'))
return
}
setInvite(data.data)
setCurrentUserEmail(sessionRes.data.user?.email ?? null)
} catch {
setError('Kunde inte ladda inbjudan.')
setError(t('load_failed'))
} finally {
setIsLoading(false)
}
}
loadInvite()
}, [token])
}, [token, t])
const secureCookieFlag = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; secure' : ''
@@ -105,8 +107,8 @@ export default function InvitePage() {
if (!res.ok) {
toast({
title: 'Kunde inte gå med',
description: body.error || 'Ett oväntat fel uppstod. Försök igen.',
title: t('join_failed_title'),
description: body.error || t('unexpected_error'),
variant: 'destructive',
})
setIsJoining(false)
@@ -114,10 +116,10 @@ export default function InvitePage() {
}
toast({
title: 'Välkommen!',
title: t('welcome_title'),
description: invite?.companyName
? `Du är nu medlem i ${invite.companyName}.`
: 'Du är nu medlem.',
? t('joined_named', { companyName: invite.companyName })
: t('joined_generic'),
})
// Full reload so the middleware re-resolves company context from the
// updated user_preferences.active_company_id.
@@ -125,8 +127,8 @@ export default function InvitePage() {
} catch (err) {
console.error('[invite] join failed:', err)
toast({
title: 'Kunde inte gå med',
description: 'Ett oväntat fel uppstod. Försök igen.',
title: t('join_failed_title'),
description: t('unexpected_error'),
variant: 'destructive',
})
setIsJoining(false)
@@ -177,7 +179,7 @@ export default function InvitePage() {
</div>
<div className="animate-fade-in">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
{error ? 'Ogiltig inbjudan' : 'Du har blivit inbjuden'}
{error ? t('header_invalid') : t('header_invited')}
</h1>
</div>
</div>
@@ -193,13 +195,13 @@ export default function InvitePage() {
<div>
<p className="font-medium">{error}</p>
<p className="text-sm text-muted-foreground mt-1">
Kontakta personen som bjöd in dig för en ny inbjudan.
{t('contact_inviter')}
</p>
<Link
href="/login"
className="text-sm text-primary hover:underline mt-3 inline-block"
>
Gå till inloggning
{t('go_to_login')}
</Link>
</div>
</div>
@@ -209,9 +211,9 @@ export default function InvitePage() {
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Inbjudan har gått ut</p>
<p className="font-medium">{t('expired_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
Be personen som bjöd in dig att skicka en ny inbjudan.
{t('expired_description')}
</p>
</div>
</div>
@@ -230,10 +232,13 @@ export default function InvitePage() {
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Du har bjudits in som medlem till detta företag.
{t('invited_to_company')}
</p>
<p className="text-sm text-muted-foreground mt-1">
Inloggad som <strong>{currentUserEmail}</strong>.
{t.rich('logged_in_as', {
email: currentUserEmail ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</div>
@@ -248,10 +253,10 @@ export default function InvitePage() {
{isJoining ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Går med…
{t('joining')}
</>
) : (
<>Gå med i {invite.companyName}</>
<>{t('join_named', { companyName: invite.companyName ?? '' })}</>
)}
</Button>
</div>
@@ -266,18 +271,21 @@ export default function InvitePage() {
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Inbjudan är skickad till <strong>{invite.email}</strong>, men
du är inloggad som <strong>{currentUserEmail}</strong>.
{t.rich('wrong_account', {
invitedEmail: invite.email,
currentEmail: currentUserEmail ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">
Logga ut och logga in igen med rätt konto för att gå med.
{t('signout_then_login')}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleSignOutAndRetry}>
Logga ut och byt konto
{t('signout_and_switch')}
</Button>
</div>
) : invite?.alreadyHasAccount ? (
@@ -291,18 +299,21 @@ export default function InvitePage() {
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Du har bjudits in som medlem till detta företag.
{t('invited_to_company')}
</p>
<p className="text-sm text-muted-foreground mt-1">
<strong>{invite.email}</strong> har redan ett konto på {branding.appName.toLowerCase()}.
Logga in för att gå med.
{t.rich('existing_account', {
email: invite.email,
appName: branding.appName.toLowerCase(),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleAcceptExistingUser}>
Logga in och gå med
{t('login_and_join')}
</Button>
</div>
) : invite ? (
@@ -316,18 +327,18 @@ export default function InvitePage() {
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Du har bjudits in som medlem till detta företag.
{t('invited_to_company')}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleAccept}>
Skapa konto och gå med
{t('create_account_and_join')}
</Button>
<p className="text-center text-xs text-muted-foreground">
Genom att skapa ett konto godkänner du våra villkor.
{t('terms_notice')}
</p>
</div>
) : null}
+18 -13
View File
@@ -2,6 +2,8 @@ import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Hedvig_Letters_Serif } from "next/font/google";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
@@ -54,16 +56,17 @@ export function generateViewport(): Viewport {
};
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const branding = getBranding();
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="sv" translate="no" suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<html lang={locale} suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<head>
<meta name="google" content="notranslate" />
<link rel="apple-touch-icon" href={branding.appleTouchIconPath} />
<script
src="https://cdn.recapt.app/browser/glimt.js"
@@ -76,16 +79,18 @@ export default function RootLayout({
<body
className="antialiased"
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
<RecaptHideWidget />
</ThemeProvider>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
<RecaptHideWidget />
</ThemeProvider>
</NextIntlClientProvider>
<Script src="/sw-register.js" strategy="afterInteractive" />
</body>
</html>
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -35,36 +36,31 @@ interface ReferenceAccount extends BASReferenceAccount {
is_system_account: boolean
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgång',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
untaxed_reserves: 'Ob. reserver',
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ChartOfAccountsManager() {
const { toast } = useToast()
const t = useTranslations('chart_of_accounts')
const classLabel = (cls: number): string => {
const key = `class_${cls}` as const
try {
return t(key)
} catch {
return ''
}
}
const typeLabel = (type: string): string => {
const key = `type_${type}` as const
try {
return t(key)
} catch {
return type
}
}
// View state
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
@@ -142,17 +138,17 @@ export default function ChartOfAccountsManager() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !account.is_active }),
})
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
if (!res.ok) throw new Error(t('toast_update_failed'))
await refreshAll()
} catch {
toast({ title: 'Kunde inte uppdatera kontot', variant: 'destructive' })
toast({ title: t('toast_update_failed'), variant: 'destructive' })
} finally {
setTogglingAccount(null)
}
}
async function deleteAccount(account: BASAccount) {
const confirmed = window.confirm(`Vill du ta bort konto ${account.account_number} ${account.account_name}?`)
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
if (!confirmed) return
setDeletingAccount(account.account_number)
try {
@@ -161,13 +157,13 @@ export default function ChartOfAccountsManager() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Kunde inte ta bort kontot')
throw new Error(data.error || t('toast_delete_failed'))
}
toast({ title: 'Konto borttaget', description: `${account.account_number} ${account.account_name}` })
toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` })
await refreshAll()
} catch (err) {
toast({
title: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
title: err instanceof Error ? err.message : t('toast_delete_failed'),
variant: 'destructive',
})
} finally {
@@ -183,14 +179,14 @@ export default function ChartOfAccountsManager() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!res.ok) throw new Error('Kunde inte aktivera kontot')
if (!res.ok) throw new Error(t('toast_activate_failed'))
const { activated } = await res.json()
if (activated > 0) {
toast({ title: 'Konto aktiverat', description: `Konto ${accountNumber} har lagts till i din kontoplan` })
toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) })
}
await refreshAll()
} catch {
toast({ title: 'Kunde inte aktivera kontot', variant: 'destructive' })
toast({ title: t('toast_activate_failed'), variant: 'destructive' })
} finally {
setActivatingAccounts((prev) => {
const next = new Set(prev)
@@ -271,7 +267,7 @@ export default function ChartOfAccountsManager() {
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Laddar kontoplan...
{t('loading')}
</CardContent>
</Card>
)
@@ -290,14 +286,14 @@ export default function ChartOfAccountsManager() {
>
<TabsList>
<TabsTrigger value="my-accounts">
Mina konton
{t('tab_my_accounts')}
<Badge variant="secondary" className="ml-1.5 text-xs">
{accounts.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="bas-catalog">
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
BAS-katalog
{t('tab_bas_catalog')}
</TabsTrigger>
</TabsList>
</Tabs>
@@ -305,7 +301,7 @@ export default function ChartOfAccountsManager() {
{view === 'my-accounts' && (
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Eget konto
{t('add_own')}
</Button>
)}
@@ -316,7 +312,7 @@ export default function ChartOfAccountsManager() {
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dölj K2-undantagna</span>
<span className="text-muted-foreground">{t('hide_k2_excluded')}</span>
</label>
)}
</div>
@@ -325,7 +321,7 @@ export default function ChartOfAccountsManager() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök konto (nummer eller namn)..."
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -355,7 +351,7 @@ export default function ChartOfAccountsManager() {
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
{t('class_heading', { cls, label: classLabel(classNum) })}
</span>
<Badge variant="secondary" className="text-xs">
{activeCount}/{classAccounts.length}
@@ -368,11 +364,11 @@ export default function ChartOfAccountsManager() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-16 text-center">Aktiv</th>
<th className="py-2 w-24">{t('col_account')}</th>
<th className="py-2">{t('col_name')}</th>
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
<th className="py-2 w-24 text-center">{t('col_type')}</th>
<th className="py-2 w-16 text-center">{t('col_active')}</th>
<th className="py-2 w-20 text-right"></th>
</tr>
</thead>
@@ -392,7 +388,7 @@ export default function ChartOfAccountsManager() {
{account.account_name}
{account.is_system_account && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
System
{t('system_badge')}
</Badge>
)}
</span>
@@ -404,7 +400,7 @@ export default function ChartOfAccountsManager() {
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
{typeLabel(account.account_type)}
</Badge>
</td>
<td className="py-2 text-center">
@@ -455,7 +451,7 @@ export default function ChartOfAccountsManager() {
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sökningen' : 'Inga konton i kontoplanen'}
{searchQuery ? t('no_matches') : t('no_accounts')}
</CardContent>
</Card>
)}
@@ -485,10 +481,10 @@ export default function ChartOfAccountsManager() {
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
{t('class_heading', { cls, label: classLabel(classNum) })}
</span>
<Badge variant="secondary" className="text-xs">
{activatedCount}/{classAccounts.length} aktiva
{t('active_count_label', { active: activatedCount, total: classAccounts.length })}
</Badge>
</div>
</button>
@@ -498,11 +494,11 @@ export default function ChartOfAccountsManager() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-28 text-right">Status</th>
<th className="py-2 w-24">{t('col_account')}</th>
<th className="py-2">{t('col_name')}</th>
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
<th className="py-2 w-24 text-center">{t('col_type')}</th>
<th className="py-2 w-28 text-right">{t('col_status')}</th>
</tr>
</thead>
<tbody>
@@ -533,14 +529,14 @@ export default function ChartOfAccountsManager() {
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
{typeLabel(account.account_type)}
</Badge>
</td>
<td className="py-2 text-right">
{account.is_activated ? (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
Aktiverat
{t('activated')}
</span>
) : (
<Button
@@ -555,7 +551,7 @@ export default function ChartOfAccountsManager() {
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lägg till
{t('add')}
</Button>
)}
</td>
@@ -572,7 +568,7 @@ export default function ChartOfAccountsManager() {
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sökningen
{t('no_matches')}
</CardContent>
</Card>
)}
+16 -13
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Info } from 'lucide-react'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
@@ -12,14 +13,17 @@ interface Props {
chain: JournalEntry[]
}
function getRole(entry: JournalEntry): { label: string; color: string } {
if (entry.source_type === 'storno') {
return { label: 'Storno', color: 'bg-destructive' }
function useGetRole() {
const t = useTranslations('journal_correction')
return (entry: JournalEntry): { label: string; color: string } => {
if (entry.source_type === 'storno') {
return { label: t('role_storno'), color: 'bg-destructive' }
}
if (entry.source_type === 'correction') {
return { label: t('role_correction'), color: 'bg-primary' }
}
return { label: t('role_original'), color: 'bg-muted-foreground' }
}
if (entry.source_type === 'correction') {
return { label: 'Rättelse', color: 'bg-primary' }
}
return { label: 'Original', color: 'bg-muted-foreground' }
}
function getTotal(entry: JournalEntry): number {
@@ -28,6 +32,8 @@ function getTotal(entry: JournalEntry): number {
}
export default function CorrectionChain({ currentEntryId, chain }: Props) {
const t = useTranslations('journal_correction')
const getRole = useGetRole()
if (chain.length === 0) return null
// Combine current entry isn't in chain — chain is "other" entries
@@ -38,14 +44,11 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">Ändringskedja</h3>
<h3 className="text-sm font-medium">{t('title')}</h3>
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Bokförda verifikationer kan inte ändras direkt. Istället skapas en stornoverifikation
som nollställer den ursprungliga, och en ny rättelsepost med de korrekta uppgifterna.
</p>
<p>{t('info')}</p>
</div>
<div className="relative space-y-0">
@@ -76,7 +79,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
<JournalEntryStatusBadge entry={entry} showStatus={false} />
{isCurrent && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Aktuell
{t('current')}
</Badge>
)}
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
+45 -5
View File
@@ -40,6 +40,22 @@ function isImageType(type: string): boolean {
return type.startsWith('image/')
}
/**
* Extract a Swedish error message from the structured error envelope
* returned by /api/documents. Falls back to message_en or null if the
* shape is unexpected.
*/
function extractErrorMessage(err: unknown): string | null {
if (typeof err === 'string') return err
if (err && typeof err === 'object') {
const e = err as { message?: unknown; message_en?: unknown; code?: unknown }
if (typeof e.message === 'string' && e.message.length > 0) return e.message
if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en
if (typeof e.code === 'string') return e.code
}
return null
}
export default function DocumentUploadZone({
files,
onFilesChange,
@@ -64,15 +80,39 @@ export default function DocumentUploadZone({
method: 'POST',
body: formData,
})
const result = await res.json()
if (result.error) {
return { ...file, status: 'error', error: result.error }
// Try to parse JSON, but tolerate non-JSON responses (auth redirect HTML, 502 etc.)
let result: { data?: { id?: string }; error?: unknown } = {}
try {
result = await res.json()
} catch {
console.warn('[DocumentUploadZone] Non-JSON response', {
status: res.status,
fileName: file.fileName,
})
const reason = res.status === 401 || res.status === 403
? 'Din session har gått ut. Ladda om sidan och logga in igen.'
: `Servern svarade ${res.status}.`
return { ...file, status: 'error', error: reason }
}
if (!res.ok || result.error) {
const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})`
console.warn('[DocumentUploadZone] Upload error', {
status: res.status,
error: result.error,
fileName: file.fileName,
})
return { ...file, status: 'error', error: errMessage }
}
return { ...file, status: 'uploaded', id: result.data?.id }
} catch {
return { ...file, status: 'error', error: 'Uppladdning misslyckades' }
} catch (err) {
console.error('[DocumentUploadZone] Upload threw', {
error: err,
fileName: file.fileName,
})
return { ...file, status: 'error', error: 'Uppladdning misslyckades — nätverksfel' }
}
}, [journalEntryId])
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -43,6 +44,7 @@ export default function JournalEntryAttachments({
journalEntryId,
onCountChange,
}: JournalEntryAttachmentsProps) {
const t = useTranslations('journal_attachments')
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
@@ -122,7 +124,7 @@ export default function JournalEntryAttachments({
if (loading) {
return (
<div className="py-2 text-sm text-muted-foreground">
Laddar underlag...
{t('loading')}
</div>
)
}
@@ -131,7 +133,7 @@ export default function JournalEntryAttachments({
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
Underlag {documents.length > 0 && `(${documents.length})`}
{t('title')} {documents.length > 0 && `(${documents.length})`}
</h4>
<Button
variant="outline"
@@ -140,7 +142,7 @@ export default function JournalEntryAttachments({
onClick={() => setShowUpload(!showUpload)}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till underlag
{t('add')}
</Button>
</div>
@@ -159,7 +161,7 @@ export default function JournalEntryAttachments({
{/* Document list */}
{documents.length === 0 && !showUpload ? (
<p className="text-sm text-muted-foreground py-1">
Inga underlag bifogade.
{t('empty')}
</p>
) : (
<div className="space-y-1">
@@ -199,7 +201,7 @@ export default function JournalEntryAttachments({
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleDownload(doc.id)}
title="Ladda ner"
title={t('download')}
>
<Download className="h-3 w-3" />
</Button>
+58 -56
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -77,6 +78,7 @@ export default function JournalEntryForm({
const { canWrite } = useCanWrite()
const { toast } = useToast()
const { company } = useCompany()
const t = useTranslations('journal_form')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
@@ -379,16 +381,16 @@ export default function JournalEntryForm({
}
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
title: t('toast_attach_failed_title'),
description: t('toast_attach_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
}
toast({
title: 'Verifikation skapad',
description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`,
title: t('toast_created_title'),
description: t('toast_created_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }),
})
setShowReview(false)
setDescription('')
@@ -408,7 +410,7 @@ export default function JournalEntryForm({
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte skapa verifikation',
title: t('toast_create_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -442,8 +444,8 @@ export default function JournalEntryForm({
}
toast({
title: 'Utkast sparat',
description: 'Utkastet kan bokföras från bokföringssidan.',
title: t('toast_draft_saved_title'),
description: t('toast_draft_saved_description'),
})
setDescription('')
setNotes('')
@@ -462,7 +464,7 @@ export default function JournalEntryForm({
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte spara utkast',
title: t('toast_save_draft_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -483,10 +485,10 @@ export default function JournalEntryForm({
: 'sm:grid-cols-[1fr_auto_1fr_3.5rem]'
}`}>
<div>
<Label>Räkenskapsår</Label>
<Label>{t('fiscal_year')}</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('fiscal_year_placeholder')} />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
@@ -499,7 +501,7 @@ export default function JournalEntryForm({
</div>
{!(embedded && initialDate) && (
<div>
<Label>Datum</Label>
<Label>{t('date')}</Label>
<Input
type="date"
value={entryDate}
@@ -508,19 +510,19 @@ export default function JournalEntryForm({
</div>
)}
<div>
<Label>Beskrivning</Label>
<Label>{t('description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Verifikationstext..."
placeholder={t('description_placeholder')}
/>
</div>
<div className={embedded ? 'hidden' : 'col-span-full'}>
<Label>Intern anteckning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Label>{t('internal_note')} <span className="text-muted-foreground font-normal">{t('internal_note_optional')}</span></Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="T.ex. anledning till bokning, referens till mejl, etc."
placeholder={t('internal_note_placeholder')}
className="mt-1 resize-none"
rows={2}
maxLength={2000}
@@ -528,7 +530,7 @@ export default function JournalEntryForm({
</div>
{!embedded && (
<div>
<Label>Serie</Label>
<Label>{t('series')}</Label>
<Input
value={voucherSeries}
onChange={(e) => {
@@ -554,8 +556,8 @@ export default function JournalEntryForm({
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<p className="font-medium">Inget räkenskapsår matchar datumet {entryDate}</p>
<p className="mt-0.5">Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.</p>
<p className="font-medium">{t('no_period_warning', { date: entryDate })}</p>
<p className="mt-0.5">{t('no_period_help')}</p>
</div>
<Button
variant="outline"
@@ -564,7 +566,7 @@ export default function JournalEntryForm({
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
Skapa räkenskapsår
{t('create_period')}
</Button>
</div>
)}
@@ -572,7 +574,7 @@ export default function JournalEntryForm({
{/* Currency section */}
<div className="flex flex-wrap items-end gap-3">
<div className="w-24">
<Label className="text-xs text-muted-foreground">Valuta</Label>
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
<Select value={entryCurrency} onValueChange={(v) => {
setEntryCurrency(v as Currency)
if (v === 'SEK') {
@@ -594,7 +596,7 @@ export default function JournalEntryForm({
<>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Omräkningskurs (1 {entryCurrency} = ? SEK)
{t('exchange_rate_label', { currency: entryCurrency })}
</Label>
<div className="relative mt-1">
<Input
@@ -613,7 +615,7 @@ export default function JournalEntryForm({
</div>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Belopp i {entryCurrency}
{t('amount_in_currency_label', { currency: entryCurrency })}
</Label>
<Input
type="number"
@@ -659,11 +661,11 @@ export default function JournalEntryForm({
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
placeholder={t('line_description_placeholder')}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Label className="text-xs text-muted-foreground">{t('col_debit')}</Label>
<Input
type="number"
value={line.debit_amount}
@@ -676,7 +678,7 @@ export default function JournalEntryForm({
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Label className="text-xs text-muted-foreground">{t('col_credit')}</Label>
<Input
type="number"
value={line.credit_amount}
@@ -694,13 +696,13 @@ export default function JournalEntryForm({
{/* Mobile totals */}
<div className="flex justify-between items-center px-1 pt-2 font-semibold text-sm">
<span>Summa</span>
<span>{t('sum')}</span>
<div className="flex gap-4">
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{t('sum_d', { amount: totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
</span>
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{t('sum_k', { amount: totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
</span>
</div>
</div>
@@ -713,7 +715,7 @@ export default function JournalEntryForm({
className="flex-1"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
@@ -727,10 +729,10 @@ export default function JournalEntryForm({
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
<th className="py-2 w-32 px-1 text-right">Kredit</th>
<th className="py-2 w-28">{t('col_account')}</th>
<th className="py-2 px-1">{t('col_description')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_debit')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_credit')}</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
@@ -748,7 +750,7 @@ export default function JournalEntryForm({
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
placeholder={t('line_description_placeholder')}
className="h-8"
/>
</td>
@@ -793,7 +795,7 @@ export default function JournalEntryForm({
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2 px-1">
Summa
{t('sum')}
</td>
<td
className={`py-2 px-1 text-right ${
@@ -821,7 +823,7 @@ export default function JournalEntryForm({
onClick={addLine}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
@@ -833,7 +835,7 @@ export default function JournalEntryForm({
{/* Document attachments */}
{!embedded && (
<div>
<Label className="mb-2 block">Underlag</Label>
<Label className="mb-2 block">{t('attachments_label')}</Label>
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
@@ -843,7 +845,7 @@ export default function JournalEntryForm({
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-destructive">
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
{t('difference', { amount: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
</p>
)}
@@ -853,31 +855,31 @@ export default function JournalEntryForm({
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : 'Sparar som utkast utan att tilldela verifikationsnummer'}
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara som utkast
{t('save_draft')}
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
{t('review_and_create')}
</Button>
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
{isUploading && <p>Vänta tills filerna laddats upp</p>}
{!description && <p>{t('validation_description')}</p>}
{!selectedPeriod && <p>{t('validation_period')}</p>}
{periodMismatch === 'no_period' && <p>{t('validation_no_matching_period')}</p>}
{isUploading && <p>{t('validation_uploading')}</p>}
{incompleteLineCount > 0 && (
<p>Alla rader med belopp måste ha ett konto (och tvärtom)</p>
<p>{t('validation_incomplete_lines')}</p>
)}
{submittableLines.length < 2 && incompleteLineCount === 0 && (
<p>Minst två rader med konto och belopp krävs</p>
<p>{t('validation_min_lines')}</p>
)}
</div>
)}
@@ -897,10 +899,10 @@ export default function JournalEntryForm({
isSubmitting={isSubmitting}
title={
!embedded && nextVoucherNumber != null
? `Granska verifikation (${voucherSeries}${nextVoucherNumber})`
: 'Granska verifikation'
? t('review_title_with_voucher', { voucher: `${voucherSeries}${nextVoucherNumber}` })
: t('review_title')
}
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
warningText={embedded ? '' : t('review_warning')}
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
@@ -926,12 +928,12 @@ export default function JournalEntryForm({
setShowReview(true)
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Inget underlag bifogat. Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan."
confirmLabel="Bokför utan underlag"
title={t('no_doc_dialog_title')}
warningText={t('no_doc_dialog_warning')}
confirmLabel={t('no_doc_confirm')}
>
<div className="text-sm text-muted-foreground">
Granska uppgifterna innan du bekräftar.
{t('no_doc_body')}
</div>
</ConfirmationDialog>
@@ -952,7 +954,7 @@ export default function JournalEntryForm({
return (
<Card>
<CardHeader>
<CardTitle>Ny verifikation</CardTitle>
<CardTitle>{t('card_title')}</CardTitle>
</CardHeader>
<CardContent>
{formContent}
+38 -36
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -39,6 +40,7 @@ export default function JournalEntryList({ periodId }: Props) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('journal_list')
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -156,15 +158,15 @@ export default function JournalEntryList({ periodId }: Props) {
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 fetchEntries()
} 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 {
setCommittingId(null)
}
@@ -175,7 +177,7 @@ export default function JournalEntryList({ periodId }: Props) {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Laddar verifikationer...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</CardContent>
</Card>
)
@@ -188,9 +190,9 @@ export default function JournalEntryList({ periodId }: Props) {
<div className="p-4 rounded-full bg-muted mb-4">
<BookOpen className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">Inga verifikationer</h3>
<h3 className="text-lg font-medium mb-1">{t('empty_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken &quot;Ny verifikation&quot;.
{t('empty_description')}
</p>
</CardContent>
</Card>
@@ -218,7 +220,7 @@ export default function JournalEntryList({ periodId }: Props) {
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
{t('show_missing')}
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
@@ -232,16 +234,16 @@ export default function JournalEntryList({ periodId }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum, nyast först</SelectItem>
<SelectItem value="date_asc">Datum, äldst först</SelectItem>
<SelectItem value="voucher_asc">Verifikat, A1 först</SelectItem>
<SelectItem value="voucher_desc">Verifikat, senaste först</SelectItem>
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="Från YYYY-MM-DD"
placeholder={t('date_from_placeholder')}
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={() => {
@@ -260,7 +262,7 @@ export default function JournalEntryList({ periodId }: Props) {
/>
<Input
type="text"
placeholder="Till YYYY-MM-DD"
placeholder={t('date_to_placeholder')}
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={() => {
@@ -283,14 +285,14 @@ export default function JournalEntryList({ periodId }: Props) {
className="h-8 text-xs shrink-0"
onClick={applyDateFilter}
>
Filtrera
{t('filter')}
</Button>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
title="Rensa datumfilter"
title={t('clear_date_filter')}
>
<X className="h-4 w-4" />
</button>
@@ -331,9 +333,9 @@ export default function JournalEntryList({ periodId }: Props) {
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title="Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året)."
title={t('out_of_period_tooltip')}
>
Efterföljande
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
@@ -341,13 +343,13 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="flex-1 truncate">{entry.description}</span>
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title="Underlag saknas">
<span className="mr-1" title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
@@ -375,9 +377,9 @@ export default function JournalEntryList({ periodId }: Props) {
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title="Bokförd i ett senare räkenskapsår, men avser det valda året."
title={t('out_of_period_tooltip_mobile')}
>
Efterföljande
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
@@ -385,13 +387,13 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
<span className="flex items-center gap-0.5 text-muted-foreground" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title="Underlag saknas">
<span title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
@@ -405,7 +407,7 @@ export default function JournalEntryList({ periodId }: Props) {
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
) : (
<>
<div className="space-y-3">
@@ -427,7 +429,7 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<div className="flex justify-between items-center pt-1 border-t text-sm">
<span className="text-muted-foreground">
{Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'}
{Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
</span>
<div className="text-right">
<span className="font-mono tabular-nums font-medium">
@@ -447,11 +449,11 @@ export default function JournalEntryList({ periodId }: Props) {
})}
<div className="rounded-lg bg-muted/50 p-3 text-sm font-semibold space-y-1">
<div className="flex justify-between">
<span>Summa debet</span>
<span>{t('sum_debit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between">
<span>Summa kredit</span>
<span>{t('sum_credit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
@@ -477,14 +479,14 @@ export default function JournalEntryList({ periodId }: Props) {
className="w-full sm:w-auto"
onClick={() => handleCommit(entry.id)}
disabled={!canWrite || committingId === entry.id}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
{t('post')}
</Button>
)}
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
<Link href={`/bookkeeping/${entry.id}`}>{t('show_details')}</Link>
</Button>
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
<Button
@@ -493,7 +495,7 @@ export default function JournalEntryList({ periodId }: Props) {
className="w-full sm:w-auto"
onClick={() => setCorrectionEntry(entry)}
>
Skapa ändringsverifikation
{t('create_correction')}
</Button>
)}
<Button
@@ -503,7 +505,7 @@ export default function JournalEntryList({ periodId }: Props) {
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
>
<Copy className="mr-2 h-4 w-4" />
Kopiera
{t('copy')}
</Button>
</div>
</CardContent>
@@ -532,10 +534,10 @@ export default function JournalEntryList({ periodId }: Props) {
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Föregående
{t('previous')}
</Button>
<span className="text-sm text-muted-foreground self-center">
Sida {page + 1} av {Math.ceil(count / pageSize)}
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
</span>
<Button
variant="outline"
@@ -543,7 +545,7 @@ export default function JournalEntryList({ periodId }: Props) {
disabled={(page + 1) * pageSize >= count}
onClick={() => setPage(page + 1)}
>
Nästa
{t('next')}
</Button>
</div>
)}
@@ -1,35 +1,53 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import type { JournalEntry } from '@/types'
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
draft: { label: 'Utkast', variant: 'secondary' },
posted: { label: 'Bokförd', variant: 'success' },
reversed: { label: 'Omförd', variant: 'warning' },
cancelled: { label: 'Makulerad', variant: 'secondary' },
type BadgeVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
const statusVariants: Record<string, BadgeVariant> = {
draft: 'secondary',
posted: 'success',
reversed: 'warning',
cancelled: 'secondary',
}
const sourceTypeBadges: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
storno: { label: 'Storno', variant: 'destructive' },
correction: { label: 'Rättelse', variant: 'default' },
const sourceTypeVariants: Record<string, BadgeVariant> = {
storno: 'destructive',
correction: 'default',
}
export const sourceTypeLabels: Record<string, string> = {
manual: 'Manuell',
bank_transaction: 'Banktransaktion',
invoice_created: 'Faktura skapad',
invoice_paid: 'Fakturabetalning',
credit_note: 'Kreditfaktura',
salary_payment: 'Lön',
opening_balance: 'Ingående balans',
year_end: 'Årsbokslut',
storno: 'Storno',
correction: 'Rättelse',
import: 'Import',
system: 'System',
supplier_invoice_registered: 'Leverantörsfaktura',
supplier_invoice_paid: 'Leverantörsbetalning',
supplier_invoice_cash_payment: 'Kontantbetalning',
currency_revaluation: 'Valutaomvärdering',
const SOURCE_TYPES = [
'manual',
'bank_transaction',
'invoice_created',
'invoice_paid',
'credit_note',
'salary_payment',
'opening_balance',
'year_end',
'storno',
'correction',
'import',
'system',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'currency_revaluation',
] as const
/**
* Hook returning the translated source-type label map. Use this in client
* components that need to render the human-readable label for a source_type.
*/
export function useSourceTypeLabels(): Record<string, string> {
const t = useTranslations('journal_status')
const out: Record<string, string> = {}
for (const key of SOURCE_TYPES) {
out[key] = t(`source_label_${key}`)
}
return out
}
interface Props {
@@ -38,19 +56,32 @@ interface Props {
}
export default function JournalEntryStatusBadge({ entry, showStatus = true }: Props) {
const status = statusConfig[entry.status]
const sourceType = sourceTypeBadges[entry.source_type]
const t = useTranslations('journal_status')
const statusVariant = statusVariants[entry.status]
const sourceVariant = sourceTypeVariants[entry.source_type]
const statusLabelKey =
entry.status === 'draft' ? 'status_draft'
: entry.status === 'posted' ? 'status_posted'
: entry.status === 'reversed' ? 'status_reversed'
: entry.status === 'cancelled' ? 'status_cancelled'
: null
const sourceLabelKey =
entry.source_type === 'storno' ? 'source_storno'
: entry.source_type === 'correction' ? 'source_correction'
: null
return (
<span className="inline-flex items-center gap-1">
{showStatus && status && (
<Badge variant={status.variant} className="text-[10px] px-1.5 py-0">
{status.label}
{showStatus && statusVariant && statusLabelKey && (
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0">
{t(statusLabelKey)}
</Badge>
)}
{sourceType && (
<Badge variant={sourceType.variant} className="text-[10px] px-1.5 py-0">
{sourceType.label}
{sourceVariant && sourceLabelKey && (
<Badge variant={sourceVariant} className="text-[10px] px-1.5 py-0">
{t(sourceLabelKey)}
</Badge>
)}
</span>
+11 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
@@ -59,14 +60,16 @@ export function FiscalYearSelector({
value,
onChange,
includeAllOption = true,
label = 'Räkenskapsår',
label,
hideFuturePeriods = false,
onReady,
className,
}: Props) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [loaded, setLoaded] = useState(false)
const effectiveLabel = label === null ? null : (label ?? t('label'))
useEffect(() => {
if (!company?.id) {
@@ -149,24 +152,24 @@ export function FiscalYearSelector({
return (
<div className={className}>
{label && <Label>{label}</Label>}
<div className={`flex items-center gap-2 ${label ? 'mt-1' : ''}`}>
{effectiveLabel && <Label>{effectiveLabel}</Label>}
<div className={`flex items-center gap-2 ${effectiveLabel ? 'mt-1' : ''}`}>
<Select
value={selectValue}
onValueChange={handleChange}
disabled={!loaded || periods.length === 0}
>
<SelectTrigger className="w-full sm:w-[280px]">
<SelectValue placeholder={loaded ? 'Välj räkenskapsår' : 'Laddar…'} />
<SelectValue placeholder={loaded ? t('placeholder') : t('loading')} />
</SelectTrigger>
<SelectContent>
{includeAllOption && (
<SelectItem value={ALL_YEARS_VALUE}>Alla räkenskapsår</SelectItem>
<SelectItem value={ALL_YEARS_VALUE}>{t('all_years')}</SelectItem>
)}
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.period_start} — {p.period_end})
{p.locked_at ? ' — låst' : p.is_closed ? ' — stängt' : ''}
{p.locked_at ? t('suffix_locked') : p.is_closed ? t('suffix_closed') : ''}
</SelectItem>
))}
</SelectContent>
@@ -175,14 +178,10 @@ export function FiscalYearSelector({
<Badge
variant="outline"
className="gap-1 text-xs font-normal shrink-0"
title={
lockState === 'locked'
? 'Räkenskapsåret är låst — ingen bokföring kan ändras eller läggas till'
: 'Räkenskapsåret är stängt (årsbokslut upprättat) — kan återöppnas av admin'
}
title={lockState === 'locked' ? t('tooltip_locked') : t('tooltip_closed')}
>
<Lock className="h-3 w-3" />
{lockState === 'locked' ? 'Låst' : 'Stängt'}
{lockState === 'locked' ? t('badge_locked') : t('badge_closed')}
</Badge>
)}
</div>
+64 -63
View File
@@ -1,9 +1,10 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,30 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateCustomerInput, CustomerType } from '@/types'
const schema = z.object({
name: z.string().min(1, 'Namn krävs'),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
import type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
initialData?: Partial<FormData>
initialData?: Partial<CreateCustomerInput>
}
export default function CustomerForm({
@@ -45,18 +28,36 @@ export default function CustomerForm({
}: CustomerFormProps) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('form_customer')
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
valid: boolean
name?: string
} | null>(null)
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}), [t])
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -101,19 +102,19 @@ export default function CustomerForm({
if (result.valid && result.name) {
toast({
title: 'VAT-nummer verifierat',
description: `Företag: ${result.name}`,
title: t('vat_verified_title'),
description: t('vat_verified_description', { name: result.name }),
})
} else if (!result.valid) {
toast({
title: 'Verifiering misslyckades',
description: result.error || 'VAT-numret kunde inte verifieras',
title: t('vat_failed_title'),
description: result.error || t('vat_failed_default'),
variant: 'destructive',
})
}
} catch {
toast({
title: 'Kunde inte verifiera VAT-nummer',
title: t('vat_error_title'),
variant: 'destructive',
})
} finally {
@@ -132,35 +133,35 @@ export default function CustomerForm({
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Customer Type */}
<div className="space-y-2">
<Label>Kundtyp *</Label>
<Label>{t('type_label')}</Label>
<Controller
name="customer_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder="Välj kundtyp" />
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Privatperson (Sverige)</SelectItem>
<SelectItem value="swedish_business">Svenskt företag eller organisation</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
<SelectItem value="individual">{t('type_individual')}</SelectItem>
<SelectItem value="swedish_business">{t('type_swedish_business')}</SelectItem>
<SelectItem value="eu_business">{t('type_eu_business')}</SelectItem>
<SelectItem value="non_eu_business">{t('type_non_eu_business')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Kundtypen påverkar hur moms hanteras på fakturor
{t('type_hint')}
</p>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Namn *</Label>
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder="Företagsnamn eller personnamn"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
@@ -171,11 +172,11 @@ export default function CustomerForm({
{/* Contact */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
type="email"
placeholder="namn@foretag.se"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
@@ -183,10 +184,10 @@ export default function CustomerForm({
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder="+46 70 123 45 67"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
</div>
@@ -194,37 +195,37 @@ export default function CustomerForm({
{/* Address */}
<div className="space-y-4">
<h3 className="font-medium">Adress</h3>
<h3 className="font-medium">{t('address_section')}</h3>
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Label htmlFor="address_line1">{t('street_label')}</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
placeholder={t('street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('postal_label')}</Label>
<Input
id="postal_code"
placeholder="123 45"
placeholder={t('postal_placeholder')}
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
placeholder="Stockholm"
placeholder={t('city_placeholder')}
{...register('city')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Land</Label>
<Label htmlFor="country">{t('country_label')}</Label>
<Input
id="country"
placeholder="Sweden"
placeholder={t('country_placeholder')}
{...register('country')}
/>
</div>
@@ -234,24 +235,24 @@ export default function CustomerForm({
{/* Business info */}
{customerType !== 'individual' && (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Företagsuppgifter</h3>
<h3 className="font-medium">{t('business_section')}</h3>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder="XXXXXX-XXXX"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
</div>
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">VAT-nummer (momsreg.nr)</Label>
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? 'DE123456789' : 'SE123456789001'}
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
@@ -269,14 +270,14 @@ export default function CustomerForm({
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
'Verifiera'
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
Verifiera VAT-numret för att kunna fakturera med omvänd skattskyldighet (0% moms)
{t('vat_hint_eu')}
</p>
)}
</div>
@@ -286,7 +287,7 @@ export default function CustomerForm({
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
<Input
id="payment_terms"
type="number"
@@ -296,10 +297,10 @@ export default function CustomerForm({
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder="Interna anteckningar om kunden..."
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
@@ -309,20 +310,20 @@ export default function CustomerForm({
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('submit_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Spara kund
{t('submit_save')}
</>
) : (
'Spara kund'
t('submit_save')
)}
</Button>
</div>
+7 -5
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { switchCompany } from '@/lib/company/actions'
@@ -10,6 +11,7 @@ import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
export default function CompanySwitcher() {
const { company, companies, isSandbox } = useCompany()
const t = useTranslations('company_switcher')
const [open, setOpen] = useState(false)
const [isPending, setIsPending] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -115,7 +117,7 @@ export default function CompanySwitcher() {
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-[13px] text-muted-foreground truncate">Lägg till företag</span>
<span className="text-[13px] text-muted-foreground truncate">{t('add_company')}</span>
</Link>
)
}
@@ -130,9 +132,9 @@ export default function CompanySwitcher() {
aria-haspopup="listbox"
>
<div className="flex-1 min-w-0">
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">Företag</p>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">{t('company_label')}</p>
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
{company?.name || 'Min verksamhet'}
{company?.name || t('default_company_name')}
</p>
</div>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground flex-shrink-0" />
@@ -149,7 +151,7 @@ export default function CompanySwitcher() {
{hasMultiple && (
<div className="px-2 py-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] px-1.5">
Företag
{t('company_label')}
</p>
</div>
)}
@@ -196,7 +198,7 @@ export default function CompanySwitcher() {
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
>
<Plus className="h-3.5 w-3.5" />
Lägg till företag
{t('add_company')}
</Link>
</div>
)}
+39 -32
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
@@ -45,6 +46,7 @@ interface DashboardContentProps {
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const t = useTranslations('dashboard')
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
@@ -96,9 +98,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="font-medium text-sm">{t('overdue_invoices')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
{t('overdue_invoices_count', { count: summary.overdueInvoicesCount })}
</p>
</div>
</div>
@@ -116,9 +118,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="font-medium text-sm">{t('unpaid_invoices')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
{t('unpaid_invoices_detail', {
count: summary.unpaidInvoicesCount - summary.overdueInvoicesCount,
amount: formatCurrency(summary.unpaidInvoicesTotal),
})}
</p>
</div>
</div>
@@ -136,9 +141,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="font-medium text-sm">{t('transactions')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
{t('uncategorized_count', { count: summary.uncategorizedCount })}
</p>
</div>
</div>
@@ -156,9 +161,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="font-medium text-sm">{t('missing_underlag')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
{t('missing_underlag_detail', { count: summary.missingUnderlagCount })}
</p>
</div>
</div>
@@ -176,9 +181,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Clock className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Gamla transaktioner</p>
<p className="font-medium text-sm">{t('stale_transactions')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.staleUncategorizedCount} transaktioner äldre än 14 dagar saknar bokföring
{t('stale_transactions_detail', { count: summary.staleUncategorizedCount })}
</p>
</div>
</div>
@@ -197,9 +202,11 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="font-medium text-sm">{t('bank_consent_expiring')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
{conn.days_left === 1
? t('bank_consent_detail_one', { bank: conn.bank_name, days: conn.days_left })
: t('bank_consent_detail_other', { bank: conn.bank_name, days: conn.days_left })}
</p>
</div>
</div>
@@ -226,7 +233,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
@@ -235,7 +242,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} i år
{formatCurrency(summary.ytd.net)} {t('this_year_short')}
</p>
</CardContent>
</Card>
@@ -244,12 +251,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
@@ -261,7 +268,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
@@ -273,10 +280,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-sm font-medium text-primary">{t('connect_bank')}</p>
</CardContent>
</Card>
</Link>
@@ -284,17 +291,17 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
<div role="status" aria-live="polite">
{todoCount > 0 ? (
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
) : (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">Allt klart!</p>
<p className="text-sm font-medium text-success">{t('all_done')}</p>
</div>
)}
</div>
@@ -303,19 +310,19 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
</div>
</section>
{/* Resultat — intäkter / kostnader (always visible) */}
{/* Result — revenue / expenses (always visible) */}
<section>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</CardContent>
@@ -323,14 +330,14 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</CardContent>
@@ -338,10 +345,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
</div>
</section>
{/* Att hantera */}
{/* Alerts */}
{alertItems.length > 0 && (
<section id="alerts-section">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<h2 className="font-display text-lg font-medium mb-4">{t('alerts_title')}</h2>
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
{visibleAlerts}
</div>
@@ -352,7 +359,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
aria-controls="alerts-list"
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{showAllAlerts ? 'Visa färre' : `Visa alla (${alertItems.length})`}
{showAllAlerts ? t('show_less') : t('show_all', { count: alertItems.length })}
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
</button>
)}
+122 -137
View File
@@ -3,6 +3,7 @@
import { useState, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -29,7 +30,7 @@ import {
Package,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
@@ -37,6 +38,8 @@ import { useCompany } from '@/contexts/CompanyContext'
import { clearRecaptIdentity } from '@/lib/recapt'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
interface ExtensionNavItem {
href: string
label: string
@@ -52,52 +55,74 @@ interface DashboardNavProps {
extensionNavItems?: ExtensionNavItem[]
}
type NavLabelKey =
| 'dashboard'
| 'kpi'
| 'invoice_inbox'
| 'invoices'
| 'customers'
| 'supplier_invoices'
| 'suppliers'
| 'review'
| 'transactions'
| 'bookkeeping'
| 'assets'
| 'reports'
| 'import'
| 'salary'
| 'employees'
| 'help'
| 'settings'
type GroupKey = 'main' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' | 'övrigt'
interface NavItem {
href: string
label: string
labelKey: NavLabelKey
icon: typeof LayoutDashboard
group: string
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
hidden?: boolean // Temporarily hide from sidebar
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
devBadge?: boolean // Shows a "Dev" badge to indicate dev-only feature
betaBadge?: boolean // Clickable; shows a "Beta" badge to indicate feature in testing
group: GroupKey
modes?: EntityType[]
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
betaBadge?: boolean
}
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
{ href: '/e/general/invoice-inbox', label: 'Dokumentinkorg', icon: Inbox, group: 'main', betaBadge: true },
// AR — Accounts Receivable
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
// AP — Accounts Payable
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: Wallet, group: 'inköp' },
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', label: 'Anläggningstillgångar', icon: Package, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
// Personal — enabled in production with a "Beta" badge while we validate the
// end-to-end salary + AGI flow with real customers.
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'main' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'main', betaBadge: true },
{ href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' },
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp', hidden: true },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/help', labelKey: 'help', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', labelKey: 'settings', icon: Settings, group: 'övrigt' },
]
const groupLabels: Record<string, string> = {
main: 'Huvudmeny',
försäljning: 'Försäljning',
inköp: 'Inköp',
personal: 'Personal',
redovisning: 'Redovisning',
övrigt: 'Övrigt',
// Map known extension hrefs to nav translation keys so sidebar labels translate.
// Extensions whose manifest label happens to be English-ready can stay null.
function extensionLabelKey(href: string): string | null {
if (href === '/e/general/tic') return 'ext_tic'
if (href === '/e/general/invoice-inbox') return 'ext_invoice_inbox'
return null
}
const groupLabelKey: Record<GroupKey, string> = {
main: 'group_main',
försäljning: 'group_sales',
inköp: 'group_purchases',
redovisning: 'group_accounting',
personal: 'group_personnel',
övrigt: 'group_other',
}
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
@@ -105,22 +130,19 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// When the user has no active company (e.g. just archived their last
// one), every company-scoped route is unreachable. We keep them visible
// so the sidebar doesn't collapse, but render them as disabled.
// Only /settings remains navigable — from there the user can either
// create a new company or delete their account.
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
const openMobileMenu = () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
@@ -140,8 +162,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
if (href === '/') {
return pathname === '/'
}
// For parent routes that have a sibling sub-route in the nav (e.g. /salary vs /salary/employees),
// only match the parent for exact or non-overlapping sub-paths
if (href === '/salary') {
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
@@ -159,12 +179,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
// Filter nav items by entity type, hidden flag, and conditional visibility
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
if (item.modes && !item.modes.includes(entityType)) return false
// Only show Granskning when there are pending operations
if (item.href === '/pending' && pendingOperationsCount === 0) return false
return true
})
@@ -172,20 +190,30 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const mainItems = filteredItems.filter(i => i.group === 'main')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
// Groups rendered as distinct sidebar sections (AR, AP, Accounting)
const sidebarGroups = [
const sidebarGroups: { key: GroupKey; items: NavItem[]; spacing: string }[] = [
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' },
{ key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' },
] as const
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
const mobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard },
{ href: '/invoices', labelKey: 'invoices', icon: Receipt },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
]
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
const baseClass =
position === 'sidebar'
? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
: 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
if (item.comingSoon) return <span className={baseClass}>{tNav('badge_coming_soon')}</span>
if (item.devBadge) return <span className={baseClass}>{tNav('badge_dev')}</span>
if (item.betaBadge) return <span className={baseClass}>{tNav('badge_beta')}</span>
return null
}
return (
<>
{/* Desktop sidebar */}
@@ -198,11 +226,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
</div>
{/* Navigation with group headers */}
<nav className="px-3" aria-label="Huvudnavigation">
{/* Huvudmeny group */}
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Main group */}
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels.main}
{tNav('group_main')}
</p>
<div className="space-y-px">
{mainItems.map((item) => {
@@ -215,20 +243,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : null}
<span className="flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'sidebar')}
</>
)
const baseClass = cn(
@@ -251,7 +267,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -264,7 +280,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (
<div key={key} className={spacing}>
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels[key]}
{tNav(groupLabelKey[key])}
</p>
<div className="space-y-px">
{items.map((item) => {
@@ -276,26 +292,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : badge !== null && (
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
@@ -322,7 +327,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title={item.comingSoon ? 'Kommer snart' : 'Lägg till ett företag för att aktivera'}
title={item.comingSoon ? tNav('badge_coming_soon') : tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -338,7 +343,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
<span>{tNav('group_other')}</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isOvrigtExpanded && "rotate-180"
@@ -349,15 +354,16 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
// Extension nav items are always company-scoped.
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
{label}
</>
)
const baseClass = cn(
@@ -380,7 +386,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -396,7 +402,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
{tNav(item.labelKey)}
</>
)
const baseClass = cn(
@@ -419,7 +425,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -442,14 +448,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
onClick={handleLogout}
>
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</Button>
</div>
</div>
</aside>
{/* Mobile bottom navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label="Mobilnavigation">
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const Icon = item.icon
@@ -475,7 +481,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<span className={cn(
"truncate",
active && "font-medium"
)}>{item.label}</span>
)}>{tNav(item.labelKey)}</span>
</>
)
const baseClass = cn(
@@ -501,11 +507,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{/* Menu button */}
<button
onClick={openMobileMenu}
aria-label="Öppna meny"
aria-label={tNav('open_menu')}
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
>
<Menu className="h-5 w-5 mb-1" />
<span>Meny</span>
<span>{tNav('menu')}</span>
</button>
</div>
</nav>
@@ -532,7 +538,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
)}
style={{ maxHeight: '85dvh', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="dialog"
aria-label="Navigeringsmeny"
aria-label={tNav('navigation_menu')}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 sticky top-0 bg-card rounded-t-2xl">
@@ -549,7 +555,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
size="icon"
className="h-8 w-8 -mr-1"
onClick={closeMobileMenu}
aria-label="Stäng meny"
aria-label={tNav('close_menu')}
>
<X className="h-4 w-4" />
</Button>
@@ -566,20 +572,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : null}
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'mobile')}
</>
)
const baseClass = cn(
@@ -614,7 +608,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav(groupLabelKey[key])}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
<div className="space-y-0.5">
@@ -627,23 +621,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'mobile')
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : badge !== null && (
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
@@ -682,7 +665,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{/* Övrigt divider */}
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">Övrigt</span>
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_other')}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
@@ -692,10 +675,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
<span className="text-sm">{label}</span>
</>
)
const baseClass = cn(
@@ -731,7 +716,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
<span className="text-sm">{tNav(item.labelKey)}</span>
</>
)
const baseClass = cn(
@@ -777,7 +762,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
}}
>
<LogOut className="mr-3 h-[18px] w-[18px]" />
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</Button>
</div>
</div>
+37 -30
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { useToast } from '@/components/ui/use-toast'
@@ -19,19 +20,23 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
const STEP_INFO = [
{ title: 'Företagsform', subtitle: 'Välj din företagsform för att komma igång.' },
{ title: 'Uppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.' },
{ title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.' },
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' },
]
type TFn = (key: string, values?: Record<string, string | number>) => string
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle') },
{ title: t('step2_title'), subtitle: t('step2_subtitle') },
{ title: t('step3_title'), subtitle: t('step3_subtitle') },
{ title: t('step4_title'), subtitle: t('step4_subtitle') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
const LOG = '[welcome-onboarding]'
@@ -63,6 +68,8 @@ export default function WelcomeOnboarding({
}: WelcomeOnboardingProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('onboarding')
const STEP_INFO = buildStepInfo(t)
const [started, setStarted] = useState(skipWelcome ?? false)
const [isSaving, setIsSaving] = useState(false)
@@ -76,7 +83,7 @@ export default function WelcomeOnboarding({
const totalSteps = 4
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Reset org_number/company_name only on a genuine change (user going back
@@ -99,8 +106,8 @@ export default function WelcomeOnboarding({
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -120,8 +127,8 @@ export default function WelcomeOnboarding({
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -141,16 +148,16 @@ export default function WelcomeOnboarding({
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
let title = 'Fel'
let description: string = result.error || 'Kunde inte skapa företag. Försök igen.'
let title = t('toast_error_title')
let description: string = result.error || t('toast_create_failed')
let backToStep2 = false
if (result.error === 'org_number_exists') {
title = 'Företaget finns redan'
description = `Det här företaget finns redan i ${branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.`
title = t('toast_company_exists_title')
description = t('toast_company_exists_description', { appName: branding.appName.toLowerCase() })
backToStep2 = true
} else if (result.error === 'org_number_invalid') {
title = 'Ogiltigt organisationsnummer'
description = 'Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.'
title = t('toast_org_invalid_title')
description = t('toast_org_invalid_description')
backToStep2 = true
}
toast({
@@ -167,14 +174,14 @@ export default function WelcomeOnboarding({
console.log(LOG, 'onboarding completed', result.companyId)
toast({
title: 'Välkommen!',
description: 'Ditt företag är nu redo.',
title: t('toast_welcome_title'),
description: t('toast_company_ready'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
@@ -194,13 +201,13 @@ export default function WelcomeOnboarding({
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
<p className="text-muted-foreground/50 text-sm mb-2">{greeting}</p>
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight leading-[1.05] mb-10">
Välkommen till {branding.appName}
{t('welcome_title', { appName: branding.appName })}
</h1>
<button
onClick={() => setStarted(true)}
className="px-5 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium hover:bg-foreground/85 transition-colors duration-150 active:scale-[0.98]"
>
{hasExistingCompanies ? 'Lägg till ett företag' : 'Lägg till ditt första företag'}
{hasExistingCompanies ? t('add_a_company') : t('add_first_company')}
</button>
</div>
)
@@ -214,7 +221,7 @@ export default function WelcomeOnboarding({
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
{hasExistingCompanies ? 'Lägg till ett företag.' : 'Lägg till ditt första företag för att komma igång.'}
{hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')}
</p>
</header>
@@ -236,7 +243,7 @@ export default function WelcomeOnboarding({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-white/60" />
<span className="text-xs text-white/40 tracking-wide uppercase">Nytt företag</span>
<span className="text-xs text-white/40 tracking-wide uppercase">{t('new_company')}</span>
</div>
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
+16 -14
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -29,6 +30,7 @@ interface TaxTodoWidgetProps {
export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {
const { toast } = useToast()
const t = useTranslations('tax_todo')
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Filter to only tax deadlines needing attention
@@ -62,10 +64,10 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
if (diffDays === 0) return 'Idag'
if (diffDays === 1) return 'Imorgon'
if (diffDays < 0) return `${Math.abs(diffDays)} dagar sedan`
if (diffDays <= 7) return `Om ${diffDays} dagar`
if (diffDays === 0) return t('today')
if (diffDays === 1) return t('tomorrow')
if (diffDays < 0) return t('days_ago', { count: Math.abs(diffDays) })
if (diffDays <= 7) return t('in_days', { count: diffDays })
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
@@ -86,14 +88,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
}
toast({
title: 'Status uppdaterad',
description: `Markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
title: t('toast_status_updated'),
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
})
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -115,14 +117,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-warning-foreground" />
Att göra - Skatt
{t('title')}
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} snart</Badge>
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
)}
</div>
</div>
@@ -193,7 +195,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
'Påbörja'
t('start')
)}
</Button>
)}
@@ -212,7 +214,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
) : (
<>
<Send className="h-3.5 w-3.5" />
Inskickad
{t('submitted')}
</>
)}
</Button>
@@ -225,13 +227,13 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{sortedDeadlines.length > 5 && (
<p className="text-xs text-muted-foreground text-center">
+{sortedDeadlines.length - 5} fler uppgifter
{t('more_tasks', { count: sortedDeadlines.length - 5 })}
</p>
)}
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between">
Visa alla deadlines
{t('view_all')}
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -21,6 +22,7 @@ interface UpcomingDeadlinesWidgetProps {
export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChange }: UpcomingDeadlinesWidgetProps) {
const { toast } = useToast()
const t = useTranslations('upcoming_deadlines')
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Get upcoming deadlines (next 7 days) + any needing attention
@@ -51,10 +53,10 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === today.toDateString()) {
return 'Idag'
return t('today')
}
if (date.toDateString() === tomorrow.toDateString()) {
return 'Imorgon'
return t('tomorrow')
}
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
@@ -75,15 +77,15 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
}
toast({
title: 'Status uppdaterad',
description: `Deadline markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
title: t('toast_status_updated'),
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
})
// Notify parent component
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -107,14 +109,14 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kommande deadlines
{t('title')}
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} åtgärd</Badge>
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
)}
</div>
</div>
@@ -147,11 +149,11 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">
{formatDate(deadline.due_date)}
{deadline.due_time && ` kl. ${deadline.due_time.slice(0, 5)}`}
{deadline.due_time && ` ${t('time_prefix')} ${deadline.due_time.slice(0, 5)}`}
</p>
{deadline.tax_deadline_type && (
<Badge variant="outline" className="text-xs px-1 py-0">
Skatt
{t('tax_badge')}
</Badge>
)}
</div>
@@ -172,7 +174,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
title="Markera som inskickad"
title={t('mark_as_submitted')}
>
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -189,7 +191,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
title="Markera som bekräftad"
title={t('mark_as_confirmed')}
>
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -205,7 +207,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between mt-2">
Visa alla deadlines
{t('view_all')}
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
+8 -6
View File
@@ -1,21 +1,23 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
accounting: { label: 'Bokföring & Skatt', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { label: 'Branschrapporter', className: 'bg-primary/10 text-primary border-primary/30' },
import: { label: 'Smart Import', className: 'bg-success/10 text-success border-success/30' },
operations: { label: 'Verktyg', className: 'bg-muted text-muted-foreground border-border' },
const CATEGORY_CONFIG: Record<ExtensionCategory, { labelKey: string; className: string }> = {
accounting: { labelKey: 'category_accounting', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { labelKey: 'category_reports', className: 'bg-primary/10 text-primary border-primary/30' },
import: { labelKey: 'category_import', className: 'bg-success/10 text-success border-success/30' },
operations: { labelKey: 'category_operations', className: 'bg-muted text-muted-foreground border-border' },
}
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
const t = useTranslations('extensions')
const config = CATEGORY_CONFIG[category]
return (
<Badge variant="outline" className={cn('text-[10px] font-medium', config.className)}>
{config.label}
{t(config.labelKey)}
</Badge>
)
}
+11 -3
View File
@@ -1,10 +1,18 @@
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { extensionNameKey, extensionDescriptionKey } from '@/lib/extensions/i18n'
import type { ExtensionDefinition } from '@/lib/extensions/types'
import CategoryBadge from './CategoryBadge'
import Link from 'next/link'
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
export default async function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
const t = await getTranslations('extensions')
const nameKey = extensionNameKey(extension.slug)
const descriptionKey = extensionDescriptionKey(extension.slug)
const name = nameKey ? t(nameKey) : extension.name
const description = descriptionKey ? t(descriptionKey) : extension.description
const Icon = resolveIcon(extension.icon)
@@ -20,10 +28,10 @@ export default function ExtensionCard({ extension }: { extension: ExtensionDefin
href={`/extensions/${extension.sector}/${extension.slug}`}
className="text-sm font-medium hover:underline"
>
{extension.name}
{name}
</Link>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{extension.description}
{description}
</p>
<div className="mt-2">
<CategoryBadge category={extension.category} />
+13 -5
View File
@@ -1,10 +1,18 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { sectorNameKey, sectorDescriptionKey } from '@/lib/extensions/i18n'
import type { Sector } from '@/lib/extensions/types'
export default function SectorCard({ sector }: { sector: Sector }) {
export default async function SectorCard({ sector }: { sector: Sector }) {
const t = await getTranslations('extensions')
const nameKey = sectorNameKey(sector.slug)
const descriptionKey = sectorDescriptionKey(sector.slug)
const name = nameKey ? t(nameKey) : sector.name
const description = descriptionKey ? t(descriptionKey) : sector.description
const Icon = resolveIcon(sector.icon)
return (
@@ -17,11 +25,11 @@ export default function SectorCard({ sector }: { sector: Sector }) {
</div>
<div>
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
{sector.name}
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{sector.description}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
<p className="text-xs text-muted-foreground mt-1.5">
{sector.extensions.length} tillägg
{t('extension_count', { count: sector.extensions.length })}
</p>
</div>
</div>
+43 -41
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -50,15 +51,15 @@ function formatPeriod(start: number, end: number): string {
return `${fmt(s)} – ${fmt(e)}`
}
function timeAgo(isoDate: string): string {
function timeAgo(isoDate: string, t: (key: string, values?: Record<string, string | number>) => string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just nu'
if (minutes < 60) return `${minutes} min sedan`
if (minutes < 1) return t('time_just_now')
if (minutes < 60) return t('time_minutes_ago', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} tim sedan`
if (hours < 24) return t('time_hours_ago', { n: hours })
const days = Math.floor(hours / 24)
return `${days} dag${days > 1 ? 'ar' : ''} sedan`
return t('time_days_ago', { n: days })
}
// Mirrors the live layout (two cards: company info + financials) so the
@@ -122,6 +123,7 @@ function ProfileSkeleton() {
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
const { toast } = useToast()
const t = useTranslations('tic_workspace')
const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
const [isFetching, setIsFetching] = useState(false)
const [noOrgNumber, setNoOrgNumber] = useState(false)
@@ -147,7 +149,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
// Get org_number from company settings
const settingsRes = await fetch('/api/settings')
if (!settingsRes.ok) {
toast({ title: 'Kunde inte hämta inställningar', variant: 'destructive' })
toast({ title: t('toast_settings_failed'), variant: 'destructive' })
return
}
const { data: settings } = await settingsRes.json()
@@ -164,7 +166,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
if (!res.ok) {
const { error } = await res.json()
toast({ title: error ?? 'Kunde inte hämta företagsprofil', variant: 'destructive' })
toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' })
setFetchFailed(true)
return
}
@@ -173,12 +175,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
setProfile(data)
await save('company_profile', data)
} catch {
toast({ title: 'Ett oväntat fel inträffade', variant: 'destructive' })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
setFetchFailed(true)
} finally {
setIsFetching(false)
}
}, [save, toast])
}, [save, toast, t])
// Auto-fetch on first visit when no cached data
useEffect(() => {
@@ -196,13 +198,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<Settings className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Inget organisationsnummer
{t('no_org_number_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Ange organisationsnummer under Inställningar för att visa företagsprofilen.
{t('no_org_number_description')}
</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/settings">Gå till Inställningar</Link>
<Link href="/settings">{t('go_to_settings')}</Link>
</Button>
</div>
)
@@ -217,17 +219,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<XCircle className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Kunde inte hämta företagsprofil
{t('fetch_failed_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Kontrollera att organisationsnumret i inställningarna är korrekt och försök igen.
{t('fetch_failed_description')}
</p>
<div className="flex gap-3 mt-4">
<Button variant="outline" asChild>
<Link href="/settings">Inställningar</Link>
<Link href="/settings">{t('settings')}</Link>
</Button>
<Button variant="outline" onClick={fetchProfile} disabled={isFetching}>
Försök igen
{t('retry')}
</Button>
</div>
</div>
@@ -238,9 +240,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const isActive = profile.activityStatus !== 'ceased'
const registrations = [
profile.registration.fTax && 'F-skatt',
profile.registration.vat && 'Moms',
profile.registration.payroll && 'Arbetsgivare',
profile.registration.fTax && t('reg_f_tax'),
profile.registration.vat && t('reg_vat'),
profile.registration.payroll && t('reg_employer'),
].filter((label): label is string => Boolean(label))
return (
@@ -256,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardDescription>
{profile.orgNumber} &middot; {profile.legalEntityType}
{!isActive && (
<span className="ml-2 text-destructive">&middot; Avregistrerat</span>
<span className="ml-2 text-destructive">&middot; {t('deregistered')}</span>
)}
</CardDescription>
</CardHeader>
@@ -285,13 +287,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{registrations.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Registrerat för</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('registered_for')}</p>
<p className="text-xs text-muted-foreground">{registrations.join(' · ')}</p>
</div>
)}
{profile.sniCodes.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('sni_codes')}</p>
<div className="space-y-0.5">
{profile.sniCodes
.filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i)
@@ -306,7 +308,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.bankAccounts.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Bankuppgifter</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('bank_accounts')}</p>
<div className="space-y-0.5">
{profile.bankAccounts.map((ba, i) => (
<p key={i} className="text-xs text-muted-foreground">
@@ -319,7 +321,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.purpose && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Verksamhet</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('purpose')}</p>
<p className="text-xs text-muted-foreground">{profile.purpose}</p>
</div>
)}
@@ -327,18 +329,18 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="pt-2 border-t">
{profile.employeeRange && (
<p className="text-xs text-muted-foreground">
Anställda: {profile.employeeRange}
{t('employees_range', { range: profile.employeeRange })}
</p>
)}
{profile.turnoverRange && (
<p className="text-xs text-muted-foreground">
Omsättning: {profile.turnoverRange}
{t('turnover_range', { range: profile.turnoverRange })}
</p>
)}
</div>
)}
<p className="pt-2 text-xs text-muted-foreground/70">
Uppdaterad {timeAgo(profile.fetchedAt)}
{t('updated_ago', { ago: timeAgo(profile.fetchedAt, t) })}
</p>
</CardContent>
</Card>
@@ -346,7 +348,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{/* Financials card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Senaste bokslut</CardTitle>
<CardTitle className="text-base">{t('latest_closing')}</CardTitle>
{profile.financials && (
<CardDescription>
{formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)}
@@ -356,31 +358,31 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardContent>
{profile.financials ? (
<div className="grid grid-cols-2 gap-4">
<FinancialCell label="Omsättning" value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell label={t('net_sales')} value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell
label="Rörelseresultat"
label={t('operating_profit')}
value={formatKSEK(profile.financials.operatingProfitK)}
negative={(profile.financials.operatingProfitK ?? 0) < 0}
/>
<FinancialCell label="Totala tillgångar" value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell label={t('total_assets')} value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell
label="Anställda"
label={t('employees')}
value={profile.financials.numberOfEmployees !== null
? String(profile.financials.numberOfEmployees)
: '—'}
/>
<FinancialCell
label="Rörelsemarginal"
label={t('operating_margin')}
value={formatPercent(profile.financials.operatingMargin)}
negative={(profile.financials.operatingMargin ?? 0) < 0}
/>
<FinancialCell
label="Soliditet"
label={t('equity_ratio')}
value={formatPercent(profile.financials.equityAssetsRatio)}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
<p className="text-sm text-muted-foreground">{t('no_financials')}</p>
)}
</CardContent>
</Card>
@@ -390,17 +392,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{profile.financialReports.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Årsredovisningar</CardTitle>
<CardTitle className="text-base">{t('annual_reports')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Titel</TableHead>
<TableHead>Inlämnad</TableHead>
<TableHead>Reviderad</TableHead>
<TableHead>Revisionsutlåtande</TableHead>
<TableHead>{t('col_period')}</TableHead>
<TableHead>{t('col_title')}</TableHead>
<TableHead>{t('col_filed')}</TableHead>
<TableHead>{t('col_audited')}</TableHead>
<TableHead>{t('col_audit_opinion')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
+87 -68
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -14,47 +15,6 @@ import { useToast } from '@/components/ui/use-toast'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
const bankSetupSchema = z.object({
bank_name: z.string().max(100).optional().or(z.literal('')),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4–5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6–12 siffror').optional().or(z.literal('')),
bankgiro: z.string().optional().or(z.literal('')),
iban: z.string().optional().or(z.literal('')),
bic: z.string().optional().or(z.literal('')),
invoice_prefix: z.string().optional().or(z.literal('')),
next_invoice_number: z.string().optional().or(z.literal('')),
}).refine(
(data) => {
const hasAccount = !!data.clearing_number && !!data.account_number
const hasBankgiro = !!data.bankgiro
return hasAccount || hasBankgiro
},
{ message: 'Ange antingen kontonummer (clearing + konto) eller bankgiro', path: ['clearing_number'] }
).refine(
(data) => {
// If one of clearing/account is filled, both must be
if (data.clearing_number && !data.account_number) return false
if (!data.clearing_number && data.account_number) return false
return true
},
{ message: 'Ange både clearingnummer och kontonummer', path: ['account_number'] }
).refine(
(data) => {
if (!data.bankgiro) return true
return validateBankgiroNumber(data.bankgiro)
},
{ message: 'Ogiltigt bankgironummer (7–8 siffror med kontrollsiffra)', path: ['bankgiro'] }
).refine(
(data) => {
if (!data.next_invoice_number) return true
const num = parseInt(data.next_invoice_number, 10)
return !isNaN(num) && num >= 1
},
{ message: 'Startnummer måste vara ett positivt heltal', path: ['next_invoice_number'] }
)
type BankSetupData = z.infer<typeof bankSetupSchema>
interface BankDetailsSetupDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -63,10 +23,68 @@ interface BankDetailsSetupDialogProps {
export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankDetailsSetupDialogProps) {
const { toast } = useToast()
const t = useTranslations('invoice_bank_setup')
const [isSaving, setIsSaving] = useState(false)
const [showInternational, setShowInternational] = useState(false)
const [bankName, setBankName] = useState('')
const bankSetupSchema = useMemo(
() =>
z
.object({
bank_name: z.string().max(100).optional().or(z.literal('')),
clearing_number: z
.string()
.regex(/^\d{4,5}$/, t('validation_clearing_format'))
.optional()
.or(z.literal('')),
account_number: z
.string()
.regex(/^\d{6,12}$/, t('validation_account_format'))
.optional()
.or(z.literal('')),
bankgiro: z.string().optional().or(z.literal('')),
iban: z.string().optional().or(z.literal('')),
bic: z.string().optional().or(z.literal('')),
invoice_prefix: z.string().optional().or(z.literal('')),
next_invoice_number: z.string().optional().or(z.literal('')),
})
.refine(
(data) => {
const hasAccount = !!data.clearing_number && !!data.account_number
const hasBankgiro = !!data.bankgiro
return hasAccount || hasBankgiro
},
{ message: t('validation_either_required'), path: ['clearing_number'] },
)
.refine(
(data) => {
if (data.clearing_number && !data.account_number) return false
if (!data.clearing_number && data.account_number) return false
return true
},
{ message: t('validation_both_required'), path: ['account_number'] },
)
.refine(
(data) => {
if (!data.bankgiro) return true
return validateBankgiroNumber(data.bankgiro)
},
{ message: t('validation_bankgiro_invalid'), path: ['bankgiro'] },
)
.refine(
(data) => {
if (!data.next_invoice_number) return true
const num = parseInt(data.next_invoice_number, 10)
return !isNaN(num) && num >= 1
},
{ message: t('validation_start_number_positive'), path: ['next_invoice_number'] },
),
[t],
)
type BankSetupData = z.infer<typeof bankSetupSchema>
const {
register,
handleSubmit,
@@ -119,18 +137,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte spara')
throw new Error(result.error || t('save_failed_fallback'))
}
toast({
title: 'Betalningsuppgifter sparade',
description: 'Du kan ändra dem senare i Inställningar.',
title: t('saved_title'),
description: t('saved_description'),
})
onComplete()
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('save_failed_title'),
description: error instanceof Error ? error.message : t('save_failed_fallback'),
variant: 'destructive',
})
} finally {
@@ -150,17 +168,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="font-display text-xl tracking-tight">Betalningsuppgifter</DialogTitle>
<DialogTitle className="font-display text-xl tracking-tight">{t('title')}</DialogTitle>
<DialogDescription>
Dessa uppgifter visas på dina fakturor. Du kan ändra dem senare i{' '}
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">Inställningar</a>.
{t('description_prefix')}
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">{t('settings_link')}</a>
{t('description_suffix')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
{/* Bank name */}
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Label htmlFor="bank_name">{t('bank_label')}</Label>
<BankNameCombobox
value={bankName}
onChange={setBankName}
@@ -170,10 +189,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Clearing + Account number */}
<div className="grid grid-cols-5 gap-3">
<div className="col-span-2 space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
placeholder="8000"
placeholder={t('clearing_placeholder')}
maxLength={5}
inputMode="numeric"
{...register('clearing_number')}
@@ -181,10 +200,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
/>
</div>
<div className="col-span-3 space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Label htmlFor="account_number">{t('account_label')}</Label>
<Input
id="account_number"
placeholder="12345678"
placeholder={t('account_placeholder')}
maxLength={12}
inputMode="numeric"
{...register('account_number')}
@@ -201,10 +220,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Bankgiro */}
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
placeholder="123-4567"
placeholder={t('bankgiro_placeholder')}
maxLength={9}
{...register('bankgiro')}
onBlur={(e) => {
@@ -232,23 +251,23 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Internationella betalningar
{t('international_toggle')}
</button>
{showInternational && (
<div className="space-y-3 pt-3 animate-in slide-in-from-top-1 duration-150">
<div className="space-y-2">
<Label htmlFor="iban">IBAN</Label>
<Label htmlFor="iban">{t('iban_label')}</Label>
<Input
id="iban"
placeholder="SE12 3456 7890 1234 5678 9012"
placeholder={t('iban_placeholder')}
{...register('iban')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bic">BIC/SWIFT</Label>
<Label htmlFor="bic">{t('bic_label')}</Label>
<Input
id="bic"
placeholder="NDEASESS"
placeholder={t('bic_placeholder')}
maxLength={11}
{...register('bic')}
/>
@@ -262,19 +281,19 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Invoice prefix + starting number */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Label htmlFor="invoice_prefix">{t('invoice_prefix_label')}</Label>
<Input
id="invoice_prefix"
placeholder="t.ex. F-"
placeholder={t('invoice_prefix_placeholder')}
maxLength={10}
{...register('invoice_prefix')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Startnummer</Label>
<Label htmlFor="next_invoice_number">{t('next_invoice_number_label')}</Label>
<Input
id="next_invoice_number"
placeholder="1"
placeholder={t('next_invoice_number_placeholder')}
inputMode="numeric"
maxLength={6}
{...register('next_invoice_number')}
@@ -282,7 +301,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
</div>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Prefix &quot;F-&quot; med startnummer 1 ger F-2026001. Lämna tomt för standard.
{t('prefix_hint')}
</p>
{errors.next_invoice_number && (
<p className="text-sm text-destructive -mt-2">{errors.next_invoice_number.message}</p>
@@ -292,7 +311,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
<div className="flex justify-end gap-3 pt-2">
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara & fortsätt
{t('save_and_continue')}
</Button>
</div>
</form>
+24 -22
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency } from '@/lib/utils'
@@ -48,12 +49,13 @@ export function InvoiceReviewContent({
numberPreview,
oreRounding,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
const customerTypeLabel: Record<string, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
individual: t('customer_type_individual'),
swedish_business: t('customer_type_swedish_business'),
eu_business: t('customer_type_eu_business'),
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown
@@ -71,7 +73,7 @@ export function InvoiceReviewContent({
<div className="space-y-4">
{numberPreview && (
<div className="text-sm text-muted-foreground">
Tilldelas fakturanummer{' '}
{t('assigned_number_prefix')}{' '}
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
</div>
)}
@@ -89,11 +91,11 @@ export function InvoiceReviewContent({
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<span className="text-muted-foreground">{t('invoice_date')}</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<span className="text-muted-foreground">{t('due_date')}</span>
<p className="font-medium">{dueDate}</p>
</div>
</div>
@@ -103,12 +105,12 @@ export function InvoiceReviewContent({
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2">{t('th_description')}</th>
<th className="py-2 w-16 text-right">{t('th_quantity')}</th>
<th className="py-2 w-16 text-center">{t('th_unit')}</th>
<th className="py-2 w-24 text-right">{t('th_unit_price')}</th>
{showVatColumn && <th className="py-2 w-16 text-right">{t('th_vat')}</th>}
<th className="py-2 w-28 text-right">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
@@ -135,7 +137,7 @@ export function InvoiceReviewContent({
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">({item.vat_rate ?? 0}% moms)</span>}
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
@@ -147,7 +149,7 @@ export function InvoiceReviewContent({
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
{Array.from(vatByRate.entries())
@@ -155,25 +157,25 @@ export function InvoiceReviewContent({
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
</div>
)}
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">Öresavrundning</span>
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, currency)}</span>
</div>
</div>
@@ -183,7 +185,7 @@ export function InvoiceReviewContent({
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
{yourReference && (
<div>
<span>Er referens:</span>
<span>{t('your_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{yourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -195,7 +197,7 @@ export function InvoiceReviewContent({
)}
{ourReference && (
<div>
<span>Vår referens:</span>
<span>{t('our_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{ourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -205,7 +207,7 @@ export function InvoiceReviewContent({
</div>
</div>
)}
{notes && <p>Anteckning: {notes}</p>}
{notes && <p>{t('notes_prefix', { notes })}</p>}
</div>
)}
</div>
+34 -32
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -38,12 +39,6 @@ interface DuplicateCandidate {
match_confidence: number
}
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
ocr_exact: 'Exakt OCR-träff',
name_amount_fuzzy: 'Sannolik träff',
amount_only: 'Möjlig träff',
}
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
@@ -68,6 +63,13 @@ export default function PaymentBookingDialog({
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const t = useTranslations('invoice_payment_dialog')
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
ocr_exact: t('match_reason_ocr_exact'),
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
amount_only: t('match_reason_amount_only'),
}
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
@@ -90,11 +92,11 @@ export default function PaymentBookingDialog({
try {
// Fetch accounts
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (!accountsRes.ok) throw new Error('Kunde inte ladda kontoplanen')
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
const fetchedAccounts: BASAccount[] = accountsData.data || []
if (!company?.id) throw new Error('Inget aktivt företag')
if (!company?.id) throw new Error(t('no_active_company'))
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
@@ -103,7 +105,7 @@ export default function PaymentBookingDialog({
.eq('company_id', company.id)
.maybeSingle()
if (settingsError) throw new Error('Kunde inte ladda företagsinställningar')
if (settingsError) throw new Error(t('load_settings_failed'))
if (cancelled) return
setAccounts(fetchedAccounts)
@@ -135,8 +137,8 @@ export default function PaymentBookingDialog({
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte ladda bokföringsdialog',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('load_dialog_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -220,7 +222,7 @@ export default function PaymentBookingDialog({
setIsSubmitting(false)
return
}
const error = new Error('Kunde inte markera som betald') as Error & { body?: unknown; status?: number }
const error = new Error(t('mark_paid_failed')) as Error & { body?: unknown; status?: number }
error.body = data
error.status = response.status
throw error
@@ -231,7 +233,7 @@ export default function PaymentBookingDialog({
} catch (error) {
const anyErr = error as { body?: unknown; status?: number }
toast({
title: 'Bokföring misslyckades',
title: t('booking_failed_title'),
description: getErrorMessage(anyErr.body ?? error, { context: 'invoice', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -252,11 +254,11 @@ export default function PaymentBookingDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>Bokför betalning{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}</DialogTitle>
<DialogTitle>{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<> ({formatCurrency(invoice.total_sek)} SEK)</>
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
)}
</DialogDescription>
</DialogHeader>
@@ -264,11 +266,11 @@ export default function PaymentBookingDialog({
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Möjlig dubblettbetalning</p>
<p className="text-sm font-medium">{t('duplicate_title')}</p>
<p className="text-sm text-muted-foreground">
{duplicateCandidates.length === 1
? 'En inkommande banktransaktion ser ut att vara denna betalning. Länka den istället för att skapa en ny verifikation, eller bokför ändå om du är säker.'
: `${duplicateCandidates.length} inkommande banktransaktioner ser ut att kunna vara denna betalning. Länka rätt transaktion istället för att skapa en ny verifikation, eller bokför ändå om du är säker.`}
? t('duplicate_one')
: t('duplicate_many', { count: duplicateCandidates.length })}
</p>
</div>
<ul className="space-y-2">
@@ -305,7 +307,7 @@ export default function PaymentBookingDialog({
onClick={() => handleLinkExisting(c.id)}
className="shrink-0"
>
Länka transaktion
{t('link_transaction')}
</Button>
</li>
)
@@ -320,7 +322,7 @@ export default function PaymentBookingDialog({
<div className="space-y-4">
{/* Payment date */}
<div className="space-y-1.5">
<Label htmlFor="payment-date">Betalningsdatum</Label>
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
<Input
id="payment-date"
type="date"
@@ -356,7 +358,7 @@ export default function PaymentBookingDialog({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Label className="text-xs text-muted-foreground">{t('debit_label')}</Label>
<Input
type="number"
step="0.01"
@@ -369,7 +371,7 @@ export default function PaymentBookingDialog({
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Label className="text-xs text-muted-foreground">{t('credit_label')}</Label>
<Input
type="number"
step="0.01"
@@ -385,7 +387,7 @@ export default function PaymentBookingDialog({
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addLine} className="w-full">
<Plus className="mr-1 h-3.5 w-3.5" /> Lägg till rad
<Plus className="mr-1 h-3.5 w-3.5" /> {t('add_row')}
</Button>
</div>
@@ -393,9 +395,9 @@ export default function PaymentBookingDialog({
<div className="hidden sm:block space-y-2">
{/* Header */}
<div className="grid grid-cols-[1fr_120px_120px_32px] gap-2 text-xs font-medium text-muted-foreground px-1">
<span>Konto</span>
<span className="text-right">Debet</span>
<span className="text-right">Kredit</span>
<span>{t('account_label')}</span>
<span className="text-right">{t('debit_label')}</span>
<span className="text-right">{t('credit_label')}</span>
<span />
</div>
@@ -449,7 +451,7 @@ export default function PaymentBookingDialog({
className="text-muted-foreground"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till rad
{t('add_row')}
</Button>
</div>
@@ -458,11 +460,11 @@ export default function PaymentBookingDialog({
<div className="flex items-center gap-2">
{isBalanced ? (
<Badge variant="secondary" className="bg-success/10 text-success">
Debet = Kredit
{t('balanced_badge')}
</Badge>
) : (
<Badge variant="destructive">
Obalanserad ({formatCurrency(Math.abs(totalDebit - totalCredit))})
{t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
</Badge>
)}
</div>
@@ -475,7 +477,7 @@ export default function PaymentBookingDialog({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
Avbryt
{t('cancel')}
</Button>
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<Button
@@ -484,7 +486,7 @@ export default function PaymentBookingDialog({
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför ändå
{t('book_anyway')}
</Button>
) : (
<Button
@@ -493,7 +495,7 @@ export default function PaymentBookingDialog({
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bekräfta &amp; bokför
{t('confirm_and_book')}
</Button>
)}
</DialogFooter>
+26 -21
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -43,6 +44,7 @@ export default function SendInvoiceDialog({
const { toast } = useToast()
const supabase = createClient()
const { company } = useCompany()
const t = useTranslations('invoice_send_dialog')
const [isSubmitting, setIsSubmitting] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
@@ -60,7 +62,7 @@ export default function SendInvoiceDialog({
async function init() {
try {
if (!company?.id) throw new Error('Inget aktivt företag')
if (!company?.id) throw new Error(t('no_active_company'))
// Fetch company settings
const { data: settings, error } = await supabase
@@ -69,7 +71,7 @@ export default function SendInvoiceDialog({
.eq('company_id', company.id)
.maybeSingle()
if (error) throw new Error('Kunde inte ladda företagsinställningar')
if (error) throw new Error(t('company_settings_failed'))
if (cancelled) return
// Fetch fiscal period for the invoice date
@@ -90,8 +92,8 @@ export default function SendInvoiceDialog({
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte ladda inställningar',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('load_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -145,7 +147,7 @@ export default function SendInvoiceDialog({
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte skicka fakturan')
throw new Error(data.error || t('send_failed_fallback'))
}
onSuccess()
@@ -153,23 +155,23 @@ export default function SendInvoiceDialog({
if (mode === 'email') {
onOpenChange(false)
toast({
title: 'Faktura skickad',
description: data.message || `Fakturan har skickats till ${invoice.customer.email}`,
title: t('send_success_title'),
description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }),
})
} else {
// For manual send, just close — no email to confirm
onOpenChange(false)
toast({
title: 'Faktura markerad som skickad',
title: t('mark_success_title'),
description: accountingMethod === 'accrual'
? 'Bokföringsverifikationen har skapats.'
? t('mark_success_voucher_created')
: undefined,
})
}
} catch (error) {
toast({
title: 'Kunde inte skicka faktura',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('send_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
variant: 'destructive',
})
}
@@ -188,15 +190,15 @@ export default function SendInvoiceDialog({
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}
{mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<> ({formatCurrency(invoice.total_sek)} SEK)</>
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
)}
{mode === 'email' && invoice.customer.email && (
<> till {invoice.customer.email}</>
<>{t('description_to_email', { email: invoice.customer.email })}</>
)}
</DialogDescription>
</DialogHeader>
@@ -210,12 +212,15 @@ export default function SendInvoiceDialog({
{showJournalPreview ? (
<>
<p className="text-sm text-muted-foreground">
Följande bokföringsverifikation skapas automatiskt:
{t('journal_preview_intro')}
</p>
<JournalEntryReviewContent
periodName={periodName}
entryDate={invoice.invoice_date}
description={`Försäljning faktura${invoice.invoice_number ? ` ${invoice.invoice_number}` : ''}${invoice.customer.name ? `, ${invoice.customer.name}` : ''}`}
description={t('voucher_description', {
numberSpace: invoice.invoice_number ? ` ${invoice.invoice_number}` : '',
customerSuffix: invoice.customer.name ? `, ${invoice.customer.name}` : '',
})}
lines={proposedLines}
totalDebit={totalDebit}
totalCredit={totalCredit}
@@ -226,10 +231,10 @@ export default function SendInvoiceDialog({
) : (
<p className="text-sm text-muted-foreground">
{accountingMethod === 'cash'
? 'Kontantmetoden — bokföring sker vid betalning, inte vid fakturering.'
? t('explain_cash')
: mode === 'email'
? `Fakturan skickas till ${invoice.customer.email}.`
: 'Fakturan markeras som skickad.'}
? t('explain_email', { email: invoice.customer.email ?? '' })
: t('explain_manual')}
</p>
)}
</div>
@@ -242,7 +247,7 @@ export default function SendInvoiceDialog({
disabled={isSubmitting}
className="w-full sm:w-auto min-h-11"
>
Avbryt
{t('cancel')}
</Button>
<Button
onClick={handleConfirm}
@@ -256,7 +261,7 @@ export default function SendInvoiceDialog({
) : (
<Send className="mr-2 h-4 w-4" />
)}
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}
{mode === 'email' ? t('send_invoice') : t('mark_as_sent')}
</Button>
</DialogFooter>
</DialogContent>
+10 -8
View File
@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
@@ -22,16 +23,17 @@ const SEGMENT_COLORS = [
]
export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
const t = useTranslations('kpi')
const { class4, class5, class6, class7 } = composition
const chartData = useMemo(
() =>
[
{ name: 'Varor (klass 4)', value: class4 },
{ name: 'Drift & lokaler (klass 5)', value: class5 },
{ name: 'Övriga externa (klass 6)', value: class6 },
{ name: 'Personal (klass 7)', value: class7 },
{ name: t('expense_mix_class4'), value: class4 },
{ name: t('expense_mix_class5'), value: class5 },
{ name: t('expense_mix_class6'), value: class6 },
{ name: t('expense_mix_class7'), value: class7 },
].filter((s) => s.value > 0),
[class4, class5, class6, class7]
[class4, class5, class6, class7, t]
)
const total = class4 + class5 + class6 + class7
@@ -44,12 +46,12 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Kostnader per klass</CardTitle>
<CardTitle className="text-base">{t('expense_mix_title')}</CardTitle>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="flex h-[240px] items-center justify-center text-sm text-muted-foreground">
Inga bokförda kostnader ännu
{t('expense_mix_empty')}
</div>
) : (
<div className="relative flex flex-col items-center">
@@ -81,7 +83,7 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
</ResponsiveContainer>
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[180px] flex flex-col items-center justify-center">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Totalt
{t('expense_mix_total')}
</span>
<span
className="font-display text-lg font-medium tabular-nums"
+47 -47
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
@@ -11,75 +12,73 @@ interface KPIHeroCardsProps {
preferences?: KPIPreferences
}
function getKPIValue(
function getKPISubtitleKey(
report: KPIReport,
id: string
): { value: number | null; subtitle: string } {
id: string,
): { key: string; args?: Record<string, string | number> } {
switch (id) {
case 'netResult':
return { value: report.netResult, subtitle: 'netto' }
return { key: 'sub_netto' }
case 'cashPosition':
return { value: report.cashPosition, subtitle: 'likvida medel' }
return { key: 'sub_likvida_medel' }
case 'outstandingReceivables':
return {
value: report.outstandingReceivables,
subtitle:
report.overdueReceivables > 0
? `varav förfallet: ${formatCurrency(report.overdueReceivables)}`
: 'utestående',
if (report.overdueReceivables > 0) {
return { key: 'sub_overdue', args: { amount: formatCurrency(report.overdueReceivables) } }
}
return { key: 'sub_utestaende' }
case 'vatLiability':
return {
value: report.vatLiability,
subtitle:
report.vatLiability > 0
? 'att betala'
: report.vatLiability < 0
? 'att återfå'
: 'jämnt',
}
if (report.vatLiability > 0) return { key: 'sub_att_betala' }
if (report.vatLiability < 0) return { key: 'sub_att_aterfa' }
return { key: 'sub_jamnt' }
case 'grossMargin':
return { value: report.grossMargin, subtitle: 'av intäkter' }
return { key: 'sub_av_intakter' }
case 'expenseRatio':
return { value: report.expenseRatio, subtitle: 'av intäkter' }
return { key: 'sub_av_intakter' }
case 'avgPaymentDays':
return { value: report.avgPaymentDays, subtitle: 'snitt' }
return { key: 'sub_snitt' }
default:
return { value: null, subtitle: '' }
return { key: '' }
}
}
function formatKPIValue(value: number | null, format: string, id: string): string {
function getKPIValue(report: KPIReport, id: string): number | null {
switch (id) {
case 'netResult': return report.netResult
case 'cashPosition': return report.cashPosition
case 'outstandingReceivables': return report.outstandingReceivables
case 'vatLiability': return report.vatLiability
case 'grossMargin': return report.grossMargin
case 'expenseRatio': return report.expenseRatio
case 'avgPaymentDays': return report.avgPaymentDays
default: return null
}
}
function formatKPIValue(value: number | null, format: string, id: string, daysSuffix: string): string {
if (value === null) return '—'
if (format === 'currency') {
if (id === 'vatLiability') return formatCurrency(Math.abs(value))
return formatCurrency(value)
}
if (format === 'percentage') return `${value}%`
if (format === 'days') return `${value} dagar`
if (format === 'days') return `${value} ${daysSuffix}`
return String(value)
}
function getValueColor(
value: number | null,
colorLogic: string
): string {
function getValueColor(value: number | null, colorLogic: string): string {
if (value === null) return 'text-muted-foreground'
if (colorLogic === 'neutral') return ''
if (colorLogic === 'positive-good') {
return value >= 0
? 'text-[hsl(var(--chart-1))]'
: 'text-[hsl(var(--chart-2))]'
return value >= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
}
if (colorLogic === 'negative-good') {
return value <= 0
? 'text-[hsl(var(--chart-1))]'
: 'text-[hsl(var(--chart-2))]'
return value <= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
}
return ''
}
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const t = useTranslations('kpi')
const prefs = preferences ?? getDefaultPreferences()
const visibleDefs = prefs.kpiOrder
@@ -90,7 +89,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Inga nyckeltal valda. Klicka på &quot;Anpassa&quot; för att välja vilka som ska visas.
{t('empty_no_kpis_chosen')}
</CardContent>
</Card>
)
@@ -106,8 +105,9 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
return (
<div className={`grid ${gridCols} gap-4`}>
{visibleDefs.map((def) => {
const { value, subtitle } = getKPIValue(report, def.id)
const formatted = formatKPIValue(value, def.format, def.id)
const value = getKPIValue(report, def.id)
const sub = getKPISubtitleKey(report, def.id)
const formatted = formatKPIValue(value, def.format, def.id, t('value_days_suffix'))
const color = getValueColor(value, def.colorLogic)
const hasOverride =
prefs.accountOverrides[def.id] &&
@@ -115,18 +115,18 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const tooltipContent = (
<div className="space-y-1.5 text-xs">
<p className="text-foreground/90">{def.description}</p>
<p className="text-foreground/90">{t(`def_${def.id}_description`)}</p>
<div>
<span className="font-medium">Formel: </span>
<span className="font-mono">{def.formula}</span>
<span className="font-medium">{t('tooltip_formula')} </span>
<span className="font-mono">{t(`def_${def.id}_formula`)}</span>
</div>
<div>
<span className="font-medium">Konton: </span>
{def.accountDescription}
<span className="font-medium">{t('tooltip_accounts')} </span>
{t(`def_${def.id}_accounts`)}
</div>
{hasOverride && (
<div className="text-primary">
<span className="font-medium">Anpassade: </span>
<span className="font-medium">{t('tooltip_overrides')} </span>
<span className="font-mono">
{prefs.accountOverrides[def.id].join(', ')}
</span>
@@ -144,7 +144,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
maxWidth="320px"
iconClassName="h-3 w-3"
>
<p className="text-xs text-muted-foreground">{def.label}</p>
<p className="text-xs text-muted-foreground">{t(`def_${def.id}_label`)}</p>
</InfoTooltip>
<p
className={`font-display text-2xl font-medium tabular-nums tracking-tight mt-2 ${color}`}
@@ -152,7 +152,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
{formatted}
</p>
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
{sub.key ? t(sub.key, sub.args) : ''}
</p>
</CardContent>
</Card>
+19 -17
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Settings2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -23,6 +24,8 @@ interface KPISettingsDialogProps {
}
export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDialogProps) {
const t = useTranslations('kpi')
const tCommon = useTranslations('common')
const [draft, setDraft] = useState<KPIPreferences>(preferences)
const [expandedKpi, setExpandedKpi] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -78,14 +81,14 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
Anpassa
{t('customize')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Anpassa nyckeltal</DialogTitle>
<DialogTitle>{t('settings_title')}</DialogTitle>
<DialogDescription>
Välj vilka nyckeltal som visas och justera beräkningarna.
{t('settings_subtitle')}
</DialogDescription>
</DialogHeader>
@@ -120,10 +123,10 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{def.label}
{t(`def_${def.id}_label`)}
{hasOverride && (
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(anpassad)
{t('settings_custom_suffix')}
</span>
)}
</p>
@@ -137,26 +140,26 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
{isExpanded && (
<div className="mt-3 ml-5.5 space-y-2.5 text-xs text-muted-foreground">
<p>{def.description}</p>
<p>{t(`def_${def.id}_description`)}</p>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
Formel
{t('settings_formula_label')}
</p>
<p className="font-mono text-[11px] bg-muted/50 rounded px-2 py-1">
{def.formula}
{t(`def_${def.id}_formula`)}
</p>
</div>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
Konton
{t('settings_accounts_label')}
</p>
<p>{def.accountDescription}</p>
<p>{t(`def_${def.id}_accounts`)}</p>
</div>
{def.customizableAccounts && (
<div className="pt-1">
<label className="font-medium text-foreground/80 block mb-1">
Anpassa konton
{t('settings_customize_accounts')}
</label>
<input
type="text"
@@ -168,8 +171,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-mono tabular-nums placeholder:text-muted-foreground/50"
/>
<p className="mt-1 text-[10px] text-muted-foreground/70">
Ange kontonummer separerade med komma (t.ex.{' '}
{def.defaultAccounts.slice(0, 3).join(', ')})
{t('settings_account_hint', { example: def.defaultAccounts.slice(0, 3).join(', ') })}
</p>
{hasOverride && (
<button
@@ -177,7 +179,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
onClick={() => clearAccountOverride(def.id)}
className="mt-1 text-[10px] text-primary hover:underline"
>
Återställ till standard
{t('settings_reset_field')}
</button>
)}
</div>
@@ -196,16 +198,16 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="h-3 w-3" />
Återställ allt
{t('settings_reset_all')}
</button>
<div className="flex gap-2">
<DialogClose asChild>
<Button variant="outline" size="sm">
Avbryt
{tCommon('cancel')}
</Button>
</DialogClose>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Sparar...' : 'Spara'}
{saving ? tCommon('saving') : tCommon('save')}
</Button>
</div>
</div>
+5 -3
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import {
BarChart,
Bar,
@@ -19,15 +20,16 @@ interface KPITopSuppliersChartProps {
const BAR_COLOR = 'hsl(var(--chart-1))'
export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
const t = useTranslations('kpi')
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Största leverantörer</CardTitle>
<CardTitle className="text-base">{t('top_suppliers_title')}</CardTitle>
</CardHeader>
<CardContent>
{suppliers.length === 0 ? (
<div className="flex h-[200px] items-center justify-center text-center text-sm text-muted-foreground px-4">
Inga registrerade leverantörsfakturor under perioden
{t('top_suppliers_empty')}
</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(160, suppliers.length * 32)}>
@@ -55,7 +57,7 @@ export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
interval={0}
/>
<Tooltip
formatter={(value) => [formatCurrency(Number(value)), 'Spend']}
formatter={(value) => [formatCurrency(Number(value)), t('top_suppliers_spend')]}
contentStyle={{
fontSize: '12px',
borderRadius: '8px',
+10 -15
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import {
Area,
XAxis,
@@ -19,12 +20,18 @@ interface KPITrendChartProps {
}
export function KPITrendChart({ months }: KPITrendChartProps) {
const t = useTranslations('kpi')
if (months.length === 0) return null
const seriesLabel = (key: string) =>
key === 'income' ? t('trend_legend_income')
: key === 'expenses' ? t('trend_legend_expenses')
: t('trend_legend_net')
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Intäkter, kostnader & resultat per månad</CardTitle>
<CardTitle className="text-base">{t('trend_title')}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
@@ -43,11 +50,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
<Tooltip
formatter={(value, name) => [
formatCurrency(Number(value)),
name === 'income'
? 'Intäkter'
: name === 'expenses'
? 'Kostnader'
: 'Resultat',
seriesLabel(String(name)),
]}
contentStyle={{
fontSize: '12px',
@@ -56,15 +59,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
backgroundColor: 'hsl(var(--card))',
}}
/>
<Legend
formatter={(value: string) =>
value === 'income'
? 'Intäkter'
: value === 'expenses'
? 'Kostnader'
: 'Resultat'
}
/>
<Legend formatter={(value: string) => seriesLabel(value)} />
<Area
type="monotone"
dataKey="income"
+32 -28
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -40,6 +41,8 @@ type SetupState =
| { kind: 'opening'; companyId: string }
| { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' }
// Swedish legal entity names (Aktiebolag, Enskild firma, etc.) are statutory
// terms — kept in Swedish in both locales.
function humanEntityType(t: string | null | undefined): string {
if (!t) return ''
if (t === 'aktiebolag') return 'Aktiebolag'
@@ -74,11 +77,12 @@ export default function BankIdCompanyPicker({
}: BankIdCompanyPickerProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('select_company')
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
const [isPending, startTransition] = useTransition()
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const busy = setup.kind !== 'idle' || isPending
@@ -127,8 +131,8 @@ export default function BankIdCompanyPicker({
// Route to the manual wizard with the known fields pre-filled instead.
if (!lookup) {
toast({
title: 'Kunde inte hämta företagsuppgifter',
description: 'Fyll i resterande uppgifter manuellt.',
title: t('toast_lookup_failed_title'),
description: t('toast_lookup_failed_description'),
})
setSetup({ kind: 'idle' })
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
@@ -139,8 +143,8 @@ export default function BankIdCompanyPicker({
// Under BFL 2 kap, bokföringsskyldighet ends when a company is struck off.
if (lookup.isCeased) {
toast({
title: 'Företaget är avregistrerat',
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -168,8 +172,8 @@ export default function BankIdCompanyPicker({
if (result.error === 'org_number_exists') {
toast({
title: 'Företaget finns redan',
description: 'Be en befintlig administratör att bjuda in dig.',
title: t('toast_company_exists_title'),
description: t('toast_company_exists_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -181,8 +185,8 @@ export default function BankIdCompanyPicker({
// above, but the server-side guard catches any race where TIC's
// cached result differs between the two calls.
toast({
title: 'Företaget är avregistrerat',
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -191,8 +195,8 @@ export default function BankIdCompanyPicker({
if (result.error === 'org_number_invalid') {
toast({
title: 'Ogiltigt organisationsnummer',
description: 'Fortsätt med manuell uppsättning.',
title: t('toast_org_invalid_title'),
description: t('toast_org_invalid_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -202,15 +206,15 @@ export default function BankIdCompanyPicker({
if (result.error || !result.companyId) {
toast({
title: 'Kunde inte skapa företag',
description: result.error ?? 'Försök igen eller lägg till manuellt.',
title: t('toast_create_failed_title'),
description: result.error ?? t('toast_create_failed_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.' })
toast({ title: t('toast_welcome_title'), description: t('toast_company_ready') })
window.location.assign('/')
})
}
@@ -224,21 +228,21 @@ export default function BankIdCompanyPicker({
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">Sätter upp ditt företag…</p>
<p className="text-muted-foreground text-sm mt-1.5">{t('setting_up')}</p>
</header>
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<div className="flex items-start gap-3">
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Org.nr {setup.orgNumber}</p>
<p className="font-medium text-sm">{t('org_nr_prefix', { orgNumber: setup.orgNumber })}</p>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-center gap-2">
{lookupDone
? <Check className="h-4 w-4 text-sage" />
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<span className={cn(!lookupDone && 'text-muted-foreground')}>
Hämtar uppgifter från Bolagsverket
{t('progress_lookup')}
</span>
</li>
<li className="flex items-center gap-2">
@@ -246,7 +250,7 @@ export default function BankIdCompanyPicker({
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
Skapar företag och kontoplan
{t('progress_provision')}
</span>
</li>
</ul>
@@ -264,7 +268,7 @@ export default function BankIdCompanyPicker({
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
Välj ett företag att öppna eller lägg till ett nytt.
{t('subtitle')}
</p>
</header>
@@ -274,7 +278,7 @@ export default function BankIdCompanyPicker({
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.
{t('enrichment_stale')}
</p>
</div>
</div>
@@ -283,7 +287,7 @@ export default function BankIdCompanyPicker({
{memberCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Dina företag i {branding.appName.toLowerCase()}
{t('section_your_companies', { appName: branding.appName.toLowerCase() })}
</h2>
<ul className="space-y-2">
{memberCompanies.map((c) => {
@@ -330,7 +334,7 @@ export default function BankIdCompanyPicker({
{ticCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Företag kopplade till ditt BankID
{t('section_bankid_companies')}
</h2>
<ul className="space-y-2">
{ticCompanies.map(({ role, status }) => {
@@ -351,11 +355,11 @@ export default function BankIdCompanyPicker({
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Finns redan i {branding.appName.toLowerCase()}
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-2">
Be en befintlig administratör att bjuda in dig.
{t('ask_admin_invite')}
</p>
</div>
</li>
@@ -382,7 +386,7 @@ export default function BankIdCompanyPicker({
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Sätts upp manuellt
{t('setup_manually')}
</span>
</div>
</Link>
@@ -422,7 +426,7 @@ export default function BankIdCompanyPicker({
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
<p className="text-sm text-muted-foreground">
Inga företag hittades. Lägg till ditt första företag nedan.
{t('no_companies_found')}
</p>
)}
@@ -432,7 +436,7 @@ export default function BankIdCompanyPicker({
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs uppercase tracking-[0.08em] text-muted-foreground">
eller
{t('or_separator')}
</span>
</div>
</div>
@@ -446,7 +450,7 @@ export default function BankIdCompanyPicker({
)}
>
<Plus className="h-4 w-4" />
Lägg till företag manuellt
{t('add_company_manually')}
</Link>
</div>
</div>
+22 -21
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
ArrowRight,
CheckCircle2,
@@ -31,6 +32,7 @@ export default function NewUserChecklist({
className,
hasSkatteverketConnected,
}: NewUserChecklistProps) {
const t = useTranslations('new_user_checklist')
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
@@ -41,11 +43,10 @@ export default function NewUserChecklist({
{/* Header */}
<div className="text-center mb-8 md:mb-12">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
Välkommen till {branding.appName.toLowerCase()}
{t('welcome', { appName: branding.appName.toLowerCase() })}
</h1>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto mt-3">
Börja med att hämta din bokföring, sedan kopplar du banken.
Ingenting ändras i ditt nuvarande system.
{t('intro')}
</p>
</div>
@@ -56,7 +57,7 @@ export default function NewUserChecklist({
1
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Hämta din bokföring
{t('step1_title')}
</h2>
</div>
@@ -72,10 +73,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Hämta från annat system
{t('migrate_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed underline decoration-foreground/20 underline-offset-2">
Inget ändras i ditt befintliga system.
{t('migrate_description')}
</p>
<div className="flex flex-wrap gap-1.5 sm:gap-2 mt-2.5 sm:mt-3">
{([
@@ -107,10 +108,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Importera SIE-fil
{t('sie_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Exportera en SIE4-fil från ditt nuvarande bokföringsprogram och ladda upp den här.
{t('sie_description')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -126,7 +127,7 @@ export default function NewUserChecklist({
2
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Koppla din bank
{t('step2_title')}
</h2>
</div>
@@ -141,12 +142,12 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Anslut ditt bankkonto
{t('bank_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
{hasBanking
? 'Koppla via PSD2 — transaktioner synkas automatiskt varje dag.'
: 'Importera kontoutdrag från din bank — CSV, OFX och de flesta svenska banker.'}
? t('bank_description_psd2')
: t('bank_description_file')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -175,9 +176,9 @@ export default function NewUserChecklist({
: '3'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Anslut Skatteverket
{t('step3_title')}
</h2>
<span className="text-xs text-muted-foreground">— valfritt</span>
<span className="text-xs text-muted-foreground">{t('optional_suffix')}</span>
</div>
<div className="ml-0 sm:ml-10">
@@ -189,10 +190,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-emerald-900 dark:text-emerald-200">
Skatteverket anslutet
{t('skatteverket_connected_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Du kan nu skicka momsdeklaration och AGI direkt, samt se saldot på skattekontot.
{t('skatteverket_connected_description')}
</p>
</div>
</div>
@@ -212,10 +213,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Anslut till Skatteverket med BankID
{t('skatteverket_connect_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Skicka momsdeklaration och arbetsgivardeklaration direkt, och hämta saldot på skattekontot — utan att lämna {branding.appName.toLowerCase()}.
{t('skatteverket_connect_description', { appName: branding.appName.toLowerCase() })}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -230,7 +231,7 @@ export default function NewUserChecklist({
<div className="space-y-5">
<div className="flex items-center gap-4">
<div className="flex-1 h-px bg-border/60" />
<span className="text-xs text-muted-foreground">eller</span>
<span className="text-xs text-muted-foreground">{t('or_separator')}</span>
<div className="flex-1 h-px bg-border/60" />
</div>
@@ -239,7 +240,7 @@ export default function NewUserChecklist({
onClick={onFreshStart}
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5 group"
>
Jag startar en ny verksamhet utan tidigare bokföring
{t('fresh_start')}
<ArrowRight className="h-3.5 w-3.5 group-hover:translate-x-0.5 transition-transform" />
</button>
</div>
@@ -247,7 +248,7 @@ export default function NewUserChecklist({
<div className="flex items-center justify-center gap-2 pt-2">
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground/50">
Din data är krypterad och lagras säkert i Sverige
{t('security_note')}
</p>
</div>
</div>
+28 -24
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -14,30 +15,33 @@ interface Step1Props {
isSaving: boolean
}
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: 'Du driver verksamhet i eget namn med F-skattsedel',
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: 'Du har ett registrerat AB med organisationsnummer',
icon: Building2,
},
]
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
const t = useTranslations('onboarding')
const [selected, setSelected] = useState<EntityType | undefined>(initialData.entity_type)
// "Enskild firma" and "Aktiebolag" are statutory legal entity types — kept
// in Swedish in both locales.
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: t('step1_ef_description'),
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: t('step1_ab_description'),
icon: Building2,
},
]
const handleNext = () => {
if (!selected) {
const msg = 'step 1: fortsätt clicked without entity type selected'
@@ -85,7 +89,7 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
<div className="flex items-center gap-2">
<span className="font-medium">{option.label}</span>
{option.disabled && (
<Badge variant="secondary" className="text-xs">Kommer snart</Badge>
<Badge variant="secondary" className="text-xs">{t('coming_soon')}</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
@@ -114,11 +118,11 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+26 -24
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -52,6 +53,7 @@ export default function Step2CompanyDetails({
isSaving,
orgNumberLocked,
}: Step2Props) {
const t = useTranslations('onboarding')
const {
register,
handleSubmit,
@@ -137,12 +139,12 @@ export default function Step2CompanyDetails({
return
}
if (res.status === 404) {
setLookupError('Inget företag hittades med det organisationsnumret.')
setLookupError(t('step2_lookup_not_found'))
onTicLookup?.(null)
return
}
if (!res.ok) {
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
return
}
@@ -163,7 +165,7 @@ export default function Step2CompanyDetails({
})
.catch((err) => {
if ((err as Error).name === 'AbortError') return
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
})
.finally(() => {
@@ -184,13 +186,13 @@ export default function Step2CompanyDetails({
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Grunduppgifter</CardTitle>
<CardTitle>{t('step2_card_title')}</CardTitle>
<CardDescription>
{ticEnabled
? 'Ange organisationsnummer så hämtas övriga uppgifter automatiskt.'
? t('step2_card_desc_tic')
: isAB
? 'Ange bolagets registrerade namn och organisationsnummer.'
: 'Ange namn på din verksamhet.'}
? t('step2_card_desc_ab')
: t('step2_card_desc_ef')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -201,11 +203,11 @@ export default function Step2CompanyDetails({
})} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="org_number">
Organisationsnummer *
{t('step2_org_number_label')}
</Label>
<Input
id="org_number"
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
placeholder={isAB ? 'XXXXXX-XXXX' : t('step2_org_number_placeholder_ef')}
{...register('org_number')}
readOnly={orgNumberLocked}
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
@@ -215,13 +217,13 @@ export default function Step2CompanyDetails({
)}
<p className="text-xs text-muted-foreground">
{isAB
? 'Obligatoriskt för aktiebolag'
: 'Vid enskild firma är orgnummer samma som ditt personnummer'}
? t('step2_org_help_ab')
: t('step2_org_help_ef')}
</p>
{ticEnabled && isLooking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Hämtar företagsuppgifter...
{t('step2_fetching_details')}
</div>
)}
{ticEnabled && lookupDone && !lookupDone.isCeased && (
@@ -233,7 +235,7 @@ export default function Step2CompanyDetails({
{ticEnabled && lookupDone?.isCeased && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
{lookupDone.companyName} — företaget är avregistrerat
{t('step2_ceased_inline', { companyName: lookupDone.companyName })}
</div>
)}
{ticEnabled && lookupError && (
@@ -243,7 +245,7 @@ export default function Step2CompanyDetails({
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
Det här företaget finns redan i {branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
</span>
</div>
)}
@@ -251,11 +253,11 @@ export default function Step2CompanyDetails({
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
{isAB ? t('step2_company_name_ab') : t('step2_company_name_ef')}
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
placeholder={isAB ? t('step2_company_name_placeholder_ab') : t('step2_company_name_placeholder_ef')}
{...register('company_name')}
/>
{errors.company_name && (
@@ -264,21 +266,21 @@ export default function Step2CompanyDetails({
</div>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Adress (för fakturor)</h3>
<h3 className="font-medium mb-4">{t('step2_address_heading')}</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Label htmlFor="address_line1">{t('step2_street_address')}</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
placeholder={t('step2_street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('step2_postal_code')}</Label>
<Input
id="postal_code"
placeholder="123 45"
@@ -286,7 +288,7 @@ export default function Step2CompanyDetails({
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('step2_city')}</Label>
<Input
id="city"
placeholder="Stockholm"
@@ -306,7 +308,7 @@ export default function Step2CompanyDetails({
className="w-full sm:w-auto"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button
type="submit"
@@ -316,11 +318,11 @@ export default function Step2CompanyDetails({
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+40 -38
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -150,6 +151,7 @@ export default function Step3TaxRegistration({
onBack,
isSaving,
}: Step3Props) {
const t = useTranslations('onboarding')
const isEF = entityType === 'enskild_firma'
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -215,7 +217,7 @@ export default function Step3TaxRegistration({
)
if (validation.error) {
toast({
title: 'Räkenskapsåret är inte giltigt',
title: t('step3_invalid_period'),
description: validation.error,
variant: 'destructive',
})
@@ -242,10 +244,10 @@ export default function Step3TaxRegistration({
? `${parseDateParts(firstEnd).day} ${monthNames[parseDateParts(firstEnd).month - 1].toLowerCase()} ${parseDateParts(firstEnd).year}`
: monthNames[endMonth - 1].toLowerCase()
const ok = await confirm({
title: 'Är du säker på brutet räkenskapsår?',
description: `Du har valt ett räkenskapsår som inte följer kalenderåret (slutar ${endLabel}). De flesta svenska företag använder kalenderår (1 januari – 31 december). Du kan ändra detta senare i inställningarna, men endast innan du har bokfört något.`,
confirmLabel: 'Ja, fortsätt',
cancelLabel: 'Ändra val',
title: t('step3_broken_year_title'),
description: t('step3_broken_year_description', { endLabel }),
confirmLabel: t('step3_broken_year_confirm'),
cancelLabel: t('step3_broken_year_cancel'),
variant: 'warning',
})
if (!ok) return
@@ -267,9 +269,9 @@ export default function Step3TaxRegistration({
<DestructiveConfirmDialog {...dialogProps} />
<Card>
<CardHeader>
<CardTitle>F-skatt och räkenskapsår</CardTitle>
<CardTitle>{t('step3_card_title')}</CardTitle>
<CardDescription>
Dessa uppgifter används för att beräkna din skattesituation.
{t('step3_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -279,8 +281,8 @@ export default function Step3TaxRegistration({
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 3 validation failed', extra: { fields } }) }).catch(() => {})
// Show first validation error to user
const firstError = Object.values(errs)[0]
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* F-skatt */}
<div className="flex items-start space-x-3">
@@ -299,19 +301,19 @@ export default function Step3TaxRegistration({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är F-skatt?</p>
<p>F-skatt betyder att du själv ansvarar för att betala skatt och avgifter till Skatteverket varje månad.</p>
<p className="text-xs text-muted-foreground">De flesta som driver företag har F-skatt. Utan F-skatt måste dina kunder göra skatteavdrag på dina fakturor.</p>
<p className="font-medium">{t('step3_fskatt_tip_title')}</p>
<p>{t('step3_fskatt_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step3_fskatt_tip_note')}</p>
</div>
}
side="right"
>
<Label htmlFor="f_skatt" className="cursor-pointer">
Jag har F-skattsedel
{t('step3_fskatt_label')}
</Label>
</InfoTooltip>
<p className="text-sm text-muted-foreground">
F-skatt innebär att du själv ansvarar för att betala in skatt och avgifter.
{t('step3_fskatt_help')}
</p>
</div>
</div>
@@ -321,16 +323,16 @@ export default function Step3TaxRegistration({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Räkenskapsår</p>
<p>Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).</p>
<p className="font-medium">{t('step3_fy_tip_title')}</p>
<p>{t('step3_fy_tip_body')}</p>
{isEF && (
<p className="text-xs text-muted-foreground">Enskild firma måste använda kalenderår enligt BFL 3 kap.</p>
<p className="text-xs text-muted-foreground">{t('step3_fy_tip_ef_note')}</p>
)}
</div>
}
side="right"
>
<Label className="text-base font-medium">Vilket räkenskapsår bokför du för?</Label>
<Label className="text-base font-medium">{t('step3_fy_question')}</Label>
</InfoTooltip>
{/* Toggle: First year vs Ongoing */}
@@ -350,8 +352,8 @@ export default function Step3TaxRegistration({
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Första räkenskapsåret</p>
<p className="text-xs text-muted-foreground mt-0.5">Nystartat företag</p>
<p className="font-medium text-sm">{t('step3_first_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_first_fy_subtitle')}</p>
</div>
{field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
@@ -372,8 +374,8 @@ export default function Step3TaxRegistration({
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Annat räkenskapsår</p>
<p className="text-xs text-muted-foreground mt-0.5">Pågående verksamhet</p>
<p className="font-medium text-sm">{t('step3_other_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_other_fy_subtitle')}</p>
</div>
{!field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
@@ -405,7 +407,7 @@ export default function Step3TaxRegistration({
// Reset end when start changes — its valid options depend on start
if (endField.value) endField.onChange('')
}}
startHelpText="Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag."
startHelpText={t('step3_start_help')}
endDate={endField.value || ''}
entityType={entityType}
endDateSlot={
@@ -413,7 +415,7 @@ export default function Step3TaxRegistration({
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>Räkenskapsåret slutar (månad)</Label>
<Label>{t('step3_fy_end_month_label')}</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => {
@@ -424,7 +426,7 @@ export default function Step3TaxRegistration({
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
@@ -440,13 +442,13 @@ export default function Step3TaxRegistration({
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>Slutdatum</Label>
<Label>{t('step3_end_date_label')}</Label>
<Select
value={endField.value || ''}
onValueChange={(v) => { if (v) endField.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj slutdatum" />
<SelectValue placeholder={t('step3_select_end_date')} />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
@@ -464,7 +466,7 @@ export default function Step3TaxRegistration({
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
{t('step3_no_valid_end')}
</p>
)}
@@ -486,14 +488,14 @@ export default function Step3TaxRegistration({
<div className="space-y-2">
{isEF ? (
<div className="rounded-lg bg-muted/50 p-4">
<p className="text-sm font-medium">Kalenderår (januari-december)</p>
<p className="text-sm font-medium">{t('step3_calendar_year')}</p>
<p className="text-xs text-muted-foreground mt-1">
Enskild firma måste använda kalenderår enligt BFL 3 kap.
{t('step3_ef_calendar_required')}
</p>
</div>
) : (
<div className="space-y-2">
<Label>När slutar företagets räkenskapsår?</Label>
<Label>{t('step3_when_fy_ends')}</Label>
<Controller
name="fiscal_year_end_month"
control={control}
@@ -503,7 +505,7 @@ export default function Step3TaxRegistration({
onValueChange={(v) => { if (v) field.onChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
@@ -516,21 +518,21 @@ export default function Step3TaxRegistration({
)}
/>
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
{t('step3_calendar_or_broken')}
</p>
{fiscalYearEndMonth && (
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt räkenskapsår
{t('step3_your_fy')}
</div>
<p className="text-sm text-muted-foreground">
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
</p>
<p className="text-xs text-muted-foreground">12 månader</p>
<p className="text-xs text-muted-foreground">{t('step3_twelve_months')}</p>
</div>
)}
</div>
@@ -547,17 +549,17 @@ export default function Step3TaxRegistration({
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+36 -35
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -56,6 +57,7 @@ export default function Step4VatAccounting({
onBack,
isSaving,
}: Step4Props) {
const t = useTranslations('onboarding')
const { toast } = useToast()
const {
@@ -105,9 +107,9 @@ export default function Step4VatAccounting({
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Moms och bokföringsmetod</CardTitle>
<CardTitle>{t('step4_card_title')}</CardTitle>
<CardDescription>
Ange din momsregistrering och välj bokföringsmetod.
{t('step4_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -116,8 +118,8 @@ export default function Step4VatAccounting({
console.error('[onboarding] step 4 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
const firstError = Object.values(errs)[0]
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* VAT section */}
<div className="space-y-4">
@@ -125,14 +127,14 @@ export default function Step4VatAccounting({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
<p className="font-medium">{t('step4_vat_tip_title')}</p>
<p>{t('step4_vat_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step4_vat_tip_note')}</p>
</div>
}
side="right"
>
<span>Momsregistrering</span>
<span>{t('step4_vat_heading')}</span>
</InfoTooltip>
</h3>
@@ -150,10 +152,10 @@ export default function Step4VatAccounting({
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
Jag är momsregistrerad
{t('step4_vat_registered_label')}
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
{t('step4_vat_registered_help')}
</p>
</div>
</div>
@@ -161,14 +163,14 @@ export default function Step4VatAccounting({
{vatRegistered && (
<div className="space-y-4 pl-0 sm:pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Label htmlFor="vat_number">{t('step4_vat_number_label')}</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
{t('step4_vat_number_format')}
</p>
</div>
@@ -176,18 +178,18 @@ export default function Step4VatAccounting({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
<p className="font-medium">{t('step4_vat_period_tip_title')}</p>
<p>{t('step4_vat_period_tip_body')}</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
<li>Över 40 miljoner = Månad</li>
<li>{t('step4_vat_period_bracket_low')}</li>
<li>{t('step4_vat_period_bracket_mid')}</li>
<li>{t('step4_vat_period_bracket_high')}</li>
</ul>
</div>
}
side="right"
>
<Label>Momsredovisningsperiod</Label>
<Label>{t('step4_vat_period_label')}</Label>
</InfoTooltip>
<Controller
name="moms_period"
@@ -198,18 +200,18 @@ export default function Step4VatAccounting({
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('step4_select_period')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
<SelectItem value="monthly">{t('step4_period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('step4_period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('step4_period_yearly')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
{t('step4_period_help')}
</p>
</div>
</div>
@@ -220,10 +222,10 @@ export default function Step4VatAccounting({
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
content={t('step4_method_tip')}
side="right"
>
<Label>Bokföringsmetod</Label>
<Label>{t('step4_method_label')}</Label>
</InfoTooltip>
<Controller
name="accounting_method"
@@ -237,7 +239,7 @@ export default function Step4VatAccounting({
onCheckedChange={(checked) => { if (checked) field.onChange('accrual') }}
/>
<Label htmlFor="method_accrual" className="cursor-pointer">
Faktureringsmetoden
{t('step4_method_accrual')}
</Label>
</div>
<div className="flex items-start space-x-3">
@@ -247,7 +249,7 @@ export default function Step4VatAccounting({
onCheckedChange={(checked) => { if (checked) field.onChange('cash') }}
/>
<Label htmlFor="method_cash" className="cursor-pointer">
Kontantmetoden
{t('step4_method_cash')}
</Label>
</div>
</div>
@@ -256,16 +258,15 @@ export default function Step4VatAccounting({
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
{accountingMethod === 'accrual' ? t('step4_method_accrual') : t('step4_method_cash')}
</div>
<p className="text-sm text-muted-foreground">
{accountingMethod === 'accrual'
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
? t('step4_method_accrual_desc')
: t('step4_method_cash_desc')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
3 MSEK (BFL 5 kap. 2 §).
{t('step4_cash_limit_note')}
</p>
</div>
</div>
@@ -279,17 +280,17 @@ export default function Step4VatAccounting({
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+30 -28
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Select,
@@ -14,53 +15,53 @@ import type { EntityType } from '@/types'
interface ReportItem {
value: string
label: string
labelKey: string
entityType?: EntityType
}
interface ReportCategory {
label: string
labelKey: string
items: ReportItem[]
}
const CATEGORIES: ReportCategory[] = [
{
label: 'Löpande',
labelKey: 'group_interim',
items: [
{ value: 'resultatrapport', label: 'Resultatrapport' },
{ value: 'balansrapport', label: 'Balansrapport' },
{ value: 'trial-balance', label: 'Saldobalans' },
{ value: 'resultatrapport', labelKey: 'name_resultatrapport' },
{ value: 'balansrapport', labelKey: 'name_balansrapport' },
{ value: 'trial-balance', labelKey: 'name_trial_balance' },
],
},
{
label: 'Bokslut',
labelKey: 'group_year_end',
items: [
{ value: 'income-statement', label: 'Resultaträkning' },
{ value: 'balance-sheet', label: 'Balansräkning' },
{ value: 'income-statement', labelKey: 'name_income_statement' },
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
],
},
{
label: 'Skatt & moms',
labelKey: 'group_tax_vat',
items: [
{ value: 'vat-declaration', label: 'Momsdeklaration' },
{ value: 'periodisk-sammanstallning', label: 'Periodisk sammanställning' },
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
{ value: 'vat-declaration', labelKey: 'name_vat_declaration' },
{ value: 'periodisk-sammanstallning', labelKey: 'name_periodisk_sammanstallning' },
{ value: 'ne-declaration', labelKey: 'name_ne_declaration', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', labelKey: 'name_ink2_declaration', entityType: 'aktiebolag' },
],
},
{
label: 'Huvudböcker',
labelKey: 'group_ledgers',
items: [
{ value: 'huvudbok', label: 'Huvudbok' },
{ value: 'grundbok', label: 'Grundbok' },
{ value: 'kundreskontra', label: 'Kundreskontra' },
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
{ value: 'huvudbok', labelKey: 'name_huvudbok' },
{ value: 'grundbok', labelKey: 'name_grundbok' },
{ value: 'kundreskontra', labelKey: 'name_kundreskontra' },
{ value: 'supplier-ledger', labelKey: 'name_supplier_ledger' },
],
},
{
label: 'Avstämning',
labelKey: 'group_reconciliation',
items: [
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
{ value: 'bank-reconciliation', labelKey: 'name_bank_reconciliation' },
],
},
]
@@ -72,6 +73,7 @@ interface ReportsNavProps {
}
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
const t = useTranslations('reports')
const filtered = CATEGORIES
.map(cat => ({
...cat,
@@ -89,11 +91,11 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
</SelectTrigger>
<SelectContent>
{filtered.map(cat => (
<SelectGroup key={cat.label}>
<SelectLabel>{cat.label}</SelectLabel>
<SelectGroup key={cat.labelKey}>
<SelectLabel>{t(cat.labelKey)}</SelectLabel>
{cat.items.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectGroup>
@@ -105,13 +107,13 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
{/* Desktop: vertical left rail */}
<nav
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
aria-label="Rapportkategorier"
aria-label={t('categories_aria')}
>
<ul className="space-y-6">
{filtered.map(cat => (
<li key={cat.label}>
<li key={cat.labelKey}>
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
{cat.label}
{t(cat.labelKey)}
</p>
<ul className="space-y-px">
{cat.items.map(item => {
@@ -129,7 +131,7 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{item.label}
{t(item.labelKey)}
</button>
</li>
)
+22 -19
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
@@ -25,6 +26,7 @@ interface Blocker {
}
export function AccountDangerZone() {
const t = useTranslations('settings_account_danger')
const router = useRouter()
const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([])
@@ -77,7 +79,7 @@ export function AccountDangerZone() {
const body = await response.json()
// Precondition tripped mid-flow — refresh the list and show inline.
setBlockers(body.blockers ?? [])
setError(body.error || 'Du måste radera eller överlåta dina företag först.')
setError(body.error || t('delete_failed_blockers'))
setIsDeleting(false)
setShowDialog(false)
return
@@ -85,12 +87,12 @@ export function AccountDangerZone() {
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera kontot')
throw new Error(body.error || t('delete_failed_default'))
}
router.push('/login')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte radera kontot')
setError(err instanceof Error ? err.message : t('delete_failed_default'))
setIsDeleting(false)
}
}
@@ -102,14 +104,14 @@ export function AccountDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera konto
{t('heading')}
</h2>
{hasBlockers && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<p className="text-sm font-medium">Företag du äger</p>
<p className="text-sm font-medium">{t('blockers_title')}</p>
<p className="text-sm text-muted-foreground">
Radera eller överlåt alla företag innan du raderar kontot.
{t('blockers_description')}
</p>
<ul className="space-y-2">
{blockers.map((b) => (
@@ -119,7 +121,7 @@ export function AccountDangerZone() {
>
<span className="text-sm font-medium">{b.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">Hantera</Link>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</li>
))}
@@ -130,8 +132,8 @@ export function AccountDangerZone() {
<RetentionNotice variant="account" />
<p className="text-sm text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</p>
{error && !showDialog && (
@@ -142,7 +144,7 @@ export function AccountDangerZone() {
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
{t('export_sie')}
</Link>
</Button>
<Button
@@ -151,7 +153,7 @@ export function AccountDangerZone() {
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
Radera mitt konto
{t('delete_button')}
</Button>
</div>
</section>
@@ -169,16 +171,17 @@ export function AccountDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogTitle>{t('dialog_title')}</DialogTitle>
<DialogDescription>
Ditt konto avidentifieras och du loggas ut från alla enheter.
Du kan inte skapa ett nytt konto med samma e-postadress — kontakta support
om du vill återaktivera kontot i framtiden.
{t('dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">
Skriv din e-postadress (<strong>{email}</strong>) för att bekräfta
{t.rich('confirm_label', {
email: email ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="delete-confirm"
@@ -200,7 +203,7 @@ export function AccountDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('cancel')}
</Button>
<Button
variant="destructive"
@@ -214,10 +217,10 @@ export function AccountDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('deleting')}
</>
) : (
'Radera konto'
t('delete_confirm_button')
)}
</Button>
</DialogFooter>
+99 -91
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -27,14 +28,14 @@ const connectorName = branding.appName.toLowerCase()
type ScopeEntry = {
scope: ApiKeyScope
label: string
labelKey: string
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
tools: number
}
type ScopeGroup = {
domain: string
label: string
labelKey: string
read: ScopeEntry | null
write: ScopeEntry | null
}
@@ -42,86 +43,86 @@ type ScopeGroup = {
const SCOPE_GROUPS: ScopeGroup[] = [
{
domain: 'transactions',
label: 'Transaktioner',
read: { scope: 'transactions:read', label: 'Läs — lista transaktioner, mallar, kategorier, inbox', tools: 8 },
write: { scope: 'transactions:write', label: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning', tools: 8 },
labelKey: 'group_transactions',
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
},
{
domain: 'customers',
label: 'Kunder',
read: { scope: 'customers:read', label: 'Läs — lista kunder', tools: 1 },
write: { scope: 'customers:write', label: 'Skriv — skapa kunder', tools: 1 },
labelKey: 'group_customers',
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
},
{
domain: 'invoices',
label: 'Fakturor',
read: { scope: 'invoices:read', label: 'Läs — lista fakturor', tools: 1 },
write: { scope: 'invoices:write', label: 'Skriv — skapa, skicka, markera betald/skickad, kreditera, konvertera', tools: 6 },
labelKey: 'group_invoices',
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
},
{
domain: 'suppliers',
label: 'Leverantörer',
read: { scope: 'suppliers:read', label: 'Läs — lista leverantörer och leverantörsfakturor', tools: 2 },
write: { scope: 'suppliers:write', label: 'Skriv — godkänn, kreditera, skapa leverantörsfaktura från inbox', tools: 3 },
labelKey: 'group_suppliers',
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
},
{
domain: 'reports',
label: 'Rapporter',
read: { scope: 'reports:read', label: 'Läs — kontoplan, huvudbok, BR, RR, moms, KPI, reskontra, perioder, bankavstämning, SIE-export', tools: 18 },
labelKey: 'group_reports',
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
write: null,
},
{
domain: 'bookkeeping',
label: 'Bokföring',
labelKey: 'group_bookkeeping',
read: null,
write: { scope: 'bookkeeping:write', label: 'Skriv — stänga/låsa perioder, IB, bokslut, SIE-import, verifikat, korrigeringar (alla stagas)', tools: 11 },
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
},
{
domain: 'payroll',
label: 'Löner',
read: { scope: 'payroll:read', label: 'Läs — lista anställda, lönekörningar, lönejournal', tools: 3 },
write: { scope: 'payroll:write', label: 'Skriv — skapa lönekörning, beräkna, generera AGI', tools: 3 },
labelKey: 'group_payroll',
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
},
{
domain: 'pending_operations',
label: 'Stagade operationer',
read: { scope: 'pending_operations:read', label: 'Läs — lista pending_operations som väntar på godkännande', tools: 1 },
write: { scope: 'pending_operations:approve', label: 'Godkänn — committa eller avvisa staged ops via API (ersätter web-UI:s granskning)', tools: 2 },
labelKey: 'group_pending_operations',
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
},
{
domain: 'documents',
label: 'Dokument (REST API)',
read: { scope: 'documents:read', label: 'Läs — lista och hämta dokumentbilagor', tools: 0 },
write: { scope: 'documents:write', label: 'Skriv — ladda upp och koppla dokument till verifikationer', tools: 0 },
labelKey: 'group_documents',
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
},
{
domain: 'companies',
label: 'Företag (REST API)',
read: { scope: 'companies:read', label: 'Läs — företagsprofiler nyckeln har åtkomst till', tools: 0 },
labelKey: 'group_companies',
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
write: null,
},
{
domain: 'events',
label: 'Händelser (REST API)',
read: { scope: 'events:read', label: 'Läs — polla event_log som webhook-fallback', tools: 0 },
labelKey: 'group_events',
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
write: null,
},
{
domain: 'webhooks',
label: 'Webhooks (REST API)',
labelKey: 'group_webhooks',
read: null,
write: { scope: 'webhooks:manage', label: 'Hantera — skapa, lista, uppdatera, radera prenumerationer', tools: 0 },
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
},
{
domain: 'operations',
label: 'Operationer (REST API)',
read: { scope: 'operations:read', label: 'Läs — status för långkörande operationer (import, bokslut, omvärdering)', tools: 0 },
labelKey: 'group_operations',
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
write: null,
},
{
domain: 'compliance',
label: 'Compliance (REST API)',
read: { scope: 'compliance:read', label: 'Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet', tools: 0 },
labelKey: 'group_compliance',
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 0 },
write: null,
},
]
@@ -146,7 +147,7 @@ interface ApiKey {
created_at: string
}
function CopyBlock({ text }: { text: string }) {
function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
@@ -169,7 +170,7 @@ function CopyBlock({ text }: { text: string }) {
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
aria-label="Kopiera"
aria-label={copyAriaLabel}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
@@ -190,9 +191,11 @@ function ScopeCard({
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const dashIdx = entry.label.indexOf(' — ')
const verb = dashIdx > 0 ? entry.label.slice(0, dashIdx) : entry.label
const description = dashIdx > 0 ? entry.label.slice(dashIdx + 3) : ''
const t = useTranslations('settings_api_keys')
const label = t(entry.labelKey)
const dashIdx = label.indexOf(' — ')
const verb = dashIdx > 0 ? label.slice(0, dashIdx) : label
const description = dashIdx > 0 ? label.slice(dashIdx + 3) : ''
return (
<label
@@ -211,7 +214,7 @@ function ScopeCard({
/>
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{entry.tools > 0 ? `${entry.tools} verktyg` : 'REST'}
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
</span>
</div>
{description && (
@@ -224,6 +227,7 @@ function ScopeCard({
}
export function ApiKeysPanel() {
const t = useTranslations('settings_api_keys')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -246,11 +250,11 @@ export function ApiKeysPanel() {
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchKeys()
@@ -262,7 +266,7 @@ export function ApiKeysPanel() {
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName || 'MCP-nyckel', scopes: Array.from(newKeyScopes) }),
body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes) }),
})
const json = await res.json()
@@ -278,7 +282,7 @@ export function ApiKeysPanel() {
setNewKeyScopes(new Set(ALL_SCOPES))
fetchKeys()
} catch {
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
toast({ title: t('toast_create_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -286,18 +290,18 @@ export function ApiKeysPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla API-nyckel',
description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' })
setKeys((prev) => prev.filter((k) => k.id !== id))
toast({ title: 'Nyckel återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -326,9 +330,9 @@ export function ApiKeysPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>API-nycklar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Hantera nycklar för MCP-klienter (Claude, Cursor) och andra integrationer.
{t('description')}
</CardDescription>
</div>
<Button
@@ -337,7 +341,7 @@ export function ApiKeysPanel() {
disabled={keys.length >= 10}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Skapa nyckel
{t('create_key')}
</Button>
</div>
</CardHeader>
@@ -349,9 +353,9 @@ export function ApiKeysPanel() {
) : keys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Key className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga API-nycklar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Skapa en nyckel för att koppla din MCP-klient.
{t('empty_help')}
</p>
</div>
) : (
@@ -368,10 +372,10 @@ export function ApiKeysPanel() {
<p className="text-sm font-medium truncate">{key.name}</p>
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? 'Alla behörigheter'
? t('all_permissions')
: scopeCount === 0
? 'Inga behörigheter'
: `${scopeCount} behörigheter`}
? t('no_permissions')
: t('permissions_count', { count: scopeCount })}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
@@ -379,12 +383,12 @@ export function ApiKeysPanel() {
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
Skapad {formatDate(key.created_at)}
{t('created')} {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? `Använd ${formatDate(key.last_used_at)}`
: 'Aldrig använd'}
? t('used_on', { date: formatDate(key.last_used_at) })
: t('never_used')}
</span>
</div>
</div>
@@ -392,7 +396,7 @@ export function ApiKeysPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={`Återkalla ${key.name}`}
aria-label={t('revoke_aria', { name: key.name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -407,27 +411,29 @@ export function ApiKeysPanel() {
<Card>
<CardHeader>
<CardTitle className="text-base">Anslut MCP-klient</CardTitle>
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-sm font-medium">Claude.ai</p>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">Rekommenderat</Badge>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">{t('recommended_badge')}</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
Gå till <strong>Settings &rarr; Integrations &rarr; Add Integration</strong> och klistra in MCP-serverns URL.
Du loggas in via ditt {connectorName}-konto — ingen API-nyckel behövs.
{t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl} />
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-2">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen — loggar in via webbläsaren:
{t('terminal_runs_browser_login')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} />
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
</div>
<div className="border-t pt-4">
@@ -437,14 +443,16 @@ export function ApiKeysPanel() {
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
Anslut med API-nyckel istället
{t('connect_with_api_key')}
</button>
{showApiKeyMethods && (
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<p className="text-xs text-muted-foreground mb-2">
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar &rarr; Developer):
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
@@ -456,17 +464,17 @@ export function ApiKeysPanel() {
}
}
}
}`} />
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen med en API-nyckel:
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url ${mcpUrl} \\
--header "Authorization: Bearer gnubok_sk_..."`} />
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
)}
@@ -478,17 +486,17 @@ export function ApiKeysPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl p-4 sm:max-w-3xl sm:p-6">
<DialogHeader>
<DialogTitle>Skapa API-nyckel</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
<DialogDescription>
Ge nyckeln ett namn så du vet vad den används till.
{t('create_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="key-name">Namn</Label>
<Label htmlFor="key-name">{t('name_label')}</Label>
<Input
id="key-name"
placeholder="t.ex. Claude Desktop"
placeholder={t('name_placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
@@ -497,19 +505,19 @@ export function ApiKeysPanel() {
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="space-y-1">
<Label>Behörigheter</Label>
<Label>{t('permissions_label')}</Label>
<p className="text-xs text-muted-foreground">
Välj vad nyckeln ska ha åtkomst till.
{t('permissions_help')}
</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{newKeyScopes.size} av {ALL_SCOPES.length} valda
{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SCOPE_GROUPS.map((group) => (
<div key={group.domain} className="space-y-2">
<h4 className="text-sm font-medium">{group.label}</h4>
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
<div className="space-y-2 px-2">
{group.read && (
<ScopeCard
@@ -555,11 +563,11 @@ export function ApiKeysPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Skapa
{t('create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -577,9 +585,9 @@ export function ApiKeysPanel() {
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Din nya API-nyckel</DialogTitle>
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
<DialogDescription>
Kopiera nyckeln nu. Den visas bara en gång.
{t('new_key_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="relative">
@@ -605,7 +613,7 @@ export function ApiKeysPanel() {
setNewKeyValue('')
setCopied(false)
}}>
Klar
{t('done')}
</Button>
</DialogFooter>
</DialogContent>
+35 -34
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -28,6 +29,7 @@ interface EstimateResponse {
const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download'
export function BackupDownloadForm() {
const t = useTranslations('settings_backup_download')
const { toast } = useToast()
const { company } = useCompany()
@@ -132,16 +134,16 @@ export function BackupDownloadForm() {
const body = await res.json().catch(() => ({}))
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
toast({
title: 'Arkivet är för stort för direktnedladdning',
title: t('toast_too_large_title'),
description: sizeMb
? `Ditt arkiv är cirka ${sizeMb} MB. Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.`
: 'Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.',
? t('toast_too_large_with_size', { size: sizeMb })
: t('toast_too_large_generic'),
variant: 'destructive',
})
return
}
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte skapa arkivet')
throw new Error(body.error || t('error_create_archive'))
}
const blob = await res.blob()
@@ -164,17 +166,17 @@ export function BackupDownloadForm() {
setLastDownloadedAt(now)
}
toast({ title: 'Säkerhetsbackup skapad', description: filename })
toast({ title: t('toast_backup_created'), description: filename })
} catch (err) {
toast({
title: 'Kunde inte skapa säkerhetsbackup',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('toast_backup_failed'),
description: err instanceof Error ? err.message : t('toast_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast])
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t])
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId)
@@ -183,31 +185,33 @@ export function BackupDownloadForm() {
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Skapa backup</CardTitle>
<CardTitle>{t('create_backup_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Omfattning</Label>
<Label>{t('scope_label')}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<ScopeRadio
checked={scope === 'all'}
onChange={() => setScope('all')}
label="Hela historiken"
description="Alla räkenskapsår och verifikationer"
label={t('scope_all_label')}
description={t('scope_all_desc')}
recommendedLabel={t('recommended')}
recommended
/>
<ScopeRadio
checked={scope === 'period'}
onChange={() => setScope('period')}
label="En period"
description="Välj ett specifikt räkenskapsår"
label={t('scope_period_label')}
description={t('scope_period_desc')}
recommendedLabel={t('recommended')}
/>
</div>
</div>
{scope === 'period' && (
<div className="space-y-2">
<Label htmlFor="backup-period">Räkenskapsår</Label>
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
<select
id="backup-period"
value={selectedPeriodId}
@@ -215,7 +219,7 @@ export function BackupDownloadForm() {
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={periods.length === 0}
>
{periods.length === 0 && <option value="">Inga räkenskapsår</option>}
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
@@ -227,10 +231,9 @@ export function BackupDownloadForm() {
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="include-documents">Inkludera kvitton och underlag</Label>
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
<p className="text-xs text-muted-foreground max-w-prose">
Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en.
Stäng av för en mindre backup med bara bokföringsdata.
{t('include_docs_help')}
</p>
</div>
<Switch
@@ -244,21 +247,19 @@ export function BackupDownloadForm() {
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-3.5 w-3.5" />
{isLoadingEstimate ? (
<span>Beräknar storlek…</span>
<span>{t('calculating_size')}</span>
) : estimate ? (
<span>
Uppskattad storlek: <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'})
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
</span>
) : (
<span>Storlek beräknas när omfattning är vald.</span>
<span>{t('size_will_calculate')}</span>
)}
</div>
{isOverLimit && (
<p className="mt-2 text-xs text-destructive">
Arkivet är större än {formatBytes(estimate!.size_limit_bytes)} och kan inte laddas ner
direkt. Välj en enskild period eller stäng av bilagor tills vidare —
automatisk molnsynkronisering kommer i senare version.
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
</p>
)}
</div>
@@ -268,18 +269,18 @@ export function BackupDownloadForm() {
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar backup…
{t('creating_backup')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Skapa och ladda ner
{t('create_and_download')}
</>
)}
</Button>
{lastDownloadedAt && (
<p className="text-xs text-muted-foreground">
Senaste nedladdning: {formatDate(lastDownloadedAt)}
{t('last_download')}: {formatDate(lastDownloadedAt)}
</p>
)}
</div>
@@ -293,13 +294,12 @@ export function BackupDownloadForm() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Molnsynkronisering
{t('cloud_sync_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground max-w-prose">
Aktivera tillägget &ldquo;Molnsynkronisering&rdquo; för att koppla Google
Drive och ladda upp säkerhetsbackupen med ett klick.
{t('cloud_sync_disabled_help')}
</p>
</CardContent>
</Card>
@@ -314,9 +314,10 @@ interface ScopeRadioProps {
label: string
description: string
recommended?: boolean
recommendedLabel: string
}
function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) {
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
return (
<button
type="button"
@@ -329,7 +330,7 @@ function ScopeRadio({ checked, onChange, label, description, recommended }: Scop
<span className="font-medium text-sm">{label}</span>
{recommended && (
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
Rekommenderas
{recommendedLabel}
</span>
)}
</div>
+14 -12
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,6 +15,7 @@ interface BankDetailsFormProps {
}
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const t = useTranslations('settings_bank_details_form')
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
@@ -23,22 +25,22 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bankuppgifter
{t('heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas på dina fakturor
{t('subheading')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Label>{t('bank_label')}</Label>
<BankNameCombobox
defaultValue={settings.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
name="clearing_number"
@@ -52,13 +54,13 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setClearingError(null); return }
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
setClearingError(!/^\d{4,5}$/.test(val) ? t('clearing_error') : null)
}}
/>
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Label htmlFor="account_number">{t('account_number_label')}</Label>
<Input
id="account_number"
name="account_number"
@@ -72,7 +74,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setAccountNumberError(null); return }
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
setAccountNumberError(!/^\d{6,12}$/.test(val) ? t('account_number_error') : null)
}}
/>
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
@@ -81,7 +83,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
name="bankgiro"
@@ -94,7 +96,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError('Ogiltigt bankgironummer')
setBankgiroError(t('bankgiro_error'))
}
}}
/>
@@ -102,11 +104,11 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="swish">Swish</Label>
<Label htmlFor="swish">{t('swish_label')}</Label>
<Input
id="swish"
name="swish"
placeholder="123 XXX XX XX eller 07X XXX XX XX"
placeholder={t('swish_placeholder')}
defaultValue={settings.swish || ''}
onBlur={(e) => {
const val = normaliseSwish(e.target.value)
@@ -115,7 +117,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = val
setSwishError(null)
} else {
setSwishError('Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)')
setSwishError(t('swish_error'))
}
}}
/>
+15 -15
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
@@ -17,6 +18,7 @@ interface BankIdIdentity {
}
export function BankIdSettings() {
const t = useTranslations('settings_bankid')
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLinking, setIsLinking] = useState(false)
@@ -45,20 +47,20 @@ export function BankIdSettings() {
const handleLinkComplete = async (result: BankIdResult) => {
if (result.error) {
const message = result.error === 'already_linked'
? 'Detta BankID ar redan kopplat till ett annat konto.'
: 'Kunde inte koppla BankID.'
? t('toast_already_linked')
: t('toast_link_failed')
toast({ title: message, variant: 'destructive' })
setIsLinking(false)
return
}
toast({ title: 'BankID kopplat till ditt konto' })
toast({ title: t('toast_linked') })
setIsLinking(false)
fetchIdentity()
}
const handleUnlink = async () => {
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
if (!confirm(t('confirm_unlink'))) return
setIsUnlinking(true)
try {
@@ -66,9 +68,9 @@ export function BankIdSettings() {
if (!res.ok) throw new Error('Unlink failed')
setIdentity(null)
toast({ title: 'BankID bortkopplat' })
toast({ title: t('toast_unlinked') })
} catch {
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
toast({ title: t('toast_unlink_failed'), variant: 'destructive' })
} finally {
setIsUnlinking(false)
}
@@ -88,8 +90,8 @@ export function BankIdSettings() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Koppla BankID</CardTitle>
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
<CardDescription>{t('link_bankid_description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
@@ -107,12 +109,10 @@ export function BankIdSettings() {
) : (
<Shield className="h-4 w-4 text-muted-foreground" />
)}
BankID
{t('title')}
</CardTitle>
<CardDescription>
{identity
? 'Ditt konto ar kopplat till BankID.'
: 'Koppla BankID for sakrare inloggning.'}
{identity ? t('linked_description') : t('not_linked_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -123,7 +123,7 @@ export function BankIdSettings() {
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
Kopplat {formatDateLong(identity.linked_at)}
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
</span>
</div>
<Button
@@ -133,7 +133,7 @@ export function BankIdSettings() {
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
{isUnlinking ? t('unlinking') : t('unlink_button')}
</Button>
</div>
) : (
@@ -141,7 +141,7 @@ export function BankIdSettings() {
variant="outline"
onClick={() => setIsLinking(true)}
>
Koppla BankID
{t('link_button')}
</Button>
)}
</CardContent>
+82 -72
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,16 +22,17 @@ import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
const ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const ENTITY_LABELS: Record<string, string> = {
all: t('entity_all'),
enskild_firma: t('entity_enskild_firma'),
aktiebolag: t('entity_aktiebolag'),
}
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -44,11 +46,11 @@ export function BookingTemplatesPanel() {
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
@@ -61,11 +63,11 @@ export function BookingTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Mall borttagen' })
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
toast({ title: t('toast_deleted') })
} finally {
setDeletingId(null)
}
@@ -82,7 +84,7 @@ export function BookingTemplatesPanel() {
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
toast({ title: t('toast_export_failed'), variant: 'destructive' })
}
}
@@ -99,13 +101,13 @@ export function BookingTemplatesPanel() {
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' })
return
}
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
fetchTemplates()
} catch {
toast({ title: 'Importfel', description: 'Ogiltig fil', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
} finally {
// Reset input so same file can be imported again
if (importRef.current) importRef.current.value = ''
@@ -113,29 +115,29 @@ export function BookingTemplatesPanel() {
}
// Group templates by scope
const systemTemplates = templates.filter((t) => t.is_system)
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
const systemTemplates = templates.filter((tt) => tt.is_system)
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
{t('description')}
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Exportera
{t('export')}
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Importera
{t('import')}
</Button>
<input
ref={importRef}
@@ -148,14 +150,15 @@ export function BookingTemplatesPanel() {
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Ny mall
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Skapa bokföringsmall</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<CreateTemplateForm
entityLabels={ENTITY_LABELS}
onCreated={() => {
setShowCreate(false)
fetchTemplates()
@@ -174,14 +177,14 @@ export function BookingTemplatesPanel() {
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
Inga mallar hittades.
{t('empty_state')}
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title="Standardmallar"
title={t('section_system')}
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
@@ -189,13 +192,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title="Teammallar"
title={t('section_team')}
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
@@ -203,13 +207,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title="Företagsmallar"
title={t('section_company')}
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
@@ -217,6 +222,7 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
</div>
@@ -235,6 +241,7 @@ function TemplateSection({
deletingId,
onDelete,
canDelete,
entityLabels,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
@@ -244,7 +251,9 @@ function TemplateSection({
deletingId: string | null
onDelete: (id: string) => void
canDelete: boolean
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
return (
<div>
<div className="flex items-center gap-2 mb-2">
@@ -253,28 +262,28 @@ function TemplateSection({
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
</div>
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
return (
<div
key={t.id}
key={tt.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : t.id)}
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t.name}</span>
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[t.category]}
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
{t.entity_type !== 'all' && (
{tt.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{ENTITY_LABELS[t.entity_type]}
{entityLabels[tt.entity_type]}
</Badge>
)}
</div>
@@ -285,12 +294,12 @@ function TemplateSection({
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(t.id)
onDelete(tt.id)
}}
disabled={deletingId === t.id}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
@@ -300,31 +309,31 @@ function TemplateSection({
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{t.description && (
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
{tt.description && (
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
)}
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
<th className="text-right py-1 w-12">Debet</th>
<th className="text-right py-1 w-12">Kredit</th>
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{t.lines.map((line: BookingTemplateLibraryLine, i: number) => (
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
{line.type === 'vat' && line.vat_rate
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) })
: line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
<td className="py-1 text-right">{line.side === 'debit' ? t('debit_short') : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? t('credit_short') : ''}</td>
</tr>
))}
</tbody>
@@ -339,7 +348,8 @@ function TemplateSection({
)
}
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void; entityLabels: Record<string, string> }) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState('')
@@ -371,7 +381,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
@@ -384,10 +394,10 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
})
if (!res.ok) {
const json = await res.json()
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
toast({ title: t('toast_created') })
onCreated()
} finally {
setIsSubmitting(false)
@@ -397,16 +407,16 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
<Label>{t('name_label')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('name_placeholder')} />
</div>
<div>
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Kategori</Label>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -417,11 +427,11 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</Select>
</div>
<div>
<Label>Företagstyp</Label>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
@@ -430,36 +440,36 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</div>
<div>
<Label>Rader</Label>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="Konto"
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder="Beskrivning"
placeholder={t('description_short_placeholder')}
className="flex-1"
/>
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">Kostnad</SelectItem>
<SelectItem value="vat">Moms</SelectItem>
<SelectItem value="settlement">Betalning</SelectItem>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
<Button
@@ -476,14 +486,14 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
))}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
</div>
</div>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Skapa mall
{t('create_button')}
</Button>
</form>
)
+41 -38
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -18,6 +19,7 @@ interface CalendarFeedWithUrls extends CalendarFeed {
}
export function CalendarFeedSettings() {
const t = useTranslations('settings_calendar_feed')
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
@@ -29,6 +31,7 @@ export function CalendarFeedSettings() {
useEffect(() => {
fetchFeed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFeed = async () => {
@@ -57,12 +60,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Kalenderfeed skapad',
description: 'Du kan nu koppla kalendern till Apple Calendar eller Google Calendar.',
title: t('toast_feed_created_title'),
description: t('toast_feed_created_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa kalenderfeed.',
title: t('toast_create_failed'),
variant: 'destructive',
})
} finally {
@@ -88,9 +91,9 @@ export function CalendarFeedSettings() {
const { data } = await response.json()
setFeed(data)
} catch (error) {
} catch {
toast({
title: 'Kunde inte uppdatera inställning.',
title: t('toast_update_failed'),
variant: 'destructive',
})
} finally {
@@ -100,9 +103,9 @@ export function CalendarFeedSettings() {
const regenerateToken = async () => {
const ok = await confirmAction({
title: 'Skapa ny kalender-länk',
description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
confirmLabel: 'Skapa ny länk',
title: t('regen_dialog_title'),
description: t('regen_dialog_description'),
confirmLabel: t('regen_confirm'),
variant: 'warning',
})
if (!ok) return
@@ -122,12 +125,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Ny länk skapad',
description: 'Den gamla länken fungerar inte längre.',
title: t('toast_new_link_title'),
description: t('toast_new_link_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa ny länk.',
title: t('toast_regen_failed'),
variant: 'destructive',
})
} finally {
@@ -141,12 +144,12 @@ export function CalendarFeedSettings() {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast({
title: 'Kopierad',
description: 'Länken har kopierats till urklipp.',
title: t('toast_copied_title'),
description: t('toast_copied_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte kopiera länken.',
title: t('toast_copy_failed'),
variant: 'destructive',
})
}
@@ -172,27 +175,27 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Synka dina deadlines med Apple Calendar, Google Calendar eller Outlook
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4 py-4">
<p className="text-muted-foreground">
Skapa en kalenderfeed för att se dina deadlines i din vanliga kalenderapp.
{t('empty_intro')}
</p>
<Button onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
{t('creating')}
</>
) : (
<>
<Calendar className="mr-2 h-4 w-4" />
Aktivera kalendersynk
{t('activate_sync')}
</>
)}
</Button>
@@ -209,10 +212,10 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Prenumerera på din kalender i Apple Calendar, Google Calendar eller Outlook
{t('subscribe_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -220,7 +223,7 @@ export function CalendarFeedSettings() {
<div className="flex gap-2">
<Button onClick={openWebcal} className="flex-1">
<Calendar className="mr-2 h-4 w-4" />
Lägg till i Apple Calendar
{t('add_to_apple_calendar')}
</Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
{copied ? (
@@ -233,7 +236,7 @@ export function CalendarFeedSettings() {
{/* URL display */}
<div className="space-y-2">
<Label>Kalenderlänk (för Google Calendar m.fl.)</Label>
<Label>{t('calendar_link_label')}</Label>
<div className="flex gap-2">
<Input
value={feed.httpsUrl}
@@ -242,14 +245,14 @@ export function CalendarFeedSettings() {
/>
</div>
<p className="text-xs text-muted-foreground">
Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.
{t('calendar_link_help')}
</p>
</div>
{/* Stats */}
{feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Senast hämtad:</span>
<span>{t('last_fetched')}</span>
<span>
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
@@ -259,7 +262,7 @@ export function CalendarFeedSettings() {
})}
</span>
<Badge variant="secondary" className="text-xs">
{feed.access_count} gånger
{t('times_count', { count: feed.access_count })}
</Badge>
</div>
)}
@@ -275,17 +278,17 @@ export function CalendarFeedSettings() {
{isRegenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar ny länk...
{t('creating_new_link')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Skapa ny länk
{t('create_new_link')}
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">
Ogiltigförklarar den gamla länken. Använd om någon obehörig fått tag i länken.
{t('regen_help')}
</p>
</div>
</CardContent>
@@ -294,17 +297,17 @@ export function CalendarFeedSettings() {
{/* Content settings */}
<Card>
<CardHeader>
<CardTitle>Innehåll i kalendern</CardTitle>
<CardTitle>{t('content_title')}</CardTitle>
<CardDescription>
Välj vilka händelser som ska visas i din kalender
{t('content_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-tax">Skattedeadlines</Label>
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, deklarationer
{t('tax_deadlines_help')}
</p>
</div>
<Switch
@@ -319,9 +322,9 @@ export function CalendarFeedSettings() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-invoices">Fakturor</Label>
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
<p className="text-sm text-muted-foreground">
Förfallodatum för fakturor
{t('invoices_help')}
</p>
</div>
<Switch
+17 -13
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
@@ -33,6 +34,7 @@ const branding = getBranding()
* capabilities.bankIdLinked boolean fetched from the user profile.
*/
export function CompanyDangerZone() {
const t = useTranslations('settings_company')
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
@@ -57,10 +59,10 @@ export function CompanyDangerZone() {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera företaget')
throw new Error(body.error || t('danger_delete_failed_default'))
}
toast({ title: 'Företaget raderades', description: company.name })
toast({ title: t('danger_deleted_title'), description: company.name })
// Stay inside settings. If the user had another company, the dashboard
// layout will resolve it and /settings/account still renders as
// normal. If this was their last company, the layout falls into the
@@ -69,8 +71,8 @@ export function CompanyDangerZone() {
router.refresh()
} catch (err) {
toast({
title: 'Kunde inte radera företaget',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('danger_delete_failed_title'),
description: err instanceof Error ? err.message : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
@@ -81,7 +83,7 @@ export function CompanyDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera företag
{t('danger_heading')}
</h2>
<RetentionNotice variant="company" />
@@ -92,7 +94,7 @@ export function CompanyDangerZone() {
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
Radera företag
{t('danger_button')}
</Button>
</div>
</section>
@@ -107,15 +109,17 @@ export function CompanyDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera {company.name}</DialogTitle>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
Företaget döljs från {branding.appName.toLowerCase()}. Bokföringen behålls säkert i 7 år enligt BFL.
Skriv företagets namn exakt för att bekräfta.
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="company-delete-confirm">
Skriv <strong>{company.name}</strong> för att bekräfta
{t.rich('danger_confirm_label', {
companyName: company.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="company-delete-confirm"
@@ -134,7 +138,7 @@ export function CompanyDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('danger_cancel')}
</Button>
<Button
variant="destructive"
@@ -144,10 +148,10 @@ export function CompanyDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('danger_deleting')}
</>
) : (
'Radera företag'
t('danger_button')
)}
</Button>
</DialogFooter>
+13 -11
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CompanySettings } from '@/types'
@@ -9,26 +10,27 @@ interface CompanyInfoFormProps {
}
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
const t = useTranslations('settings_company')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsuppgifter
{t('company_info_heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Label htmlFor="company_name">{t('company_name_label')}</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
/>
<p className="text-xs text-muted-foreground">
Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).
{t('company_name_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
name="org_number"
@@ -36,13 +38,13 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Label htmlFor="address_line1">{t('address_label')}</Label>
<Input
id="address_line1"
name="address_line1"
@@ -52,7 +54,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('postal_code_label')}</Label>
<Input
id="postal_code"
name="postal_code"
@@ -60,7 +62,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
name="city"
@@ -71,7 +73,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
name="phone"
@@ -80,7 +82,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
name="email"
@@ -91,7 +93,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="website">Webbplats</Label>
<Label htmlFor="website">{t('website_label')}</Label>
<Input
id="website"
name="website"
+33 -31
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,16 +32,17 @@ interface CompanyInvitation {
created_at: string
}
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
admin: 'Admin',
member: 'Medlem',
viewer: 'Läsbehörighet',
}
export function CompanyMembersSection() {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { company } = useCompany()
const roleLabels: Record<string, string> = {
owner: t('members_role_owner'),
admin: t('members_role_admin'),
member: t('members_role_member'),
viewer: t('members_role_viewer'),
}
const [isLoading, setIsLoading] = useState(true)
const [members, setMembers] = useState<CompanyMemberItem[]>([])
const [invitations, setInvitations] = useState<CompanyInvitation[]>([])
@@ -94,16 +96,16 @@ export function CompanyMembersSection() {
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
}
toast({
title: 'Inbjudan skickad',
title: t('members_invite_sent_title'),
description: data.data.inviteUrl
? 'Länk loggad i konsolen (F12)'
: `E-post skickad till ${email}.`,
? t('members_invite_sent_dev_url')
: t('members_invite_sent_description', { email }),
})
setInviteEmail('')
setInviteRole('viewer')
fetchMembers()
} catch {
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_failed'), variant: 'destructive' })
} finally {
setIsSending(false)
}
@@ -120,10 +122,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Medlem borttagen' })
toast({ title: t('members_removed') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
toast({ title: t('members_remove_failed'), variant: 'destructive' })
} finally {
setRemovingId(null)
}
@@ -140,10 +142,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Inbjudan återkallad' })
toast({ title: t('members_invite_revoked') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_revoke_failed'), variant: 'destructive' })
} finally {
setRevokingId(null)
}
@@ -163,19 +165,19 @@ export function CompanyMembersSection() {
{canInvite && (
<Card>
<CardHeader>
<CardTitle className="text-base">Bjud in till {company?.name}</CardTitle>
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
<CardDescription>
Personen får tillgång till enbart detta företag.
{t('members_invite_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex gap-3">
<div className="flex-1">
<Label htmlFor="company-invite-email" className="sr-only">E-postadress</Label>
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
<Input
id="company-invite-email"
type="email"
placeholder="namn@example.com"
placeholder={t('members_invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
@@ -187,9 +189,9 @@ export function CompanyMembersSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Läsbehörighet</SelectItem>
<SelectItem value="member">Medlem</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
<SelectItem value="member">{t('members_role_member')}</SelectItem>
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
@@ -198,7 +200,7 @@ export function CompanyMembersSection() {
) : (
<>
<Plus className="h-4 w-4 mr-1.5" />
Bjud in
{t('members_invite_button')}
</>
)}
</Button>
@@ -212,10 +214,10 @@ export function CompanyMembersSection() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Medlemmar
{t('members_title')}
</CardTitle>
<CardDescription>
{members.length} {members.length === 1 ? 'medlem' : 'medlemmar'} i {company?.name}
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
@@ -232,16 +234,16 @@ export function CompanyMembersSection() {
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">(du)</span>
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
)}
</p>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{ROLE_LABELS[member.role] || member.role}
{roleLabels[member.role] || member.role}
</span>
{member.source === 'team' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Team
{t('members_team_badge')}
</Badge>
)}
</div>
@@ -272,7 +274,7 @@ export function CompanyMembersSection() {
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Väntande inbjudningar</CardTitle>
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
@@ -287,14 +289,14 @@ export function CompanyMembersSection() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
Går ut {formatDateLong(inv.expires_at)}
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[inv.role] || inv.role}
{roleLabels[inv.role] || inv.role}
</Badge>
{canInvite && (
<Button
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,22 +11,6 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import type { CategorizationTemplate } from '@/types'
const SOURCE_LABELS: Record<string, string> = {
sie_import: 'SIE-import',
user_approved: 'Godkänd',
auto_learned: 'Automatisk',
sni_default: 'Standard',
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: 'Omvänd',
export: 'Export',
exempt: 'Momsfri',
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('sv-SE', {
@@ -42,8 +27,25 @@ function confidenceColor(c: number): string {
}
export function CounterpartyTemplatesPanel() {
const t = useTranslations('settings_counterparty_templates')
const { toast } = useToast()
const SOURCE_LABELS: Record<string, string> = {
sie_import: t('source_sie_import'),
user_approved: t('source_user_approved'),
auto_learned: t('source_auto_learned'),
sni_default: t('source_sni_default'),
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: t('vat_reverse_charge'),
export: t('vat_export'),
exempt: t('vat_exempt'),
}
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -57,11 +59,11 @@ export function CounterpartyTemplatesPanel() {
setTemplates(json.data)
}
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchTemplates()
@@ -76,14 +78,14 @@ export function CounterpartyTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
if (expandedId === id) setExpandedId(null)
toast({ title: 'Mall borttagen' })
toast({ title: t('toast_deleted') })
} catch {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
} finally {
setDeletingId(null)
}
@@ -93,10 +95,9 @@ export function CounterpartyTemplatesPanel() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Inlärda mönster som automatiskt föreslår kontering baserat på motpart.
Mallar skapas från SIE-import och när du godkänner kategoriseringar.
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -107,54 +108,54 @@ export function CounterpartyTemplatesPanel() {
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga mallar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Mallar skapas automatiskt när du kategoriserar transaktioner eller importerar en SIE-fil.
{t('empty_help')}
</p>
</div>
) : (
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
const isMultiLine = t.line_pattern && t.line_pattern.length > 0
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
return (
<div key={t.id} className="rounded-md border overflow-hidden">
<div key={tt.id} className="rounded-md border overflow-hidden">
{/* Clickable summary row */}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : t.id)}
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{formatCounterpartyName(t.counterparty_name)}</p>
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
<Badge variant="outline" className="text-[10px] shrink-0">
{SOURCE_LABELS[t.source] || t.source}
{SOURCE_LABELS[tt.source] || tt.source}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
{isMultiLine ? (
<span className="font-mono">
{t.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
</span>
) : (
<>
<span className="font-mono">{t.debit_account}</span>
<span className="font-mono">{tt.debit_account}</span>
<span className="text-muted-foreground/50">→</span>
<span className="font-mono">{t.credit_account}</span>
<span className="font-mono">{tt.credit_account}</span>
</>
)}
{t.vat_treatment && (
{tt.vat_treatment && (
<>
<span className="text-muted-foreground/30">·</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span>{t.occurrence_count} ggr</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span>{t('times_count', { count: tt.occurrence_count })}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
</div>
@@ -166,17 +167,17 @@ export function CounterpartyTemplatesPanel() {
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
{/* Account lines */}
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Kontering</p>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
{isMultiLine ? (
<div className="space-y-1">
{t.line_pattern!.map((lp, i) => (
{tt.line_pattern!.map((lp, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">
{lp.side === 'debit' ? 'Debet' : 'Kredit'}
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
</Badge>
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
{lp.type === 'vat' && lp.vat_rate && (
<span className="text-muted-foreground">({Math.round(lp.vat_rate * 100)}% moms)</span>
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
)}
{lp.ratio !== undefined && (
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
@@ -187,12 +188,12 @@ export function CounterpartyTemplatesPanel() {
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Debet</Badge>
<span className="font-mono">{formatAccountWithName(t.debit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('debit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Kredit</Badge>
<span className="font-mono">{formatAccountWithName(t.credit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('credit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
</div>
</div>
)}
@@ -200,30 +201,30 @@ export function CounterpartyTemplatesPanel() {
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
{t.vat_treatment && (
{tt.vat_treatment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Antal bokföringar</span>
<span className="tabular-nums">{t.occurrence_count}</span>
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
<span className="tabular-nums">{tt.occurrence_count}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Säkerhet</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span className="text-muted-foreground">{t('confidence_label')}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Senast använd</span>
<span>{formatDate(t.last_seen_date)}</span>
<span className="text-muted-foreground">{t('last_seen_label')}</span>
<span>{formatDate(tt.last_seen_date)}</span>
</div>
{t.counterparty_aliases && t.counterparty_aliases.length > 1 && (
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
<div className="col-span-2 flex justify-between">
<span className="text-muted-foreground">Alias</span>
<span className="text-right truncate ml-4">{t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''}</span>
<span className="text-muted-foreground">{t('aliases_label')}</span>
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
</div>
)}
</div>
@@ -233,16 +234,16 @@ export function CounterpartyTemplatesPanel() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(t.id)}
disabled={deletingId === t.id}
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
className="text-destructive hover:text-destructive text-xs h-7"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3 w-3" />
)}
Ta bort mall
{t('delete_button')}
</Button>
</div>
</div>
+42 -35
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -34,6 +35,7 @@ function isCalendarYear(period: { period_start: string; period_end: string }): b
}
export function FiscalPeriodEditor() {
const t = useTranslations('settings_company')
const { company, role } = useCompany()
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -59,7 +61,7 @@ export function FiscalPeriodEditor() {
setLoadError(null)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) throw new Error('Kunde inte hämta räkenskapsår')
if (!res.ok) throw new Error(t('fp_load_error_periods'))
const { data } = (await res.json()) as { data: FiscalPeriod[] }
if (!data || data.length === 0) {
if (!cancelled) {
@@ -72,7 +74,7 @@ export function FiscalPeriodEditor() {
const first = sorted[0]
const countRes = await fetch(`/api/bookkeeping/fiscal-periods/${first.id}/entry-count`)
if (!countRes.ok) throw new Error('Kunde inte hämta verifikationsantal')
if (!countRes.ok) throw new Error(t('fp_load_error_entry_count'))
const { data: countData } = (await countRes.json()) as { data: { posted_count: number } }
if (cancelled) return
@@ -82,7 +84,7 @@ export function FiscalPeriodEditor() {
setEndDate(first.period_end)
} catch (err) {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : 'Okänt fel')
setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
}
} finally {
if (!cancelled) setIsLoading(false)
@@ -93,7 +95,7 @@ export function FiscalPeriodEditor() {
return () => {
cancelled = true
}
}, [company])
}, [company, t])
const validation = validateFirstPeriod(
startDate,
@@ -114,10 +116,15 @@ export function FiscalPeriodEditor() {
if (!isDirty) return
const ok = await confirm({
title: 'Ändra första räkenskapsåret?',
description: `Detta ändrar ditt första räkenskapsår från ${formatSwedishDate(period.period_start)} – ${formatSwedishDate(period.period_end)} till ${formatSwedishDate(startDate)} – ${formatSwedishDate(endDate)}. Ändringen är bara tillåten eftersom inga verifikationer är bokförda ännu. Fortsätt?`,
confirmLabel: 'Ja, ändra räkenskapsår',
cancelLabel: 'Avbryt',
title: t('fp_confirm_title'),
description: t('fp_confirm_description', {
oldStart: formatSwedishDate(period.period_start),
oldEnd: formatSwedishDate(period.period_end),
newStart: formatSwedishDate(startDate),
newEnd: formatSwedishDate(endDate),
}),
confirmLabel: t('fp_confirm_yes'),
cancelLabel: t('fp_confirm_cancel'),
variant: 'warning',
})
if (!ok) return
@@ -128,8 +135,8 @@ export function FiscalPeriodEditor() {
const endYear = parseDateParts(endDate).year
const newName =
startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
? t('fp_year_label_single', { year: startYear })
: t('fp_year_label_range', { startYear, endYear })
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -141,17 +148,17 @@ export function FiscalPeriodEditor() {
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body.error || 'Kunde inte ändra räkenskapsår')
throw new Error(body.error || t('fp_update_failed_title'))
}
setPeriod(body.data as FiscalPeriod)
toast({
title: 'Räkenskapsår uppdaterat',
title: t('fp_updated_title'),
description: `${formatSwedishDate(body.data.period_start)} – ${formatSwedishDate(body.data.period_end)}`,
})
} catch (err) {
toast({
title: 'Kunde inte ändra räkenskapsår',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('fp_update_failed_title'),
description: err instanceof Error ? err.message : t('fp_try_again'),
variant: 'destructive',
})
} finally {
@@ -170,22 +177,22 @@ export function FiscalPeriodEditor() {
<section className="space-y-4 border-t border-border/8 pt-8">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Första räkenskapsår
{t('fp_heading')}
</h2>
<p className="text-sm text-muted-foreground">
Om du valde fel räkenskapsår vid uppstart kan du justera det här — så länge du inte har bokfört någon verifikation ännu.
{t('fp_intro')}
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Hämtar räkenskapsår...
{t('fp_loading')}
</div>
) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : !period ? (
<p className="text-sm text-muted-foreground">Inget räkenskapsår hittades.</p>
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
) : isBlocked ? (
<BlockedState
period={period}
@@ -196,10 +203,10 @@ export function FiscalPeriodEditor() {
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Ändra med omsorg.</p>
<p className="font-medium">{t('fp_warning_title')}</p>
<p className="text-muted-foreground">
Ändringen påverkar öppningsbalanser och rapporter.
{isEF && ' Enskild firma måste använda kalenderår enligt BFL 3 kap.'}
{t('fp_warning_body')}
{isEF && t('fp_warning_ef_suffix')}
</p>
</div>
</div>
@@ -209,10 +216,10 @@ export function FiscalPeriodEditor() {
onStartDateChange={setStartDate}
endDate={endDate}
entityType={company?.entity_type}
summaryTitle="Föreslaget räkenskapsår"
summaryTitle={t('fp_summary_title')}
endDateSlot={
<div className="space-y-2">
<Label htmlFor="fp_end">Slutdatum</Label>
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
<Input
id="fp_end"
type="date"
@@ -220,7 +227,7 @@ export function FiscalPeriodEditor() {
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Måste vara sista dagen i en månad.
{t('fp_end_date_help')}
</p>
</div>
}
@@ -233,7 +240,7 @@ export function FiscalPeriodEditor() {
onClick={handleReset}
disabled={!isDirty || isSaving}
>
Återställ
{t('fp_reset')}
</Button>
<Button
type="button"
@@ -249,10 +256,10 @@ export function FiscalPeriodEditor() {
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('fp_saving')}
</>
) : (
'Spara ändring'
t('fp_save')
)}
</Button>
</div>
@@ -271,32 +278,32 @@ function BlockedState({
period: FiscalPeriod
postedCount: number
}) {
const t = useTranslations('settings_company')
const reason = period.locked_at
? 'Räkenskapsåret är låst.'
? t('fp_blocked_reason_locked')
: period.is_closed
? 'Räkenskapsåret är stängt.'
: `${postedCount} bokförd${postedCount === 1 ? '' : 'a'} verifikation${postedCount === 1 ? '' : 'er'} finns redan i perioden.`
? t('fp_blocked_reason_closed')
: t('fp_blocked_reason_posted', { count: postedCount })
return (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<div className="flex gap-2">
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Räkenskapsåret kan inte längre ändras</p>
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
Första räkenskapsåret:{' '}
{t('fp_blocked_first_year')}{' '}
<span className="font-medium text-foreground">
{formatSwedishDate(period.period_start)} &ndash; {formatSwedishDate(period.period_end)}
</span>
{isCalendarYear(period) ? ' (kalenderår)' : ' (brutet räkenskapsår)'}
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
</p>
<p>
Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt.
Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.
{t('fp_blocked_explainer')}
</p>
</div>
</div>
+10 -8
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
@@ -10,24 +11,25 @@ interface InvoiceSettingsFormProps {
}
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
const t = useTranslations('settings_invoice_form')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Fakturainställningar
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
placeholder={t('prefix_placeholder')}
defaultValue={settings.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
@@ -37,7 +39,7 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
@@ -49,16 +51,16 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
placeholder={t('default_notes_placeholder')}
defaultValue={settings.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Föreslås automatiskt vid ny faktura.
{t('default_notes_help')}
</p>
</div>
</section>
+14 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
@@ -12,6 +13,7 @@ interface LogoUploadProps {
}
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -23,11 +25,11 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
function validateAndUpload(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
toast({ title: 'Otillåten filtyp', description: 'PNG, JPG, SVG eller WebP.', variant: 'destructive' })
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
return
}
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
handleUpload(file)
@@ -48,15 +50,15 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Uppladdning misslyckades')
throw new Error(result.error || t('logo_upload_failed_default'))
}
setPreview(result.data.logo_url)
onUpdate(result.data.logo_url)
} catch (error) {
toast({
title: 'Kunde inte ladda upp',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('logo_upload_failed_title'),
description: error instanceof Error ? error.message : t('logo_try_again'),
variant: 'destructive',
})
}
@@ -75,7 +77,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
onUpdate(null)
if (inputRef.current) inputRef.current.value = ''
} catch {
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
}
setIsDeleting(false)
@@ -107,10 +109,10 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Logotyp
{t('logo_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
{t('logo_help')}
</p>
{preview ? (
@@ -119,7 +121,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Företagslogotyp"
alt={t('logo_alt')}
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
@@ -131,7 +133,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
disabled={isUploading}
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
Byt logotyp
{t('logo_change')}
</Button>
<Button
variant="ghost"
@@ -141,7 +143,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
className="text-muted-foreground hover:text-destructive"
>
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
Ta bort
{t('logo_remove')}
</Button>
</div>
</div>
@@ -164,7 +166,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
)}
<Label className="text-sm text-muted-foreground cursor-pointer">
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</Label>
</button>
)}
+30 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -26,6 +27,7 @@ interface OAuthClient {
}
export function OAuthClientsPanel() {
const t = useTranslations('settings_oauth_clients')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -44,11 +46,11 @@ export function OAuthClientsPanel() {
setClients(json.data.filter((c: OAuthClient) => !c.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta OAuth-klienter', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchClients()
@@ -61,14 +63,14 @@ export function OAuthClientsPanel() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: clientName.trim() || 'OAuth-klient',
client_name: clientName.trim() || t('default_client_name'),
redirect_uri: redirectUri.trim(),
}),
})
const json = await res.json()
if (!res.ok) {
toast({ title: json.error ?? 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: json.error ?? t('toast_register_failed'), variant: 'destructive' })
return
}
@@ -77,7 +79,7 @@ export function OAuthClientsPanel() {
setRedirectUri('')
fetchClients()
} catch {
toast({ title: 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: t('toast_register_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -85,30 +87,26 @@ export function OAuthClientsPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla OAuth-klient',
description: `"${name}" tas bort från allowlist. Pågående auth-flöden slutar fungera direkt; redan utfärdade API-nycklar fortsätter att gälla tills de återkallas separat.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
const res = await fetch(`/api/settings/oauth-clients/${id}`, { method: 'DELETE' })
if (!res.ok) {
// Surface the server error rather than optimistically pretending the
// revocation succeeded — a silent fail leaves the row in the
// allowlist while the UI says it's gone, which is the opposite of
// what the user expected.
const body = await res.json().catch(() => ({}))
toast({
title: body?.error || 'Kunde inte återkalla klient',
title: body?.error || t('toast_revoke_failed'),
variant: 'destructive',
})
return
}
setClients((prev) => prev.filter((c) => c.id !== id))
toast({ title: 'Klient återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla klient', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -126,15 +124,14 @@ export function OAuthClientsPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>OAuth-klienter</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Registrera redirect-URI:er för egenutvecklade MCP-klienter. Claude.ai och localhost
är redan godkända som standard — registrera bara här om du bygger en egen app.
{t('description')}
</CardDescription>
</div>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Registrera URI
{t('register_uri')}
</Button>
</div>
</CardHeader>
@@ -146,9 +143,9 @@ export function OAuthClientsPanel() {
) : clients.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga egna OAuth-klienter registrerade.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Bygger du en agent som ska ansluta via OAuth? Registrera dess callback-URI här.
{t('empty_help')}
</p>
</div>
) : (
@@ -165,7 +162,7 @@ export function OAuthClientsPanel() {
{c.redirect_uri}
</code>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Registrerad {formatDate(c.created_at)}
{t('registered_on')} {formatDate(c.created_at)}
</span>
</div>
</div>
@@ -173,7 +170,7 @@ export function OAuthClientsPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(c.id, c.client_name)}
aria-label={`Återkalla ${c.client_name}`}
aria-label={t('revoke_aria', { name: c.client_name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -188,29 +185,26 @@ export function OAuthClientsPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrera redirect URI</DialogTitle>
<DialogTitle>{t('register_dialog_title')}</DialogTitle>
<DialogDescription>
Bara för egenbyggda MCP-klienter med en publik HTTPS-callback. Lägg{' '}
<span className="font-medium">inte</span> till{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">localhost</code>{' '}
eller{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">claude.ai</code>{' '}
— de fungerar redan utan registrering. URI:n jämförs ord-för-ord mot
redirect_uri-parametern i OAuth-flödet.
{t.rich('register_dialog_description', {
bold: (chunks) => <span className="font-medium">{chunks}</span>,
code: (chunks) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{chunks}</code>,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-name">Klientnamn</Label>
<Label htmlFor="client-name">{t('client_name_label')}</Label>
<Input
id="client-name"
placeholder="t.ex. Min bokföringsagent"
placeholder={t('client_name_placeholder')}
value={clientName}
onChange={(e) => setClientName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="redirect-uri">Redirect URI</Label>
<Label htmlFor="redirect-uri">{t('redirect_uri_label')}</Label>
<Input
id="redirect-uri"
type="url"
@@ -223,11 +217,11 @@ export function OAuthClientsPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || !redirectUri.trim()}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Registrera
{t('register')}
</Button>
</DialogFooter>
</DialogContent>
+30 -28
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
@@ -13,6 +14,7 @@ interface PdfPrintSettingsProps {
}
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const t = useTranslations('settings_pdf_print')
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
@@ -27,9 +29,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const savePosition = useCallback(async (value: 'header' | 'footer') => {
try {
@@ -41,9 +43,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ invoice_company_name_position: value })
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const saveText = useCallback(async (field: string, value: string) => {
try {
@@ -55,21 +57,21 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utskrift & PDF
{t('heading')}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Öresavrundning</Label>
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
<Label>{t('ore_rounding_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
</div>
<Switch
checked={settings.ore_rounding ?? true}
@@ -79,8 +81,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa OCR-referens</Label>
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
<Label>{t('show_ocr_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p>
</div>
<Switch
checked={settings.invoice_show_ocr ?? true}
@@ -90,8 +92,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa bankgiro</Label>
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
<Label>{t('show_bankgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_bankgiro ?? true}
@@ -101,8 +103,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa plusgiro</Label>
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
<Label>{t('show_plusgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_plusgiro ?? true}
@@ -112,8 +114,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa Swish</Label>
<p className="text-xs text-muted-foreground">Visa Swish-nummer på fakturautskrift</p>
<Label>{t('show_swish_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? true}
@@ -123,8 +125,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa logga</Label>
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
<Label>{t('show_logo_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
</div>
<Switch
checked={settings.invoice_show_logo ?? true}
@@ -135,8 +137,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Visa företagsnamn i faktura</Label>
<p className="text-xs text-muted-foreground">Visa företagsnamn i fakturan</p>
<Label>{t('show_company_name_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
</div>
<Switch
checked={settings.invoice_show_company_name ?? true}
@@ -145,10 +147,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
{(settings.invoice_show_company_name ?? true) && (
<div className="flex items-center justify-between pl-0">
<p className="text-xs text-muted-foreground">Placering</p>
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
<div
role="group"
aria-label="Placering av företagsnamn"
aria-label={t('placement_aria_label')}
className="inline-flex rounded-md border border-border/60 p-0.5"
>
{(['header', 'footer'] as const).map((pos) => {
@@ -166,7 +168,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
: 'text-muted-foreground hover:text-foreground')
}
>
{pos === 'header' ? 'Huvud' : 'Sidfot'}
{pos === 'header' ? t('placement_header') : t('placement_footer')}
</button>
)
})}
@@ -178,11 +180,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
<Textarea
id="invoice_late_fee_text"
rows={2}
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
placeholder={t('late_fee_placeholder')}
value={lateFeeText}
onChange={(e) => setLateFeeText(e.target.value)}
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
@@ -190,11 +192,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
<div className="space-y-2">
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
<Label htmlFor="invoice_credit_terms_text">{t('credit_terms_label')}</Label>
<Textarea
id="invoice_credit_terms_text"
rows={2}
placeholder="T.ex. Betalning sker till angivet bankgiro."
placeholder={t('credit_terms_placeholder')}
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
+11 -9
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -10,15 +11,16 @@ interface PeriodLockingSettingsProps {
}
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
const t = useTranslations('settings_period_locking')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodlåsning
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
<Input
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
@@ -26,12 +28,12 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
defaultValue={settings.bookkeeping_locked_through || ''}
/>
<p className="text-xs text-muted-foreground">
Verifikationer med datum före detta datum kan inte skapas eller ändras.
{t('locked_through_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label>
<Select
name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
@@ -40,14 +42,14 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
<SelectItem value="none">{t('auto_lock_none')}</SelectItem>
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Låser automatiskt perioder efter valt antal dagar.
{t('auto_lock_help')}
</p>
</div>
</div>
+37 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -19,6 +20,7 @@ const mfaRequired = isMfaRequired()
const bankIdEnabled = isBankIdEnabled()
export function SecuritySettings() {
const t = useTranslations('settings_security')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isChangingPassword, setIsChangingPassword] = useState(false)
@@ -59,8 +61,8 @@ export function SecuritySettings() {
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('toast_weak_password_title'),
description: t('toast_weak_password_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -69,8 +71,8 @@ export function SecuritySettings() {
if (newPassword !== confirmPassword) {
toast({
title: 'Lösenorden matchar inte',
description: 'Kontrollera att du skrev samma lösenord i båda fälten.',
title: t('toast_mismatch_title'),
description: t('toast_mismatch_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -87,24 +89,24 @@ export function SecuritySettings() {
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('toast_update_failed_title'),
description: body.error || t('toast_update_failed_description'),
variant: 'destructive',
})
return
}
toast({
title: 'Lösenord uppdaterat',
description: 'Ditt lösenord har ändrats.',
title: t('toast_password_updated_title'),
description: t('toast_password_updated_description'),
})
setNewPassword('')
setConfirmPassword('')
setHasPassword(true)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -121,7 +123,7 @@ export function SecuritySettings() {
if (error) {
toast({
title: 'Kunde inte inaktivera 2FA',
title: t('toast_unenroll_failed_title'),
description: error.message,
variant: 'destructive',
})
@@ -129,15 +131,15 @@ export function SecuritySettings() {
}
toast({
title: 'Tvåfaktorsautentisering inaktiverad',
description: '2FA har tagits bort från ditt konto.',
title: t('toast_mfa_disabled_title'),
description: t('toast_mfa_disabled_description'),
})
setHasMfa(false)
setMfaFactorId(null)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -182,21 +184,21 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
Ändra lösenord
{t('change_password_title')}
</CardTitle>
<CardDescription>
Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.
{t('change_password_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="new_password">Nytt lösenord</Label>
<Label htmlFor="new_password">{t('new_password_label')}</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken"
placeholder={t('new_password_placeholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
@@ -205,12 +207,12 @@ export function SecuritySettings() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_new_password">Bekräfta nytt lösenord</Label>
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -222,10 +224,10 @@ export function SecuritySettings() {
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
'Uppdatera lösenord'
t('update_password_button')
)}
</Button>
</form>
@@ -239,27 +241,26 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Tvåfaktorsautentisering (2FA)
{t('mfa_title')}
</CardTitle>
<CardDescription>
Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod
utöver ditt lösenord.
{t('mfa_description')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar...
{t('loading')}
</div>
) : hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900">
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-500" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">2FA är aktiverad</p>
<p className="font-medium text-green-900 dark:text-green-100">{t('mfa_active_title')}</p>
<p className="text-sm text-green-700 dark:text-green-400">
Ditt konto skyddas med tvåfaktorsautentisering.
{t('mfa_active_description')}
</p>
</div>
</div>
@@ -272,19 +273,19 @@ export function SecuritySettings() {
{isUnenrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Inaktiverar...
{t('disabling')}
</>
) : (
<>
<ShieldOff className="mr-2 h-4 w-4" />
Inaktivera 2FA
{t('disable_mfa')}
</>
)}
</Button>
)}
{mfaRequired && (
<p className="text-xs text-muted-foreground">
Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras.
{t('mfa_required_note')}
</p>
)}
</div>
@@ -293,9 +294,9 @@ export function SecuritySettings() {
<div className="flex items-center gap-3 p-4 rounded-lg border">
<ShieldOff className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">2FA är inte aktiverad</p>
<p className="font-medium">{t('mfa_inactive_title')}</p>
<p className="text-sm text-muted-foreground">
Vi rekommenderar att du aktiverar tvåfaktorsautentisering.
{t('mfa_inactive_description')}
</p>
</div>
</div>
@@ -307,14 +308,14 @@ export function SecuritySettings() {
)
}
>
Sätt ett lösenord först
{t('set_password_first')}
</Button>
) : (
<Button
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
{t('enable_mfa')}
</Button>
)}
</div>
+11 -9
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
@@ -17,6 +18,7 @@ interface SettingsFormWrapperProps {
}
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false)
@@ -71,7 +73,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
throw new Error(messages.join(' • '))
}
}
throw new Error(result.error || 'Kunde inte spara inställningar')
throw new Error(result.error || t('wrapper_save_failed_default'))
}
onSuccess?.(result.data ?? updates)
@@ -79,14 +81,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('wrapper_save_failed_title'),
description: error instanceof Error ? error.message : t('wrapper_try_again'),
variant: 'destructive',
})
}
setIsSaving(false)
}, [onSave, toast])
}, [onSave, toast, t])
return (
<form onSubmit={handleSubmit} className={className}>
@@ -96,27 +98,27 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
{saved && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" />
Sparat
{t('wrapper_saved')}
</span>
)}
<Button
type="submit"
disabled={isSaving || !canWrite}
size="sm"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sparar...
{t('wrapper_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-3.5 w-3.5" />
Spara ändringar
{t('wrapper_save_changes')}
</>
) : (
'Spara ändringar'
t('wrapper_save_changes')
)}
</Button>
</div>
+15 -13
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
@@ -17,6 +18,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company } = useCompany()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
@@ -24,18 +26,18 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
const items: NavItem[] = [
{ href: '/settings/company', label: 'Företag', show: hasCompany },
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
{ href: '/settings/team', label: 'Lag', show: false },
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: 'Skatteverket', show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
{ href: '/settings/account', label: 'Konto', show: true },
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
{ href: '/settings/company', label: t('company'), show: hasCompany },
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
{ href: '/settings/team', label: t('team'), show: false },
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
@@ -61,7 +63,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{/* Desktop: horizontal tabs with bottom border */}
<nav
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
aria-label="Inställningar"
aria-label={t('aria_label')}
>
<ul className="flex gap-0 -mb-px">
{items.map(item => {
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,21 +22,22 @@ type Status =
disabled?: boolean
}
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: 'Momsdeklaration',
inkforetag: 'Företagsinformation',
skahmst: 'Skattekonto – saldo & transaktioner',
skattekonto: 'Skattekonto',
agd: 'Arbetsgivardeklaration',
}
export function SkatteverketConnectPanel() {
const t = useTranslations('settings_skatteverket_connect')
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: t('scope_momsdeklaration'),
inkforetag: t('scope_inkforetag'),
skahmst: t('scope_skahmst'),
skattekonto: t('scope_skattekonto'),
agd: t('scope_agd'),
}
async function loadStatus() {
setLoading(true)
try {
@@ -68,12 +70,12 @@ export function SkatteverketConnectPanel() {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) throw new Error('Frånkoppling misslyckades')
toast({ title: 'Skatteverket frånkopplad' })
if (!res.ok) throw new Error(t('disconnect_failed'))
toast({ title: t('toast_disconnected') })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla från',
title: t('toast_disconnect_failed'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -86,7 +88,7 @@ export function SkatteverketConnectPanel() {
return (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Hämtar status…
{t('loading_status')}
</CardContent>
</Card>
)
@@ -97,7 +99,7 @@ export function SkatteverketConnectPanel() {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Skatteverket</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
</div>
</CardHeader>
@@ -105,23 +107,20 @@ export function SkatteverketConnectPanel() {
{status?.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.</p>
<p>{t('disabled_message')}</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
arbetsgivardeklaration och hämta saldot på skattekontot.
{t('connect_intro')}
</p>
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
På Skatteverkets samtyckessida visas en av behörigheterna som{' '}
<span className="font-mono">skahmst (Rubrik saknas)</span> — det är
scope-namnet för skattekontots saldo och transaktioner (Skattekonto
HuvudMan STatus). Skatteverket har inte publicerat en svensk
beskrivning för den ännu. Det är ofarligt att godkänna.
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</div>
<Button onClick={startConnect} disabled={status?.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut med BankID
{t('connect_with_bankid')}
</Button>
</CardContent>
</Card>
@@ -139,13 +138,13 @@ export function SkatteverketConnectPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Skatteverket
{t('title')}
{status.expired ? (
<Badge variant="destructive">Utgången</Badge>
<Badge variant="destructive">{t('expired')}</Badge>
) : (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
Ansluten
{t('connected')}
</Badge>
)}
</CardTitle>
@@ -155,27 +154,27 @@ export function SkatteverketConnectPanel() {
<CardContent className="space-y-4">
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Token utgår</dt>
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
<dd className="font-medium tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<span className="ml-2 text-muted-foreground">
(om {expiresInMinutes} min)
{t('expires_in_minutes', { minutes: expiresInMinutes })}
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Förnyelse</dt>
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
<dd className="font-medium">
{status.canRefresh ? 'Förnyas automatiskt' : 'Förnyelse uttömd — anslut igen'}
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
Behörigheter
{t('permissions_label')}
</p>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
@@ -186,15 +185,12 @@ export function SkatteverketConnectPanel() {
</div>
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Skattekonto saknas — koppla från och anslut igen
för att aktivera saldo- och transaktionsvyn.
{t('missing_skattekonto')}
</p>
)}
{!scopes.includes('agd') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla
från och anslut igen för att kunna skicka AGI direkt från {`gnubok`}.
Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.
{t('missing_agd')}
</p>
)}
</div>
@@ -202,7 +198,7 @@ export function SkatteverketConnectPanel() {
{status.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.</p>
<p>{t('disabled_filings_message')}</p>
</div>
)}
@@ -210,7 +206,7 @@ export function SkatteverketConnectPanel() {
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button onClick={startConnect} disabled={status.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
{t('reconnect')}
</Button>
)}
<Button
@@ -219,7 +215,7 @@ export function SkatteverketConnectPanel() {
disabled={disconnecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? 'Kopplar från…' : 'Koppla från'}
{disconnecting ? t('disconnecting') : t('disconnect')}
</Button>
</div>
</CardContent>
@@ -228,11 +224,12 @@ export function SkatteverketConnectPanel() {
}
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
const t = useTranslations('settings_skatteverket_connect')
if (disabled) {
return (
<Badge variant="destructive">
<ShieldAlert className="mr-1 h-3 w-3" />
Avstängd
{t('env_disabled')}
</Badge>
)
}
@@ -240,14 +237,14 @@ function EnvironmentBadge({ environment, disabled }: { environment?: Environment
return (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:border-amber-600 dark:text-amber-400">
<FlaskConical className="mr-1 h-3 w-3" />
Testmiljö
{t('env_test')}
</Badge>
)
}
if (environment === 'prod') {
return (
<Badge variant="outline" className="border-emerald-400 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400">
Produktion
{t('env_prod')}
</Badge>
)
}

Some files were not shown because too many files have changed in this diff Show More