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:
@@ -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
@@ -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"
|
||||
>
|
||||
"Glömt lösenord?"
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "Glömt lösenord?" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
"Skapa ändringsverifikation" om du vill korrigera källverifikatet.
|
||||
{t('copy_banner_body')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 "{searchQuery}"
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '' }),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 "Privatkonto" 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">×</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "Ångra kreditering".
|
||||
{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>
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "Ny verifikation".
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · {profile.legalEntityType}
|
||||
{!isActive && (
|
||||
<span className="ml-2 text-destructive">· Avregistrerat</span>
|
||||
<span className="ml-2 text-destructive">· {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>
|
||||
|
||||
@@ -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 "F-" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & bokför
|
||||
{t('confirm_and_book')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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å "Anpassa" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 → Integrations → 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 → 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>
|
||||
|
||||
@@ -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 “Molnsynkronisering” 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>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} – {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user