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

* feat: add user locale preference to user_preferences table

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

chore: declare CSS module support in TypeScript

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

* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -35,36 +36,31 @@ interface ReferenceAccount extends BASReferenceAccount {
is_system_account: boolean
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgång',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
untaxed_reserves: 'Ob. reserver',
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ChartOfAccountsManager() {
const { toast } = useToast()
const t = useTranslations('chart_of_accounts')
const classLabel = (cls: number): string => {
const key = `class_${cls}` as const
try {
return t(key)
} catch {
return ''
}
}
const typeLabel = (type: string): string => {
const key = `type_${type}` as const
try {
return t(key)
} catch {
return type
}
}
// View state
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
@@ -142,17 +138,17 @@ export default function ChartOfAccountsManager() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !account.is_active }),
})
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
if (!res.ok) throw new Error(t('toast_update_failed'))
await refreshAll()
} catch {
toast({ title: 'Kunde inte uppdatera kontot', variant: 'destructive' })
toast({ title: t('toast_update_failed'), variant: 'destructive' })
} finally {
setTogglingAccount(null)
}
}
async function deleteAccount(account: BASAccount) {
const confirmed = window.confirm(`Vill du ta bort konto ${account.account_number} ${account.account_name}?`)
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
if (!confirmed) return
setDeletingAccount(account.account_number)
try {
@@ -161,13 +157,13 @@ export default function ChartOfAccountsManager() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Kunde inte ta bort kontot')
throw new Error(data.error || t('toast_delete_failed'))
}
toast({ title: 'Konto borttaget', description: `${account.account_number} ${account.account_name}` })
toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` })
await refreshAll()
} catch (err) {
toast({
title: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
title: err instanceof Error ? err.message : t('toast_delete_failed'),
variant: 'destructive',
})
} finally {
@@ -183,14 +179,14 @@ export default function ChartOfAccountsManager() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!res.ok) throw new Error('Kunde inte aktivera kontot')
if (!res.ok) throw new Error(t('toast_activate_failed'))
const { activated } = await res.json()
if (activated > 0) {
toast({ title: 'Konto aktiverat', description: `Konto ${accountNumber} har lagts till i din kontoplan` })
toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) })
}
await refreshAll()
} catch {
toast({ title: 'Kunde inte aktivera kontot', variant: 'destructive' })
toast({ title: t('toast_activate_failed'), variant: 'destructive' })
} finally {
setActivatingAccounts((prev) => {
const next = new Set(prev)
@@ -271,7 +267,7 @@ export default function ChartOfAccountsManager() {
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Laddar kontoplan...
{t('loading')}
</CardContent>
</Card>
)
@@ -290,14 +286,14 @@ export default function ChartOfAccountsManager() {
>
<TabsList>
<TabsTrigger value="my-accounts">
Mina konton
{t('tab_my_accounts')}
<Badge variant="secondary" className="ml-1.5 text-xs">
{accounts.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="bas-catalog">
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
BAS-katalog
{t('tab_bas_catalog')}
</TabsTrigger>
</TabsList>
</Tabs>
@@ -305,7 +301,7 @@ export default function ChartOfAccountsManager() {
{view === 'my-accounts' && (
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Eget konto
{t('add_own')}
</Button>
)}
@@ -316,7 +312,7 @@ export default function ChartOfAccountsManager() {
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dölj K2-undantagna</span>
<span className="text-muted-foreground">{t('hide_k2_excluded')}</span>
</label>
)}
</div>
@@ -325,7 +321,7 @@ export default function ChartOfAccountsManager() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök konto (nummer eller namn)..."
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -355,7 +351,7 @@ export default function ChartOfAccountsManager() {
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
{t('class_heading', { cls, label: classLabel(classNum) })}
</span>
<Badge variant="secondary" className="text-xs">
{activeCount}/{classAccounts.length}
@@ -368,11 +364,11 @@ export default function ChartOfAccountsManager() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-16 text-center">Aktiv</th>
<th className="py-2 w-24">{t('col_account')}</th>
<th className="py-2">{t('col_name')}</th>
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
<th className="py-2 w-24 text-center">{t('col_type')}</th>
<th className="py-2 w-16 text-center">{t('col_active')}</th>
<th className="py-2 w-20 text-right"></th>
</tr>
</thead>
@@ -392,7 +388,7 @@ export default function ChartOfAccountsManager() {
{account.account_name}
{account.is_system_account && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
System
{t('system_badge')}
</Badge>
)}
</span>
@@ -404,7 +400,7 @@ export default function ChartOfAccountsManager() {
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
{typeLabel(account.account_type)}
</Badge>
</td>
<td className="py-2 text-center">
@@ -455,7 +451,7 @@ export default function ChartOfAccountsManager() {
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sökningen' : 'Inga konton i kontoplanen'}
{searchQuery ? t('no_matches') : t('no_accounts')}
</CardContent>
</Card>
)}
@@ -485,10 +481,10 @@ export default function ChartOfAccountsManager() {
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
{t('class_heading', { cls, label: classLabel(classNum) })}
</span>
<Badge variant="secondary" className="text-xs">
{activatedCount}/{classAccounts.length} aktiva
{t('active_count_label', { active: activatedCount, total: classAccounts.length })}
</Badge>
</div>
</button>
@@ -498,11 +494,11 @@ export default function ChartOfAccountsManager() {
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-28 text-right">Status</th>
<th className="py-2 w-24">{t('col_account')}</th>
<th className="py-2">{t('col_name')}</th>
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
<th className="py-2 w-24 text-center">{t('col_type')}</th>
<th className="py-2 w-28 text-right">{t('col_status')}</th>
</tr>
</thead>
<tbody>
@@ -533,14 +529,14 @@ export default function ChartOfAccountsManager() {
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
{typeLabel(account.account_type)}
</Badge>
</td>
<td className="py-2 text-right">
{account.is_activated ? (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
Aktiverat
{t('activated')}
</span>
) : (
<Button
@@ -555,7 +551,7 @@ export default function ChartOfAccountsManager() {
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lägg till
{t('add')}
</Button>
)}
</td>
@@ -572,7 +568,7 @@ export default function ChartOfAccountsManager() {
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sökningen
{t('no_matches')}
</CardContent>
</Card>
)}
+16 -13
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Info } from 'lucide-react'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
@@ -12,14 +13,17 @@ interface Props {
chain: JournalEntry[]
}
function getRole(entry: JournalEntry): { label: string; color: string } {
if (entry.source_type === 'storno') {
return { label: 'Storno', color: 'bg-destructive' }
function useGetRole() {
const t = useTranslations('journal_correction')
return (entry: JournalEntry): { label: string; color: string } => {
if (entry.source_type === 'storno') {
return { label: t('role_storno'), color: 'bg-destructive' }
}
if (entry.source_type === 'correction') {
return { label: t('role_correction'), color: 'bg-primary' }
}
return { label: t('role_original'), color: 'bg-muted-foreground' }
}
if (entry.source_type === 'correction') {
return { label: 'Rättelse', color: 'bg-primary' }
}
return { label: 'Original', color: 'bg-muted-foreground' }
}
function getTotal(entry: JournalEntry): number {
@@ -28,6 +32,8 @@ function getTotal(entry: JournalEntry): number {
}
export default function CorrectionChain({ currentEntryId, chain }: Props) {
const t = useTranslations('journal_correction')
const getRole = useGetRole()
if (chain.length === 0) return null
// Combine current entry isn't in chain — chain is "other" entries
@@ -38,14 +44,11 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">Ändringskedja</h3>
<h3 className="text-sm font-medium">{t('title')}</h3>
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<p>
Bokförda verifikationer kan inte ändras direkt. Istället skapas en stornoverifikation
som nollställer den ursprungliga, och en ny rättelsepost med de korrekta uppgifterna.
</p>
<p>{t('info')}</p>
</div>
<div className="relative space-y-0">
@@ -76,7 +79,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
<JournalEntryStatusBadge entry={entry} showStatus={false} />
{isCurrent && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Aktuell
{t('current')}
</Badge>
)}
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
+45 -5
View File
@@ -40,6 +40,22 @@ function isImageType(type: string): boolean {
return type.startsWith('image/')
}
/**
* Extract a Swedish error message from the structured error envelope
* returned by /api/documents. Falls back to message_en or null if the
* shape is unexpected.
*/
function extractErrorMessage(err: unknown): string | null {
if (typeof err === 'string') return err
if (err && typeof err === 'object') {
const e = err as { message?: unknown; message_en?: unknown; code?: unknown }
if (typeof e.message === 'string' && e.message.length > 0) return e.message
if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en
if (typeof e.code === 'string') return e.code
}
return null
}
export default function DocumentUploadZone({
files,
onFilesChange,
@@ -64,15 +80,39 @@ export default function DocumentUploadZone({
method: 'POST',
body: formData,
})
const result = await res.json()
if (result.error) {
return { ...file, status: 'error', error: result.error }
// Try to parse JSON, but tolerate non-JSON responses (auth redirect HTML, 502 etc.)
let result: { data?: { id?: string }; error?: unknown } = {}
try {
result = await res.json()
} catch {
console.warn('[DocumentUploadZone] Non-JSON response', {
status: res.status,
fileName: file.fileName,
})
const reason = res.status === 401 || res.status === 403
? 'Din session har gått ut. Ladda om sidan och logga in igen.'
: `Servern svarade ${res.status}.`
return { ...file, status: 'error', error: reason }
}
if (!res.ok || result.error) {
const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})`
console.warn('[DocumentUploadZone] Upload error', {
status: res.status,
error: result.error,
fileName: file.fileName,
})
return { ...file, status: 'error', error: errMessage }
}
return { ...file, status: 'uploaded', id: result.data?.id }
} catch {
return { ...file, status: 'error', error: 'Uppladdning misslyckades' }
} catch (err) {
console.error('[DocumentUploadZone] Upload threw', {
error: err,
fileName: file.fileName,
})
return { ...file, status: 'error', error: 'Uppladdning misslyckades — nätverksfel' }
}
}, [journalEntryId])
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -43,6 +44,7 @@ export default function JournalEntryAttachments({
journalEntryId,
onCountChange,
}: JournalEntryAttachmentsProps) {
const t = useTranslations('journal_attachments')
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
@@ -122,7 +124,7 @@ export default function JournalEntryAttachments({
if (loading) {
return (
<div className="py-2 text-sm text-muted-foreground">
Laddar underlag...
{t('loading')}
</div>
)
}
@@ -131,7 +133,7 @@ export default function JournalEntryAttachments({
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
Underlag {documents.length > 0 && `(${documents.length})`}
{t('title')} {documents.length > 0 && `(${documents.length})`}
</h4>
<Button
variant="outline"
@@ -140,7 +142,7 @@ export default function JournalEntryAttachments({
onClick={() => setShowUpload(!showUpload)}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till underlag
{t('add')}
</Button>
</div>
@@ -159,7 +161,7 @@ export default function JournalEntryAttachments({
{/* Document list */}
{documents.length === 0 && !showUpload ? (
<p className="text-sm text-muted-foreground py-1">
Inga underlag bifogade.
{t('empty')}
</p>
) : (
<div className="space-y-1">
@@ -199,7 +201,7 @@ export default function JournalEntryAttachments({
size="sm"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleDownload(doc.id)}
title="Ladda ner"
title={t('download')}
>
<Download className="h-3 w-3" />
</Button>
+58 -56
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -77,6 +78,7 @@ export default function JournalEntryForm({
const { canWrite } = useCanWrite()
const { toast } = useToast()
const { company } = useCompany()
const t = useTranslations('journal_form')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
@@ -379,16 +381,16 @@ export default function JournalEntryForm({
}
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
title: t('toast_attach_failed_title'),
description: t('toast_attach_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
}
toast({
title: 'Verifikation skapad',
description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`,
title: t('toast_created_title'),
description: t('toast_created_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }),
})
setShowReview(false)
setDescription('')
@@ -408,7 +410,7 @@ export default function JournalEntryForm({
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte skapa verifikation',
title: t('toast_create_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -442,8 +444,8 @@ export default function JournalEntryForm({
}
toast({
title: 'Utkast sparat',
description: 'Utkastet kan bokföras från bokföringssidan.',
title: t('toast_draft_saved_title'),
description: t('toast_draft_saved_description'),
})
setDescription('')
setNotes('')
@@ -462,7 +464,7 @@ export default function JournalEntryForm({
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte spara utkast',
title: t('toast_save_draft_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -483,10 +485,10 @@ export default function JournalEntryForm({
: 'sm:grid-cols-[1fr_auto_1fr_3.5rem]'
}`}>
<div>
<Label>Räkenskapsår</Label>
<Label>{t('fiscal_year')}</Label>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('fiscal_year_placeholder')} />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
@@ -499,7 +501,7 @@ export default function JournalEntryForm({
</div>
{!(embedded && initialDate) && (
<div>
<Label>Datum</Label>
<Label>{t('date')}</Label>
<Input
type="date"
value={entryDate}
@@ -508,19 +510,19 @@ export default function JournalEntryForm({
</div>
)}
<div>
<Label>Beskrivning</Label>
<Label>{t('description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Verifikationstext..."
placeholder={t('description_placeholder')}
/>
</div>
<div className={embedded ? 'hidden' : 'col-span-full'}>
<Label>Intern anteckning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Label>{t('internal_note')} <span className="text-muted-foreground font-normal">{t('internal_note_optional')}</span></Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="T.ex. anledning till bokning, referens till mejl, etc."
placeholder={t('internal_note_placeholder')}
className="mt-1 resize-none"
rows={2}
maxLength={2000}
@@ -528,7 +530,7 @@ export default function JournalEntryForm({
</div>
{!embedded && (
<div>
<Label>Serie</Label>
<Label>{t('series')}</Label>
<Input
value={voucherSeries}
onChange={(e) => {
@@ -554,8 +556,8 @@ export default function JournalEntryForm({
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground">
<p className="font-medium">Inget räkenskapsår matchar datumet {entryDate}</p>
<p className="mt-0.5">Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.</p>
<p className="font-medium">{t('no_period_warning', { date: entryDate })}</p>
<p className="mt-0.5">{t('no_period_help')}</p>
</div>
<Button
variant="outline"
@@ -564,7 +566,7 @@ export default function JournalEntryForm({
className="shrink-0"
>
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
Skapa räkenskapsår
{t('create_period')}
</Button>
</div>
)}
@@ -572,7 +574,7 @@ export default function JournalEntryForm({
{/* Currency section */}
<div className="flex flex-wrap items-end gap-3">
<div className="w-24">
<Label className="text-xs text-muted-foreground">Valuta</Label>
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
<Select value={entryCurrency} onValueChange={(v) => {
setEntryCurrency(v as Currency)
if (v === 'SEK') {
@@ -594,7 +596,7 @@ export default function JournalEntryForm({
<>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Omräkningskurs (1 {entryCurrency} = ? SEK)
{t('exchange_rate_label', { currency: entryCurrency })}
</Label>
<div className="relative mt-1">
<Input
@@ -613,7 +615,7 @@ export default function JournalEntryForm({
</div>
<div className="w-40">
<Label className="text-xs text-muted-foreground">
Belopp i {entryCurrency}
{t('amount_in_currency_label', { currency: entryCurrency })}
</Label>
<Input
type="number"
@@ -659,11 +661,11 @@ export default function JournalEntryForm({
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
placeholder={t('line_description_placeholder')}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Label className="text-xs text-muted-foreground">{t('col_debit')}</Label>
<Input
type="number"
value={line.debit_amount}
@@ -676,7 +678,7 @@ export default function JournalEntryForm({
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Label className="text-xs text-muted-foreground">{t('col_credit')}</Label>
<Input
type="number"
value={line.credit_amount}
@@ -694,13 +696,13 @@ export default function JournalEntryForm({
{/* Mobile totals */}
<div className="flex justify-between items-center px-1 pt-2 font-semibold text-sm">
<span>Summa</span>
<span>{t('sum')}</span>
<div className="flex gap-4">
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{t('sum_d', { amount: totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
</span>
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{t('sum_k', { amount: totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
</span>
</div>
</div>
@@ -713,7 +715,7 @@ export default function JournalEntryForm({
className="flex-1"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
@@ -727,10 +729,10 @@ export default function JournalEntryForm({
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
<th className="py-2 w-32 px-1 text-right">Kredit</th>
<th className="py-2 w-28">{t('col_account')}</th>
<th className="py-2 px-1">{t('col_description')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_debit')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_credit')}</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
@@ -748,7 +750,7 @@ export default function JournalEntryForm({
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
placeholder={t('line_description_placeholder')}
className="h-8"
/>
</td>
@@ -793,7 +795,7 @@ export default function JournalEntryForm({
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2 px-1">
Summa
{t('sum')}
</td>
<td
className={`py-2 px-1 text-right ${
@@ -821,7 +823,7 @@ export default function JournalEntryForm({
onClick={addLine}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
@@ -833,7 +835,7 @@ export default function JournalEntryForm({
{/* Document attachments */}
{!embedded && (
<div>
<Label className="mb-2 block">Underlag</Label>
<Label className="mb-2 block">{t('attachments_label')}</Label>
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
@@ -843,7 +845,7 @@ export default function JournalEntryForm({
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-destructive">
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
{t('difference', { amount: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
</p>
)}
@@ -853,31 +855,31 @@ export default function JournalEntryForm({
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : 'Sparar som utkast utan att tilldela verifikationsnummer'}
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara som utkast
{t('save_draft')}
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
{t('review_and_create')}
</Button>
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
{isUploading && <p>Vänta tills filerna laddats upp</p>}
{!description && <p>{t('validation_description')}</p>}
{!selectedPeriod && <p>{t('validation_period')}</p>}
{periodMismatch === 'no_period' && <p>{t('validation_no_matching_period')}</p>}
{isUploading && <p>{t('validation_uploading')}</p>}
{incompleteLineCount > 0 && (
<p>Alla rader med belopp måste ha ett konto (och tvärtom)</p>
<p>{t('validation_incomplete_lines')}</p>
)}
{submittableLines.length < 2 && incompleteLineCount === 0 && (
<p>Minst två rader med konto och belopp krävs</p>
<p>{t('validation_min_lines')}</p>
)}
</div>
)}
@@ -897,10 +899,10 @@ export default function JournalEntryForm({
isSubmitting={isSubmitting}
title={
!embedded && nextVoucherNumber != null
? `Granska verifikation (${voucherSeries}${nextVoucherNumber})`
: 'Granska verifikation'
? t('review_title_with_voucher', { voucher: `${voucherSeries}${nextVoucherNumber}` })
: t('review_title')
}
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
warningText={embedded ? '' : t('review_warning')}
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
@@ -926,12 +928,12 @@ export default function JournalEntryForm({
setShowReview(true)
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Inget underlag bifogat. Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan."
confirmLabel="Bokför utan underlag"
title={t('no_doc_dialog_title')}
warningText={t('no_doc_dialog_warning')}
confirmLabel={t('no_doc_confirm')}
>
<div className="text-sm text-muted-foreground">
Granska uppgifterna innan du bekräftar.
{t('no_doc_body')}
</div>
</ConfirmationDialog>
@@ -952,7 +954,7 @@ export default function JournalEntryForm({
return (
<Card>
<CardHeader>
<CardTitle>Ny verifikation</CardTitle>
<CardTitle>{t('card_title')}</CardTitle>
</CardHeader>
<CardContent>
{formContent}
+38 -36
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -39,6 +40,7 @@ export default function JournalEntryList({ periodId }: Props) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('journal_list')
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -156,15 +158,15 @@ export default function JournalEntryList({ periodId }: Props) {
if (res.ok) {
const posted = result.data
toast({
title: 'Verifikat bokfört',
description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`,
title: t('toast_posted_title'),
description: t('toast_posted_description', { voucher: `${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''}` }),
})
await fetchEntries()
} else {
toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' })
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
} finally {
setCommittingId(null)
}
@@ -175,7 +177,7 @@ export default function JournalEntryList({ periodId }: Props) {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Laddar verifikationer...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</CardContent>
</Card>
)
@@ -188,9 +190,9 @@ export default function JournalEntryList({ periodId }: Props) {
<div className="p-4 rounded-full bg-muted mb-4">
<BookOpen className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">Inga verifikationer</h3>
<h3 className="text-lg font-medium mb-1">{t('empty_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken &quot;Ny verifikation&quot;.
{t('empty_description')}
</p>
</CardContent>
</Card>
@@ -218,7 +220,7 @@ export default function JournalEntryList({ periodId }: Props) {
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
{t('show_missing')}
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
@@ -232,16 +234,16 @@ export default function JournalEntryList({ periodId }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">Datum, nyast först</SelectItem>
<SelectItem value="date_asc">Datum, äldst först</SelectItem>
<SelectItem value="voucher_asc">Verifikat, A1 först</SelectItem>
<SelectItem value="voucher_desc">Verifikat, senaste först</SelectItem>
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="Från YYYY-MM-DD"
placeholder={t('date_from_placeholder')}
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={() => {
@@ -260,7 +262,7 @@ export default function JournalEntryList({ periodId }: Props) {
/>
<Input
type="text"
placeholder="Till YYYY-MM-DD"
placeholder={t('date_to_placeholder')}
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={() => {
@@ -283,14 +285,14 @@ export default function JournalEntryList({ periodId }: Props) {
className="h-8 text-xs shrink-0"
onClick={applyDateFilter}
>
Filtrera
{t('filter')}
</Button>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
title="Rensa datumfilter"
title={t('clear_date_filter')}
>
<X className="h-4 w-4" />
</button>
@@ -331,9 +333,9 @@ export default function JournalEntryList({ periodId }: Props) {
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title="Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året)."
title={t('out_of_period_tooltip')}
>
Efterföljande
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
@@ -341,13 +343,13 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="flex-1 truncate">{entry.description}</span>
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title="Underlag saknas">
<span className="mr-1" title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
@@ -375,9 +377,9 @@ export default function JournalEntryList({ periodId }: Props) {
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title="Bokförd i ett senare räkenskapsår, men avser det valda året."
title={t('out_of_period_tooltip_mobile')}
>
Efterföljande
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
@@ -385,13 +387,13 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
<span className="flex items-center gap-0.5 text-muted-foreground" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title="Underlag saknas">
<span title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
@@ -405,7 +407,7 @@ export default function JournalEntryList({ periodId }: Props) {
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
) : (
<>
<div className="space-y-3">
@@ -427,7 +429,7 @@ export default function JournalEntryList({ periodId }: Props) {
)}
<div className="flex justify-between items-center pt-1 border-t text-sm">
<span className="text-muted-foreground">
{Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'}
{Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
</span>
<div className="text-right">
<span className="font-mono tabular-nums font-medium">
@@ -447,11 +449,11 @@ export default function JournalEntryList({ periodId }: Props) {
})}
<div className="rounded-lg bg-muted/50 p-3 text-sm font-semibold space-y-1">
<div className="flex justify-between">
<span>Summa debet</span>
<span>{t('sum_debit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between">
<span>Summa kredit</span>
<span>{t('sum_credit')}</span>
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
@@ -477,14 +479,14 @@ export default function JournalEntryList({ periodId }: Props) {
className="w-full sm:w-auto"
onClick={() => handleCommit(entry.id)}
disabled={!canWrite || committingId === entry.id}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
{t('post')}
</Button>
)}
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
<Link href={`/bookkeeping/${entry.id}`}>{t('show_details')}</Link>
</Button>
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
<Button
@@ -493,7 +495,7 @@ export default function JournalEntryList({ periodId }: Props) {
className="w-full sm:w-auto"
onClick={() => setCorrectionEntry(entry)}
>
Skapa ändringsverifikation
{t('create_correction')}
</Button>
)}
<Button
@@ -503,7 +505,7 @@ export default function JournalEntryList({ periodId }: Props) {
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
>
<Copy className="mr-2 h-4 w-4" />
Kopiera
{t('copy')}
</Button>
</div>
</CardContent>
@@ -532,10 +534,10 @@ export default function JournalEntryList({ periodId }: Props) {
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Föregående
{t('previous')}
</Button>
<span className="text-sm text-muted-foreground self-center">
Sida {page + 1} av {Math.ceil(count / pageSize)}
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
</span>
<Button
variant="outline"
@@ -543,7 +545,7 @@ export default function JournalEntryList({ periodId }: Props) {
disabled={(page + 1) * pageSize >= count}
onClick={() => setPage(page + 1)}
>
Nästa
{t('next')}
</Button>
</div>
)}
@@ -1,35 +1,53 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import type { JournalEntry } from '@/types'
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
draft: { label: 'Utkast', variant: 'secondary' },
posted: { label: 'Bokförd', variant: 'success' },
reversed: { label: 'Omförd', variant: 'warning' },
cancelled: { label: 'Makulerad', variant: 'secondary' },
type BadgeVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
const statusVariants: Record<string, BadgeVariant> = {
draft: 'secondary',
posted: 'success',
reversed: 'warning',
cancelled: 'secondary',
}
const sourceTypeBadges: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
storno: { label: 'Storno', variant: 'destructive' },
correction: { label: 'Rättelse', variant: 'default' },
const sourceTypeVariants: Record<string, BadgeVariant> = {
storno: 'destructive',
correction: 'default',
}
export const sourceTypeLabels: Record<string, string> = {
manual: 'Manuell',
bank_transaction: 'Banktransaktion',
invoice_created: 'Faktura skapad',
invoice_paid: 'Fakturabetalning',
credit_note: 'Kreditfaktura',
salary_payment: 'Lön',
opening_balance: 'Ingående balans',
year_end: 'Årsbokslut',
storno: 'Storno',
correction: 'Rättelse',
import: 'Import',
system: 'System',
supplier_invoice_registered: 'Leverantörsfaktura',
supplier_invoice_paid: 'Leverantörsbetalning',
supplier_invoice_cash_payment: 'Kontantbetalning',
currency_revaluation: 'Valutaomvärdering',
const SOURCE_TYPES = [
'manual',
'bank_transaction',
'invoice_created',
'invoice_paid',
'credit_note',
'salary_payment',
'opening_balance',
'year_end',
'storno',
'correction',
'import',
'system',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'currency_revaluation',
] as const
/**
* Hook returning the translated source-type label map. Use this in client
* components that need to render the human-readable label for a source_type.
*/
export function useSourceTypeLabels(): Record<string, string> {
const t = useTranslations('journal_status')
const out: Record<string, string> = {}
for (const key of SOURCE_TYPES) {
out[key] = t(`source_label_${key}`)
}
return out
}
interface Props {
@@ -38,19 +56,32 @@ interface Props {
}
export default function JournalEntryStatusBadge({ entry, showStatus = true }: Props) {
const status = statusConfig[entry.status]
const sourceType = sourceTypeBadges[entry.source_type]
const t = useTranslations('journal_status')
const statusVariant = statusVariants[entry.status]
const sourceVariant = sourceTypeVariants[entry.source_type]
const statusLabelKey =
entry.status === 'draft' ? 'status_draft'
: entry.status === 'posted' ? 'status_posted'
: entry.status === 'reversed' ? 'status_reversed'
: entry.status === 'cancelled' ? 'status_cancelled'
: null
const sourceLabelKey =
entry.source_type === 'storno' ? 'source_storno'
: entry.source_type === 'correction' ? 'source_correction'
: null
return (
<span className="inline-flex items-center gap-1">
{showStatus && status && (
<Badge variant={status.variant} className="text-[10px] px-1.5 py-0">
{status.label}
{showStatus && statusVariant && statusLabelKey && (
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0">
{t(statusLabelKey)}
</Badge>
)}
{sourceType && (
<Badge variant={sourceType.variant} className="text-[10px] px-1.5 py-0">
{sourceType.label}
{sourceVariant && sourceLabelKey && (
<Badge variant={sourceVariant} className="text-[10px] px-1.5 py-0">
{t(sourceLabelKey)}
</Badge>
)}
</span>
+11 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
@@ -59,14 +60,16 @@ export function FiscalYearSelector({
value,
onChange,
includeAllOption = true,
label = 'Räkenskapsår',
label,
hideFuturePeriods = false,
onReady,
className,
}: Props) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [loaded, setLoaded] = useState(false)
const effectiveLabel = label === null ? null : (label ?? t('label'))
useEffect(() => {
if (!company?.id) {
@@ -149,24 +152,24 @@ export function FiscalYearSelector({
return (
<div className={className}>
{label && <Label>{label}</Label>}
<div className={`flex items-center gap-2 ${label ? 'mt-1' : ''}`}>
{effectiveLabel && <Label>{effectiveLabel}</Label>}
<div className={`flex items-center gap-2 ${effectiveLabel ? 'mt-1' : ''}`}>
<Select
value={selectValue}
onValueChange={handleChange}
disabled={!loaded || periods.length === 0}
>
<SelectTrigger className="w-full sm:w-[280px]">
<SelectValue placeholder={loaded ? 'Välj räkenskapsår' : 'Laddar…'} />
<SelectValue placeholder={loaded ? t('placeholder') : t('loading')} />
</SelectTrigger>
<SelectContent>
{includeAllOption && (
<SelectItem value={ALL_YEARS_VALUE}>Alla räkenskapsår</SelectItem>
<SelectItem value={ALL_YEARS_VALUE}>{t('all_years')}</SelectItem>
)}
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.period_start} — {p.period_end})
{p.locked_at ? ' — låst' : p.is_closed ? ' — stängt' : ''}
{p.locked_at ? t('suffix_locked') : p.is_closed ? t('suffix_closed') : ''}
</SelectItem>
))}
</SelectContent>
@@ -175,14 +178,10 @@ export function FiscalYearSelector({
<Badge
variant="outline"
className="gap-1 text-xs font-normal shrink-0"
title={
lockState === 'locked'
? 'Räkenskapsåret är låst — ingen bokföring kan ändras eller läggas till'
: 'Räkenskapsåret är stängt (årsbokslut upprättat) — kan återöppnas av admin'
}
title={lockState === 'locked' ? t('tooltip_locked') : t('tooltip_closed')}
>
<Lock className="h-3 w-3" />
{lockState === 'locked' ? 'Låst' : 'Stängt'}
{lockState === 'locked' ? t('badge_locked') : t('badge_closed')}
</Badge>
)}
</div>
+64 -63
View File
@@ -1,9 +1,10 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,30 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateCustomerInput, CustomerType } from '@/types'
const schema = z.object({
name: z.string().min(1, 'Namn krävs'),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
import type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
initialData?: Partial<FormData>
initialData?: Partial<CreateCustomerInput>
}
export default function CustomerForm({
@@ -45,18 +28,36 @@ export default function CustomerForm({
}: CustomerFormProps) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('form_customer')
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
valid: boolean
name?: string
} | null>(null)
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}), [t])
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -101,19 +102,19 @@ export default function CustomerForm({
if (result.valid && result.name) {
toast({
title: 'VAT-nummer verifierat',
description: `Företag: ${result.name}`,
title: t('vat_verified_title'),
description: t('vat_verified_description', { name: result.name }),
})
} else if (!result.valid) {
toast({
title: 'Verifiering misslyckades',
description: result.error || 'VAT-numret kunde inte verifieras',
title: t('vat_failed_title'),
description: result.error || t('vat_failed_default'),
variant: 'destructive',
})
}
} catch {
toast({
title: 'Kunde inte verifiera VAT-nummer',
title: t('vat_error_title'),
variant: 'destructive',
})
} finally {
@@ -132,35 +133,35 @@ export default function CustomerForm({
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Customer Type */}
<div className="space-y-2">
<Label>Kundtyp *</Label>
<Label>{t('type_label')}</Label>
<Controller
name="customer_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder="Välj kundtyp" />
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Privatperson (Sverige)</SelectItem>
<SelectItem value="swedish_business">Svenskt företag eller organisation</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
<SelectItem value="individual">{t('type_individual')}</SelectItem>
<SelectItem value="swedish_business">{t('type_swedish_business')}</SelectItem>
<SelectItem value="eu_business">{t('type_eu_business')}</SelectItem>
<SelectItem value="non_eu_business">{t('type_non_eu_business')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Kundtypen påverkar hur moms hanteras på fakturor
{t('type_hint')}
</p>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Namn *</Label>
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder="Företagsnamn eller personnamn"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
@@ -171,11 +172,11 @@ export default function CustomerForm({
{/* Contact */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
type="email"
placeholder="namn@foretag.se"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
@@ -183,10 +184,10 @@ export default function CustomerForm({
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder="+46 70 123 45 67"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
</div>
@@ -194,37 +195,37 @@ export default function CustomerForm({
{/* Address */}
<div className="space-y-4">
<h3 className="font-medium">Adress</h3>
<h3 className="font-medium">{t('address_section')}</h3>
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Label htmlFor="address_line1">{t('street_label')}</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
placeholder={t('street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('postal_label')}</Label>
<Input
id="postal_code"
placeholder="123 45"
placeholder={t('postal_placeholder')}
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
placeholder="Stockholm"
placeholder={t('city_placeholder')}
{...register('city')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Land</Label>
<Label htmlFor="country">{t('country_label')}</Label>
<Input
id="country"
placeholder="Sweden"
placeholder={t('country_placeholder')}
{...register('country')}
/>
</div>
@@ -234,24 +235,24 @@ export default function CustomerForm({
{/* Business info */}
{customerType !== 'individual' && (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Företagsuppgifter</h3>
<h3 className="font-medium">{t('business_section')}</h3>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder="XXXXXX-XXXX"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
</div>
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">VAT-nummer (momsreg.nr)</Label>
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? 'DE123456789' : 'SE123456789001'}
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
@@ -269,14 +270,14 @@ export default function CustomerForm({
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
'Verifiera'
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
Verifiera VAT-numret för att kunna fakturera med omvänd skattskyldighet (0% moms)
{t('vat_hint_eu')}
</p>
)}
</div>
@@ -286,7 +287,7 @@ export default function CustomerForm({
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
<Input
id="payment_terms"
type="number"
@@ -296,10 +297,10 @@ export default function CustomerForm({
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder="Interna anteckningar om kunden..."
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
@@ -309,20 +310,20 @@ export default function CustomerForm({
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('submit_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Spara kund
{t('submit_save')}
</>
) : (
'Spara kund'
t('submit_save')
)}
</Button>
</div>
+7 -5
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { switchCompany } from '@/lib/company/actions'
@@ -10,6 +11,7 @@ import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
export default function CompanySwitcher() {
const { company, companies, isSandbox } = useCompany()
const t = useTranslations('company_switcher')
const [open, setOpen] = useState(false)
const [isPending, setIsPending] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -115,7 +117,7 @@ export default function CompanySwitcher() {
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-[13px] text-muted-foreground truncate">Lägg till företag</span>
<span className="text-[13px] text-muted-foreground truncate">{t('add_company')}</span>
</Link>
)
}
@@ -130,9 +132,9 @@ export default function CompanySwitcher() {
aria-haspopup="listbox"
>
<div className="flex-1 min-w-0">
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">Företag</p>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">{t('company_label')}</p>
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
{company?.name || 'Min verksamhet'}
{company?.name || t('default_company_name')}
</p>
</div>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground flex-shrink-0" />
@@ -149,7 +151,7 @@ export default function CompanySwitcher() {
{hasMultiple && (
<div className="px-2 py-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] px-1.5">
Företag
{t('company_label')}
</p>
</div>
)}
@@ -196,7 +198,7 @@ export default function CompanySwitcher() {
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
>
<Plus className="h-3.5 w-3.5" />
Lägg till företag
{t('add_company')}
</Link>
</div>
)}
+39 -32
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
@@ -45,6 +46,7 @@ interface DashboardContentProps {
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const t = useTranslations('dashboard')
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
@@ -96,9 +98,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="font-medium text-sm">{t('overdue_invoices')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
{t('overdue_invoices_count', { count: summary.overdueInvoicesCount })}
</p>
</div>
</div>
@@ -116,9 +118,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="font-medium text-sm">{t('unpaid_invoices')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
{t('unpaid_invoices_detail', {
count: summary.unpaidInvoicesCount - summary.overdueInvoicesCount,
amount: formatCurrency(summary.unpaidInvoicesTotal),
})}
</p>
</div>
</div>
@@ -136,9 +141,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="font-medium text-sm">{t('transactions')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
{t('uncategorized_count', { count: summary.uncategorizedCount })}
</p>
</div>
</div>
@@ -156,9 +161,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="font-medium text-sm">{t('missing_underlag')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
{t('missing_underlag_detail', { count: summary.missingUnderlagCount })}
</p>
</div>
</div>
@@ -176,9 +181,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Clock className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Gamla transaktioner</p>
<p className="font-medium text-sm">{t('stale_transactions')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.staleUncategorizedCount} transaktioner äldre än 14 dagar saknar bokföring
{t('stale_transactions_detail', { count: summary.staleUncategorizedCount })}
</p>
</div>
</div>
@@ -197,9 +202,11 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="font-medium text-sm">{t('bank_consent_expiring')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
{conn.days_left === 1
? t('bank_consent_detail_one', { bank: conn.bank_name, days: conn.days_left })
: t('bank_consent_detail_other', { bank: conn.bank_name, days: conn.days_left })}
</p>
</div>
</div>
@@ -226,7 +233,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
@@ -235,7 +242,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} i år
{formatCurrency(summary.ytd.net)} {t('this_year_short')}
</p>
</CardContent>
</Card>
@@ -244,12 +251,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
@@ -261,7 +268,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
@@ -273,10 +280,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-sm font-medium text-primary">{t('connect_bank')}</p>
</CardContent>
</Card>
</Link>
@@ -284,17 +291,17 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
<div role="status" aria-live="polite">
{todoCount > 0 ? (
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
</p>
) : (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">Allt klart!</p>
<p className="text-sm font-medium text-success">{t('all_done')}</p>
</div>
)}
</div>
@@ -303,19 +310,19 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
</div>
</section>
{/* Resultat — intäkter / kostnader (always visible) */}
{/* Result — revenue / expenses (always visible) */}
<section>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</CardContent>
@@ -323,14 +330,14 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</CardContent>
@@ -338,10 +345,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
</div>
</section>
{/* Att hantera */}
{/* Alerts */}
{alertItems.length > 0 && (
<section id="alerts-section">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<h2 className="font-display text-lg font-medium mb-4">{t('alerts_title')}</h2>
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
{visibleAlerts}
</div>
@@ -352,7 +359,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
aria-controls="alerts-list"
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{showAllAlerts ? 'Visa färre' : `Visa alla (${alertItems.length})`}
{showAllAlerts ? t('show_less') : t('show_all', { count: alertItems.length })}
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
</button>
)}
+122 -137
View File
@@ -3,6 +3,7 @@
import { useState, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -29,7 +30,7 @@ import {
Package,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
@@ -37,6 +38,8 @@ import { useCompany } from '@/contexts/CompanyContext'
import { clearRecaptIdentity } from '@/lib/recapt'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
interface ExtensionNavItem {
href: string
label: string
@@ -52,52 +55,74 @@ interface DashboardNavProps {
extensionNavItems?: ExtensionNavItem[]
}
type NavLabelKey =
| 'dashboard'
| 'kpi'
| 'invoice_inbox'
| 'invoices'
| 'customers'
| 'supplier_invoices'
| 'suppliers'
| 'review'
| 'transactions'
| 'bookkeeping'
| 'assets'
| 'reports'
| 'import'
| 'salary'
| 'employees'
| 'help'
| 'settings'
type GroupKey = 'main' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' | 'övrigt'
interface NavItem {
href: string
label: string
labelKey: NavLabelKey
icon: typeof LayoutDashboard
group: string
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
hidden?: boolean // Temporarily hide from sidebar
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
devBadge?: boolean // Shows a "Dev" badge to indicate dev-only feature
betaBadge?: boolean // Clickable; shows a "Beta" badge to indicate feature in testing
group: GroupKey
modes?: EntityType[]
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
betaBadge?: boolean
}
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
{ href: '/e/general/invoice-inbox', label: 'Dokumentinkorg', icon: Inbox, group: 'main', betaBadge: true },
// AR — Accounts Receivable
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
// AP — Accounts Payable
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: Wallet, group: 'inköp' },
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', label: 'Anläggningstillgångar', icon: Package, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
// Personal — enabled in production with a "Beta" badge while we validate the
// end-to-end salary + AGI flow with real customers.
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'main' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'main', betaBadge: true },
{ href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' },
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp', hidden: true },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
{ href: '/help', labelKey: 'help', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', labelKey: 'settings', icon: Settings, group: 'övrigt' },
]
const groupLabels: Record<string, string> = {
main: 'Huvudmeny',
försäljning: 'Försäljning',
inköp: 'Inköp',
personal: 'Personal',
redovisning: 'Redovisning',
övrigt: 'Övrigt',
// Map known extension hrefs to nav translation keys so sidebar labels translate.
// Extensions whose manifest label happens to be English-ready can stay null.
function extensionLabelKey(href: string): string | null {
if (href === '/e/general/tic') return 'ext_tic'
if (href === '/e/general/invoice-inbox') return 'ext_invoice_inbox'
return null
}
const groupLabelKey: Record<GroupKey, string> = {
main: 'group_main',
försäljning: 'group_sales',
inköp: 'group_purchases',
redovisning: 'group_accounting',
personal: 'group_personnel',
övrigt: 'group_other',
}
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
@@ -105,22 +130,19 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// When the user has no active company (e.g. just archived their last
// one), every company-scoped route is unreachable. We keep them visible
// so the sidebar doesn't collapse, but render them as disabled.
// Only /settings remains navigable — from there the user can either
// create a new company or delete their account.
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
const openMobileMenu = () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
@@ -140,8 +162,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
if (href === '/') {
return pathname === '/'
}
// For parent routes that have a sibling sub-route in the nav (e.g. /salary vs /salary/employees),
// only match the parent for exact or non-overlapping sub-paths
if (href === '/salary') {
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
@@ -159,12 +179,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
// Filter nav items by entity type, hidden flag, and conditional visibility
const filteredItems = navItems.filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
if (item.modes && !item.modes.includes(entityType)) return false
// Only show Granskning when there are pending operations
if (item.href === '/pending' && pendingOperationsCount === 0) return false
return true
})
@@ -172,20 +190,30 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const mainItems = filteredItems.filter(i => i.group === 'main')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
// Groups rendered as distinct sidebar sections (AR, AP, Accounting)
const sidebarGroups = [
const sidebarGroups: { key: GroupKey; items: NavItem[]; spacing: string }[] = [
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' },
{ key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' },
] as const
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
const mobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard },
{ href: '/invoices', labelKey: 'invoices', icon: Receipt },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
]
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
const baseClass =
position === 'sidebar'
? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
: 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
if (item.comingSoon) return <span className={baseClass}>{tNav('badge_coming_soon')}</span>
if (item.devBadge) return <span className={baseClass}>{tNav('badge_dev')}</span>
if (item.betaBadge) return <span className={baseClass}>{tNav('badge_beta')}</span>
return null
}
return (
<>
{/* Desktop sidebar */}
@@ -198,11 +226,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
</div>
{/* Navigation with group headers */}
<nav className="px-3" aria-label="Huvudnavigation">
{/* Huvudmeny group */}
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Main group */}
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels.main}
{tNav('group_main')}
</p>
<div className="space-y-px">
{mainItems.map((item) => {
@@ -215,20 +243,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : null}
<span className="flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'sidebar')}
</>
)
const baseClass = cn(
@@ -251,7 +267,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -264,7 +280,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (
<div key={key} className={spacing}>
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels[key]}
{tNav(groupLabelKey[key])}
</p>
<div className="space-y-px">
{items.map((item) => {
@@ -276,26 +292,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : badge !== null && (
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
@@ -322,7 +327,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title={item.comingSoon ? 'Kommer snart' : 'Lägg till ett företag för att aktivera'}
title={item.comingSoon ? tNav('badge_coming_soon') : tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -338,7 +343,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
<span>{tNav('group_other')}</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isOvrigtExpanded && "rotate-180"
@@ -349,15 +354,16 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
// Extension nav items are always company-scoped.
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
{label}
</>
)
const baseClass = cn(
@@ -380,7 +386,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -396,7 +402,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
{tNav(item.labelKey)}
</>
)
const baseClass = cn(
@@ -419,7 +425,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={tNav('needs_company_tooltip')}
>
{content}
</div>
@@ -442,14 +448,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
onClick={handleLogout}
>
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</Button>
</div>
</div>
</aside>
{/* Mobile bottom navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label="Mobilnavigation">
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const Icon = item.icon
@@ -475,7 +481,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<span className={cn(
"truncate",
active && "font-medium"
)}>{item.label}</span>
)}>{tNav(item.labelKey)}</span>
</>
)
const baseClass = cn(
@@ -501,11 +507,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{/* Menu button */}
<button
onClick={openMobileMenu}
aria-label="Öppna meny"
aria-label={tNav('open_menu')}
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
>
<Menu className="h-5 w-5 mb-1" />
<span>Meny</span>
<span>{tNav('menu')}</span>
</button>
</div>
</nav>
@@ -532,7 +538,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
)}
style={{ maxHeight: '85dvh', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="dialog"
aria-label="Navigeringsmeny"
aria-label={tNav('navigation_menu')}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 sticky top-0 bg-card rounded-t-2xl">
@@ -549,7 +555,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
size="icon"
className="h-8 w-8 -mr-1"
onClick={closeMobileMenu}
aria-label="Stäng meny"
aria-label={tNav('close_menu')}
>
<X className="h-4 w-4" />
</Button>
@@ -566,20 +572,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : null}
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{renderBadge(item, 'mobile')}
</>
)
const baseClass = cn(
@@ -614,7 +608,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav(groupLabelKey[key])}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
<div className="space-y-0.5">
@@ -627,23 +621,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
const decorBadge = renderBadge(item, 'mobile')
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : item.betaBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Beta
</span>
) : badge !== null && (
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
@@ -682,7 +665,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{/* Övrigt divider */}
<div className="flex items-center gap-3 my-1.5 px-3">
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">Övrigt</span>
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_other')}</span>
<div className="flex-1 h-px bg-border/30" />
</div>
@@ -692,10 +675,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
const enabled = hasCompany
const labelTranslationKey = extensionLabelKey(item.href)
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
<span className="text-sm">{label}</span>
</>
)
const baseClass = cn(
@@ -731,7 +716,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
<span className="text-sm">{tNav(item.labelKey)}</span>
</>
)
const baseClass = cn(
@@ -777,7 +762,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
}}
>
<LogOut className="mr-3 h-[18px] w-[18px]" />
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
</Button>
</div>
</div>
+37 -30
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { useToast } from '@/components/ui/use-toast'
@@ -19,19 +20,23 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
const STEP_INFO = [
{ title: 'Företagsform', subtitle: 'Välj din företagsform för att komma igång.' },
{ title: 'Uppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.' },
{ title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.' },
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' },
]
type TFn = (key: string, values?: Record<string, string | number>) => string
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
function buildStepInfo(t: TFn) {
return [
{ title: t('step1_title'), subtitle: t('step1_subtitle') },
{ title: t('step2_title'), subtitle: t('step2_subtitle') },
{ title: t('step3_title'), subtitle: t('step3_subtitle') },
{ title: t('step4_title'), subtitle: t('step4_subtitle') },
]
}
function translatePeriodError(msg: string, t: TFn): string {
if (msg.includes('end must be after')) return t('period_error_end_after_start')
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
return t('period_error_invalid')
}
const LOG = '[welcome-onboarding]'
@@ -63,6 +68,8 @@ export default function WelcomeOnboarding({
}: WelcomeOnboardingProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('onboarding')
const STEP_INFO = buildStepInfo(t)
const [started, setStarted] = useState(skipWelcome ?? false)
const [isSaving, setIsSaving] = useState(false)
@@ -76,7 +83,7 @@ export default function WelcomeOnboarding({
const totalSteps = 4
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Reset org_number/company_name only on a genuine change (user going back
@@ -99,8 +106,8 @@ export default function WelcomeOnboarding({
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -120,8 +127,8 @@ export default function WelcomeOnboarding({
const periodResult = computeFiscalPeriod(mergedSettings)
if (periodResult.error) {
toast({
title: 'Ogiltigt räkenskapsår',
description: translatePeriodError(periodResult.error),
title: t('toast_invalid_fiscal_year'),
description: translatePeriodError(periodResult.error, t),
variant: 'destructive',
})
return
@@ -141,16 +148,16 @@ export default function WelcomeOnboarding({
if (result.error || !result.companyId) {
logError('create company action failed', { error: result.error })
let title = 'Fel'
let description: string = result.error || 'Kunde inte skapa företag. Försök igen.'
let title = t('toast_error_title')
let description: string = result.error || t('toast_create_failed')
let backToStep2 = false
if (result.error === 'org_number_exists') {
title = 'Företaget finns redan'
description = `Det här företaget finns redan i ${branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.`
title = t('toast_company_exists_title')
description = t('toast_company_exists_description', { appName: branding.appName.toLowerCase() })
backToStep2 = true
} else if (result.error === 'org_number_invalid') {
title = 'Ogiltigt organisationsnummer'
description = 'Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.'
title = t('toast_org_invalid_title')
description = t('toast_org_invalid_description')
backToStep2 = true
}
toast({
@@ -167,14 +174,14 @@ export default function WelcomeOnboarding({
console.log(LOG, 'onboarding completed', result.companyId)
toast({
title: 'Välkommen!',
description: 'Ditt företag är nu redo.',
title: t('toast_welcome_title'),
description: t('toast_company_ready'),
})
router.push('/')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
toast({ title: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
@@ -194,13 +201,13 @@ export default function WelcomeOnboarding({
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
<p className="text-muted-foreground/50 text-sm mb-2">{greeting}</p>
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight leading-[1.05] mb-10">
Välkommen till {branding.appName}
{t('welcome_title', { appName: branding.appName })}
</h1>
<button
onClick={() => setStarted(true)}
className="px-5 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium hover:bg-foreground/85 transition-colors duration-150 active:scale-[0.98]"
>
{hasExistingCompanies ? 'Lägg till ett företag' : 'Lägg till ditt första företag'}
{hasExistingCompanies ? t('add_a_company') : t('add_first_company')}
</button>
</div>
)
@@ -214,7 +221,7 @@ export default function WelcomeOnboarding({
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
{hasExistingCompanies ? 'Lägg till ett företag.' : 'Lägg till ditt första företag för att komma igång.'}
{hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')}
</p>
</header>
@@ -236,7 +243,7 @@ export default function WelcomeOnboarding({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-white/60" />
<span className="text-xs text-white/40 tracking-wide uppercase">Nytt företag</span>
<span className="text-xs text-white/40 tracking-wide uppercase">{t('new_company')}</span>
</div>
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
+16 -14
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -29,6 +30,7 @@ interface TaxTodoWidgetProps {
export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {
const { toast } = useToast()
const t = useTranslations('tax_todo')
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Filter to only tax deadlines needing attention
@@ -62,10 +64,10 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
if (diffDays === 0) return 'Idag'
if (diffDays === 1) return 'Imorgon'
if (diffDays < 0) return `${Math.abs(diffDays)} dagar sedan`
if (diffDays <= 7) return `Om ${diffDays} dagar`
if (diffDays === 0) return t('today')
if (diffDays === 1) return t('tomorrow')
if (diffDays < 0) return t('days_ago', { count: Math.abs(diffDays) })
if (diffDays <= 7) return t('in_days', { count: diffDays })
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
@@ -86,14 +88,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
}
toast({
title: 'Status uppdaterad',
description: `Markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
title: t('toast_status_updated'),
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
})
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -115,14 +117,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-warning-foreground" />
Att göra - Skatt
{t('title')}
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} snart</Badge>
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
)}
</div>
</div>
@@ -193,7 +195,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
'Påbörja'
t('start')
)}
</Button>
)}
@@ -212,7 +214,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
) : (
<>
<Send className="h-3.5 w-3.5" />
Inskickad
{t('submitted')}
</>
)}
</Button>
@@ -225,13 +227,13 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{sortedDeadlines.length > 5 && (
<p className="text-xs text-muted-foreground text-center">
+{sortedDeadlines.length - 5} fler uppgifter
{t('more_tasks', { count: sortedDeadlines.length - 5 })}
</p>
)}
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between">
Visa alla deadlines
{t('view_all')}
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -21,6 +22,7 @@ interface UpcomingDeadlinesWidgetProps {
export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChange }: UpcomingDeadlinesWidgetProps) {
const { toast } = useToast()
const t = useTranslations('upcoming_deadlines')
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Get upcoming deadlines (next 7 days) + any needing attention
@@ -51,10 +53,10 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === today.toDateString()) {
return 'Idag'
return t('today')
}
if (date.toDateString() === tomorrow.toDateString()) {
return 'Imorgon'
return t('tomorrow')
}
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
@@ -75,15 +77,15 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
}
toast({
title: 'Status uppdaterad',
description: `Deadline markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
title: t('toast_status_updated'),
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
})
// Notify parent component
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
variant: 'destructive',
})
} finally {
@@ -107,14 +109,14 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kommande deadlines
{t('title')}
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} åtgärd</Badge>
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
)}
</div>
</div>
@@ -147,11 +149,11 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">
{formatDate(deadline.due_date)}
{deadline.due_time && ` kl. ${deadline.due_time.slice(0, 5)}`}
{deadline.due_time && ` ${t('time_prefix')} ${deadline.due_time.slice(0, 5)}`}
</p>
{deadline.tax_deadline_type && (
<Badge variant="outline" className="text-xs px-1 py-0">
Skatt
{t('tax_badge')}
</Badge>
)}
</div>
@@ -172,7 +174,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
title="Markera som inskickad"
title={t('mark_as_submitted')}
>
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -189,7 +191,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
title="Markera som bekräftad"
title={t('mark_as_confirmed')}
>
{isUpdating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -205,7 +207,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between mt-2">
Visa alla deadlines
{t('view_all')}
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
+8 -6
View File
@@ -1,21 +1,23 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
accounting: { label: 'Bokföring & Skatt', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { label: 'Branschrapporter', className: 'bg-primary/10 text-primary border-primary/30' },
import: { label: 'Smart Import', className: 'bg-success/10 text-success border-success/30' },
operations: { label: 'Verktyg', className: 'bg-muted text-muted-foreground border-border' },
const CATEGORY_CONFIG: Record<ExtensionCategory, { labelKey: string; className: string }> = {
accounting: { labelKey: 'category_accounting', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { labelKey: 'category_reports', className: 'bg-primary/10 text-primary border-primary/30' },
import: { labelKey: 'category_import', className: 'bg-success/10 text-success border-success/30' },
operations: { labelKey: 'category_operations', className: 'bg-muted text-muted-foreground border-border' },
}
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
const t = useTranslations('extensions')
const config = CATEGORY_CONFIG[category]
return (
<Badge variant="outline" className={cn('text-[10px] font-medium', config.className)}>
{config.label}
{t(config.labelKey)}
</Badge>
)
}
+11 -3
View File
@@ -1,10 +1,18 @@
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { extensionNameKey, extensionDescriptionKey } from '@/lib/extensions/i18n'
import type { ExtensionDefinition } from '@/lib/extensions/types'
import CategoryBadge from './CategoryBadge'
import Link from 'next/link'
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
export default async function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
const t = await getTranslations('extensions')
const nameKey = extensionNameKey(extension.slug)
const descriptionKey = extensionDescriptionKey(extension.slug)
const name = nameKey ? t(nameKey) : extension.name
const description = descriptionKey ? t(descriptionKey) : extension.description
const Icon = resolveIcon(extension.icon)
@@ -20,10 +28,10 @@ export default function ExtensionCard({ extension }: { extension: ExtensionDefin
href={`/extensions/${extension.sector}/${extension.slug}`}
className="text-sm font-medium hover:underline"
>
{extension.name}
{name}
</Link>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{extension.description}
{description}
</p>
<div className="mt-2">
<CategoryBadge category={extension.category} />
+13 -5
View File
@@ -1,10 +1,18 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { Card, CardContent } from '@/components/ui/card'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { sectorNameKey, sectorDescriptionKey } from '@/lib/extensions/i18n'
import type { Sector } from '@/lib/extensions/types'
export default function SectorCard({ sector }: { sector: Sector }) {
export default async function SectorCard({ sector }: { sector: Sector }) {
const t = await getTranslations('extensions')
const nameKey = sectorNameKey(sector.slug)
const descriptionKey = sectorDescriptionKey(sector.slug)
const name = nameKey ? t(nameKey) : sector.name
const description = descriptionKey ? t(descriptionKey) : sector.description
const Icon = resolveIcon(sector.icon)
return (
@@ -17,11 +25,11 @@ export default function SectorCard({ sector }: { sector: Sector }) {
</div>
<div>
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
{sector.name}
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{sector.description}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
<p className="text-xs text-muted-foreground mt-1.5">
{sector.extensions.length} tillägg
{t('extension_count', { count: sector.extensions.length })}
</p>
</div>
</div>
+43 -41
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -50,15 +51,15 @@ function formatPeriod(start: number, end: number): string {
return `${fmt(s)} – ${fmt(e)}`
}
function timeAgo(isoDate: string): string {
function timeAgo(isoDate: string, t: (key: string, values?: Record<string, string | number>) => string): string {
const diff = Date.now() - new Date(isoDate).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just nu'
if (minutes < 60) return `${minutes} min sedan`
if (minutes < 1) return t('time_just_now')
if (minutes < 60) return t('time_minutes_ago', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} tim sedan`
if (hours < 24) return t('time_hours_ago', { n: hours })
const days = Math.floor(hours / 24)
return `${days} dag${days > 1 ? 'ar' : ''} sedan`
return t('time_days_ago', { n: days })
}
// Mirrors the live layout (two cards: company info + financials) so the
@@ -122,6 +123,7 @@ function ProfileSkeleton() {
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
const { toast } = useToast()
const t = useTranslations('tic_workspace')
const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
const [isFetching, setIsFetching] = useState(false)
const [noOrgNumber, setNoOrgNumber] = useState(false)
@@ -147,7 +149,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
// Get org_number from company settings
const settingsRes = await fetch('/api/settings')
if (!settingsRes.ok) {
toast({ title: 'Kunde inte hämta inställningar', variant: 'destructive' })
toast({ title: t('toast_settings_failed'), variant: 'destructive' })
return
}
const { data: settings } = await settingsRes.json()
@@ -164,7 +166,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
if (!res.ok) {
const { error } = await res.json()
toast({ title: error ?? 'Kunde inte hämta företagsprofil', variant: 'destructive' })
toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' })
setFetchFailed(true)
return
}
@@ -173,12 +175,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
setProfile(data)
await save('company_profile', data)
} catch {
toast({ title: 'Ett oväntat fel inträffade', variant: 'destructive' })
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
setFetchFailed(true)
} finally {
setIsFetching(false)
}
}, [save, toast])
}, [save, toast, t])
// Auto-fetch on first visit when no cached data
useEffect(() => {
@@ -196,13 +198,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<Settings className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Inget organisationsnummer
{t('no_org_number_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Ange organisationsnummer under Inställningar för att visa företagsprofilen.
{t('no_org_number_description')}
</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/settings">Gå till Inställningar</Link>
<Link href="/settings">{t('go_to_settings')}</Link>
</Button>
</div>
)
@@ -217,17 +219,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<XCircle className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Kunde inte hämta företagsprofil
{t('fetch_failed_title')}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Kontrollera att organisationsnumret i inställningarna är korrekt och försök igen.
{t('fetch_failed_description')}
</p>
<div className="flex gap-3 mt-4">
<Button variant="outline" asChild>
<Link href="/settings">Inställningar</Link>
<Link href="/settings">{t('settings')}</Link>
</Button>
<Button variant="outline" onClick={fetchProfile} disabled={isFetching}>
Försök igen
{t('retry')}
</Button>
</div>
</div>
@@ -238,9 +240,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const isActive = profile.activityStatus !== 'ceased'
const registrations = [
profile.registration.fTax && 'F-skatt',
profile.registration.vat && 'Moms',
profile.registration.payroll && 'Arbetsgivare',
profile.registration.fTax && t('reg_f_tax'),
profile.registration.vat && t('reg_vat'),
profile.registration.payroll && t('reg_employer'),
].filter((label): label is string => Boolean(label))
return (
@@ -256,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardDescription>
{profile.orgNumber} &middot; {profile.legalEntityType}
{!isActive && (
<span className="ml-2 text-destructive">&middot; Avregistrerat</span>
<span className="ml-2 text-destructive">&middot; {t('deregistered')}</span>
)}
</CardDescription>
</CardHeader>
@@ -285,13 +287,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{registrations.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Registrerat för</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('registered_for')}</p>
<p className="text-xs text-muted-foreground">{registrations.join(' · ')}</p>
</div>
)}
{profile.sniCodes.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('sni_codes')}</p>
<div className="space-y-0.5">
{profile.sniCodes
.filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i)
@@ -306,7 +308,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.bankAccounts.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Bankuppgifter</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('bank_accounts')}</p>
<div className="space-y-0.5">
{profile.bankAccounts.map((ba, i) => (
<p key={i} className="text-xs text-muted-foreground">
@@ -319,7 +321,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
)}
{profile.purpose && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Verksamhet</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('purpose')}</p>
<p className="text-xs text-muted-foreground">{profile.purpose}</p>
</div>
)}
@@ -327,18 +329,18 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<div className="pt-2 border-t">
{profile.employeeRange && (
<p className="text-xs text-muted-foreground">
Anställda: {profile.employeeRange}
{t('employees_range', { range: profile.employeeRange })}
</p>
)}
{profile.turnoverRange && (
<p className="text-xs text-muted-foreground">
Omsättning: {profile.turnoverRange}
{t('turnover_range', { range: profile.turnoverRange })}
</p>
)}
</div>
)}
<p className="pt-2 text-xs text-muted-foreground/70">
Uppdaterad {timeAgo(profile.fetchedAt)}
{t('updated_ago', { ago: timeAgo(profile.fetchedAt, t) })}
</p>
</CardContent>
</Card>
@@ -346,7 +348,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{/* Financials card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Senaste bokslut</CardTitle>
<CardTitle className="text-base">{t('latest_closing')}</CardTitle>
{profile.financials && (
<CardDescription>
{formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)}
@@ -356,31 +358,31 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardContent>
{profile.financials ? (
<div className="grid grid-cols-2 gap-4">
<FinancialCell label="Omsättning" value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell label={t('net_sales')} value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell
label="Rörelseresultat"
label={t('operating_profit')}
value={formatKSEK(profile.financials.operatingProfitK)}
negative={(profile.financials.operatingProfitK ?? 0) < 0}
/>
<FinancialCell label="Totala tillgångar" value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell label={t('total_assets')} value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell
label="Anställda"
label={t('employees')}
value={profile.financials.numberOfEmployees !== null
? String(profile.financials.numberOfEmployees)
: '—'}
/>
<FinancialCell
label="Rörelsemarginal"
label={t('operating_margin')}
value={formatPercent(profile.financials.operatingMargin)}
negative={(profile.financials.operatingMargin ?? 0) < 0}
/>
<FinancialCell
label="Soliditet"
label={t('equity_ratio')}
value={formatPercent(profile.financials.equityAssetsRatio)}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
<p className="text-sm text-muted-foreground">{t('no_financials')}</p>
)}
</CardContent>
</Card>
@@ -390,17 +392,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{profile.financialReports.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Årsredovisningar</CardTitle>
<CardTitle className="text-base">{t('annual_reports')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Titel</TableHead>
<TableHead>Inlämnad</TableHead>
<TableHead>Reviderad</TableHead>
<TableHead>Revisionsutlåtande</TableHead>
<TableHead>{t('col_period')}</TableHead>
<TableHead>{t('col_title')}</TableHead>
<TableHead>{t('col_filed')}</TableHead>
<TableHead>{t('col_audited')}</TableHead>
<TableHead>{t('col_audit_opinion')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
+87 -68
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -14,47 +15,6 @@ import { useToast } from '@/components/ui/use-toast'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
const bankSetupSchema = z.object({
bank_name: z.string().max(100).optional().or(z.literal('')),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4–5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6–12 siffror').optional().or(z.literal('')),
bankgiro: z.string().optional().or(z.literal('')),
iban: z.string().optional().or(z.literal('')),
bic: z.string().optional().or(z.literal('')),
invoice_prefix: z.string().optional().or(z.literal('')),
next_invoice_number: z.string().optional().or(z.literal('')),
}).refine(
(data) => {
const hasAccount = !!data.clearing_number && !!data.account_number
const hasBankgiro = !!data.bankgiro
return hasAccount || hasBankgiro
},
{ message: 'Ange antingen kontonummer (clearing + konto) eller bankgiro', path: ['clearing_number'] }
).refine(
(data) => {
// If one of clearing/account is filled, both must be
if (data.clearing_number && !data.account_number) return false
if (!data.clearing_number && data.account_number) return false
return true
},
{ message: 'Ange både clearingnummer och kontonummer', path: ['account_number'] }
).refine(
(data) => {
if (!data.bankgiro) return true
return validateBankgiroNumber(data.bankgiro)
},
{ message: 'Ogiltigt bankgironummer (7–8 siffror med kontrollsiffra)', path: ['bankgiro'] }
).refine(
(data) => {
if (!data.next_invoice_number) return true
const num = parseInt(data.next_invoice_number, 10)
return !isNaN(num) && num >= 1
},
{ message: 'Startnummer måste vara ett positivt heltal', path: ['next_invoice_number'] }
)
type BankSetupData = z.infer<typeof bankSetupSchema>
interface BankDetailsSetupDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -63,10 +23,68 @@ interface BankDetailsSetupDialogProps {
export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankDetailsSetupDialogProps) {
const { toast } = useToast()
const t = useTranslations('invoice_bank_setup')
const [isSaving, setIsSaving] = useState(false)
const [showInternational, setShowInternational] = useState(false)
const [bankName, setBankName] = useState('')
const bankSetupSchema = useMemo(
() =>
z
.object({
bank_name: z.string().max(100).optional().or(z.literal('')),
clearing_number: z
.string()
.regex(/^\d{4,5}$/, t('validation_clearing_format'))
.optional()
.or(z.literal('')),
account_number: z
.string()
.regex(/^\d{6,12}$/, t('validation_account_format'))
.optional()
.or(z.literal('')),
bankgiro: z.string().optional().or(z.literal('')),
iban: z.string().optional().or(z.literal('')),
bic: z.string().optional().or(z.literal('')),
invoice_prefix: z.string().optional().or(z.literal('')),
next_invoice_number: z.string().optional().or(z.literal('')),
})
.refine(
(data) => {
const hasAccount = !!data.clearing_number && !!data.account_number
const hasBankgiro = !!data.bankgiro
return hasAccount || hasBankgiro
},
{ message: t('validation_either_required'), path: ['clearing_number'] },
)
.refine(
(data) => {
if (data.clearing_number && !data.account_number) return false
if (!data.clearing_number && data.account_number) return false
return true
},
{ message: t('validation_both_required'), path: ['account_number'] },
)
.refine(
(data) => {
if (!data.bankgiro) return true
return validateBankgiroNumber(data.bankgiro)
},
{ message: t('validation_bankgiro_invalid'), path: ['bankgiro'] },
)
.refine(
(data) => {
if (!data.next_invoice_number) return true
const num = parseInt(data.next_invoice_number, 10)
return !isNaN(num) && num >= 1
},
{ message: t('validation_start_number_positive'), path: ['next_invoice_number'] },
),
[t],
)
type BankSetupData = z.infer<typeof bankSetupSchema>
const {
register,
handleSubmit,
@@ -119,18 +137,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte spara')
throw new Error(result.error || t('save_failed_fallback'))
}
toast({
title: 'Betalningsuppgifter sparade',
description: 'Du kan ändra dem senare i Inställningar.',
title: t('saved_title'),
description: t('saved_description'),
})
onComplete()
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('save_failed_title'),
description: error instanceof Error ? error.message : t('save_failed_fallback'),
variant: 'destructive',
})
} finally {
@@ -150,17 +168,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="font-display text-xl tracking-tight">Betalningsuppgifter</DialogTitle>
<DialogTitle className="font-display text-xl tracking-tight">{t('title')}</DialogTitle>
<DialogDescription>
Dessa uppgifter visas på dina fakturor. Du kan ändra dem senare i{' '}
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">Inställningar</a>.
{t('description_prefix')}
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">{t('settings_link')}</a>
{t('description_suffix')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
{/* Bank name */}
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Label htmlFor="bank_name">{t('bank_label')}</Label>
<BankNameCombobox
value={bankName}
onChange={setBankName}
@@ -170,10 +189,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Clearing + Account number */}
<div className="grid grid-cols-5 gap-3">
<div className="col-span-2 space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
placeholder="8000"
placeholder={t('clearing_placeholder')}
maxLength={5}
inputMode="numeric"
{...register('clearing_number')}
@@ -181,10 +200,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
/>
</div>
<div className="col-span-3 space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Label htmlFor="account_number">{t('account_label')}</Label>
<Input
id="account_number"
placeholder="12345678"
placeholder={t('account_placeholder')}
maxLength={12}
inputMode="numeric"
{...register('account_number')}
@@ -201,10 +220,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Bankgiro */}
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
placeholder="123-4567"
placeholder={t('bankgiro_placeholder')}
maxLength={9}
{...register('bankgiro')}
onBlur={(e) => {
@@ -232,23 +251,23 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Internationella betalningar
{t('international_toggle')}
</button>
{showInternational && (
<div className="space-y-3 pt-3 animate-in slide-in-from-top-1 duration-150">
<div className="space-y-2">
<Label htmlFor="iban">IBAN</Label>
<Label htmlFor="iban">{t('iban_label')}</Label>
<Input
id="iban"
placeholder="SE12 3456 7890 1234 5678 9012"
placeholder={t('iban_placeholder')}
{...register('iban')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bic">BIC/SWIFT</Label>
<Label htmlFor="bic">{t('bic_label')}</Label>
<Input
id="bic"
placeholder="NDEASESS"
placeholder={t('bic_placeholder')}
maxLength={11}
{...register('bic')}
/>
@@ -262,19 +281,19 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
{/* Invoice prefix + starting number */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Label htmlFor="invoice_prefix">{t('invoice_prefix_label')}</Label>
<Input
id="invoice_prefix"
placeholder="t.ex. F-"
placeholder={t('invoice_prefix_placeholder')}
maxLength={10}
{...register('invoice_prefix')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Startnummer</Label>
<Label htmlFor="next_invoice_number">{t('next_invoice_number_label')}</Label>
<Input
id="next_invoice_number"
placeholder="1"
placeholder={t('next_invoice_number_placeholder')}
inputMode="numeric"
maxLength={6}
{...register('next_invoice_number')}
@@ -282,7 +301,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
</div>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Prefix &quot;F-&quot; med startnummer 1 ger F-2026001. Lämna tomt för standard.
{t('prefix_hint')}
</p>
{errors.next_invoice_number && (
<p className="text-sm text-destructive -mt-2">{errors.next_invoice_number.message}</p>
@@ -292,7 +311,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
<div className="flex justify-end gap-3 pt-2">
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara & fortsätt
{t('save_and_continue')}
</Button>
</div>
</form>
+24 -22
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency } from '@/lib/utils'
@@ -48,12 +49,13 @@ export function InvoiceReviewContent({
numberPreview,
oreRounding,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
const customerTypeLabel: Record<string, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
individual: t('customer_type_individual'),
swedish_business: t('customer_type_swedish_business'),
eu_business: t('customer_type_eu_business'),
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown
@@ -71,7 +73,7 @@ export function InvoiceReviewContent({
<div className="space-y-4">
{numberPreview && (
<div className="text-sm text-muted-foreground">
Tilldelas fakturanummer{' '}
{t('assigned_number_prefix')}{' '}
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
</div>
)}
@@ -89,11 +91,11 @@ export function InvoiceReviewContent({
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<span className="text-muted-foreground">{t('invoice_date')}</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<span className="text-muted-foreground">{t('due_date')}</span>
<p className="font-medium">{dueDate}</p>
</div>
</div>
@@ -103,12 +105,12 @@ export function InvoiceReviewContent({
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2">{t('th_description')}</th>
<th className="py-2 w-16 text-right">{t('th_quantity')}</th>
<th className="py-2 w-16 text-center">{t('th_unit')}</th>
<th className="py-2 w-24 text-right">{t('th_unit_price')}</th>
{showVatColumn && <th className="py-2 w-16 text-right">{t('th_vat')}</th>}
<th className="py-2 w-28 text-right">{t('th_amount')}</th>
</tr>
</thead>
<tbody>
@@ -135,7 +137,7 @@ export function InvoiceReviewContent({
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">({item.vat_rate ?? 0}% moms)</span>}
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
@@ -147,7 +149,7 @@ export function InvoiceReviewContent({
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
{Array.from(vatByRate.entries())
@@ -155,25 +157,25 @@ export function InvoiceReviewContent({
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
</div>
)}
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">Öresavrundning</span>
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, currency)}</span>
</div>
</div>
@@ -183,7 +185,7 @@ export function InvoiceReviewContent({
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
{yourReference && (
<div>
<span>Er referens:</span>
<span>{t('your_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{yourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -195,7 +197,7 @@ export function InvoiceReviewContent({
)}
{ourReference && (
<div>
<span>Vår referens:</span>
<span>{t('our_reference')}</span>
<div className="flex flex-wrap gap-1 mt-1">
{ourReference.split(',').map((ref, i) => (
<Badge key={i} variant="secondary" className="text-xs font-normal">
@@ -205,7 +207,7 @@ export function InvoiceReviewContent({
</div>
</div>
)}
{notes && <p>Anteckning: {notes}</p>}
{notes && <p>{t('notes_prefix', { notes })}</p>}
</div>
)}
</div>
+34 -32
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -38,12 +39,6 @@ interface DuplicateCandidate {
match_confidence: number
}
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
ocr_exact: 'Exakt OCR-träff',
name_amount_fuzzy: 'Sannolik träff',
amount_only: 'Möjlig träff',
}
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
@@ -68,6 +63,13 @@ export default function PaymentBookingDialog({
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const t = useTranslations('invoice_payment_dialog')
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
ocr_exact: t('match_reason_ocr_exact'),
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
amount_only: t('match_reason_amount_only'),
}
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
@@ -90,11 +92,11 @@ export default function PaymentBookingDialog({
try {
// Fetch accounts
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (!accountsRes.ok) throw new Error('Kunde inte ladda kontoplanen')
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
const fetchedAccounts: BASAccount[] = accountsData.data || []
if (!company?.id) throw new Error('Inget aktivt företag')
if (!company?.id) throw new Error(t('no_active_company'))
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
@@ -103,7 +105,7 @@ export default function PaymentBookingDialog({
.eq('company_id', company.id)
.maybeSingle()
if (settingsError) throw new Error('Kunde inte ladda företagsinställningar')
if (settingsError) throw new Error(t('load_settings_failed'))
if (cancelled) return
setAccounts(fetchedAccounts)
@@ -135,8 +137,8 @@ export default function PaymentBookingDialog({
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte ladda bokföringsdialog',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('load_dialog_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -220,7 +222,7 @@ export default function PaymentBookingDialog({
setIsSubmitting(false)
return
}
const error = new Error('Kunde inte markera som betald') as Error & { body?: unknown; status?: number }
const error = new Error(t('mark_paid_failed')) as Error & { body?: unknown; status?: number }
error.body = data
error.status = response.status
throw error
@@ -231,7 +233,7 @@ export default function PaymentBookingDialog({
} catch (error) {
const anyErr = error as { body?: unknown; status?: number }
toast({
title: 'Bokföring misslyckades',
title: t('booking_failed_title'),
description: getErrorMessage(anyErr.body ?? error, { context: 'invoice', statusCode: anyErr.status }),
variant: 'destructive',
})
@@ -252,11 +254,11 @@ export default function PaymentBookingDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>Bokför betalning{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}</DialogTitle>
<DialogTitle>{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<> ({formatCurrency(invoice.total_sek)} SEK)</>
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
)}
</DialogDescription>
</DialogHeader>
@@ -264,11 +266,11 @@ export default function PaymentBookingDialog({
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Möjlig dubblettbetalning</p>
<p className="text-sm font-medium">{t('duplicate_title')}</p>
<p className="text-sm text-muted-foreground">
{duplicateCandidates.length === 1
? 'En inkommande banktransaktion ser ut att vara denna betalning. Länka den istället för att skapa en ny verifikation, eller bokför ändå om du är säker.'
: `${duplicateCandidates.length} inkommande banktransaktioner ser ut att kunna vara denna betalning. Länka rätt transaktion istället för att skapa en ny verifikation, eller bokför ändå om du är säker.`}
? t('duplicate_one')
: t('duplicate_many', { count: duplicateCandidates.length })}
</p>
</div>
<ul className="space-y-2">
@@ -305,7 +307,7 @@ export default function PaymentBookingDialog({
onClick={() => handleLinkExisting(c.id)}
className="shrink-0"
>
Länka transaktion
{t('link_transaction')}
</Button>
</li>
)
@@ -320,7 +322,7 @@ export default function PaymentBookingDialog({
<div className="space-y-4">
{/* Payment date */}
<div className="space-y-1.5">
<Label htmlFor="payment-date">Betalningsdatum</Label>
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
<Input
id="payment-date"
type="date"
@@ -356,7 +358,7 @@ export default function PaymentBookingDialog({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Label className="text-xs text-muted-foreground">{t('debit_label')}</Label>
<Input
type="number"
step="0.01"
@@ -369,7 +371,7 @@ export default function PaymentBookingDialog({
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Label className="text-xs text-muted-foreground">{t('credit_label')}</Label>
<Input
type="number"
step="0.01"
@@ -385,7 +387,7 @@ export default function PaymentBookingDialog({
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addLine} className="w-full">
<Plus className="mr-1 h-3.5 w-3.5" /> Lägg till rad
<Plus className="mr-1 h-3.5 w-3.5" /> {t('add_row')}
</Button>
</div>
@@ -393,9 +395,9 @@ export default function PaymentBookingDialog({
<div className="hidden sm:block space-y-2">
{/* Header */}
<div className="grid grid-cols-[1fr_120px_120px_32px] gap-2 text-xs font-medium text-muted-foreground px-1">
<span>Konto</span>
<span className="text-right">Debet</span>
<span className="text-right">Kredit</span>
<span>{t('account_label')}</span>
<span className="text-right">{t('debit_label')}</span>
<span className="text-right">{t('credit_label')}</span>
<span />
</div>
@@ -449,7 +451,7 @@ export default function PaymentBookingDialog({
className="text-muted-foreground"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till rad
{t('add_row')}
</Button>
</div>
@@ -458,11 +460,11 @@ export default function PaymentBookingDialog({
<div className="flex items-center gap-2">
{isBalanced ? (
<Badge variant="secondary" className="bg-success/10 text-success">
Debet = Kredit
{t('balanced_badge')}
</Badge>
) : (
<Badge variant="destructive">
Obalanserad ({formatCurrency(Math.abs(totalDebit - totalCredit))})
{t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
</Badge>
)}
</div>
@@ -475,7 +477,7 @@ export default function PaymentBookingDialog({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
Avbryt
{t('cancel')}
</Button>
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<Button
@@ -484,7 +486,7 @@ export default function PaymentBookingDialog({
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför ändå
{t('book_anyway')}
</Button>
) : (
<Button
@@ -493,7 +495,7 @@ export default function PaymentBookingDialog({
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bekräfta &amp; bokför
{t('confirm_and_book')}
</Button>
)}
</DialogFooter>
+26 -21
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -43,6 +44,7 @@ export default function SendInvoiceDialog({
const { toast } = useToast()
const supabase = createClient()
const { company } = useCompany()
const t = useTranslations('invoice_send_dialog')
const [isSubmitting, setIsSubmitting] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
@@ -60,7 +62,7 @@ export default function SendInvoiceDialog({
async function init() {
try {
if (!company?.id) throw new Error('Inget aktivt företag')
if (!company?.id) throw new Error(t('no_active_company'))
// Fetch company settings
const { data: settings, error } = await supabase
@@ -69,7 +71,7 @@ export default function SendInvoiceDialog({
.eq('company_id', company.id)
.maybeSingle()
if (error) throw new Error('Kunde inte ladda företagsinställningar')
if (error) throw new Error(t('company_settings_failed'))
if (cancelled) return
// Fetch fiscal period for the invoice date
@@ -90,8 +92,8 @@ export default function SendInvoiceDialog({
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte ladda inställningar',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('load_failed_title'),
description: err instanceof Error ? err.message : t('try_again'),
variant: 'destructive',
})
onOpenChange(false)
@@ -145,7 +147,7 @@ export default function SendInvoiceDialog({
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte skicka fakturan')
throw new Error(data.error || t('send_failed_fallback'))
}
onSuccess()
@@ -153,23 +155,23 @@ export default function SendInvoiceDialog({
if (mode === 'email') {
onOpenChange(false)
toast({
title: 'Faktura skickad',
description: data.message || `Fakturan har skickats till ${invoice.customer.email}`,
title: t('send_success_title'),
description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }),
})
} else {
// For manual send, just close — no email to confirm
onOpenChange(false)
toast({
title: 'Faktura markerad som skickad',
title: t('mark_success_title'),
description: accountingMethod === 'accrual'
? 'Bokföringsverifikationen har skapats.'
? t('mark_success_voucher_created')
: undefined,
})
}
} catch (error) {
toast({
title: 'Kunde inte skicka faktura',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('send_failed_title'),
description: error instanceof Error ? error.message : t('try_again'),
variant: 'destructive',
})
}
@@ -188,15 +190,15 @@ export default function SendInvoiceDialog({
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}
{mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
</DialogTitle>
<DialogDescription>
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<> ({formatCurrency(invoice.total_sek)} SEK)</>
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
)}
{mode === 'email' && invoice.customer.email && (
<> till {invoice.customer.email}</>
<>{t('description_to_email', { email: invoice.customer.email })}</>
)}
</DialogDescription>
</DialogHeader>
@@ -210,12 +212,15 @@ export default function SendInvoiceDialog({
{showJournalPreview ? (
<>
<p className="text-sm text-muted-foreground">
Följande bokföringsverifikation skapas automatiskt:
{t('journal_preview_intro')}
</p>
<JournalEntryReviewContent
periodName={periodName}
entryDate={invoice.invoice_date}
description={`Försäljning faktura${invoice.invoice_number ? ` ${invoice.invoice_number}` : ''}${invoice.customer.name ? `, ${invoice.customer.name}` : ''}`}
description={t('voucher_description', {
numberSpace: invoice.invoice_number ? ` ${invoice.invoice_number}` : '',
customerSuffix: invoice.customer.name ? `, ${invoice.customer.name}` : '',
})}
lines={proposedLines}
totalDebit={totalDebit}
totalCredit={totalCredit}
@@ -226,10 +231,10 @@ export default function SendInvoiceDialog({
) : (
<p className="text-sm text-muted-foreground">
{accountingMethod === 'cash'
? 'Kontantmetoden — bokföring sker vid betalning, inte vid fakturering.'
? t('explain_cash')
: mode === 'email'
? `Fakturan skickas till ${invoice.customer.email}.`
: 'Fakturan markeras som skickad.'}
? t('explain_email', { email: invoice.customer.email ?? '' })
: t('explain_manual')}
</p>
)}
</div>
@@ -242,7 +247,7 @@ export default function SendInvoiceDialog({
disabled={isSubmitting}
className="w-full sm:w-auto min-h-11"
>
Avbryt
{t('cancel')}
</Button>
<Button
onClick={handleConfirm}
@@ -256,7 +261,7 @@ export default function SendInvoiceDialog({
) : (
<Send className="mr-2 h-4 w-4" />
)}
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}
{mode === 'email' ? t('send_invoice') : t('mark_as_sent')}
</Button>
</DialogFooter>
</DialogContent>
+10 -8
View File
@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
@@ -22,16 +23,17 @@ const SEGMENT_COLORS = [
]
export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
const t = useTranslations('kpi')
const { class4, class5, class6, class7 } = composition
const chartData = useMemo(
() =>
[
{ name: 'Varor (klass 4)', value: class4 },
{ name: 'Drift & lokaler (klass 5)', value: class5 },
{ name: 'Övriga externa (klass 6)', value: class6 },
{ name: 'Personal (klass 7)', value: class7 },
{ name: t('expense_mix_class4'), value: class4 },
{ name: t('expense_mix_class5'), value: class5 },
{ name: t('expense_mix_class6'), value: class6 },
{ name: t('expense_mix_class7'), value: class7 },
].filter((s) => s.value > 0),
[class4, class5, class6, class7]
[class4, class5, class6, class7, t]
)
const total = class4 + class5 + class6 + class7
@@ -44,12 +46,12 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Kostnader per klass</CardTitle>
<CardTitle className="text-base">{t('expense_mix_title')}</CardTitle>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="flex h-[240px] items-center justify-center text-sm text-muted-foreground">
Inga bokförda kostnader ännu
{t('expense_mix_empty')}
</div>
) : (
<div className="relative flex flex-col items-center">
@@ -81,7 +83,7 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
</ResponsiveContainer>
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[180px] flex flex-col items-center justify-center">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Totalt
{t('expense_mix_total')}
</span>
<span
className="font-display text-lg font-medium tabular-nums"
+47 -47
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
@@ -11,75 +12,73 @@ interface KPIHeroCardsProps {
preferences?: KPIPreferences
}
function getKPIValue(
function getKPISubtitleKey(
report: KPIReport,
id: string
): { value: number | null; subtitle: string } {
id: string,
): { key: string; args?: Record<string, string | number> } {
switch (id) {
case 'netResult':
return { value: report.netResult, subtitle: 'netto' }
return { key: 'sub_netto' }
case 'cashPosition':
return { value: report.cashPosition, subtitle: 'likvida medel' }
return { key: 'sub_likvida_medel' }
case 'outstandingReceivables':
return {
value: report.outstandingReceivables,
subtitle:
report.overdueReceivables > 0
? `varav förfallet: ${formatCurrency(report.overdueReceivables)}`
: 'utestående',
if (report.overdueReceivables > 0) {
return { key: 'sub_overdue', args: { amount: formatCurrency(report.overdueReceivables) } }
}
return { key: 'sub_utestaende' }
case 'vatLiability':
return {
value: report.vatLiability,
subtitle:
report.vatLiability > 0
? 'att betala'
: report.vatLiability < 0
? 'att återfå'
: 'jämnt',
}
if (report.vatLiability > 0) return { key: 'sub_att_betala' }
if (report.vatLiability < 0) return { key: 'sub_att_aterfa' }
return { key: 'sub_jamnt' }
case 'grossMargin':
return { value: report.grossMargin, subtitle: 'av intäkter' }
return { key: 'sub_av_intakter' }
case 'expenseRatio':
return { value: report.expenseRatio, subtitle: 'av intäkter' }
return { key: 'sub_av_intakter' }
case 'avgPaymentDays':
return { value: report.avgPaymentDays, subtitle: 'snitt' }
return { key: 'sub_snitt' }
default:
return { value: null, subtitle: '' }
return { key: '' }
}
}
function formatKPIValue(value: number | null, format: string, id: string): string {
function getKPIValue(report: KPIReport, id: string): number | null {
switch (id) {
case 'netResult': return report.netResult
case 'cashPosition': return report.cashPosition
case 'outstandingReceivables': return report.outstandingReceivables
case 'vatLiability': return report.vatLiability
case 'grossMargin': return report.grossMargin
case 'expenseRatio': return report.expenseRatio
case 'avgPaymentDays': return report.avgPaymentDays
default: return null
}
}
function formatKPIValue(value: number | null, format: string, id: string, daysSuffix: string): string {
if (value === null) return '—'
if (format === 'currency') {
if (id === 'vatLiability') return formatCurrency(Math.abs(value))
return formatCurrency(value)
}
if (format === 'percentage') return `${value}%`
if (format === 'days') return `${value} dagar`
if (format === 'days') return `${value} ${daysSuffix}`
return String(value)
}
function getValueColor(
value: number | null,
colorLogic: string
): string {
function getValueColor(value: number | null, colorLogic: string): string {
if (value === null) return 'text-muted-foreground'
if (colorLogic === 'neutral') return ''
if (colorLogic === 'positive-good') {
return value >= 0
? 'text-[hsl(var(--chart-1))]'
: 'text-[hsl(var(--chart-2))]'
return value >= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
}
if (colorLogic === 'negative-good') {
return value <= 0
? 'text-[hsl(var(--chart-1))]'
: 'text-[hsl(var(--chart-2))]'
return value <= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
}
return ''
}
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const t = useTranslations('kpi')
const prefs = preferences ?? getDefaultPreferences()
const visibleDefs = prefs.kpiOrder
@@ -90,7 +89,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Inga nyckeltal valda. Klicka på &quot;Anpassa&quot; för att välja vilka som ska visas.
{t('empty_no_kpis_chosen')}
</CardContent>
</Card>
)
@@ -106,8 +105,9 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
return (
<div className={`grid ${gridCols} gap-4`}>
{visibleDefs.map((def) => {
const { value, subtitle } = getKPIValue(report, def.id)
const formatted = formatKPIValue(value, def.format, def.id)
const value = getKPIValue(report, def.id)
const sub = getKPISubtitleKey(report, def.id)
const formatted = formatKPIValue(value, def.format, def.id, t('value_days_suffix'))
const color = getValueColor(value, def.colorLogic)
const hasOverride =
prefs.accountOverrides[def.id] &&
@@ -115,18 +115,18 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
const tooltipContent = (
<div className="space-y-1.5 text-xs">
<p className="text-foreground/90">{def.description}</p>
<p className="text-foreground/90">{t(`def_${def.id}_description`)}</p>
<div>
<span className="font-medium">Formel: </span>
<span className="font-mono">{def.formula}</span>
<span className="font-medium">{t('tooltip_formula')} </span>
<span className="font-mono">{t(`def_${def.id}_formula`)}</span>
</div>
<div>
<span className="font-medium">Konton: </span>
{def.accountDescription}
<span className="font-medium">{t('tooltip_accounts')} </span>
{t(`def_${def.id}_accounts`)}
</div>
{hasOverride && (
<div className="text-primary">
<span className="font-medium">Anpassade: </span>
<span className="font-medium">{t('tooltip_overrides')} </span>
<span className="font-mono">
{prefs.accountOverrides[def.id].join(', ')}
</span>
@@ -144,7 +144,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
maxWidth="320px"
iconClassName="h-3 w-3"
>
<p className="text-xs text-muted-foreground">{def.label}</p>
<p className="text-xs text-muted-foreground">{t(`def_${def.id}_label`)}</p>
</InfoTooltip>
<p
className={`font-display text-2xl font-medium tabular-nums tracking-tight mt-2 ${color}`}
@@ -152,7 +152,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
{formatted}
</p>
<p className="text-xs text-muted-foreground mt-1">
{subtitle}
{sub.key ? t(sub.key, sub.args) : ''}
</p>
</CardContent>
</Card>
+19 -17
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Settings2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -23,6 +24,8 @@ interface KPISettingsDialogProps {
}
export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDialogProps) {
const t = useTranslations('kpi')
const tCommon = useTranslations('common')
const [draft, setDraft] = useState<KPIPreferences>(preferences)
const [expandedKpi, setExpandedKpi] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -78,14 +81,14 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
Anpassa
{t('customize')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Anpassa nyckeltal</DialogTitle>
<DialogTitle>{t('settings_title')}</DialogTitle>
<DialogDescription>
Välj vilka nyckeltal som visas och justera beräkningarna.
{t('settings_subtitle')}
</DialogDescription>
</DialogHeader>
@@ -120,10 +123,10 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{def.label}
{t(`def_${def.id}_label`)}
{hasOverride && (
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(anpassad)
{t('settings_custom_suffix')}
</span>
)}
</p>
@@ -137,26 +140,26 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
{isExpanded && (
<div className="mt-3 ml-5.5 space-y-2.5 text-xs text-muted-foreground">
<p>{def.description}</p>
<p>{t(`def_${def.id}_description`)}</p>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
Formel
{t('settings_formula_label')}
</p>
<p className="font-mono text-[11px] bg-muted/50 rounded px-2 py-1">
{def.formula}
{t(`def_${def.id}_formula`)}
</p>
</div>
<div>
<p className="font-medium text-foreground/80 mb-0.5">
Konton
{t('settings_accounts_label')}
</p>
<p>{def.accountDescription}</p>
<p>{t(`def_${def.id}_accounts`)}</p>
</div>
{def.customizableAccounts && (
<div className="pt-1">
<label className="font-medium text-foreground/80 block mb-1">
Anpassa konton
{t('settings_customize_accounts')}
</label>
<input
type="text"
@@ -168,8 +171,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-mono tabular-nums placeholder:text-muted-foreground/50"
/>
<p className="mt-1 text-[10px] text-muted-foreground/70">
Ange kontonummer separerade med komma (t.ex.{' '}
{def.defaultAccounts.slice(0, 3).join(', ')})
{t('settings_account_hint', { example: def.defaultAccounts.slice(0, 3).join(', ') })}
</p>
{hasOverride && (
<button
@@ -177,7 +179,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
onClick={() => clearAccountOverride(def.id)}
className="mt-1 text-[10px] text-primary hover:underline"
>
Återställ till standard
{t('settings_reset_field')}
</button>
)}
</div>
@@ -196,16 +198,16 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="h-3 w-3" />
Återställ allt
{t('settings_reset_all')}
</button>
<div className="flex gap-2">
<DialogClose asChild>
<Button variant="outline" size="sm">
Avbryt
{tCommon('cancel')}
</Button>
</DialogClose>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Sparar...' : 'Spara'}
{saving ? tCommon('saving') : tCommon('save')}
</Button>
</div>
</div>
+5 -3
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import {
BarChart,
Bar,
@@ -19,15 +20,16 @@ interface KPITopSuppliersChartProps {
const BAR_COLOR = 'hsl(var(--chart-1))'
export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
const t = useTranslations('kpi')
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Största leverantörer</CardTitle>
<CardTitle className="text-base">{t('top_suppliers_title')}</CardTitle>
</CardHeader>
<CardContent>
{suppliers.length === 0 ? (
<div className="flex h-[200px] items-center justify-center text-center text-sm text-muted-foreground px-4">
Inga registrerade leverantörsfakturor under perioden
{t('top_suppliers_empty')}
</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(160, suppliers.length * 32)}>
@@ -55,7 +57,7 @@ export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
interval={0}
/>
<Tooltip
formatter={(value) => [formatCurrency(Number(value)), 'Spend']}
formatter={(value) => [formatCurrency(Number(value)), t('top_suppliers_spend')]}
contentStyle={{
fontSize: '12px',
borderRadius: '8px',
+10 -15
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import {
Area,
XAxis,
@@ -19,12 +20,18 @@ interface KPITrendChartProps {
}
export function KPITrendChart({ months }: KPITrendChartProps) {
const t = useTranslations('kpi')
if (months.length === 0) return null
const seriesLabel = (key: string) =>
key === 'income' ? t('trend_legend_income')
: key === 'expenses' ? t('trend_legend_expenses')
: t('trend_legend_net')
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Intäkter, kostnader & resultat per månad</CardTitle>
<CardTitle className="text-base">{t('trend_title')}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
@@ -43,11 +50,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
<Tooltip
formatter={(value, name) => [
formatCurrency(Number(value)),
name === 'income'
? 'Intäkter'
: name === 'expenses'
? 'Kostnader'
: 'Resultat',
seriesLabel(String(name)),
]}
contentStyle={{
fontSize: '12px',
@@ -56,15 +59,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
backgroundColor: 'hsl(var(--card))',
}}
/>
<Legend
formatter={(value: string) =>
value === 'income'
? 'Intäkter'
: value === 'expenses'
? 'Kostnader'
: 'Resultat'
}
/>
<Legend formatter={(value: string) => seriesLabel(value)} />
<Area
type="monotone"
dataKey="income"
+32 -28
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -40,6 +41,8 @@ type SetupState =
| { kind: 'opening'; companyId: string }
| { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' }
// Swedish legal entity names (Aktiebolag, Enskild firma, etc.) are statutory
// terms — kept in Swedish in both locales.
function humanEntityType(t: string | null | undefined): string {
if (!t) return ''
if (t === 'aktiebolag') return 'Aktiebolag'
@@ -74,11 +77,12 @@ export default function BankIdCompanyPicker({
}: BankIdCompanyPickerProps) {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('select_company')
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
const [isPending, startTransition] = useTransition()
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
const busy = setup.kind !== 'idle' || isPending
@@ -127,8 +131,8 @@ export default function BankIdCompanyPicker({
// Route to the manual wizard with the known fields pre-filled instead.
if (!lookup) {
toast({
title: 'Kunde inte hämta företagsuppgifter',
description: 'Fyll i resterande uppgifter manuellt.',
title: t('toast_lookup_failed_title'),
description: t('toast_lookup_failed_description'),
})
setSetup({ kind: 'idle' })
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
@@ -139,8 +143,8 @@ export default function BankIdCompanyPicker({
// Under BFL 2 kap, bokföringsskyldighet ends when a company is struck off.
if (lookup.isCeased) {
toast({
title: 'Företaget är avregistrerat',
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -168,8 +172,8 @@ export default function BankIdCompanyPicker({
if (result.error === 'org_number_exists') {
toast({
title: 'Företaget finns redan',
description: 'Be en befintlig administratör att bjuda in dig.',
title: t('toast_company_exists_title'),
description: t('toast_company_exists_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -181,8 +185,8 @@ export default function BankIdCompanyPicker({
// above, but the server-side guard catches any race where TIC's
// cached result differs between the two calls.
toast({
title: 'Företaget är avregistrerat',
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
title: t('toast_company_ceased_title'),
description: t('toast_company_ceased_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -191,8 +195,8 @@ export default function BankIdCompanyPicker({
if (result.error === 'org_number_invalid') {
toast({
title: 'Ogiltigt organisationsnummer',
description: 'Fortsätt med manuell uppsättning.',
title: t('toast_org_invalid_title'),
description: t('toast_org_invalid_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
@@ -202,15 +206,15 @@ export default function BankIdCompanyPicker({
if (result.error || !result.companyId) {
toast({
title: 'Kunde inte skapa företag',
description: result.error ?? 'Försök igen eller lägg till manuellt.',
title: t('toast_create_failed_title'),
description: result.error ?? t('toast_create_failed_description'),
variant: 'destructive',
})
setSetup({ kind: 'idle' })
return
}
toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.' })
toast({ title: t('toast_welcome_title'), description: t('toast_company_ready') })
window.location.assign('/')
})
}
@@ -224,21 +228,21 @@ export default function BankIdCompanyPicker({
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">Sätter upp ditt företag…</p>
<p className="text-muted-foreground text-sm mt-1.5">{t('setting_up')}</p>
</header>
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<div className="flex items-start gap-3">
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Org.nr {setup.orgNumber}</p>
<p className="font-medium text-sm">{t('org_nr_prefix', { orgNumber: setup.orgNumber })}</p>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-center gap-2">
{lookupDone
? <Check className="h-4 w-4 text-sage" />
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<span className={cn(!lookupDone && 'text-muted-foreground')}>
Hämtar uppgifter från Bolagsverket
{t('progress_lookup')}
</span>
</li>
<li className="flex items-center gap-2">
@@ -246,7 +250,7 @@ export default function BankIdCompanyPicker({
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
Skapar företag och kontoplan
{t('progress_provision')}
</span>
</li>
</ul>
@@ -264,7 +268,7 @@ export default function BankIdCompanyPicker({
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
Välj ett företag att öppna eller lägg till ett nytt.
{t('subtitle')}
</p>
</header>
@@ -274,7 +278,7 @@ export default function BankIdCompanyPicker({
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.
{t('enrichment_stale')}
</p>
</div>
</div>
@@ -283,7 +287,7 @@ export default function BankIdCompanyPicker({
{memberCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Dina företag i {branding.appName.toLowerCase()}
{t('section_your_companies', { appName: branding.appName.toLowerCase() })}
</h2>
<ul className="space-y-2">
{memberCompanies.map((c) => {
@@ -330,7 +334,7 @@ export default function BankIdCompanyPicker({
{ticCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Företag kopplade till ditt BankID
{t('section_bankid_companies')}
</h2>
<ul className="space-y-2">
{ticCompanies.map(({ role, status }) => {
@@ -351,11 +355,11 @@ export default function BankIdCompanyPicker({
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Finns redan i {branding.appName.toLowerCase()}
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-2">
Be en befintlig administratör att bjuda in dig.
{t('ask_admin_invite')}
</p>
</div>
</li>
@@ -382,7 +386,7 @@ export default function BankIdCompanyPicker({
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Sätts upp manuellt
{t('setup_manually')}
</span>
</div>
</Link>
@@ -422,7 +426,7 @@ export default function BankIdCompanyPicker({
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
<p className="text-sm text-muted-foreground">
Inga företag hittades. Lägg till ditt första företag nedan.
{t('no_companies_found')}
</p>
)}
@@ -432,7 +436,7 @@ export default function BankIdCompanyPicker({
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs uppercase tracking-[0.08em] text-muted-foreground">
eller
{t('or_separator')}
</span>
</div>
</div>
@@ -446,7 +450,7 @@ export default function BankIdCompanyPicker({
)}
>
<Plus className="h-4 w-4" />
Lägg till företag manuellt
{t('add_company_manually')}
</Link>
</div>
</div>
+22 -21
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
ArrowRight,
CheckCircle2,
@@ -31,6 +32,7 @@ export default function NewUserChecklist({
className,
hasSkatteverketConnected,
}: NewUserChecklistProps) {
const t = useTranslations('new_user_checklist')
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
@@ -41,11 +43,10 @@ export default function NewUserChecklist({
{/* Header */}
<div className="text-center mb-8 md:mb-12">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
Välkommen till {branding.appName.toLowerCase()}
{t('welcome', { appName: branding.appName.toLowerCase() })}
</h1>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto mt-3">
Börja med att hämta din bokföring, sedan kopplar du banken.
Ingenting ändras i ditt nuvarande system.
{t('intro')}
</p>
</div>
@@ -56,7 +57,7 @@ export default function NewUserChecklist({
1
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Hämta din bokföring
{t('step1_title')}
</h2>
</div>
@@ -72,10 +73,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Hämta från annat system
{t('migrate_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed underline decoration-foreground/20 underline-offset-2">
Inget ändras i ditt befintliga system.
{t('migrate_description')}
</p>
<div className="flex flex-wrap gap-1.5 sm:gap-2 mt-2.5 sm:mt-3">
{([
@@ -107,10 +108,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Importera SIE-fil
{t('sie_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Exportera en SIE4-fil från ditt nuvarande bokföringsprogram och ladda upp den här.
{t('sie_description')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -126,7 +127,7 @@ export default function NewUserChecklist({
2
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Koppla din bank
{t('step2_title')}
</h2>
</div>
@@ -141,12 +142,12 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Anslut ditt bankkonto
{t('bank_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
{hasBanking
? 'Koppla via PSD2 — transaktioner synkas automatiskt varje dag.'
: 'Importera kontoutdrag från din bank — CSV, OFX och de flesta svenska banker.'}
? t('bank_description_psd2')
: t('bank_description_file')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -175,9 +176,9 @@ export default function NewUserChecklist({
: '3'}
</span>
<h2 className="font-display text-base font-medium tracking-tight">
Anslut Skatteverket
{t('step3_title')}
</h2>
<span className="text-xs text-muted-foreground">— valfritt</span>
<span className="text-xs text-muted-foreground">{t('optional_suffix')}</span>
</div>
<div className="ml-0 sm:ml-10">
@@ -189,10 +190,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base text-emerald-900 dark:text-emerald-200">
Skatteverket anslutet
{t('skatteverket_connected_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Du kan nu skicka momsdeklaration och AGI direkt, samt se saldot på skattekontot.
{t('skatteverket_connected_description')}
</p>
</div>
</div>
@@ -212,10 +213,10 @@ export default function NewUserChecklist({
</div>
<div className="flex-1 min-w-0">
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Anslut till Skatteverket med BankID
{t('skatteverket_connect_title')}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
Skicka momsdeklaration och arbetsgivardeklaration direkt, och hämta saldot på skattekontot — utan att lämna {branding.appName.toLowerCase()}.
{t('skatteverket_connect_description', { appName: branding.appName.toLowerCase() })}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
@@ -230,7 +231,7 @@ export default function NewUserChecklist({
<div className="space-y-5">
<div className="flex items-center gap-4">
<div className="flex-1 h-px bg-border/60" />
<span className="text-xs text-muted-foreground">eller</span>
<span className="text-xs text-muted-foreground">{t('or_separator')}</span>
<div className="flex-1 h-px bg-border/60" />
</div>
@@ -239,7 +240,7 @@ export default function NewUserChecklist({
onClick={onFreshStart}
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5 group"
>
Jag startar en ny verksamhet utan tidigare bokföring
{t('fresh_start')}
<ArrowRight className="h-3.5 w-3.5 group-hover:translate-x-0.5 transition-transform" />
</button>
</div>
@@ -247,7 +248,7 @@ export default function NewUserChecklist({
<div className="flex items-center justify-center gap-2 pt-2">
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground/50">
Din data är krypterad och lagras säkert i Sverige
{t('security_note')}
</p>
</div>
</div>
+28 -24
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -14,30 +15,33 @@ interface Step1Props {
isSaving: boolean
}
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: 'Du driver verksamhet i eget namn med F-skattsedel',
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: 'Du har ett registrerat AB med organisationsnummer',
icon: Building2,
},
]
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
const t = useTranslations('onboarding')
const [selected, setSelected] = useState<EntityType | undefined>(initialData.entity_type)
// "Enskild firma" and "Aktiebolag" are statutory legal entity types — kept
// in Swedish in both locales.
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: t('step1_ef_description'),
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: t('step1_ab_description'),
icon: Building2,
},
]
const handleNext = () => {
if (!selected) {
const msg = 'step 1: fortsätt clicked without entity type selected'
@@ -85,7 +89,7 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
<div className="flex items-center gap-2">
<span className="font-medium">{option.label}</span>
{option.disabled && (
<Badge variant="secondary" className="text-xs">Kommer snart</Badge>
<Badge variant="secondary" className="text-xs">{t('coming_soon')}</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
@@ -114,11 +118,11 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+26 -24
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -52,6 +53,7 @@ export default function Step2CompanyDetails({
isSaving,
orgNumberLocked,
}: Step2Props) {
const t = useTranslations('onboarding')
const {
register,
handleSubmit,
@@ -137,12 +139,12 @@ export default function Step2CompanyDetails({
return
}
if (res.status === 404) {
setLookupError('Inget företag hittades med det organisationsnumret.')
setLookupError(t('step2_lookup_not_found'))
onTicLookup?.(null)
return
}
if (!res.ok) {
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
return
}
@@ -163,7 +165,7 @@ export default function Step2CompanyDetails({
})
.catch((err) => {
if ((err as Error).name === 'AbortError') return
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
setLookupError(t('step2_lookup_failed'))
onTicLookup?.(null)
})
.finally(() => {
@@ -184,13 +186,13 @@ export default function Step2CompanyDetails({
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Grunduppgifter</CardTitle>
<CardTitle>{t('step2_card_title')}</CardTitle>
<CardDescription>
{ticEnabled
? 'Ange organisationsnummer så hämtas övriga uppgifter automatiskt.'
? t('step2_card_desc_tic')
: isAB
? 'Ange bolagets registrerade namn och organisationsnummer.'
: 'Ange namn på din verksamhet.'}
? t('step2_card_desc_ab')
: t('step2_card_desc_ef')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -201,11 +203,11 @@ export default function Step2CompanyDetails({
})} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="org_number">
Organisationsnummer *
{t('step2_org_number_label')}
</Label>
<Input
id="org_number"
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
placeholder={isAB ? 'XXXXXX-XXXX' : t('step2_org_number_placeholder_ef')}
{...register('org_number')}
readOnly={orgNumberLocked}
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
@@ -215,13 +217,13 @@ export default function Step2CompanyDetails({
)}
<p className="text-xs text-muted-foreground">
{isAB
? 'Obligatoriskt för aktiebolag'
: 'Vid enskild firma är orgnummer samma som ditt personnummer'}
? t('step2_org_help_ab')
: t('step2_org_help_ef')}
</p>
{ticEnabled && isLooking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Hämtar företagsuppgifter...
{t('step2_fetching_details')}
</div>
)}
{ticEnabled && lookupDone && !lookupDone.isCeased && (
@@ -233,7 +235,7 @@ export default function Step2CompanyDetails({
{ticEnabled && lookupDone?.isCeased && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
{lookupDone.companyName} — företaget är avregistrerat
{t('step2_ceased_inline', { companyName: lookupDone.companyName })}
</div>
)}
{ticEnabled && lookupError && (
@@ -243,7 +245,7 @@ export default function Step2CompanyDetails({
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
Det här företaget finns redan i {branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
</span>
</div>
)}
@@ -251,11 +253,11 @@ export default function Step2CompanyDetails({
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
{isAB ? t('step2_company_name_ab') : t('step2_company_name_ef')}
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
placeholder={isAB ? t('step2_company_name_placeholder_ab') : t('step2_company_name_placeholder_ef')}
{...register('company_name')}
/>
{errors.company_name && (
@@ -264,21 +266,21 @@ export default function Step2CompanyDetails({
</div>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Adress (för fakturor)</h3>
<h3 className="font-medium mb-4">{t('step2_address_heading')}</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Label htmlFor="address_line1">{t('step2_street_address')}</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
placeholder={t('step2_street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('step2_postal_code')}</Label>
<Input
id="postal_code"
placeholder="123 45"
@@ -286,7 +288,7 @@ export default function Step2CompanyDetails({
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('step2_city')}</Label>
<Input
id="city"
placeholder="Stockholm"
@@ -306,7 +308,7 @@ export default function Step2CompanyDetails({
className="w-full sm:w-auto"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button
type="submit"
@@ -316,11 +318,11 @@ export default function Step2CompanyDetails({
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+40 -38
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -150,6 +151,7 @@ export default function Step3TaxRegistration({
onBack,
isSaving,
}: Step3Props) {
const t = useTranslations('onboarding')
const isEF = entityType === 'enskild_firma'
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -215,7 +217,7 @@ export default function Step3TaxRegistration({
)
if (validation.error) {
toast({
title: 'Räkenskapsåret är inte giltigt',
title: t('step3_invalid_period'),
description: validation.error,
variant: 'destructive',
})
@@ -242,10 +244,10 @@ export default function Step3TaxRegistration({
? `${parseDateParts(firstEnd).day} ${monthNames[parseDateParts(firstEnd).month - 1].toLowerCase()} ${parseDateParts(firstEnd).year}`
: monthNames[endMonth - 1].toLowerCase()
const ok = await confirm({
title: 'Är du säker på brutet räkenskapsår?',
description: `Du har valt ett räkenskapsår som inte följer kalenderåret (slutar ${endLabel}). De flesta svenska företag använder kalenderår (1 januari – 31 december). Du kan ändra detta senare i inställningarna, men endast innan du har bokfört något.`,
confirmLabel: 'Ja, fortsätt',
cancelLabel: 'Ändra val',
title: t('step3_broken_year_title'),
description: t('step3_broken_year_description', { endLabel }),
confirmLabel: t('step3_broken_year_confirm'),
cancelLabel: t('step3_broken_year_cancel'),
variant: 'warning',
})
if (!ok) return
@@ -267,9 +269,9 @@ export default function Step3TaxRegistration({
<DestructiveConfirmDialog {...dialogProps} />
<Card>
<CardHeader>
<CardTitle>F-skatt och räkenskapsår</CardTitle>
<CardTitle>{t('step3_card_title')}</CardTitle>
<CardDescription>
Dessa uppgifter används för att beräkna din skattesituation.
{t('step3_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -279,8 +281,8 @@ export default function Step3TaxRegistration({
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 3 validation failed', extra: { fields } }) }).catch(() => {})
// Show first validation error to user
const firstError = Object.values(errs)[0]
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* F-skatt */}
<div className="flex items-start space-x-3">
@@ -299,19 +301,19 @@ export default function Step3TaxRegistration({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är F-skatt?</p>
<p>F-skatt betyder att du själv ansvarar för att betala skatt och avgifter till Skatteverket varje månad.</p>
<p className="text-xs text-muted-foreground">De flesta som driver företag har F-skatt. Utan F-skatt måste dina kunder göra skatteavdrag på dina fakturor.</p>
<p className="font-medium">{t('step3_fskatt_tip_title')}</p>
<p>{t('step3_fskatt_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step3_fskatt_tip_note')}</p>
</div>
}
side="right"
>
<Label htmlFor="f_skatt" className="cursor-pointer">
Jag har F-skattsedel
{t('step3_fskatt_label')}
</Label>
</InfoTooltip>
<p className="text-sm text-muted-foreground">
F-skatt innebär att du själv ansvarar för att betala in skatt och avgifter.
{t('step3_fskatt_help')}
</p>
</div>
</div>
@@ -321,16 +323,16 @@ export default function Step3TaxRegistration({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Räkenskapsår</p>
<p>Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).</p>
<p className="font-medium">{t('step3_fy_tip_title')}</p>
<p>{t('step3_fy_tip_body')}</p>
{isEF && (
<p className="text-xs text-muted-foreground">Enskild firma måste använda kalenderår enligt BFL 3 kap.</p>
<p className="text-xs text-muted-foreground">{t('step3_fy_tip_ef_note')}</p>
)}
</div>
}
side="right"
>
<Label className="text-base font-medium">Vilket räkenskapsår bokför du för?</Label>
<Label className="text-base font-medium">{t('step3_fy_question')}</Label>
</InfoTooltip>
{/* Toggle: First year vs Ongoing */}
@@ -350,8 +352,8 @@ export default function Step3TaxRegistration({
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Första räkenskapsåret</p>
<p className="text-xs text-muted-foreground mt-0.5">Nystartat företag</p>
<p className="font-medium text-sm">{t('step3_first_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_first_fy_subtitle')}</p>
</div>
{field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
@@ -372,8 +374,8 @@ export default function Step3TaxRegistration({
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Annat räkenskapsår</p>
<p className="text-xs text-muted-foreground mt-0.5">Pågående verksamhet</p>
<p className="font-medium text-sm">{t('step3_other_fy_title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_other_fy_subtitle')}</p>
</div>
{!field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
@@ -405,7 +407,7 @@ export default function Step3TaxRegistration({
// Reset end when start changes — its valid options depend on start
if (endField.value) endField.onChange('')
}}
startHelpText="Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag."
startHelpText={t('step3_start_help')}
endDate={endField.value || ''}
entityType={entityType}
endDateSlot={
@@ -413,7 +415,7 @@ export default function Step3TaxRegistration({
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>Räkenskapsåret slutar (månad)</Label>
<Label>{t('step3_fy_end_month_label')}</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => {
@@ -424,7 +426,7 @@ export default function Step3TaxRegistration({
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
@@ -440,13 +442,13 @@ export default function Step3TaxRegistration({
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>Slutdatum</Label>
<Label>{t('step3_end_date_label')}</Label>
<Select
value={endField.value || ''}
onValueChange={(v) => { if (v) endField.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj slutdatum" />
<SelectValue placeholder={t('step3_select_end_date')} />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
@@ -464,7 +466,7 @@ export default function Step3TaxRegistration({
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
{t('step3_no_valid_end')}
</p>
)}
@@ -486,14 +488,14 @@ export default function Step3TaxRegistration({
<div className="space-y-2">
{isEF ? (
<div className="rounded-lg bg-muted/50 p-4">
<p className="text-sm font-medium">Kalenderår (januari-december)</p>
<p className="text-sm font-medium">{t('step3_calendar_year')}</p>
<p className="text-xs text-muted-foreground mt-1">
Enskild firma måste använda kalenderår enligt BFL 3 kap.
{t('step3_ef_calendar_required')}
</p>
</div>
) : (
<div className="space-y-2">
<Label>När slutar företagets räkenskapsår?</Label>
<Label>{t('step3_when_fy_ends')}</Label>
<Controller
name="fiscal_year_end_month"
control={control}
@@ -503,7 +505,7 @@ export default function Step3TaxRegistration({
onValueChange={(v) => { if (v) field.onChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
<SelectValue placeholder={t('step3_select_month')} />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
@@ -516,21 +518,21 @@ export default function Step3TaxRegistration({
)}
/>
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
{t('step3_calendar_or_broken')}
</p>
{fiscalYearEndMonth && (
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt räkenskapsår
{t('step3_your_fy')}
</div>
<p className="text-sm text-muted-foreground">
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
</p>
<p className="text-xs text-muted-foreground">12 månader</p>
<p className="text-xs text-muted-foreground">{t('step3_twelve_months')}</p>
</div>
)}
</div>
@@ -547,17 +549,17 @@ export default function Step3TaxRegistration({
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+36 -35
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -56,6 +57,7 @@ export default function Step4VatAccounting({
onBack,
isSaving,
}: Step4Props) {
const t = useTranslations('onboarding')
const { toast } = useToast()
const {
@@ -105,9 +107,9 @@ export default function Step4VatAccounting({
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Moms och bokföringsmetod</CardTitle>
<CardTitle>{t('step4_card_title')}</CardTitle>
<CardDescription>
Ange din momsregistrering och välj bokföringsmetod.
{t('step4_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -116,8 +118,8 @@ export default function Step4VatAccounting({
console.error('[onboarding] step 4 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
const firstError = Object.values(errs)[0]
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
const message = firstError?.message || t('check_all_fields')
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* VAT section */}
<div className="space-y-4">
@@ -125,14 +127,14 @@ export default function Step4VatAccounting({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
<p className="font-medium">{t('step4_vat_tip_title')}</p>
<p>{t('step4_vat_tip_body')}</p>
<p className="text-xs text-muted-foreground">{t('step4_vat_tip_note')}</p>
</div>
}
side="right"
>
<span>Momsregistrering</span>
<span>{t('step4_vat_heading')}</span>
</InfoTooltip>
</h3>
@@ -150,10 +152,10 @@ export default function Step4VatAccounting({
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
Jag är momsregistrerad
{t('step4_vat_registered_label')}
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
{t('step4_vat_registered_help')}
</p>
</div>
</div>
@@ -161,14 +163,14 @@ export default function Step4VatAccounting({
{vatRegistered && (
<div className="space-y-4 pl-0 sm:pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Label htmlFor="vat_number">{t('step4_vat_number_label')}</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
{t('step4_vat_number_format')}
</p>
</div>
@@ -176,18 +178,18 @@ export default function Step4VatAccounting({
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
<p className="font-medium">{t('step4_vat_period_tip_title')}</p>
<p>{t('step4_vat_period_tip_body')}</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
<li>Över 40 miljoner = Månad</li>
<li>{t('step4_vat_period_bracket_low')}</li>
<li>{t('step4_vat_period_bracket_mid')}</li>
<li>{t('step4_vat_period_bracket_high')}</li>
</ul>
</div>
}
side="right"
>
<Label>Momsredovisningsperiod</Label>
<Label>{t('step4_vat_period_label')}</Label>
</InfoTooltip>
<Controller
name="moms_period"
@@ -198,18 +200,18 @@ export default function Step4VatAccounting({
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('step4_select_period')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
<SelectItem value="monthly">{t('step4_period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('step4_period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('step4_period_yearly')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
{t('step4_period_help')}
</p>
</div>
</div>
@@ -220,10 +222,10 @@ export default function Step4VatAccounting({
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
content={t('step4_method_tip')}
side="right"
>
<Label>Bokföringsmetod</Label>
<Label>{t('step4_method_label')}</Label>
</InfoTooltip>
<Controller
name="accounting_method"
@@ -237,7 +239,7 @@ export default function Step4VatAccounting({
onCheckedChange={(checked) => { if (checked) field.onChange('accrual') }}
/>
<Label htmlFor="method_accrual" className="cursor-pointer">
Faktureringsmetoden
{t('step4_method_accrual')}
</Label>
</div>
<div className="flex items-start space-x-3">
@@ -247,7 +249,7 @@ export default function Step4VatAccounting({
onCheckedChange={(checked) => { if (checked) field.onChange('cash') }}
/>
<Label htmlFor="method_cash" className="cursor-pointer">
Kontantmetoden
{t('step4_method_cash')}
</Label>
</div>
</div>
@@ -256,16 +258,15 @@ export default function Step4VatAccounting({
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
{accountingMethod === 'accrual' ? t('step4_method_accrual') : t('step4_method_cash')}
</div>
<p className="text-sm text-muted-foreground">
{accountingMethod === 'accrual'
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
? t('step4_method_accrual_desc')
: t('step4_method_cash_desc')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
3 MSEK (BFL 5 kap. 2 §).
{t('step4_cash_limit_note')}
</p>
</div>
</div>
@@ -279,17 +280,17 @@ export default function Step4VatAccounting({
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
{t('back')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
<>
Fortsätt
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
+30 -28
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
Select,
@@ -14,53 +15,53 @@ import type { EntityType } from '@/types'
interface ReportItem {
value: string
label: string
labelKey: string
entityType?: EntityType
}
interface ReportCategory {
label: string
labelKey: string
items: ReportItem[]
}
const CATEGORIES: ReportCategory[] = [
{
label: 'Löpande',
labelKey: 'group_interim',
items: [
{ value: 'resultatrapport', label: 'Resultatrapport' },
{ value: 'balansrapport', label: 'Balansrapport' },
{ value: 'trial-balance', label: 'Saldobalans' },
{ value: 'resultatrapport', labelKey: 'name_resultatrapport' },
{ value: 'balansrapport', labelKey: 'name_balansrapport' },
{ value: 'trial-balance', labelKey: 'name_trial_balance' },
],
},
{
label: 'Bokslut',
labelKey: 'group_year_end',
items: [
{ value: 'income-statement', label: 'Resultaträkning' },
{ value: 'balance-sheet', label: 'Balansräkning' },
{ value: 'income-statement', labelKey: 'name_income_statement' },
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
],
},
{
label: 'Skatt & moms',
labelKey: 'group_tax_vat',
items: [
{ value: 'vat-declaration', label: 'Momsdeklaration' },
{ value: 'periodisk-sammanstallning', label: 'Periodisk sammanställning' },
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
{ value: 'vat-declaration', labelKey: 'name_vat_declaration' },
{ value: 'periodisk-sammanstallning', labelKey: 'name_periodisk_sammanstallning' },
{ value: 'ne-declaration', labelKey: 'name_ne_declaration', entityType: 'enskild_firma' },
{ value: 'ink2-declaration', labelKey: 'name_ink2_declaration', entityType: 'aktiebolag' },
],
},
{
label: 'Huvudböcker',
labelKey: 'group_ledgers',
items: [
{ value: 'huvudbok', label: 'Huvudbok' },
{ value: 'grundbok', label: 'Grundbok' },
{ value: 'kundreskontra', label: 'Kundreskontra' },
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
{ value: 'huvudbok', labelKey: 'name_huvudbok' },
{ value: 'grundbok', labelKey: 'name_grundbok' },
{ value: 'kundreskontra', labelKey: 'name_kundreskontra' },
{ value: 'supplier-ledger', labelKey: 'name_supplier_ledger' },
],
},
{
label: 'Avstämning',
labelKey: 'group_reconciliation',
items: [
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
{ value: 'bank-reconciliation', labelKey: 'name_bank_reconciliation' },
],
},
]
@@ -72,6 +73,7 @@ interface ReportsNavProps {
}
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
const t = useTranslations('reports')
const filtered = CATEGORIES
.map(cat => ({
...cat,
@@ -89,11 +91,11 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
</SelectTrigger>
<SelectContent>
{filtered.map(cat => (
<SelectGroup key={cat.label}>
<SelectLabel>{cat.label}</SelectLabel>
<SelectGroup key={cat.labelKey}>
<SelectLabel>{t(cat.labelKey)}</SelectLabel>
{cat.items.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.labelKey)}
</SelectItem>
))}
</SelectGroup>
@@ -105,13 +107,13 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
{/* Desktop: vertical left rail */}
<nav
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
aria-label="Rapportkategorier"
aria-label={t('categories_aria')}
>
<ul className="space-y-6">
{filtered.map(cat => (
<li key={cat.label}>
<li key={cat.labelKey}>
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
{cat.label}
{t(cat.labelKey)}
</p>
<ul className="space-y-px">
{cat.items.map(item => {
@@ -129,7 +131,7 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{item.label}
{t(item.labelKey)}
</button>
</li>
)
+22 -19
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
@@ -25,6 +26,7 @@ interface Blocker {
}
export function AccountDangerZone() {
const t = useTranslations('settings_account_danger')
const router = useRouter()
const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([])
@@ -77,7 +79,7 @@ export function AccountDangerZone() {
const body = await response.json()
// Precondition tripped mid-flow — refresh the list and show inline.
setBlockers(body.blockers ?? [])
setError(body.error || 'Du måste radera eller överlåta dina företag först.')
setError(body.error || t('delete_failed_blockers'))
setIsDeleting(false)
setShowDialog(false)
return
@@ -85,12 +87,12 @@ export function AccountDangerZone() {
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera kontot')
throw new Error(body.error || t('delete_failed_default'))
}
router.push('/login')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte radera kontot')
setError(err instanceof Error ? err.message : t('delete_failed_default'))
setIsDeleting(false)
}
}
@@ -102,14 +104,14 @@ export function AccountDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera konto
{t('heading')}
</h2>
{hasBlockers && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<p className="text-sm font-medium">Företag du äger</p>
<p className="text-sm font-medium">{t('blockers_title')}</p>
<p className="text-sm text-muted-foreground">
Radera eller överlåt alla företag innan du raderar kontot.
{t('blockers_description')}
</p>
<ul className="space-y-2">
{blockers.map((b) => (
@@ -119,7 +121,7 @@ export function AccountDangerZone() {
>
<span className="text-sm font-medium">{b.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">Hantera</Link>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</li>
))}
@@ -130,8 +132,8 @@ export function AccountDangerZone() {
<RetentionNotice variant="account" />
<p className="text-sm text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</p>
{error && !showDialog && (
@@ -142,7 +144,7 @@ export function AccountDangerZone() {
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
{t('export_sie')}
</Link>
</Button>
<Button
@@ -151,7 +153,7 @@ export function AccountDangerZone() {
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
Radera mitt konto
{t('delete_button')}
</Button>
</div>
</section>
@@ -169,16 +171,17 @@ export function AccountDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogTitle>{t('dialog_title')}</DialogTitle>
<DialogDescription>
Ditt konto avidentifieras och du loggas ut från alla enheter.
Du kan inte skapa ett nytt konto med samma e-postadress — kontakta support
om du vill återaktivera kontot i framtiden.
{t('dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">
Skriv din e-postadress (<strong>{email}</strong>) för att bekräfta
{t.rich('confirm_label', {
email: email ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="delete-confirm"
@@ -200,7 +203,7 @@ export function AccountDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('cancel')}
</Button>
<Button
variant="destructive"
@@ -214,10 +217,10 @@ export function AccountDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('deleting')}
</>
) : (
'Radera konto'
t('delete_confirm_button')
)}
</Button>
</DialogFooter>
+99 -91
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -27,14 +28,14 @@ const connectorName = branding.appName.toLowerCase()
type ScopeEntry = {
scope: ApiKeyScope
label: string
labelKey: string
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
tools: number
}
type ScopeGroup = {
domain: string
label: string
labelKey: string
read: ScopeEntry | null
write: ScopeEntry | null
}
@@ -42,86 +43,86 @@ type ScopeGroup = {
const SCOPE_GROUPS: ScopeGroup[] = [
{
domain: 'transactions',
label: 'Transaktioner',
read: { scope: 'transactions:read', label: 'Läs — lista transaktioner, mallar, kategorier, inbox', tools: 8 },
write: { scope: 'transactions:write', label: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning', tools: 8 },
labelKey: 'group_transactions',
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
},
{
domain: 'customers',
label: 'Kunder',
read: { scope: 'customers:read', label: 'Läs — lista kunder', tools: 1 },
write: { scope: 'customers:write', label: 'Skriv — skapa kunder', tools: 1 },
labelKey: 'group_customers',
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
},
{
domain: 'invoices',
label: 'Fakturor',
read: { scope: 'invoices:read', label: 'Läs — lista fakturor', tools: 1 },
write: { scope: 'invoices:write', label: 'Skriv — skapa, skicka, markera betald/skickad, kreditera, konvertera', tools: 6 },
labelKey: 'group_invoices',
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
},
{
domain: 'suppliers',
label: 'Leverantörer',
read: { scope: 'suppliers:read', label: 'Läs — lista leverantörer och leverantörsfakturor', tools: 2 },
write: { scope: 'suppliers:write', label: 'Skriv — godkänn, kreditera, skapa leverantörsfaktura från inbox', tools: 3 },
labelKey: 'group_suppliers',
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
},
{
domain: 'reports',
label: 'Rapporter',
read: { scope: 'reports:read', label: 'Läs — kontoplan, huvudbok, BR, RR, moms, KPI, reskontra, perioder, bankavstämning, SIE-export', tools: 18 },
labelKey: 'group_reports',
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
write: null,
},
{
domain: 'bookkeeping',
label: 'Bokföring',
labelKey: 'group_bookkeeping',
read: null,
write: { scope: 'bookkeeping:write', label: 'Skriv — stänga/låsa perioder, IB, bokslut, SIE-import, verifikat, korrigeringar (alla stagas)', tools: 11 },
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
},
{
domain: 'payroll',
label: 'Löner',
read: { scope: 'payroll:read', label: 'Läs — lista anställda, lönekörningar, lönejournal', tools: 3 },
write: { scope: 'payroll:write', label: 'Skriv — skapa lönekörning, beräkna, generera AGI', tools: 3 },
labelKey: 'group_payroll',
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
},
{
domain: 'pending_operations',
label: 'Stagade operationer',
read: { scope: 'pending_operations:read', label: 'Läs — lista pending_operations som väntar på godkännande', tools: 1 },
write: { scope: 'pending_operations:approve', label: 'Godkänn — committa eller avvisa staged ops via API (ersätter web-UI:s granskning)', tools: 2 },
labelKey: 'group_pending_operations',
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
},
{
domain: 'documents',
label: 'Dokument (REST API)',
read: { scope: 'documents:read', label: 'Läs — lista och hämta dokumentbilagor', tools: 0 },
write: { scope: 'documents:write', label: 'Skriv — ladda upp och koppla dokument till verifikationer', tools: 0 },
labelKey: 'group_documents',
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
},
{
domain: 'companies',
label: 'Företag (REST API)',
read: { scope: 'companies:read', label: 'Läs — företagsprofiler nyckeln har åtkomst till', tools: 0 },
labelKey: 'group_companies',
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
write: null,
},
{
domain: 'events',
label: 'Händelser (REST API)',
read: { scope: 'events:read', label: 'Läs — polla event_log som webhook-fallback', tools: 0 },
labelKey: 'group_events',
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
write: null,
},
{
domain: 'webhooks',
label: 'Webhooks (REST API)',
labelKey: 'group_webhooks',
read: null,
write: { scope: 'webhooks:manage', label: 'Hantera — skapa, lista, uppdatera, radera prenumerationer', tools: 0 },
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
},
{
domain: 'operations',
label: 'Operationer (REST API)',
read: { scope: 'operations:read', label: 'Läs — status för långkörande operationer (import, bokslut, omvärdering)', tools: 0 },
labelKey: 'group_operations',
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
write: null,
},
{
domain: 'compliance',
label: 'Compliance (REST API)',
read: { scope: 'compliance:read', label: 'Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet', tools: 0 },
labelKey: 'group_compliance',
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 0 },
write: null,
},
]
@@ -146,7 +147,7 @@ interface ApiKey {
created_at: string
}
function CopyBlock({ text }: { text: string }) {
function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
@@ -169,7 +170,7 @@ function CopyBlock({ text }: { text: string }) {
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
aria-label="Kopiera"
aria-label={copyAriaLabel}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
@@ -190,9 +191,11 @@ function ScopeCard({
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const dashIdx = entry.label.indexOf(' — ')
const verb = dashIdx > 0 ? entry.label.slice(0, dashIdx) : entry.label
const description = dashIdx > 0 ? entry.label.slice(dashIdx + 3) : ''
const t = useTranslations('settings_api_keys')
const label = t(entry.labelKey)
const dashIdx = label.indexOf(' — ')
const verb = dashIdx > 0 ? label.slice(0, dashIdx) : label
const description = dashIdx > 0 ? label.slice(dashIdx + 3) : ''
return (
<label
@@ -211,7 +214,7 @@ function ScopeCard({
/>
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{entry.tools > 0 ? `${entry.tools} verktyg` : 'REST'}
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
</span>
</div>
{description && (
@@ -224,6 +227,7 @@ function ScopeCard({
}
export function ApiKeysPanel() {
const t = useTranslations('settings_api_keys')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -246,11 +250,11 @@ export function ApiKeysPanel() {
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchKeys()
@@ -262,7 +266,7 @@ export function ApiKeysPanel() {
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName || 'MCP-nyckel', scopes: Array.from(newKeyScopes) }),
body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes) }),
})
const json = await res.json()
@@ -278,7 +282,7 @@ export function ApiKeysPanel() {
setNewKeyScopes(new Set(ALL_SCOPES))
fetchKeys()
} catch {
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
toast({ title: t('toast_create_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -286,18 +290,18 @@ export function ApiKeysPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla API-nyckel',
description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' })
setKeys((prev) => prev.filter((k) => k.id !== id))
toast({ title: 'Nyckel återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -326,9 +330,9 @@ export function ApiKeysPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>API-nycklar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Hantera nycklar för MCP-klienter (Claude, Cursor) och andra integrationer.
{t('description')}
</CardDescription>
</div>
<Button
@@ -337,7 +341,7 @@ export function ApiKeysPanel() {
disabled={keys.length >= 10}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Skapa nyckel
{t('create_key')}
</Button>
</div>
</CardHeader>
@@ -349,9 +353,9 @@ export function ApiKeysPanel() {
) : keys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Key className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga API-nycklar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Skapa en nyckel för att koppla din MCP-klient.
{t('empty_help')}
</p>
</div>
) : (
@@ -368,10 +372,10 @@ export function ApiKeysPanel() {
<p className="text-sm font-medium truncate">{key.name}</p>
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? 'Alla behörigheter'
? t('all_permissions')
: scopeCount === 0
? 'Inga behörigheter'
: `${scopeCount} behörigheter`}
? t('no_permissions')
: t('permissions_count', { count: scopeCount })}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
@@ -379,12 +383,12 @@ export function ApiKeysPanel() {
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
Skapad {formatDate(key.created_at)}
{t('created')} {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? `Använd ${formatDate(key.last_used_at)}`
: 'Aldrig använd'}
? t('used_on', { date: formatDate(key.last_used_at) })
: t('never_used')}
</span>
</div>
</div>
@@ -392,7 +396,7 @@ export function ApiKeysPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={`Återkalla ${key.name}`}
aria-label={t('revoke_aria', { name: key.name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -407,27 +411,29 @@ export function ApiKeysPanel() {
<Card>
<CardHeader>
<CardTitle className="text-base">Anslut MCP-klient</CardTitle>
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-sm font-medium">Claude.ai</p>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">Rekommenderat</Badge>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">{t('recommended_badge')}</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
Gå till <strong>Settings &rarr; Integrations &rarr; Add Integration</strong> och klistra in MCP-serverns URL.
Du loggas in via ditt {connectorName}-konto — ingen API-nyckel behövs.
{t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl} />
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-2">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen — loggar in via webbläsaren:
{t('terminal_runs_browser_login')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} />
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
</div>
<div className="border-t pt-4">
@@ -437,14 +443,16 @@ export function ApiKeysPanel() {
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
Anslut med API-nyckel istället
{t('connect_with_api_key')}
</button>
{showApiKeyMethods && (
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<p className="text-xs text-muted-foreground mb-2">
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar &rarr; Developer):
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
@@ -456,17 +464,17 @@ export function ApiKeysPanel() {
}
}
}
}`} />
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen med en API-nyckel:
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url ${mcpUrl} \\
--header "Authorization: Bearer gnubok_sk_..."`} />
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
)}
@@ -478,17 +486,17 @@ export function ApiKeysPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl p-4 sm:max-w-3xl sm:p-6">
<DialogHeader>
<DialogTitle>Skapa API-nyckel</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
<DialogDescription>
Ge nyckeln ett namn så du vet vad den används till.
{t('create_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="key-name">Namn</Label>
<Label htmlFor="key-name">{t('name_label')}</Label>
<Input
id="key-name"
placeholder="t.ex. Claude Desktop"
placeholder={t('name_placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
@@ -497,19 +505,19 @@ export function ApiKeysPanel() {
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="space-y-1">
<Label>Behörigheter</Label>
<Label>{t('permissions_label')}</Label>
<p className="text-xs text-muted-foreground">
Välj vad nyckeln ska ha åtkomst till.
{t('permissions_help')}
</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{newKeyScopes.size} av {ALL_SCOPES.length} valda
{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SCOPE_GROUPS.map((group) => (
<div key={group.domain} className="space-y-2">
<h4 className="text-sm font-medium">{group.label}</h4>
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
<div className="space-y-2 px-2">
{group.read && (
<ScopeCard
@@ -555,11 +563,11 @@ export function ApiKeysPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Skapa
{t('create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -577,9 +585,9 @@ export function ApiKeysPanel() {
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Din nya API-nyckel</DialogTitle>
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
<DialogDescription>
Kopiera nyckeln nu. Den visas bara en gång.
{t('new_key_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="relative">
@@ -605,7 +613,7 @@ export function ApiKeysPanel() {
setNewKeyValue('')
setCopied(false)
}}>
Klar
{t('done')}
</Button>
</DialogFooter>
</DialogContent>
+35 -34
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -28,6 +29,7 @@ interface EstimateResponse {
const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download'
export function BackupDownloadForm() {
const t = useTranslations('settings_backup_download')
const { toast } = useToast()
const { company } = useCompany()
@@ -132,16 +134,16 @@ export function BackupDownloadForm() {
const body = await res.json().catch(() => ({}))
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
toast({
title: 'Arkivet är för stort för direktnedladdning',
title: t('toast_too_large_title'),
description: sizeMb
? `Ditt arkiv är cirka ${sizeMb} MB. Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.`
: 'Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.',
? t('toast_too_large_with_size', { size: sizeMb })
: t('toast_too_large_generic'),
variant: 'destructive',
})
return
}
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte skapa arkivet')
throw new Error(body.error || t('error_create_archive'))
}
const blob = await res.blob()
@@ -164,17 +166,17 @@ export function BackupDownloadForm() {
setLastDownloadedAt(now)
}
toast({ title: 'Säkerhetsbackup skapad', description: filename })
toast({ title: t('toast_backup_created'), description: filename })
} catch (err) {
toast({
title: 'Kunde inte skapa säkerhetsbackup',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('toast_backup_failed'),
description: err instanceof Error ? err.message : t('toast_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast])
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t])
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId)
@@ -183,31 +185,33 @@ export function BackupDownloadForm() {
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Skapa backup</CardTitle>
<CardTitle>{t('create_backup_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Omfattning</Label>
<Label>{t('scope_label')}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<ScopeRadio
checked={scope === 'all'}
onChange={() => setScope('all')}
label="Hela historiken"
description="Alla räkenskapsår och verifikationer"
label={t('scope_all_label')}
description={t('scope_all_desc')}
recommendedLabel={t('recommended')}
recommended
/>
<ScopeRadio
checked={scope === 'period'}
onChange={() => setScope('period')}
label="En period"
description="Välj ett specifikt räkenskapsår"
label={t('scope_period_label')}
description={t('scope_period_desc')}
recommendedLabel={t('recommended')}
/>
</div>
</div>
{scope === 'period' && (
<div className="space-y-2">
<Label htmlFor="backup-period">Räkenskapsår</Label>
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
<select
id="backup-period"
value={selectedPeriodId}
@@ -215,7 +219,7 @@ export function BackupDownloadForm() {
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={periods.length === 0}
>
{periods.length === 0 && <option value="">Inga räkenskapsår</option>}
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
@@ -227,10 +231,9 @@ export function BackupDownloadForm() {
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="include-documents">Inkludera kvitton och underlag</Label>
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
<p className="text-xs text-muted-foreground max-w-prose">
Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en.
Stäng av för en mindre backup med bara bokföringsdata.
{t('include_docs_help')}
</p>
</div>
<Switch
@@ -244,21 +247,19 @@ export function BackupDownloadForm() {
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-3.5 w-3.5" />
{isLoadingEstimate ? (
<span>Beräknar storlek…</span>
<span>{t('calculating_size')}</span>
) : estimate ? (
<span>
Uppskattad storlek: <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'})
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
</span>
) : (
<span>Storlek beräknas när omfattning är vald.</span>
<span>{t('size_will_calculate')}</span>
)}
</div>
{isOverLimit && (
<p className="mt-2 text-xs text-destructive">
Arkivet är större än {formatBytes(estimate!.size_limit_bytes)} och kan inte laddas ner
direkt. Välj en enskild period eller stäng av bilagor tills vidare —
automatisk molnsynkronisering kommer i senare version.
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
</p>
)}
</div>
@@ -268,18 +269,18 @@ export function BackupDownloadForm() {
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar backup…
{t('creating_backup')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Skapa och ladda ner
{t('create_and_download')}
</>
)}
</Button>
{lastDownloadedAt && (
<p className="text-xs text-muted-foreground">
Senaste nedladdning: {formatDate(lastDownloadedAt)}
{t('last_download')}: {formatDate(lastDownloadedAt)}
</p>
)}
</div>
@@ -293,13 +294,12 @@ export function BackupDownloadForm() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Molnsynkronisering
{t('cloud_sync_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground max-w-prose">
Aktivera tillägget &ldquo;Molnsynkronisering&rdquo; för att koppla Google
Drive och ladda upp säkerhetsbackupen med ett klick.
{t('cloud_sync_disabled_help')}
</p>
</CardContent>
</Card>
@@ -314,9 +314,10 @@ interface ScopeRadioProps {
label: string
description: string
recommended?: boolean
recommendedLabel: string
}
function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) {
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
return (
<button
type="button"
@@ -329,7 +330,7 @@ function ScopeRadio({ checked, onChange, label, description, recommended }: Scop
<span className="font-medium text-sm">{label}</span>
{recommended && (
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
Rekommenderas
{recommendedLabel}
</span>
)}
</div>
+14 -12
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,6 +15,7 @@ interface BankDetailsFormProps {
}
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const t = useTranslations('settings_bank_details_form')
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
@@ -23,22 +25,22 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bankuppgifter
{t('heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas på dina fakturor
{t('subheading')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Label>{t('bank_label')}</Label>
<BankNameCombobox
defaultValue={settings.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
name="clearing_number"
@@ -52,13 +54,13 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setClearingError(null); return }
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
setClearingError(!/^\d{4,5}$/.test(val) ? t('clearing_error') : null)
}}
/>
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Label htmlFor="account_number">{t('account_number_label')}</Label>
<Input
id="account_number"
name="account_number"
@@ -72,7 +74,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setAccountNumberError(null); return }
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
setAccountNumberError(!/^\d{6,12}$/.test(val) ? t('account_number_error') : null)
}}
/>
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
@@ -81,7 +83,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
name="bankgiro"
@@ -94,7 +96,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError('Ogiltigt bankgironummer')
setBankgiroError(t('bankgiro_error'))
}
}}
/>
@@ -102,11 +104,11 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="swish">Swish</Label>
<Label htmlFor="swish">{t('swish_label')}</Label>
<Input
id="swish"
name="swish"
placeholder="123 XXX XX XX eller 07X XXX XX XX"
placeholder={t('swish_placeholder')}
defaultValue={settings.swish || ''}
onBlur={(e) => {
const val = normaliseSwish(e.target.value)
@@ -115,7 +117,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = val
setSwishError(null)
} else {
setSwishError('Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)')
setSwishError(t('swish_error'))
}
}}
/>
+15 -15
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
@@ -17,6 +18,7 @@ interface BankIdIdentity {
}
export function BankIdSettings() {
const t = useTranslations('settings_bankid')
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLinking, setIsLinking] = useState(false)
@@ -45,20 +47,20 @@ export function BankIdSettings() {
const handleLinkComplete = async (result: BankIdResult) => {
if (result.error) {
const message = result.error === 'already_linked'
? 'Detta BankID ar redan kopplat till ett annat konto.'
: 'Kunde inte koppla BankID.'
? t('toast_already_linked')
: t('toast_link_failed')
toast({ title: message, variant: 'destructive' })
setIsLinking(false)
return
}
toast({ title: 'BankID kopplat till ditt konto' })
toast({ title: t('toast_linked') })
setIsLinking(false)
fetchIdentity()
}
const handleUnlink = async () => {
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
if (!confirm(t('confirm_unlink'))) return
setIsUnlinking(true)
try {
@@ -66,9 +68,9 @@ export function BankIdSettings() {
if (!res.ok) throw new Error('Unlink failed')
setIdentity(null)
toast({ title: 'BankID bortkopplat' })
toast({ title: t('toast_unlinked') })
} catch {
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
toast({ title: t('toast_unlink_failed'), variant: 'destructive' })
} finally {
setIsUnlinking(false)
}
@@ -88,8 +90,8 @@ export function BankIdSettings() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Koppla BankID</CardTitle>
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
<CardDescription>{t('link_bankid_description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
@@ -107,12 +109,10 @@ export function BankIdSettings() {
) : (
<Shield className="h-4 w-4 text-muted-foreground" />
)}
BankID
{t('title')}
</CardTitle>
<CardDescription>
{identity
? 'Ditt konto ar kopplat till BankID.'
: 'Koppla BankID for sakrare inloggning.'}
{identity ? t('linked_description') : t('not_linked_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -123,7 +123,7 @@ export function BankIdSettings() {
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
Kopplat {formatDateLong(identity.linked_at)}
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
</span>
</div>
<Button
@@ -133,7 +133,7 @@ export function BankIdSettings() {
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
{isUnlinking ? t('unlinking') : t('unlink_button')}
</Button>
</div>
) : (
@@ -141,7 +141,7 @@ export function BankIdSettings() {
variant="outline"
onClick={() => setIsLinking(true)}
>
Koppla BankID
{t('link_button')}
</Button>
)}
</CardContent>
+82 -72
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,16 +22,17 @@ import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
const ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const ENTITY_LABELS: Record<string, string> = {
all: t('entity_all'),
enskild_firma: t('entity_enskild_firma'),
aktiebolag: t('entity_aktiebolag'),
}
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -44,11 +46,11 @@ export function BookingTemplatesPanel() {
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
@@ -61,11 +63,11 @@ export function BookingTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Mall borttagen' })
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
toast({ title: t('toast_deleted') })
} finally {
setDeletingId(null)
}
@@ -82,7 +84,7 @@ export function BookingTemplatesPanel() {
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
toast({ title: t('toast_export_failed'), variant: 'destructive' })
}
}
@@ -99,13 +101,13 @@ export function BookingTemplatesPanel() {
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' })
return
}
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
fetchTemplates()
} catch {
toast({ title: 'Importfel', description: 'Ogiltig fil', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
} finally {
// Reset input so same file can be imported again
if (importRef.current) importRef.current.value = ''
@@ -113,29 +115,29 @@ export function BookingTemplatesPanel() {
}
// Group templates by scope
const systemTemplates = templates.filter((t) => t.is_system)
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
const systemTemplates = templates.filter((tt) => tt.is_system)
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
{t('description')}
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Exportera
{t('export')}
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Importera
{t('import')}
</Button>
<input
ref={importRef}
@@ -148,14 +150,15 @@ export function BookingTemplatesPanel() {
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Ny mall
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Skapa bokföringsmall</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<CreateTemplateForm
entityLabels={ENTITY_LABELS}
onCreated={() => {
setShowCreate(false)
fetchTemplates()
@@ -174,14 +177,14 @@ export function BookingTemplatesPanel() {
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
Inga mallar hittades.
{t('empty_state')}
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title="Standardmallar"
title={t('section_system')}
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
@@ -189,13 +192,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title="Teammallar"
title={t('section_team')}
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
@@ -203,13 +207,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title="Företagsmallar"
title={t('section_company')}
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
@@ -217,6 +222,7 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
</div>
@@ -235,6 +241,7 @@ function TemplateSection({
deletingId,
onDelete,
canDelete,
entityLabels,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
@@ -244,7 +251,9 @@ function TemplateSection({
deletingId: string | null
onDelete: (id: string) => void
canDelete: boolean
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
return (
<div>
<div className="flex items-center gap-2 mb-2">
@@ -253,28 +262,28 @@ function TemplateSection({
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
</div>
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
return (
<div
key={t.id}
key={tt.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : t.id)}
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t.name}</span>
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[t.category]}
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
{t.entity_type !== 'all' && (
{tt.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{ENTITY_LABELS[t.entity_type]}
{entityLabels[tt.entity_type]}
</Badge>
)}
</div>
@@ -285,12 +294,12 @@ function TemplateSection({
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(t.id)
onDelete(tt.id)
}}
disabled={deletingId === t.id}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
@@ -300,31 +309,31 @@ function TemplateSection({
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{t.description && (
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
{tt.description && (
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
)}
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
<th className="text-right py-1 w-12">Debet</th>
<th className="text-right py-1 w-12">Kredit</th>
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{t.lines.map((line: BookingTemplateLibraryLine, i: number) => (
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
{line.type === 'vat' && line.vat_rate
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) })
: line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
<td className="py-1 text-right">{line.side === 'debit' ? t('debit_short') : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? t('credit_short') : ''}</td>
</tr>
))}
</tbody>
@@ -339,7 +348,8 @@ function TemplateSection({
)
}
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void; entityLabels: Record<string, string> }) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState('')
@@ -371,7 +381,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
@@ -384,10 +394,10 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
})
if (!res.ok) {
const json = await res.json()
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
toast({ title: t('toast_created') })
onCreated()
} finally {
setIsSubmitting(false)
@@ -397,16 +407,16 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
<Label>{t('name_label')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('name_placeholder')} />
</div>
<div>
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Kategori</Label>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -417,11 +427,11 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</Select>
</div>
<div>
<Label>Företagstyp</Label>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
@@ -430,36 +440,36 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</div>
<div>
<Label>Rader</Label>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="Konto"
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder="Beskrivning"
placeholder={t('description_short_placeholder')}
className="flex-1"
/>
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">Kostnad</SelectItem>
<SelectItem value="vat">Moms</SelectItem>
<SelectItem value="settlement">Betalning</SelectItem>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
<Button
@@ -476,14 +486,14 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
))}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
</div>
</div>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Skapa mall
{t('create_button')}
</Button>
</form>
)
+41 -38
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -18,6 +19,7 @@ interface CalendarFeedWithUrls extends CalendarFeed {
}
export function CalendarFeedSettings() {
const t = useTranslations('settings_calendar_feed')
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
@@ -29,6 +31,7 @@ export function CalendarFeedSettings() {
useEffect(() => {
fetchFeed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFeed = async () => {
@@ -57,12 +60,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Kalenderfeed skapad',
description: 'Du kan nu koppla kalendern till Apple Calendar eller Google Calendar.',
title: t('toast_feed_created_title'),
description: t('toast_feed_created_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa kalenderfeed.',
title: t('toast_create_failed'),
variant: 'destructive',
})
} finally {
@@ -88,9 +91,9 @@ export function CalendarFeedSettings() {
const { data } = await response.json()
setFeed(data)
} catch (error) {
} catch {
toast({
title: 'Kunde inte uppdatera inställning.',
title: t('toast_update_failed'),
variant: 'destructive',
})
} finally {
@@ -100,9 +103,9 @@ export function CalendarFeedSettings() {
const regenerateToken = async () => {
const ok = await confirmAction({
title: 'Skapa ny kalender-länk',
description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
confirmLabel: 'Skapa ny länk',
title: t('regen_dialog_title'),
description: t('regen_dialog_description'),
confirmLabel: t('regen_confirm'),
variant: 'warning',
})
if (!ok) return
@@ -122,12 +125,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Ny länk skapad',
description: 'Den gamla länken fungerar inte längre.',
title: t('toast_new_link_title'),
description: t('toast_new_link_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa ny länk.',
title: t('toast_regen_failed'),
variant: 'destructive',
})
} finally {
@@ -141,12 +144,12 @@ export function CalendarFeedSettings() {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast({
title: 'Kopierad',
description: 'Länken har kopierats till urklipp.',
title: t('toast_copied_title'),
description: t('toast_copied_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte kopiera länken.',
title: t('toast_copy_failed'),
variant: 'destructive',
})
}
@@ -172,27 +175,27 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Synka dina deadlines med Apple Calendar, Google Calendar eller Outlook
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4 py-4">
<p className="text-muted-foreground">
Skapa en kalenderfeed för att se dina deadlines i din vanliga kalenderapp.
{t('empty_intro')}
</p>
<Button onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
{t('creating')}
</>
) : (
<>
<Calendar className="mr-2 h-4 w-4" />
Aktivera kalendersynk
{t('activate_sync')}
</>
)}
</Button>
@@ -209,10 +212,10 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Prenumerera på din kalender i Apple Calendar, Google Calendar eller Outlook
{t('subscribe_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -220,7 +223,7 @@ export function CalendarFeedSettings() {
<div className="flex gap-2">
<Button onClick={openWebcal} className="flex-1">
<Calendar className="mr-2 h-4 w-4" />
Lägg till i Apple Calendar
{t('add_to_apple_calendar')}
</Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
{copied ? (
@@ -233,7 +236,7 @@ export function CalendarFeedSettings() {
{/* URL display */}
<div className="space-y-2">
<Label>Kalenderlänk (för Google Calendar m.fl.)</Label>
<Label>{t('calendar_link_label')}</Label>
<div className="flex gap-2">
<Input
value={feed.httpsUrl}
@@ -242,14 +245,14 @@ export function CalendarFeedSettings() {
/>
</div>
<p className="text-xs text-muted-foreground">
Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.
{t('calendar_link_help')}
</p>
</div>
{/* Stats */}
{feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Senast hämtad:</span>
<span>{t('last_fetched')}</span>
<span>
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
@@ -259,7 +262,7 @@ export function CalendarFeedSettings() {
})}
</span>
<Badge variant="secondary" className="text-xs">
{feed.access_count} gånger
{t('times_count', { count: feed.access_count })}
</Badge>
</div>
)}
@@ -275,17 +278,17 @@ export function CalendarFeedSettings() {
{isRegenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar ny länk...
{t('creating_new_link')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Skapa ny länk
{t('create_new_link')}
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">
Ogiltigförklarar den gamla länken. Använd om någon obehörig fått tag i länken.
{t('regen_help')}
</p>
</div>
</CardContent>
@@ -294,17 +297,17 @@ export function CalendarFeedSettings() {
{/* Content settings */}
<Card>
<CardHeader>
<CardTitle>Innehåll i kalendern</CardTitle>
<CardTitle>{t('content_title')}</CardTitle>
<CardDescription>
Välj vilka händelser som ska visas i din kalender
{t('content_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-tax">Skattedeadlines</Label>
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, deklarationer
{t('tax_deadlines_help')}
</p>
</div>
<Switch
@@ -319,9 +322,9 @@ export function CalendarFeedSettings() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-invoices">Fakturor</Label>
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
<p className="text-sm text-muted-foreground">
Förfallodatum för fakturor
{t('invoices_help')}
</p>
</div>
<Switch
+17 -13
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
@@ -33,6 +34,7 @@ const branding = getBranding()
* capabilities.bankIdLinked boolean fetched from the user profile.
*/
export function CompanyDangerZone() {
const t = useTranslations('settings_company')
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
@@ -57,10 +59,10 @@ export function CompanyDangerZone() {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera företaget')
throw new Error(body.error || t('danger_delete_failed_default'))
}
toast({ title: 'Företaget raderades', description: company.name })
toast({ title: t('danger_deleted_title'), description: company.name })
// Stay inside settings. If the user had another company, the dashboard
// layout will resolve it and /settings/account still renders as
// normal. If this was their last company, the layout falls into the
@@ -69,8 +71,8 @@ export function CompanyDangerZone() {
router.refresh()
} catch (err) {
toast({
title: 'Kunde inte radera företaget',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('danger_delete_failed_title'),
description: err instanceof Error ? err.message : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
@@ -81,7 +83,7 @@ export function CompanyDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera företag
{t('danger_heading')}
</h2>
<RetentionNotice variant="company" />
@@ -92,7 +94,7 @@ export function CompanyDangerZone() {
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
Radera företag
{t('danger_button')}
</Button>
</div>
</section>
@@ -107,15 +109,17 @@ export function CompanyDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera {company.name}</DialogTitle>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
Företaget döljs från {branding.appName.toLowerCase()}. Bokföringen behålls säkert i 7 år enligt BFL.
Skriv företagets namn exakt för att bekräfta.
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="company-delete-confirm">
Skriv <strong>{company.name}</strong> för att bekräfta
{t.rich('danger_confirm_label', {
companyName: company.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="company-delete-confirm"
@@ -134,7 +138,7 @@ export function CompanyDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('danger_cancel')}
</Button>
<Button
variant="destructive"
@@ -144,10 +148,10 @@ export function CompanyDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('danger_deleting')}
</>
) : (
'Radera företag'
t('danger_button')
)}
</Button>
</DialogFooter>
+13 -11
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CompanySettings } from '@/types'
@@ -9,26 +10,27 @@ interface CompanyInfoFormProps {
}
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
const t = useTranslations('settings_company')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsuppgifter
{t('company_info_heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Label htmlFor="company_name">{t('company_name_label')}</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
/>
<p className="text-xs text-muted-foreground">
Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).
{t('company_name_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
name="org_number"
@@ -36,13 +38,13 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Label htmlFor="address_line1">{t('address_label')}</Label>
<Input
id="address_line1"
name="address_line1"
@@ -52,7 +54,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('postal_code_label')}</Label>
<Input
id="postal_code"
name="postal_code"
@@ -60,7 +62,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
name="city"
@@ -71,7 +73,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
name="phone"
@@ -80,7 +82,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
name="email"
@@ -91,7 +93,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="website">Webbplats</Label>
<Label htmlFor="website">{t('website_label')}</Label>
<Input
id="website"
name="website"
+33 -31
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,16 +32,17 @@ interface CompanyInvitation {
created_at: string
}
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
admin: 'Admin',
member: 'Medlem',
viewer: 'Läsbehörighet',
}
export function CompanyMembersSection() {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { company } = useCompany()
const roleLabels: Record<string, string> = {
owner: t('members_role_owner'),
admin: t('members_role_admin'),
member: t('members_role_member'),
viewer: t('members_role_viewer'),
}
const [isLoading, setIsLoading] = useState(true)
const [members, setMembers] = useState<CompanyMemberItem[]>([])
const [invitations, setInvitations] = useState<CompanyInvitation[]>([])
@@ -94,16 +96,16 @@ export function CompanyMembersSection() {
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
}
toast({
title: 'Inbjudan skickad',
title: t('members_invite_sent_title'),
description: data.data.inviteUrl
? 'Länk loggad i konsolen (F12)'
: `E-post skickad till ${email}.`,
? t('members_invite_sent_dev_url')
: t('members_invite_sent_description', { email }),
})
setInviteEmail('')
setInviteRole('viewer')
fetchMembers()
} catch {
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_failed'), variant: 'destructive' })
} finally {
setIsSending(false)
}
@@ -120,10 +122,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Medlem borttagen' })
toast({ title: t('members_removed') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
toast({ title: t('members_remove_failed'), variant: 'destructive' })
} finally {
setRemovingId(null)
}
@@ -140,10 +142,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Inbjudan återkallad' })
toast({ title: t('members_invite_revoked') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_revoke_failed'), variant: 'destructive' })
} finally {
setRevokingId(null)
}
@@ -163,19 +165,19 @@ export function CompanyMembersSection() {
{canInvite && (
<Card>
<CardHeader>
<CardTitle className="text-base">Bjud in till {company?.name}</CardTitle>
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
<CardDescription>
Personen får tillgång till enbart detta företag.
{t('members_invite_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex gap-3">
<div className="flex-1">
<Label htmlFor="company-invite-email" className="sr-only">E-postadress</Label>
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
<Input
id="company-invite-email"
type="email"
placeholder="namn@example.com"
placeholder={t('members_invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
@@ -187,9 +189,9 @@ export function CompanyMembersSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Läsbehörighet</SelectItem>
<SelectItem value="member">Medlem</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
<SelectItem value="member">{t('members_role_member')}</SelectItem>
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
@@ -198,7 +200,7 @@ export function CompanyMembersSection() {
) : (
<>
<Plus className="h-4 w-4 mr-1.5" />
Bjud in
{t('members_invite_button')}
</>
)}
</Button>
@@ -212,10 +214,10 @@ export function CompanyMembersSection() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Medlemmar
{t('members_title')}
</CardTitle>
<CardDescription>
{members.length} {members.length === 1 ? 'medlem' : 'medlemmar'} i {company?.name}
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
@@ -232,16 +234,16 @@ export function CompanyMembersSection() {
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">(du)</span>
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
)}
</p>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{ROLE_LABELS[member.role] || member.role}
{roleLabels[member.role] || member.role}
</span>
{member.source === 'team' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Team
{t('members_team_badge')}
</Badge>
)}
</div>
@@ -272,7 +274,7 @@ export function CompanyMembersSection() {
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Väntande inbjudningar</CardTitle>
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
@@ -287,14 +289,14 @@ export function CompanyMembersSection() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
Går ut {formatDateLong(inv.expires_at)}
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[inv.role] || inv.role}
{roleLabels[inv.role] || inv.role}
</Badge>
{canInvite && (
<Button
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,22 +11,6 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import type { CategorizationTemplate } from '@/types'
const SOURCE_LABELS: Record<string, string> = {
sie_import: 'SIE-import',
user_approved: 'Godkänd',
auto_learned: 'Automatisk',
sni_default: 'Standard',
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: 'Omvänd',
export: 'Export',
exempt: 'Momsfri',
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('sv-SE', {
@@ -42,8 +27,25 @@ function confidenceColor(c: number): string {
}
export function CounterpartyTemplatesPanel() {
const t = useTranslations('settings_counterparty_templates')
const { toast } = useToast()
const SOURCE_LABELS: Record<string, string> = {
sie_import: t('source_sie_import'),
user_approved: t('source_user_approved'),
auto_learned: t('source_auto_learned'),
sni_default: t('source_sni_default'),
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: t('vat_reverse_charge'),
export: t('vat_export'),
exempt: t('vat_exempt'),
}
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -57,11 +59,11 @@ export function CounterpartyTemplatesPanel() {
setTemplates(json.data)
}
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchTemplates()
@@ -76,14 +78,14 @@ export function CounterpartyTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
if (expandedId === id) setExpandedId(null)
toast({ title: 'Mall borttagen' })
toast({ title: t('toast_deleted') })
} catch {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
} finally {
setDeletingId(null)
}
@@ -93,10 +95,9 @@ export function CounterpartyTemplatesPanel() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Inlärda mönster som automatiskt föreslår kontering baserat på motpart.
Mallar skapas från SIE-import och när du godkänner kategoriseringar.
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -107,54 +108,54 @@ export function CounterpartyTemplatesPanel() {
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga mallar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Mallar skapas automatiskt när du kategoriserar transaktioner eller importerar en SIE-fil.
{t('empty_help')}
</p>
</div>
) : (
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
const isMultiLine = t.line_pattern && t.line_pattern.length > 0
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
return (
<div key={t.id} className="rounded-md border overflow-hidden">
<div key={tt.id} className="rounded-md border overflow-hidden">
{/* Clickable summary row */}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : t.id)}
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{formatCounterpartyName(t.counterparty_name)}</p>
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
<Badge variant="outline" className="text-[10px] shrink-0">
{SOURCE_LABELS[t.source] || t.source}
{SOURCE_LABELS[tt.source] || tt.source}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
{isMultiLine ? (
<span className="font-mono">
{t.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
</span>
) : (
<>
<span className="font-mono">{t.debit_account}</span>
<span className="font-mono">{tt.debit_account}</span>
<span className="text-muted-foreground/50">→</span>
<span className="font-mono">{t.credit_account}</span>
<span className="font-mono">{tt.credit_account}</span>
</>
)}
{t.vat_treatment && (
{tt.vat_treatment && (
<>
<span className="text-muted-foreground/30">·</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span>{t.occurrence_count} ggr</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span>{t('times_count', { count: tt.occurrence_count })}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
</div>
@@ -166,17 +167,17 @@ export function CounterpartyTemplatesPanel() {
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
{/* Account lines */}
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Kontering</p>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
{isMultiLine ? (
<div className="space-y-1">
{t.line_pattern!.map((lp, i) => (
{tt.line_pattern!.map((lp, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">
{lp.side === 'debit' ? 'Debet' : 'Kredit'}
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
</Badge>
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
{lp.type === 'vat' && lp.vat_rate && (
<span className="text-muted-foreground">({Math.round(lp.vat_rate * 100)}% moms)</span>
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
)}
{lp.ratio !== undefined && (
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
@@ -187,12 +188,12 @@ export function CounterpartyTemplatesPanel() {
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Debet</Badge>
<span className="font-mono">{formatAccountWithName(t.debit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('debit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Kredit</Badge>
<span className="font-mono">{formatAccountWithName(t.credit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('credit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
</div>
</div>
)}
@@ -200,30 +201,30 @@ export function CounterpartyTemplatesPanel() {
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
{t.vat_treatment && (
{tt.vat_treatment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Antal bokföringar</span>
<span className="tabular-nums">{t.occurrence_count}</span>
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
<span className="tabular-nums">{tt.occurrence_count}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Säkerhet</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span className="text-muted-foreground">{t('confidence_label')}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Senast använd</span>
<span>{formatDate(t.last_seen_date)}</span>
<span className="text-muted-foreground">{t('last_seen_label')}</span>
<span>{formatDate(tt.last_seen_date)}</span>
</div>
{t.counterparty_aliases && t.counterparty_aliases.length > 1 && (
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
<div className="col-span-2 flex justify-between">
<span className="text-muted-foreground">Alias</span>
<span className="text-right truncate ml-4">{t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''}</span>
<span className="text-muted-foreground">{t('aliases_label')}</span>
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
</div>
)}
</div>
@@ -233,16 +234,16 @@ export function CounterpartyTemplatesPanel() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(t.id)}
disabled={deletingId === t.id}
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
className="text-destructive hover:text-destructive text-xs h-7"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3 w-3" />
)}
Ta bort mall
{t('delete_button')}
</Button>
</div>
</div>
+42 -35
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -34,6 +35,7 @@ function isCalendarYear(period: { period_start: string; period_end: string }): b
}
export function FiscalPeriodEditor() {
const t = useTranslations('settings_company')
const { company, role } = useCompany()
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -59,7 +61,7 @@ export function FiscalPeriodEditor() {
setLoadError(null)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) throw new Error('Kunde inte hämta räkenskapsår')
if (!res.ok) throw new Error(t('fp_load_error_periods'))
const { data } = (await res.json()) as { data: FiscalPeriod[] }
if (!data || data.length === 0) {
if (!cancelled) {
@@ -72,7 +74,7 @@ export function FiscalPeriodEditor() {
const first = sorted[0]
const countRes = await fetch(`/api/bookkeeping/fiscal-periods/${first.id}/entry-count`)
if (!countRes.ok) throw new Error('Kunde inte hämta verifikationsantal')
if (!countRes.ok) throw new Error(t('fp_load_error_entry_count'))
const { data: countData } = (await countRes.json()) as { data: { posted_count: number } }
if (cancelled) return
@@ -82,7 +84,7 @@ export function FiscalPeriodEditor() {
setEndDate(first.period_end)
} catch (err) {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : 'Okänt fel')
setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
}
} finally {
if (!cancelled) setIsLoading(false)
@@ -93,7 +95,7 @@ export function FiscalPeriodEditor() {
return () => {
cancelled = true
}
}, [company])
}, [company, t])
const validation = validateFirstPeriod(
startDate,
@@ -114,10 +116,15 @@ export function FiscalPeriodEditor() {
if (!isDirty) return
const ok = await confirm({
title: 'Ändra första räkenskapsåret?',
description: `Detta ändrar ditt första räkenskapsår från ${formatSwedishDate(period.period_start)} – ${formatSwedishDate(period.period_end)} till ${formatSwedishDate(startDate)} – ${formatSwedishDate(endDate)}. Ändringen är bara tillåten eftersom inga verifikationer är bokförda ännu. Fortsätt?`,
confirmLabel: 'Ja, ändra räkenskapsår',
cancelLabel: 'Avbryt',
title: t('fp_confirm_title'),
description: t('fp_confirm_description', {
oldStart: formatSwedishDate(period.period_start),
oldEnd: formatSwedishDate(period.period_end),
newStart: formatSwedishDate(startDate),
newEnd: formatSwedishDate(endDate),
}),
confirmLabel: t('fp_confirm_yes'),
cancelLabel: t('fp_confirm_cancel'),
variant: 'warning',
})
if (!ok) return
@@ -128,8 +135,8 @@ export function FiscalPeriodEditor() {
const endYear = parseDateParts(endDate).year
const newName =
startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
? t('fp_year_label_single', { year: startYear })
: t('fp_year_label_range', { startYear, endYear })
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -141,17 +148,17 @@ export function FiscalPeriodEditor() {
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body.error || 'Kunde inte ändra räkenskapsår')
throw new Error(body.error || t('fp_update_failed_title'))
}
setPeriod(body.data as FiscalPeriod)
toast({
title: 'Räkenskapsår uppdaterat',
title: t('fp_updated_title'),
description: `${formatSwedishDate(body.data.period_start)} – ${formatSwedishDate(body.data.period_end)}`,
})
} catch (err) {
toast({
title: 'Kunde inte ändra räkenskapsår',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('fp_update_failed_title'),
description: err instanceof Error ? err.message : t('fp_try_again'),
variant: 'destructive',
})
} finally {
@@ -170,22 +177,22 @@ export function FiscalPeriodEditor() {
<section className="space-y-4 border-t border-border/8 pt-8">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Första räkenskapsår
{t('fp_heading')}
</h2>
<p className="text-sm text-muted-foreground">
Om du valde fel räkenskapsår vid uppstart kan du justera det här — så länge du inte har bokfört någon verifikation ännu.
{t('fp_intro')}
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Hämtar räkenskapsår...
{t('fp_loading')}
</div>
) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : !period ? (
<p className="text-sm text-muted-foreground">Inget räkenskapsår hittades.</p>
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
) : isBlocked ? (
<BlockedState
period={period}
@@ -196,10 +203,10 @@ export function FiscalPeriodEditor() {
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Ändra med omsorg.</p>
<p className="font-medium">{t('fp_warning_title')}</p>
<p className="text-muted-foreground">
Ändringen påverkar öppningsbalanser och rapporter.
{isEF && ' Enskild firma måste använda kalenderår enligt BFL 3 kap.'}
{t('fp_warning_body')}
{isEF && t('fp_warning_ef_suffix')}
</p>
</div>
</div>
@@ -209,10 +216,10 @@ export function FiscalPeriodEditor() {
onStartDateChange={setStartDate}
endDate={endDate}
entityType={company?.entity_type}
summaryTitle="Föreslaget räkenskapsår"
summaryTitle={t('fp_summary_title')}
endDateSlot={
<div className="space-y-2">
<Label htmlFor="fp_end">Slutdatum</Label>
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
<Input
id="fp_end"
type="date"
@@ -220,7 +227,7 @@ export function FiscalPeriodEditor() {
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Måste vara sista dagen i en månad.
{t('fp_end_date_help')}
</p>
</div>
}
@@ -233,7 +240,7 @@ export function FiscalPeriodEditor() {
onClick={handleReset}
disabled={!isDirty || isSaving}
>
Återställ
{t('fp_reset')}
</Button>
<Button
type="button"
@@ -249,10 +256,10 @@ export function FiscalPeriodEditor() {
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('fp_saving')}
</>
) : (
'Spara ändring'
t('fp_save')
)}
</Button>
</div>
@@ -271,32 +278,32 @@ function BlockedState({
period: FiscalPeriod
postedCount: number
}) {
const t = useTranslations('settings_company')
const reason = period.locked_at
? 'Räkenskapsåret är låst.'
? t('fp_blocked_reason_locked')
: period.is_closed
? 'Räkenskapsåret är stängt.'
: `${postedCount} bokförd${postedCount === 1 ? '' : 'a'} verifikation${postedCount === 1 ? '' : 'er'} finns redan i perioden.`
? t('fp_blocked_reason_closed')
: t('fp_blocked_reason_posted', { count: postedCount })
return (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<div className="flex gap-2">
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Räkenskapsåret kan inte längre ändras</p>
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
Första räkenskapsåret:{' '}
{t('fp_blocked_first_year')}{' '}
<span className="font-medium text-foreground">
{formatSwedishDate(period.period_start)} &ndash; {formatSwedishDate(period.period_end)}
</span>
{isCalendarYear(period) ? ' (kalenderår)' : ' (brutet räkenskapsår)'}
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
</p>
<p>
Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt.
Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.
{t('fp_blocked_explainer')}
</p>
</div>
</div>
+10 -8
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
@@ -10,24 +11,25 @@ interface InvoiceSettingsFormProps {
}
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
const t = useTranslations('settings_invoice_form')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Fakturainställningar
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
placeholder={t('prefix_placeholder')}
defaultValue={settings.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
@@ -37,7 +39,7 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
@@ -49,16 +51,16 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
placeholder={t('default_notes_placeholder')}
defaultValue={settings.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Föreslås automatiskt vid ny faktura.
{t('default_notes_help')}
</p>
</div>
</section>
+14 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
@@ -12,6 +13,7 @@ interface LogoUploadProps {
}
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -23,11 +25,11 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
function validateAndUpload(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
toast({ title: 'Otillåten filtyp', description: 'PNG, JPG, SVG eller WebP.', variant: 'destructive' })
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
return
}
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
handleUpload(file)
@@ -48,15 +50,15 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Uppladdning misslyckades')
throw new Error(result.error || t('logo_upload_failed_default'))
}
setPreview(result.data.logo_url)
onUpdate(result.data.logo_url)
} catch (error) {
toast({
title: 'Kunde inte ladda upp',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('logo_upload_failed_title'),
description: error instanceof Error ? error.message : t('logo_try_again'),
variant: 'destructive',
})
}
@@ -75,7 +77,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
onUpdate(null)
if (inputRef.current) inputRef.current.value = ''
} catch {
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
}
setIsDeleting(false)
@@ -107,10 +109,10 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Logotyp
{t('logo_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
{t('logo_help')}
</p>
{preview ? (
@@ -119,7 +121,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Företagslogotyp"
alt={t('logo_alt')}
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
@@ -131,7 +133,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
disabled={isUploading}
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
Byt logotyp
{t('logo_change')}
</Button>
<Button
variant="ghost"
@@ -141,7 +143,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
className="text-muted-foreground hover:text-destructive"
>
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
Ta bort
{t('logo_remove')}
</Button>
</div>
</div>
@@ -164,7 +166,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
)}
<Label className="text-sm text-muted-foreground cursor-pointer">
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</Label>
</button>
)}
+30 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -26,6 +27,7 @@ interface OAuthClient {
}
export function OAuthClientsPanel() {
const t = useTranslations('settings_oauth_clients')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -44,11 +46,11 @@ export function OAuthClientsPanel() {
setClients(json.data.filter((c: OAuthClient) => !c.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta OAuth-klienter', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchClients()
@@ -61,14 +63,14 @@ export function OAuthClientsPanel() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: clientName.trim() || 'OAuth-klient',
client_name: clientName.trim() || t('default_client_name'),
redirect_uri: redirectUri.trim(),
}),
})
const json = await res.json()
if (!res.ok) {
toast({ title: json.error ?? 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: json.error ?? t('toast_register_failed'), variant: 'destructive' })
return
}
@@ -77,7 +79,7 @@ export function OAuthClientsPanel() {
setRedirectUri('')
fetchClients()
} catch {
toast({ title: 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: t('toast_register_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -85,30 +87,26 @@ export function OAuthClientsPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla OAuth-klient',
description: `"${name}" tas bort från allowlist. Pågående auth-flöden slutar fungera direkt; redan utfärdade API-nycklar fortsätter att gälla tills de återkallas separat.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
const res = await fetch(`/api/settings/oauth-clients/${id}`, { method: 'DELETE' })
if (!res.ok) {
// Surface the server error rather than optimistically pretending the
// revocation succeeded — a silent fail leaves the row in the
// allowlist while the UI says it's gone, which is the opposite of
// what the user expected.
const body = await res.json().catch(() => ({}))
toast({
title: body?.error || 'Kunde inte återkalla klient',
title: body?.error || t('toast_revoke_failed'),
variant: 'destructive',
})
return
}
setClients((prev) => prev.filter((c) => c.id !== id))
toast({ title: 'Klient återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla klient', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -126,15 +124,14 @@ export function OAuthClientsPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>OAuth-klienter</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Registrera redirect-URI:er för egenutvecklade MCP-klienter. Claude.ai och localhost
är redan godkända som standard — registrera bara här om du bygger en egen app.
{t('description')}
</CardDescription>
</div>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Registrera URI
{t('register_uri')}
</Button>
</div>
</CardHeader>
@@ -146,9 +143,9 @@ export function OAuthClientsPanel() {
) : clients.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga egna OAuth-klienter registrerade.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Bygger du en agent som ska ansluta via OAuth? Registrera dess callback-URI här.
{t('empty_help')}
</p>
</div>
) : (
@@ -165,7 +162,7 @@ export function OAuthClientsPanel() {
{c.redirect_uri}
</code>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Registrerad {formatDate(c.created_at)}
{t('registered_on')} {formatDate(c.created_at)}
</span>
</div>
</div>
@@ -173,7 +170,7 @@ export function OAuthClientsPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(c.id, c.client_name)}
aria-label={`Återkalla ${c.client_name}`}
aria-label={t('revoke_aria', { name: c.client_name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -188,29 +185,26 @@ export function OAuthClientsPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrera redirect URI</DialogTitle>
<DialogTitle>{t('register_dialog_title')}</DialogTitle>
<DialogDescription>
Bara för egenbyggda MCP-klienter med en publik HTTPS-callback. Lägg{' '}
<span className="font-medium">inte</span> till{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">localhost</code>{' '}
eller{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">claude.ai</code>{' '}
— de fungerar redan utan registrering. URI:n jämförs ord-för-ord mot
redirect_uri-parametern i OAuth-flödet.
{t.rich('register_dialog_description', {
bold: (chunks) => <span className="font-medium">{chunks}</span>,
code: (chunks) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{chunks}</code>,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-name">Klientnamn</Label>
<Label htmlFor="client-name">{t('client_name_label')}</Label>
<Input
id="client-name"
placeholder="t.ex. Min bokföringsagent"
placeholder={t('client_name_placeholder')}
value={clientName}
onChange={(e) => setClientName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="redirect-uri">Redirect URI</Label>
<Label htmlFor="redirect-uri">{t('redirect_uri_label')}</Label>
<Input
id="redirect-uri"
type="url"
@@ -223,11 +217,11 @@ export function OAuthClientsPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || !redirectUri.trim()}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Registrera
{t('register')}
</Button>
</DialogFooter>
</DialogContent>
+30 -28
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
@@ -13,6 +14,7 @@ interface PdfPrintSettingsProps {
}
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const t = useTranslations('settings_pdf_print')
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
@@ -27,9 +29,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const savePosition = useCallback(async (value: 'header' | 'footer') => {
try {
@@ -41,9 +43,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ invoice_company_name_position: value })
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const saveText = useCallback(async (field: string, value: string) => {
try {
@@ -55,21 +57,21 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utskrift & PDF
{t('heading')}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Öresavrundning</Label>
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
<Label>{t('ore_rounding_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
</div>
<Switch
checked={settings.ore_rounding ?? true}
@@ -79,8 +81,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa OCR-referens</Label>
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
<Label>{t('show_ocr_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p>
</div>
<Switch
checked={settings.invoice_show_ocr ?? true}
@@ -90,8 +92,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa bankgiro</Label>
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
<Label>{t('show_bankgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_bankgiro ?? true}
@@ -101,8 +103,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa plusgiro</Label>
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
<Label>{t('show_plusgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_plusgiro ?? true}
@@ -112,8 +114,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa Swish</Label>
<p className="text-xs text-muted-foreground">Visa Swish-nummer på fakturautskrift</p>
<Label>{t('show_swish_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? true}
@@ -123,8 +125,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa logga</Label>
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
<Label>{t('show_logo_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
</div>
<Switch
checked={settings.invoice_show_logo ?? true}
@@ -135,8 +137,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Visa företagsnamn i faktura</Label>
<p className="text-xs text-muted-foreground">Visa företagsnamn i fakturan</p>
<Label>{t('show_company_name_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
</div>
<Switch
checked={settings.invoice_show_company_name ?? true}
@@ -145,10 +147,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
{(settings.invoice_show_company_name ?? true) && (
<div className="flex items-center justify-between pl-0">
<p className="text-xs text-muted-foreground">Placering</p>
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
<div
role="group"
aria-label="Placering av företagsnamn"
aria-label={t('placement_aria_label')}
className="inline-flex rounded-md border border-border/60 p-0.5"
>
{(['header', 'footer'] as const).map((pos) => {
@@ -166,7 +168,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
: 'text-muted-foreground hover:text-foreground')
}
>
{pos === 'header' ? 'Huvud' : 'Sidfot'}
{pos === 'header' ? t('placement_header') : t('placement_footer')}
</button>
)
})}
@@ -178,11 +180,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
<Textarea
id="invoice_late_fee_text"
rows={2}
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
placeholder={t('late_fee_placeholder')}
value={lateFeeText}
onChange={(e) => setLateFeeText(e.target.value)}
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
@@ -190,11 +192,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
<div className="space-y-2">
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
<Label htmlFor="invoice_credit_terms_text">{t('credit_terms_label')}</Label>
<Textarea
id="invoice_credit_terms_text"
rows={2}
placeholder="T.ex. Betalning sker till angivet bankgiro."
placeholder={t('credit_terms_placeholder')}
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
+11 -9
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -10,15 +11,16 @@ interface PeriodLockingSettingsProps {
}
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
const t = useTranslations('settings_period_locking')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodlåsning
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
<Input
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
@@ -26,12 +28,12 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
defaultValue={settings.bookkeeping_locked_through || ''}
/>
<p className="text-xs text-muted-foreground">
Verifikationer med datum före detta datum kan inte skapas eller ändras.
{t('locked_through_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label>
<Select
name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
@@ -40,14 +42,14 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
<SelectItem value="none">{t('auto_lock_none')}</SelectItem>
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Låser automatiskt perioder efter valt antal dagar.
{t('auto_lock_help')}
</p>
</div>
</div>
+37 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -19,6 +20,7 @@ const mfaRequired = isMfaRequired()
const bankIdEnabled = isBankIdEnabled()
export function SecuritySettings() {
const t = useTranslations('settings_security')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isChangingPassword, setIsChangingPassword] = useState(false)
@@ -59,8 +61,8 @@ export function SecuritySettings() {
if (!strong) {
toast({
title: 'Lösenordet är för svagt',
description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.',
title: t('toast_weak_password_title'),
description: t('toast_weak_password_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -69,8 +71,8 @@ export function SecuritySettings() {
if (newPassword !== confirmPassword) {
toast({
title: 'Lösenorden matchar inte',
description: 'Kontrollera att du skrev samma lösenord i båda fälten.',
title: t('toast_mismatch_title'),
description: t('toast_mismatch_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -87,24 +89,24 @@ export function SecuritySettings() {
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
toast({
title: 'Kunde inte uppdatera lösenord',
description: body.error || 'Försök igen senare.',
title: t('toast_update_failed_title'),
description: body.error || t('toast_update_failed_description'),
variant: 'destructive',
})
return
}
toast({
title: 'Lösenord uppdaterat',
description: 'Ditt lösenord har ändrats.',
title: t('toast_password_updated_title'),
description: t('toast_password_updated_description'),
})
setNewPassword('')
setConfirmPassword('')
setHasPassword(true)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -121,7 +123,7 @@ export function SecuritySettings() {
if (error) {
toast({
title: 'Kunde inte inaktivera 2FA',
title: t('toast_unenroll_failed_title'),
description: error.message,
variant: 'destructive',
})
@@ -129,15 +131,15 @@ export function SecuritySettings() {
}
toast({
title: 'Tvåfaktorsautentisering inaktiverad',
description: '2FA har tagits bort från ditt konto.',
title: t('toast_mfa_disabled_title'),
description: t('toast_mfa_disabled_description'),
})
setHasMfa(false)
setMfaFactorId(null)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -182,21 +184,21 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
Ändra lösenord
{t('change_password_title')}
</CardTitle>
<CardDescription>
Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.
{t('change_password_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="new_password">Nytt lösenord</Label>
<Label htmlFor="new_password">{t('new_password_label')}</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken"
placeholder={t('new_password_placeholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
@@ -205,12 +207,12 @@ export function SecuritySettings() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_new_password">Bekräfta nytt lösenord</Label>
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -222,10 +224,10 @@ export function SecuritySettings() {
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
'Uppdatera lösenord'
t('update_password_button')
)}
</Button>
</form>
@@ -239,27 +241,26 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Tvåfaktorsautentisering (2FA)
{t('mfa_title')}
</CardTitle>
<CardDescription>
Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod
utöver ditt lösenord.
{t('mfa_description')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar...
{t('loading')}
</div>
) : hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900">
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-500" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">2FA är aktiverad</p>
<p className="font-medium text-green-900 dark:text-green-100">{t('mfa_active_title')}</p>
<p className="text-sm text-green-700 dark:text-green-400">
Ditt konto skyddas med tvåfaktorsautentisering.
{t('mfa_active_description')}
</p>
</div>
</div>
@@ -272,19 +273,19 @@ export function SecuritySettings() {
{isUnenrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Inaktiverar...
{t('disabling')}
</>
) : (
<>
<ShieldOff className="mr-2 h-4 w-4" />
Inaktivera 2FA
{t('disable_mfa')}
</>
)}
</Button>
)}
{mfaRequired && (
<p className="text-xs text-muted-foreground">
Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras.
{t('mfa_required_note')}
</p>
)}
</div>
@@ -293,9 +294,9 @@ export function SecuritySettings() {
<div className="flex items-center gap-3 p-4 rounded-lg border">
<ShieldOff className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">2FA är inte aktiverad</p>
<p className="font-medium">{t('mfa_inactive_title')}</p>
<p className="text-sm text-muted-foreground">
Vi rekommenderar att du aktiverar tvåfaktorsautentisering.
{t('mfa_inactive_description')}
</p>
</div>
</div>
@@ -307,14 +308,14 @@ export function SecuritySettings() {
)
}
>
Sätt ett lösenord först
{t('set_password_first')}
</Button>
) : (
<Button
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
{t('enable_mfa')}
</Button>
)}
</div>
+11 -9
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
@@ -17,6 +18,7 @@ interface SettingsFormWrapperProps {
}
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false)
@@ -71,7 +73,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
throw new Error(messages.join(' • '))
}
}
throw new Error(result.error || 'Kunde inte spara inställningar')
throw new Error(result.error || t('wrapper_save_failed_default'))
}
onSuccess?.(result.data ?? updates)
@@ -79,14 +81,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('wrapper_save_failed_title'),
description: error instanceof Error ? error.message : t('wrapper_try_again'),
variant: 'destructive',
})
}
setIsSaving(false)
}, [onSave, toast])
}, [onSave, toast, t])
return (
<form onSubmit={handleSubmit} className={className}>
@@ -96,27 +98,27 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
{saved && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" />
Sparat
{t('wrapper_saved')}
</span>
)}
<Button
type="submit"
disabled={isSaving || !canWrite}
size="sm"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sparar...
{t('wrapper_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-3.5 w-3.5" />
Spara ändringar
{t('wrapper_save_changes')}
</>
) : (
'Spara ändringar'
t('wrapper_save_changes')
)}
</Button>
</div>
+15 -13
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
@@ -17,6 +18,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company } = useCompany()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
@@ -24,18 +26,18 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
const items: NavItem[] = [
{ href: '/settings/company', label: 'Företag', show: hasCompany },
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
{ href: '/settings/team', label: 'Lag', show: false },
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: 'Skatteverket', show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
{ href: '/settings/account', label: 'Konto', show: true },
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
{ href: '/settings/company', label: t('company'), show: hasCompany },
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
{ href: '/settings/team', label: t('team'), show: false },
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
@@ -61,7 +63,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{/* Desktop: horizontal tabs with bottom border */}
<nav
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
aria-label="Inställningar"
aria-label={t('aria_label')}
>
<ul className="flex gap-0 -mb-px">
{items.map(item => {
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,21 +22,22 @@ type Status =
disabled?: boolean
}
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: 'Momsdeklaration',
inkforetag: 'Företagsinformation',
skahmst: 'Skattekonto – saldo & transaktioner',
skattekonto: 'Skattekonto',
agd: 'Arbetsgivardeklaration',
}
export function SkatteverketConnectPanel() {
const t = useTranslations('settings_skatteverket_connect')
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: t('scope_momsdeklaration'),
inkforetag: t('scope_inkforetag'),
skahmst: t('scope_skahmst'),
skattekonto: t('scope_skattekonto'),
agd: t('scope_agd'),
}
async function loadStatus() {
setLoading(true)
try {
@@ -68,12 +70,12 @@ export function SkatteverketConnectPanel() {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) throw new Error('Frånkoppling misslyckades')
toast({ title: 'Skatteverket frånkopplad' })
if (!res.ok) throw new Error(t('disconnect_failed'))
toast({ title: t('toast_disconnected') })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla från',
title: t('toast_disconnect_failed'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -86,7 +88,7 @@ export function SkatteverketConnectPanel() {
return (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Hämtar status…
{t('loading_status')}
</CardContent>
</Card>
)
@@ -97,7 +99,7 @@ export function SkatteverketConnectPanel() {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Skatteverket</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
</div>
</CardHeader>
@@ -105,23 +107,20 @@ export function SkatteverketConnectPanel() {
{status?.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.</p>
<p>{t('disabled_message')}</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
arbetsgivardeklaration och hämta saldot på skattekontot.
{t('connect_intro')}
</p>
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
På Skatteverkets samtyckessida visas en av behörigheterna som{' '}
<span className="font-mono">skahmst (Rubrik saknas)</span> — det är
scope-namnet för skattekontots saldo och transaktioner (Skattekonto
HuvudMan STatus). Skatteverket har inte publicerat en svensk
beskrivning för den ännu. Det är ofarligt att godkänna.
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</div>
<Button onClick={startConnect} disabled={status?.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut med BankID
{t('connect_with_bankid')}
</Button>
</CardContent>
</Card>
@@ -139,13 +138,13 @@ export function SkatteverketConnectPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Skatteverket
{t('title')}
{status.expired ? (
<Badge variant="destructive">Utgången</Badge>
<Badge variant="destructive">{t('expired')}</Badge>
) : (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
Ansluten
{t('connected')}
</Badge>
)}
</CardTitle>
@@ -155,27 +154,27 @@ export function SkatteverketConnectPanel() {
<CardContent className="space-y-4">
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Token utgår</dt>
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
<dd className="font-medium tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<span className="ml-2 text-muted-foreground">
(om {expiresInMinutes} min)
{t('expires_in_minutes', { minutes: expiresInMinutes })}
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Förnyelse</dt>
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
<dd className="font-medium">
{status.canRefresh ? 'Förnyas automatiskt' : 'Förnyelse uttömd — anslut igen'}
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
Behörigheter
{t('permissions_label')}
</p>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
@@ -186,15 +185,12 @@ export function SkatteverketConnectPanel() {
</div>
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Skattekonto saknas — koppla från och anslut igen
för att aktivera saldo- och transaktionsvyn.
{t('missing_skattekonto')}
</p>
)}
{!scopes.includes('agd') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla
från och anslut igen för att kunna skicka AGI direkt från {`gnubok`}.
Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.
{t('missing_agd')}
</p>
)}
</div>
@@ -202,7 +198,7 @@ export function SkatteverketConnectPanel() {
{status.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.</p>
<p>{t('disabled_filings_message')}</p>
</div>
)}
@@ -210,7 +206,7 @@ export function SkatteverketConnectPanel() {
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button onClick={startConnect} disabled={status.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
{t('reconnect')}
</Button>
)}
<Button
@@ -219,7 +215,7 @@ export function SkatteverketConnectPanel() {
disabled={disconnecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? 'Kopplar från…' : 'Koppla från'}
{disconnecting ? t('disconnecting') : t('disconnect')}
</Button>
</div>
</CardContent>
@@ -228,11 +224,12 @@ export function SkatteverketConnectPanel() {
}
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
const t = useTranslations('settings_skatteverket_connect')
if (disabled) {
return (
<Badge variant="destructive">
<ShieldAlert className="mr-1 h-3 w-3" />
Avstängd
{t('env_disabled')}
</Badge>
)
}
@@ -240,14 +237,14 @@ function EnvironmentBadge({ environment, disabled }: { environment?: Environment
return (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:border-amber-600 dark:text-amber-400">
<FlaskConical className="mr-1 h-3 w-3" />
Testmiljö
{t('env_test')}
</Badge>
)
}
if (environment === 'prod') {
return (
<Badge variant="outline" className="border-emerald-400 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400">
Produktion
{t('env_prod')}
</Badge>
)
}
+46 -42
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,25 +14,32 @@ interface TaxSettingsFormProps {
}
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
const t = useTranslations('settings_tax_form')
const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false)
const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true)
const [paysSalaries, setPaysSalaries] = useState(settings.pays_salaries ?? false)
const isEnskildFirma = settings.entity_type === 'enskild_firma'
const months = [
t('month_jan'), t('month_feb'), t('month_mar'), t('month_apr'),
t('month_may'), t('month_jun'), t('month_jul'), t('month_aug'),
t('month_sep'), t('month_oct'), t('month_nov'), t('month_dec'),
]
return (
<div className="space-y-8">
{/* Entity type — read-only */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsform
{t('entity_form_heading')}
</h2>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-sm">
{settings.entity_type === 'aktiebolag' ? 'Aktiebolag' : 'Enskild firma'}
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
</Badge>
<p className="text-xs text-muted-foreground">
Företagsform kan inte ändras. Kontakta support vid behov.
{t('entity_form_help')}
</p>
</div>
</section>
@@ -39,7 +47,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* F-skatt */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Skatt & moms
{t('tax_vat_heading')}
</h2>
<div className="space-y-4">
@@ -51,9 +59,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="f_skatt" className="cursor-pointer">F-skattsedel</Label>
<Label htmlFor="f_skatt" className="cursor-pointer">{t('f_skatt_label')}</Label>
<p className="text-xs text-muted-foreground">
Godkänd för F-skatt (självständig näringsverksamhet).
{t('f_skatt_help')}
</p>
</div>
</div>
@@ -66,9 +74,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">Momsregistrerad</Label>
<Label htmlFor="vat_registered" className="cursor-pointer">{t('vat_registered_label')}</Label>
<p className="text-xs text-muted-foreground">
Obligatoriskt om omsättningen överstiger 120 000 kr per år.
{t('vat_registered_help')}
</p>
</div>
</div>
@@ -76,7 +84,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{vatRegistered && (
<div className="space-y-4 pl-7">
<div className="max-w-xs space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Label htmlFor="vat_number">{t('vat_number_label')}</Label>
<Input
id="vat_number"
name="vat_number"
@@ -84,47 +92,46 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
defaultValue={settings.vat_number || ''}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
{t('vat_number_help')}
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Momsredovisningsperiod</Label>
<Label>{t('moms_period_label')}</Label>
<Select
name="moms_period"
defaultValue={settings.moms_period || undefined}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('period_yearly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Enligt beslut från Skatteverket.
{t('moms_period_help')}
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Period för periodisk sammanställning</Label>
<Label>{t('periodisk_label')}</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Varuförsäljning till EU ska normalt rapporteras månadsvis (35 kap. 2 § SFL).
Kvartal kräver tillstånd från Skatteverket och gäller endast tjänsteförsäljning.
{t('periodisk_help')}
</p>
</div>
</div>
@@ -135,24 +142,24 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Tax contact — required for SKV-filings */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Kontaktperson för skatteärenden
{t('tax_contact_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.).
{t('tax_contact_help')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="tax_contact_name">Namn</Label>
<Label htmlFor="tax_contact_name">{t('tax_contact_name_label')}</Label>
<Input
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder="Anna Andersson"
placeholder={t('tax_contact_name_placeholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax_contact_phone">Telefon</Label>
<Label htmlFor="tax_contact_phone">{t('tax_contact_phone_label')}</Label>
<Input
id="tax_contact_phone"
name="tax_contact_phone"
@@ -161,7 +168,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="tax_contact_email">E-post</Label>
<Label htmlFor="tax_contact_email">{t('tax_contact_email_label')}</Label>
<Input
id="tax_contact_email"
name="tax_contact_email"
@@ -176,17 +183,17 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Fiscal year & salaries */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Räkenskapsår & löner
{t('fiscal_year_salaries_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label>Räkenskapsårets startmånad</Label>
<Label>{t('fiscal_year_start_label')}</Label>
{isEnskildFirma ? (
<>
<Input value="Januari" disabled />
<Input value={t('month_jan')} disabled />
<input type="hidden" name="fiscal_year_start_month" value="1" />
<p className="text-xs text-muted-foreground">
Enskild firma måste använda kalenderår (BFL 3 kap.).
{t('fiscal_year_ef_help')}
</p>
</>
) : (
@@ -199,16 +206,13 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
].map((month, i) => (
{months.map((month, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{month}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Ändring påverkar framtida räkenskapsår.
{t('fiscal_year_change_help')}
</p>
</>
)}
@@ -222,9 +226,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="pays_salaries" className="cursor-pointer">Betalar löner</Label>
<Label htmlFor="pays_salaries" className="cursor-pointer">{t('pays_salaries_label')}</Label>
<p className="text-xs text-muted-foreground">
Påverkar vilka skattedeadlines som visas (arbetsgivardeklaration m.m.).
{t('pays_salaries_help')}
</p>
</div>
</div>
@@ -233,12 +237,12 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Preliminary tax */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Preliminärskatt
{t('preliminary_tax_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
{t('preliminary_tax_monthly_label')}
</Label>
<Input
id="preliminary_tax_monthly"
@@ -247,7 +251,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
defaultValue={settings.preliminary_tax_monthly || ''}
/>
<p className="text-xs text-muted-foreground">
Belopp i SEK som betalas varje månad.
{t('preliminary_tax_monthly_help')}
</p>
</div>
</section>
+14 -9
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2, Users } from 'lucide-react'
@@ -13,17 +14,21 @@ interface TeamMember {
is_current_user: boolean
}
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
admin: 'Admin',
member: 'Medlem',
}
export function TeamPanel() {
const t = useTranslations('settings_team_panel')
const [isLoading, setIsLoading] = useState(true)
const [members, setMembers] = useState<TeamMember[]>([])
const [teamName, setTeamName] = useState('')
const roleLabel = (role: string) => {
switch (role) {
case 'owner': return t('role_owner')
case 'admin': return t('role_admin')
case 'member': return t('role_member')
default: return role
}
}
const fetchMembers = useCallback(async () => {
try {
const res = await fetch('/api/team/members')
@@ -57,7 +62,7 @@ export function TeamPanel() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
{teamName || 'Team'}
{teamName || t('team_fallback')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -74,11 +79,11 @@ export function TeamPanel() {
<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('you_suffix')}</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{ROLE_LABELS[member.role] || member.role}
{roleLabel(member.role)}
</p>
</div>
</div>
+9 -7
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -18,6 +19,7 @@ interface VoucherSeriesManagerProps {
}
export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) {
const t = useTranslations('settings_voucher_series')
const { company } = useCompany()
const [series, setSeries] = useState<VoucherSeries[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -48,7 +50,7 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Verifikationsserier
{t('heading')}
</h2>
{isLoading ? (
@@ -58,22 +60,22 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
</div>
) : seriesEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga verifikationsserier ännu. Serie {defaultSeries || 'A'} skapas automatiskt vid första verifikationen.
{t('empty_state', { series: defaultSeries || 'A' })}
</p>
) : (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
<Label className="text-xs text-muted-foreground">{t('active_series_label')}</Label>
<div className="divide-y divide-border/8">
{seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
<span className="text-sm font-medium tabular-nums">{t('series_prefix')} {letter}</span>
{letter === (defaultSeries || 'A') && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">standard</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{t('default_badge')}</Badge>
)}
</div>
<span className="text-sm text-muted-foreground tabular-nums">
Senaste nr: {lastNum}
{t('latest_number')}: {lastNum}
</span>
</div>
))}
@@ -82,7 +84,7 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
)}
<p className="text-xs text-muted-foreground">
Nya serier skapas automatiskt första gången de används vid bokföring.
{t('footnote')}
</p>
</section>
)
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -54,6 +55,7 @@ export function SkattekontoMatchDialog({
onClose: () => void
onMatched: () => void
}) {
const t = useTranslations('tx_skattekonto_match')
const { toast } = useToast()
const [candidates, setCandidates] = useState<MatchCandidate[] | null>(null)
const [loading, setLoading] = useState(false)
@@ -74,13 +76,13 @@ export function SkattekontoMatchDialog({
const json = await res.json()
if (cancelled) return
if (!res.ok) {
throw new Error(json.error || 'Kunde inte söka kandidater')
throw new Error(json.error || t('search_failed_default'))
}
setCandidates(json.data.candidates as MatchCandidate[])
} catch (err) {
if (cancelled) return
toast({
title: 'Kunde inte hämta kandidater',
title: t('fetch_candidates_failed_title'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -92,7 +94,7 @@ export function SkattekontoMatchDialog({
return () => {
cancelled = true
}
}, [open, row, toast, onClose])
}, [open, row, toast, onClose, t])
async function confirmMatch(journalEntryId: string) {
if (!row) return
@@ -108,14 +110,14 @@ export function SkattekontoMatchDialog({
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Matchning misslyckades')
throw new Error(json.error || t('match_failed_default'))
}
toast({ title: 'Transaktion kopplad till verifikat' })
toast({ title: t('match_success_title') })
onMatched()
onClose()
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
title: t('match_failed_title'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -128,7 +130,7 @@ export function SkattekontoMatchDialog({
<Dialog open={open} onOpenChange={o => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{row && (
<>
@@ -143,18 +145,17 @@ export function SkattekontoMatchDialog({
{loading && (
<p className="py-6 text-center text-sm text-muted-foreground">
Söker kandidater…
{t('searching')}
</p>
)}
{!loading && candidates && candidates.length === 0 && (
<div className="space-y-2 py-4 text-sm">
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
<p>{t('no_candidates_title')}</p>
<p className="text-muted-foreground">
Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar
från transaktionsdatumet, och får inte redan vara kopplad till en
annan skattekonto-transaktion. Använd <strong>Bokför</strong> för
att skapa ett nytt verifikat istället.
{t.rich('no_candidates_help', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
)}
@@ -164,10 +165,10 @@ export function SkattekontoMatchDialog({
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Verifikat</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('th_date')}</TableHead>
<TableHead>{t('th_voucher')}</TableHead>
<TableHead>{t('th_description')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@@ -185,11 +186,11 @@ export function SkattekontoMatchDialog({
</TableCell>
<TableCell>
{c.status === 'posted' ? (
<Badge variant="secondary">Bokförd</Badge>
<Badge variant="secondary">{t('status_posted')}</Badge>
) : c.status === 'draft' ? (
<Badge variant="outline">Utkast</Badge>
<Badge variant="outline">{t('status_draft')}</Badge>
) : (
<Badge variant="destructive">Makulerad</Badge>
<Badge variant="destructive">{t('status_reversed')}</Badge>
)}
</TableCell>
<TableCell className="text-right">
@@ -198,7 +199,7 @@ export function SkattekontoMatchDialog({
onClick={() => confirmMatch(c.journal_entry_id)}
disabled={submittingId === c.journal_entry_id}
>
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
{submittingId === c.journal_entry_id ? t('linking') : t('link')}
</Button>
</TableCell>
</TableRow>
@@ -210,7 +211,7 @@ export function SkattekontoMatchDialog({
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Avbryt
{t('cancel')}
</Button>
</DialogFooter>
</DialogContent>
+67 -65
View File
@@ -1,8 +1,10 @@
'use client'
import { useMemo } 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'
@@ -10,36 +12,12 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateSupplierInput, SupplierType } from '@/types'
const schema = z.object({
name: z.string().min(1, 'Namn krävs'),
supplier_type: z.enum(['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(),
bankgiro: z.string().optional(),
plusgiro: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
default_expense_account: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
default_currency: z.string().optional(),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
import type { CreateSupplierInput } from '@/types'
interface SupplierFormProps {
onSubmit: (data: CreateSupplierInput) => Promise<void>
isLoading: boolean
initialData?: Partial<FormData>
initialData?: Partial<CreateSupplierInput>
}
export default function SupplierForm({
@@ -48,6 +26,32 @@ export default function SupplierForm({
initialData,
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_supplier')
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
supplier_type: z.enum(['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(),
bankgiro: z.string().optional(),
plusgiro: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
default_expense_account: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
default_currency: z.string().optional(),
notes: z.string().optional(),
}), [t])
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
@@ -88,19 +92,19 @@ export default function SupplierForm({
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Supplier Type */}
<div className="space-y-2">
<Label>Leverantörstyp *</Label>
<Label>{t('type_label')}</Label>
<Controller
name="supplier_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder="Välj typ" />
<SelectValue placeholder={t('type_label')} />
</SelectTrigger>
<SelectContent>
<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="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>
)}
@@ -109,10 +113,10 @@ export default function SupplierForm({
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Namn *</Label>
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder="Leverantörens namn"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
@@ -123,11 +127,11 @@ export default function SupplierForm({
{/* 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="fakturor@foretag.se"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
@@ -135,7 +139,7 @@ export default function SupplierForm({
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder="+46 8 123 45 67"
@@ -146,21 +150,21 @@ export default function SupplierForm({
{/* Business info */}
<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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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>
<div className="space-y-2">
<Label htmlFor="vat_number">VAT-nummer</Label>
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
placeholder={t('vat_placeholder_se')}
{...register('vat_number')}
/>
</div>
@@ -169,9 +173,9 @@ export default function SupplierForm({
{/* Address */}
<div className="space-y-4 pt-4 border-t">
<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"
@@ -180,15 +184,15 @@ export default function SupplierForm({
</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" {...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" {...register('city')} />
</div>
<div className="space-y-2">
<Label htmlFor="country">Land</Label>
<Label htmlFor="country">{t('country_label')}</Label>
<Input id="country" placeholder="SE" {...register('country')} />
</div>
</div>
@@ -196,24 +200,24 @@ export default function SupplierForm({
{/* Payment details */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Betalningsuppgifter</h3>
<h3 className="font-medium">{t('payment_section')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Input id="bankgiro" placeholder="XXX-XXXX" {...register('bankgiro')} />
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input id="bankgiro" placeholder={t('bankgiro_placeholder')} {...register('bankgiro')} />
</div>
<div className="space-y-2">
<Label htmlFor="plusgiro">Plusgiro</Label>
<Label htmlFor="plusgiro">{t('plusgiro_label')}</Label>
<Input id="plusgiro" placeholder="XXXXXXX-X" {...register('plusgiro')} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="iban">IBAN</Label>
<Input id="iban" placeholder="SE00 0000 0000 0000 0000 0000" {...register('iban')} />
<Label htmlFor="iban">{t('iban_label')}</Label>
<Input id="iban" placeholder={t('iban_placeholder')} {...register('iban')} />
</div>
<div className="space-y-2">
<Label htmlFor="bic">BIC/SWIFT</Label>
<Label htmlFor="bic">{t('swift_label')}</Label>
<Input id="bic" placeholder="SWEDSESS" {...register('bic')} />
</div>
</div>
@@ -221,19 +225,17 @@ export default function SupplierForm({
{/* Defaults */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Standardvärden</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="default_expense_account">Kostnadskonto</Label>
<Label htmlFor="default_expense_account">{t('default_account_label')}</Label>
<Input
id="default_expense_account"
placeholder="5010"
placeholder={t('default_account_placeholder')}
{...register('default_expense_account')}
/>
<p className="text-xs text-muted-foreground">Standardkonto för denna leverantör</p>
</div>
<div className="space-y-2">
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Label htmlFor="payment_terms">{t('default_payment_terms_label')}</Label>
<Input
id="payment_terms"
type="number"
@@ -241,7 +243,7 @@ export default function SupplierForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_currency">Valuta</Label>
<Label htmlFor="default_currency">{t('default_account_label')}</Label>
<Controller
name="default_currency"
control={control}
@@ -267,10 +269,10 @@ export default function SupplierForm({
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder="Interna anteckningar..."
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
@@ -280,20 +282,20 @@ export default function SupplierForm({
<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 leverantör
{t('submit_save')}
</>
) : (
'Spara leverantör'
t('submit_save')
)}
</Button>
</div>
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
@@ -137,16 +138,6 @@ function buildJournalPreview(
return lines
}
const ACCOUNT_LABELS: Record<string, string> = {
'2440': 'Leverantörsskulder',
'2641': 'Ingående moms',
'2645': 'Beräknad ing. moms förvärv utlandet',
'2647': 'Beräknad ing. moms omvänd i Sverige',
'2614': 'Utg. moms omvänd 25%',
'2624': 'Utg. moms omvänd 12%',
'2634': 'Utg. moms omvänd 6%',
}
export function SupplierInvoiceReviewContent({
supplier,
invoiceNumber,
@@ -162,6 +153,7 @@ export function SupplierInvoiceReviewContent({
totalVat,
total,
}: SupplierInvoiceReviewContentProps) {
const t = useTranslations('supplier_invoice_editor')
const parsedRate = exchangeRate ? parseFloat(exchangeRate) : NaN
const fxRate = currency !== 'SEK' && Number.isFinite(parsedRate) && parsedRate > 0 ? parsedRate : 1
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type, fxRate)
@@ -169,24 +161,34 @@ export function SupplierInvoiceReviewContent({
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
const showingSek = fxRate !== 1
const ACCOUNT_LABELS: Record<string, string> = {
'2440': t('account_2440'),
'2641': t('account_2641'),
'2645': t('account_2645'),
'2647': t('account_2647'),
'2614': t('account_2614'),
'2624': t('account_2624'),
'2634': t('account_2634'),
}
return (
<div className="space-y-4">
{/* Supplier info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{supplier.name}</p>
<p className="text-sm text-muted-foreground">Fakturanr: {invoiceNumber}</p>
<p className="text-sm text-muted-foreground">{t('review_invoice_number_label', { number: invoiceNumber })}</p>
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
{reverseCharge && (
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
Omvänd skattskyldighet
{t('reverse_charge_badge')}
</Badge>
)}
{currency !== 'SEK' && (
<Badge variant="outline" className="text-sm">
{currency}
{exchangeRate && ` (kurs ${exchangeRate})`}
{exchangeRate && t('review_currency_rate_suffix', { rate: exchangeRate })}
</Badge>
)}
</div>
@@ -195,16 +197,16 @@ export function SupplierInvoiceReviewContent({
{/* Dates */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<span className="text-muted-foreground">{t('invoice_date_label')}</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_label')}</span>
<p className="font-medium">{dueDate}</p>
</div>
{deliveryDate && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<span className="text-muted-foreground">{t('delivery_date_label')}</span>
<p className="font-medium">{deliveryDate}</p>
</div>
)}
@@ -215,11 +217,11 @@ export function SupplierInvoiceReviewContent({
<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-20">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-16 text-right">Moms%</th>
<th className="py-2 w-24 text-right">Moms</th>
<th className="py-2 w-20">{t('col_account')}</th>
<th className="py-2">{t('col_description')}</th>
<th className="py-2 w-28 text-right">{t('col_amount')}</th>
<th className="py-2 w-16 text-right">{t('col_vat_rate')}</th>
<th className="py-2 w-24 text-right">{t('col_vat')}</th>
</tr>
</thead>
<tbody>
@@ -251,7 +253,7 @@ export function SupplierInvoiceReviewContent({
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{Math.round(item.vat_rate * 100)}% moms ({formatAmount(vatAmount)})</span>
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(item.vat_rate * 100), amount: formatAmount(vatAmount) })}</span>
</div>
</div>
)
@@ -261,21 +263,21 @@ export function SupplierInvoiceReviewContent({
{/* Totals */}
<div 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>{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="text-muted-foreground">{t('vat_label_short')}</span>
<span>{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{t('total_label')}</span>
<span>{formatCurrency(total, currency)}</span>
</div>
{currency !== 'SEK' && exchangeRate && (
<div className="flex justify-between text-muted-foreground">
<span>SEK-belopp (vid kurs {exchangeRate})</span>
<span>{t('review_sek_amount_at_rate', { rate: exchangeRate })}</span>
<span>{formatCurrency(total * parseFloat(exchangeRate))}</span>
</div>
)}
@@ -284,19 +286,19 @@ export function SupplierInvoiceReviewContent({
{/* Verifikation preview */}
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
<p className="text-sm font-semibold text-muted-foreground">
Verifikation som bokförs
{t('review_voucher_preview_title')}
{showingSek && (
<span className="ml-1.5 font-normal text-xs">(i SEK)</span>
<span className="ml-1.5 font-normal text-xs">{t('review_voucher_in_sek_suffix')}</span>
)}
</p>
<div className="hidden sm:block">
<table className="w-full text-sm font-mono">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="text-left">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
<th className="pb-1 w-24 text-right">Kredit</th>
<th className="pb-1 w-16">{t('col_account')}</th>
<th className="pb-1">{t('col_description')}</th>
<th className="pb-1 w-24 text-right">{t('col_debit')}</th>
<th className="pb-1 w-24 text-right">{t('col_credit')}</th>
</tr>
</thead>
<tbody>
@@ -319,7 +321,7 @@ export function SupplierInvoiceReviewContent({
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>SUMMA</td>
<td className="pt-1" colSpan={2}>{t('sum_label')}</td>
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
</tr>
@@ -338,13 +340,13 @@ export function SupplierInvoiceReviewContent({
</div>
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{line.debit > 0 ? `D ${formatAmount(line.debit)}` : `K ${formatAmount(line.credit)}`}
{line.debit > 0 ? t('debit_short', { amount: formatAmount(line.debit) }) : t('credit_short', { amount: formatAmount(line.credit) })}
</span>
</div>
))}
<div className="flex justify-between pt-1 border-t font-semibold text-xs font-mono">
<span>SUMMA</span>
<span>D {formatAmount(totalDebit)} / K {formatAmount(totalCredit)}</span>
<span>{t('sum_label')}</span>
<span>{t('debit_credit_short', { debit: formatAmount(totalDebit), credit: formatAmount(totalCredit) })}</span>
</div>
</div>
</div>
@@ -352,7 +354,7 @@ export function SupplierInvoiceReviewContent({
{/* Payment reference */}
{paymentReference && (
<div className="border-t pt-3 text-sm text-muted-foreground">
<p>Betalningsreferens: {paymentReference}</p>
<p>{t('review_payment_reference_inline', { reference: paymentReference })}</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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
@@ -27,6 +28,8 @@ export default function BatchCategorySelector({
onSelectCategory,
progress,
}: BatchCategorySelectorProps) {
const t = useTranslations('tx_batch_selector')
const tCat = useTranslations('tx_categories')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const isProcessing = progress !== null
@@ -41,13 +44,13 @@ export default function BatchCategorySelector({
<DialogHeader>
<DialogTitle>
{isProcessing
? `Bokför ${progress.done}/${progress.total}...`
: `Bokför ${selectedCount} transaktioner`}
? t('title_processing', { done: progress.done, total: progress.total })
: t('title_default', { count: selectedCount })}
</DialogTitle>
<DialogDescription>
{isProcessing
? 'Vänta medan transaktionerna bokförs'
: 'Välj en kategori som ska tillämpas på alla valda transaktioner'}
? t('description_processing')
: t('description_default')}
</DialogDescription>
</DialogHeader>
@@ -55,7 +58,7 @@ export default function BatchCategorySelector({
<div className="py-4">
<Progress value={(progress.done / progress.total) * 100} />
<p className="text-sm text-muted-foreground mt-2 text-center">
{progress.done} av {progress.total} klara
{t('progress_label', { done: progress.done, total: progress.total })}
</p>
</div>
) : (
@@ -64,19 +67,19 @@ export default function BatchCategorySelector({
<div className="flex items-start gap-2 rounded-lg bg-warning/10 border border-warning/30 p-3">
<Paperclip className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-xs text-warning-foreground">
Underlag behöver bifogas separat för varje transaktion efter bokföring.
{t('underlag_reminder')}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<h4 className="text-sm font-medium text-muted-foreground mb-2">{t('vat_label')}</h4>
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
<h4 className="text-sm font-medium text-muted-foreground mb-2">{t('expenses_label')}</h4>
<div className="grid grid-cols-2 gap-1.5">
{expenseCategories.map((cat) => (
<Button
@@ -86,13 +89,13 @@ export default function BatchCategorySelector({
className="justify-start text-xs"
onClick={() => handleSelectCategory(cat.value)}
>
{cat.label}
{tCat(cat.labelKey)}
</Button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Intäkter</h4>
<h4 className="text-sm font-medium text-muted-foreground mb-2">{t('income_label')}</h4>
<div className="grid grid-cols-2 gap-1.5">
{incomeCategories.map((cat) => (
<Button
@@ -102,7 +105,7 @@ export default function BatchCategorySelector({
className="justify-start text-xs"
onClick={() => handleSelectCategory(cat.value)}
>
{cat.label}
{tCat(cat.labelKey)}
</Button>
))}
</div>
+10 -8
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Check, Upload, Plus } from 'lucide-react'
@@ -11,6 +12,7 @@ interface InboxZeroStateProps {
}
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
const t = useTranslations('tx_inbox_zero')
if (!hasTransactions) {
// No transactions at all
return (
@@ -19,20 +21,20 @@ export default function InboxZeroState({ hasTransactions, onCreateTransaction }:
<div className="p-5 rounded-full bg-muted mb-6">
<Upload className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">Inga transaktioner</h3>
<h3 className="text-lg font-medium mb-2">{t('empty_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
Importera kontoutdrag från din bank eller lägg till transaktioner manuellt för att komma igång.
{t('empty_description')}
</p>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto px-4 sm:px-0">
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera transaktioner
{t('import_btn')}
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Lägg till manuellt
{t('add_manual_btn')}
</Button>
</div>
</CardContent>
@@ -47,20 +49,20 @@ export default function InboxZeroState({ hasTransactions, onCreateTransaction }:
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h3 className="text-lg font-medium">Alla transaktioner bokförda!</h3>
<h3 className="text-lg font-medium">{t('done_title')}</h3>
<p className="text-muted-foreground text-center mt-1 max-w-sm">
Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik.
{t('done_description')}
</p>
<div className="flex gap-2 mt-6">
<Button asChild variant="outline">
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera fler
{t('import_more_btn')}
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Ny transaktion
{t('new_btn')}
</Button>
</div>
</CardContent>
+38 -28
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -34,6 +35,7 @@ export default function InvoiceMatchDialog({
onConfirm,
onLinkToExisting,
}: InvoiceMatchDialogProps) {
const t = useTranslations('tx_invoice_match')
const isSupplierInvoice = !!transaction?.potential_supplier_invoice
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
@@ -74,10 +76,10 @@ export default function InvoiceMatchDialog({
// Supplier invoices show the negative-amount paid-out match; customer
// invoices show the positive-amount paid-in match. Each side carries its
// own follow-up action language.
const matchTitle = isSupplierInvoice ? 'Bekräfta leverantörsfakturamatchning' : 'Bekräfta fakturamatchning'
const matchTitle = isSupplierInvoice ? t('title_supplier') : t('title_customer')
const matchDescription = isSupplierInvoice
? 'Vill du koppla denna transaktion till leverantörsfakturan? Fakturan kommer att markeras som betald och en betalningsverifikation skapas.'
: 'Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.'
? t('description_supplier')
: t('description_customer')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -95,10 +97,18 @@ export default function InvoiceMatchDialog({
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
<div className="text-sm space-y-1">
<p className="font-medium text-warning-foreground">Möjlig dubblettbokning</p>
<p className="font-medium text-warning-foreground">{t('duplicate_title')}</p>
<p className="text-muted-foreground">
Det finns redan en bokförd verifikation <span className="font-mono">{candidate.voucher_label}</span> på samma belopp ({formatCurrency(candidate.amount, transaction.currency)}) {candidate.reason === 'exact_amount_same_date' ? 'på samma datum' : `inom ±7 dagar (${formatDate(candidate.entry_date)})`}.
Har du redan bokfört denna betalning manuellt?
{candidate.reason === 'exact_amount_same_date'
? t('duplicate_body_same_date', {
label: candidate.voucher_label,
amount: formatCurrency(candidate.amount, transaction.currency),
})
: t('duplicate_body_window', {
label: candidate.voucher_label,
amount: formatCurrency(candidate.amount, transaction.currency),
date: formatDate(candidate.entry_date),
})}
</p>
{candidate.description && (
// Truncate to a short head before render. The
@@ -125,7 +135,7 @@ export default function InvoiceMatchDialog({
disabled={isConfirming}
className="sm:flex-1"
>
Koppla till {candidate.voucher_label}
{t('link_to_existing', { label: candidate.voucher_label })}
</Button>
<Button
variant="outline"
@@ -142,7 +152,7 @@ export default function InvoiceMatchDialog({
disabled={isConfirming}
className="sm:flex-1"
>
Skapa ny verifikation ändå
{t('create_new_anyway')}
</Button>
</div>
)}
@@ -151,7 +161,7 @@ export default function InvoiceMatchDialog({
{/* Transaction details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
<p className="text-sm font-medium text-muted-foreground">{t('transaction_label')}</p>
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatDate(transaction.date)}</span>
@@ -165,16 +175,16 @@ export default function InvoiceMatchDialog({
{/* Invoice details */}
{isCustomerInvoice && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Faktura</p>
<p className="text-sm font-medium text-muted-foreground">{t('invoice_label')}</p>
<p className="font-medium">
Faktura {transaction.potential_invoice!.invoice_number}
{t('invoice_number', { number: transaction.potential_invoice!.invoice_number ?? '' })}
</p>
<p className="text-sm text-muted-foreground">
{transaction.potential_invoice!.customer?.name || 'Okänd kund'}
{transaction.potential_invoice!.customer?.name || t('unknown_customer')}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Förfaller: {formatDate(transaction.potential_invoice!.due_date)}
{t('due_date', { date: formatDate(transaction.potential_invoice!.due_date) })}
</span>
<span className="font-medium">
{formatCurrency(
@@ -188,16 +198,16 @@ export default function InvoiceMatchDialog({
{isSupplierInvoice && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Leverantörsfaktura</p>
<p className="text-sm font-medium text-muted-foreground">{t('supplier_invoice_label')}</p>
<p className="font-medium">
Faktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
{t('invoice_number', { number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '' })}
</p>
<p className="text-sm text-muted-foreground">
Ankomstnr: {transaction.potential_supplier_invoice!.arrival_number}
{t('arrival_number', { number: transaction.potential_supplier_invoice!.arrival_number ?? '' })}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Förfaller: {formatDate(transaction.potential_supplier_invoice!.due_date)}
{t('due_date', { date: formatDate(transaction.potential_supplier_invoice!.due_date) })}
</span>
<span className="font-medium">
{formatCurrency(
@@ -225,7 +235,7 @@ export default function InvoiceMatchDialog({
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<p className="text-sm font-medium">Beloppen stämmer</p>
<p className="text-sm font-medium">{t('amounts_match')}</p>
</div>
)
}
@@ -235,11 +245,11 @@ export default function InvoiceMatchDialog({
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">Beloppen skiljer sig</p>
<p className="font-medium">{t('amounts_differ')}</p>
<p>
Differens: {formatCurrency(diff, transaction.currency)}
{!sameCurrency && ' (olika valutor)'}
{isSupplierInvoice && diff > 0.01 && sameCurrency && ' — fakturan blir delbetald.'}
{t('amount_diff', { amount: formatCurrency(diff, transaction.currency) })}
{!sameCurrency && t('different_currencies')}
{isSupplierInvoice && diff > 0.01 && sameCurrency && t('partial_payment_note')}
</p>
</div>
</div>
@@ -248,11 +258,11 @@ export default function InvoiceMatchDialog({
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">Vid bekräftelse:</p>
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Transaktionen kopplas till {isSupplierInvoice ? 'leverantörsfakturan' : 'fakturan'}</li>
<li>• {isSupplierInvoice ? 'Leverantörsfakturan markeras som betald' : 'Fakturan markeras som betald'}</li>
<li>• Bokföringsverifikation skapas automatiskt</li>
<li>• {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}</li>
<li>• {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}</li>
<li>• {t('on_confirm_voucher')}</li>
</ul>
</div>
</div>
@@ -260,10 +270,10 @@ export default function InvoiceMatchDialog({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={() => onConfirm()} disabled={isConfirming || isCheckingDuplicate}>
{isConfirming ? 'Bekräftar...' : 'Bekräfta matchning'}
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>
</DialogFooter>
</DialogContent>
+11 -10
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Input } from '@/components/ui/input'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
@@ -18,6 +19,7 @@ interface InvoicePickerProps {
}
export default function InvoicePicker({ transaction, onSelect, isProcessing }: InvoicePickerProps) {
const t = useTranslations('tx_invoice_picker')
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
const [invoices, setInvoices] = useState<OpenInvoice[]>([])
@@ -110,7 +112,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Laddar fakturor...
{t('loading')}
</div>
)
}
@@ -118,7 +120,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
if (invoices.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">Inga öppna fakturor att matcha mot.</p>
<p className="text-sm">{t('empty')}</p>
</div>
)
}
@@ -128,7 +130,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
<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 fakturanummer eller kund..."
placeholder={t('search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
@@ -167,22 +169,21 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm">
{invoice.invoice_number ?? '(utan nummer)'}
{invoice.invoice_number ?? t('no_number')}
</span>
{invoice.status === 'overdue' && (
<span className="text-[10px] uppercase tracking-wide text-destructive">
Förfallen
{t('status_overdue')}
</span>
)}
{invoice.status === 'partially_paid' && (
<span className="text-[10px] uppercase tracking-wide text-warning-foreground">
Delbetald
{t('status_partially_paid')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{invoice.customer?.name || 'Okänd kund'} · Förfaller{' '}
{formatDate(invoice.due_date)}
{invoice.customer?.name || t('unknown_customer')} · {t('due_short', { date: formatDate(invoice.due_date) })}
</p>
</div>
<div className="text-right flex-shrink-0">
@@ -194,7 +195,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
>
{formatCurrency(remaining, invoice.currency)}
</p>
{exact && <p className="text-[10px] text-success">Exakt match</p>}
{exact && <p className="text-[10px] text-success">{t('exact_match')}</p>}
</div>
</div>
</button>
@@ -202,7 +203,7 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
})}
{sorted.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
Ingen faktura matchar &quot;{search}&quot;
{t('no_search_results', { term: search })}
</p>
)}
</div>
+38 -28
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
@@ -57,6 +58,8 @@ export default function QuickReviewDialog({
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
const t = useTranslations('tx_quick_review')
const tCat = useTranslations('tx_categories')
const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
@@ -82,7 +85,7 @@ export default function QuickReviewDialog({
try {
const res = await fetch(`/api/documents/${preAttachedDocumentId}`)
if (!res.ok) {
toast({ title: 'Kunde inte öppna underlaget', variant: 'destructive' })
toast({ title: t('open_attached_failed'), variant: 'destructive' })
return
}
const { data } = await res.json()
@@ -92,7 +95,7 @@ export default function QuickReviewDialog({
} finally {
setIsOpeningDoc(false)
}
}, [preAttachedDocumentId, isOpeningDoc, toast])
}, [preAttachedDocumentId, isOpeningDoc, toast, t])
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -148,7 +151,7 @@ export default function QuickReviewDialog({
const json = await res.json()
if (cancelled) return
if (!res.ok) {
setRateError(json?.error?.message || 'Kunde inte hämta växelkursen.')
setRateError(json?.error?.message || t('exchange_rate_fetch_failed'))
return
}
if (json?.data) {
@@ -158,7 +161,7 @@ export default function QuickReviewDialog({
} })
}
} catch {
if (!cancelled) setRateError('Kunde inte hämta växelkursen.')
if (!cancelled) setRateError(t('exchange_rate_fetch_failed'))
} finally {
if (!cancelled) setRateLoading(false)
}
@@ -167,7 +170,7 @@ export default function QuickReviewDialog({
return () => {
cancelled = true
}
}, [open, transaction])
}, [open, transaction, t])
if (!transaction || !category) return null
@@ -218,8 +221,8 @@ export default function QuickReviewDialog({
}
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
title: t('doc_link_failed_title'),
description: t('doc_link_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
@@ -228,7 +231,7 @@ export default function QuickReviewDialog({
setUploadedFiles([])
setShowUploadZone(false)
} catch {
setError('Ett fel uppstod vid bokföring.')
setError(t('generic_error'))
} finally {
// Always reset isProcessing — without this, an onConfirm that resolves
// with null (e.g. server returned a structured 4xx error like
@@ -248,9 +251,9 @@ export default function QuickReviewDialog({
}}>
<DialogContent className="max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Granska bokföring</DialogTitle>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{isTemplateBooking ? 'Granska verifikationen innan du bokför' : 'Kontrollera konto och moms innan du bokför'}
{isTemplateBooking ? t('description_template') : t('description_default')}
</DialogDescription>
</DialogHeader>
@@ -278,8 +281,8 @@ export default function QuickReviewDialog({
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{rateLoading || sekConversionMissing
? 'Hämtar växelkurs…'
: `≈ ${isIncome ? '+' : ''}${formatCurrency(sekAmount, 'SEK')}`}
? t('amount_loading')
: t('amount_approx', { sign: isIncome ? '+' : '', sek: formatCurrency(sekAmount, 'SEK') })}
</p>
</>
) : (
@@ -293,7 +296,11 @@ export default function QuickReviewDialog({
{isForeign && tx.exchange_rate != null && tx.exchange_rate_date && !sekConversionMissing && (
<p className="text-xs text-muted-foreground -mt-1">
Bokförs i SEK med Riksbankens kurs {formatCurrency(tx.exchange_rate, 'SEK')} per {tx.currency} ({formatDate(tx.exchange_rate_date)}).
{t('rate_footnote', {
rate: formatCurrency(tx.exchange_rate, 'SEK'),
currency: tx.currency,
date: formatDate(tx.exchange_rate_date),
})}
</p>
)}
@@ -306,7 +313,7 @@ export default function QuickReviewDialog({
{/* Template or Category */}
<div>
<label className="text-sm font-medium text-muted-foreground">
{isCounterpartyTemplate ? 'Motpartsmall' : template ? 'Mall' : 'Kategori'}
{isCounterpartyTemplate ? t('label_counterparty_template') : template ? t('label_template') : t('label_category')}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
@@ -318,7 +325,7 @@ export default function QuickReviewDialog({
className="text-xs text-primary hover:underline"
onClick={onChangeTemplate}
>
Byt mall
{t('change_template')}
</button>
)}
</div>
@@ -353,7 +360,7 @@ export default function QuickReviewDialog({
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
{t('reverse_charge_warning')}
</p>
</div>
</div>
@@ -384,7 +391,7 @@ export default function QuickReviewDialog({
{!isTemplateBooking && (
<>
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<label className="text-sm font-medium text-muted-foreground">{t('label_account')}</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
@@ -395,11 +402,11 @@ export default function QuickReviewDialog({
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<label className="text-sm font-medium text-muted-foreground">{t('label_vat_treatment')}</label>
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms för skuld-/eget kapital-konton
{t('no_vat_liability_account')}
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -408,14 +415,17 @@ export default function QuickReviewDialog({
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{(() => {
const opt = VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)
return opt ? tCat(opt.labelKey) : t('no_vat_default')
})()}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Ändra
{t('change')}
</button>
</p>
)}
@@ -430,9 +440,9 @@ export default function QuickReviewDialog({
<div className="rounded-lg border flex items-center justify-between px-3 py-2.5 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">Underlag bifogat</span>
<span className="font-medium">{t('attached_doc_label')}</span>
<span className="text-xs text-muted-foreground truncate">
från dokumentinkorgen
{t('attached_doc_source')}
</span>
</div>
<button
@@ -441,7 +451,7 @@ export default function QuickReviewDialog({
disabled={isOpeningDoc}
className="text-xs text-primary hover:underline shrink-0"
>
{isOpeningDoc ? 'Öppnar…' : 'Visa'}
{isOpeningDoc ? t('opening') : t('view')}
</button>
</div>
) : (
@@ -453,10 +463,10 @@ export default function QuickReviewDialog({
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
<span className="font-medium">{t('doc_label')}</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
</span>
)}
</div>
@@ -492,7 +502,7 @@ export default function QuickReviewDialog({
onClick={() => onOpenChange(false)}
disabled={isProcessing}
>
Avbryt
{t('cancel')}
</Button>
<Button
className="flex-1"
@@ -505,7 +515,7 @@ export default function QuickReviewDialog({
}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : rateLoading ? 'Hämtar växelkurs…' : 'Bokför'}
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
</Button>
</div>
</DialogContent>
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { motion } from 'framer-motion'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -38,6 +39,7 @@ export default function SkattekontoInboxCard({
onMatch: (row: StoredSkattekontoTransaction) => void
onAnimationComplete?: (id: string) => void
}) {
const t = useTranslations('tx_skattekonto_card')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
@@ -86,7 +88,7 @@ export default function SkattekontoInboxCard({
<p className="font-medium truncate">{row.transaktionstext}</p>
<Badge variant="outline" className="gap-1 text-[10px]">
<Landmark className="h-3 w-3" />
Skatteverket
{t('skv_badge')}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
@@ -113,17 +115,15 @@ export default function SkattekontoInboxCard({
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
<div className="min-w-0">
<p className="font-medium">
Möjlig dublett av verifikat{' '}
{matchSuggestion.voucher_series && matchSuggestion.voucher_number
? `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}`
: '(utkast)'}
? t('duplicate_title_with_voucher', { label: `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}` })
: t('duplicate_title_draft')}
</p>
<p className="text-muted-foreground truncate">
{matchSuggestion.entry_date} • {matchSuggestion.description}
</p>
<p className="text-muted-foreground">
Det här ser ut som samma kassaflöde — koppla istället för
att bokföra om.
{t('duplicate_body')}
</p>
</div>
</div>
@@ -140,7 +140,7 @@ export default function SkattekontoInboxCard({
disabled={processing}
>
<Link2 className="mr-1.5 h-3 w-3" />
Koppla till verifikat
{t('link_to_voucher')}
</Button>
<Button
size="sm"
@@ -152,7 +152,7 @@ export default function SkattekontoInboxCard({
{processing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
Bokför ändå
{t('book_anyway')}
</Button>
</>
) : (
@@ -167,7 +167,7 @@ export default function SkattekontoInboxCard({
{processing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
Bokför
{t('book')}
</Button>
<Button
size="sm"
@@ -177,7 +177,7 @@ export default function SkattekontoInboxCard({
disabled={processing}
>
<Link2 className="mr-1.5 h-3 w-3" />
Matcha mot verifikat
{t('match_to_voucher')}
</Button>
</>
)}
@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } from 'framer-motion'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -47,6 +48,8 @@ export default function SwipeCategorizationView({
onClose,
entityType,
}: SwipeCategorizationViewProps) {
const t = useTranslations('tx_swipe_view')
const tCat = useTranslations('tx_categories')
const { toast } = useToast()
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
@@ -244,8 +247,8 @@ export default function SwipeCategorizationView({
}
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
title: t('doc_link_failed_title'),
description: t('doc_link_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
@@ -259,10 +262,10 @@ export default function SwipeCategorizationView({
setPendingInboxItemId(null)
moveToNext()
} else {
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
setError(t('booking_failed_skip'))
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
setError(t('generic_error_skip'))
} finally {
setIsProcessing(false)
}
@@ -281,10 +284,10 @@ export default function SwipeCategorizationView({
if (success) {
moveToNext()
} else {
setError('Kunde inte matcha faktura. Tryck "Hoppa över" för att gå vidare.')
setError(t('match_failed_skip'))
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
setError(t('generic_error_skip'))
} finally {
setIsProcessing(false)
}
@@ -308,12 +311,12 @@ export default function SwipeCategorizationView({
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h2 className="font-display text-2xl font-medium">Klart!</h2>
<h2 className="font-display text-2xl font-medium">{t('done_title')}</h2>
<p className="text-muted-foreground mt-2">
Alla transaktioner är nu bokförda
{t('done_subtitle')}
</p>
<Button onClick={onClose} className="mt-6">
Tillbaka till transaktioner
{t('back_to_transactions')}
</Button>
</div>
</div>
@@ -330,7 +333,7 @@ export default function SwipeCategorizationView({
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Välj mall</h1>
<h1 className="font-semibold">{t('choose_template')}</h1>
<div className="w-10" />
</div>
@@ -366,7 +369,7 @@ export default function SwipeCategorizationView({
onClick={handleSkip}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
{t('skip')}
</Button>
</div>
</div>
@@ -374,9 +377,10 @@ export default function SwipeCategorizationView({
}
if (showReviewStep && pendingCategory) {
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
const catOption = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
)
const categoryLabel = catOption ? tCat(catOption.labelKey) : pendingCategory
const selectedTemplate = pendingTemplate
// Auto-clear VAT when a class 2 (liability/equity) account is selected
@@ -395,7 +399,7 @@ export default function SwipeCategorizationView({
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska bokföring</h1>
<h1 className="font-semibold">{t('review_title')}</h1>
<div className="w-10" />
</div>
@@ -432,7 +436,7 @@ export default function SwipeCategorizationView({
{/* Selected template or category */}
<div>
<label className="text-sm font-medium text-muted-foreground">
{selectedTemplate ? 'Mall' : 'Kategori'}
{selectedTemplate ? t('label_template') : t('label_category')}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
@@ -446,7 +450,7 @@ export default function SwipeCategorizationView({
setShowCategorySelect(true)
}}
>
Byt mall
{t('change_template')}
</button>
</div>
{selectedTemplate && (
@@ -480,7 +484,7 @@ export default function SwipeCategorizationView({
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
{t('reverse_charge_warning')}
</p>
</div>
</div>
@@ -503,7 +507,7 @@ export default function SwipeCategorizationView({
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<label className="text-sm font-medium text-muted-foreground">{t('label_account')}</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
@@ -515,11 +519,11 @@ export default function SwipeCategorizationView({
{/* VAT treatment */}
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<label className="text-sm font-medium text-muted-foreground">{t('label_vat_treatment')}</label>
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms för skuld-/eget kapital-konton
{t('no_vat_liability_account')}
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -528,14 +532,17 @@ export default function SwipeCategorizationView({
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{(() => {
const opt = VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)
return opt ? tCat(opt.labelKey) : t('no_vat_default')
})()}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Ändra
{t('change')}
</button>
</p>
)}
@@ -552,10 +559,10 @@ export default function SwipeCategorizationView({
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
<span className="font-medium">{t('doc_label')}</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
</span>
)}
</div>
@@ -592,7 +599,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
{isProcessing ? t('booking') : t('book')}
</Button>
<Button
variant="ghost"
@@ -601,7 +608,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
{t('skip')}
</Button>
</div>
</div>
@@ -617,7 +624,7 @@ export default function SwipeCategorizationView({
</Button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
{currentIndex + 1} av {transactions.length}
{t('progress_label', { current: currentIndex + 1, total: transactions.length })}
</p>
</div>
<div className="w-10" />
@@ -628,10 +635,10 @@ export default function SwipeCategorizationView({
<div className="flex items-center gap-2 text-muted-foreground">
<ArrowLeft className="h-4 w-4" />
<SkipForward className="h-4 w-4" />
<span>Hoppa över</span>
<span>{t('instr_skip')}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>Bokför</span>
<span>{t('instr_book')}</span>
<Building className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</div>
@@ -645,14 +652,14 @@ export default function SwipeCategorizationView({
style={{ opacity: skipIndicatorOpacity }}
>
<SkipForward className="h-8 w-8" />
<span className="font-semibold">Hoppa över</span>
<span className="font-semibold">{t('indicator_skip')}</span>
</motion.div>
<motion.div
className="absolute right-8 flex items-center gap-2 text-success"
style={{ opacity: businessIndicatorOpacity }}
>
<span className="font-semibold">Företag</span>
<span className="font-semibold">{t('indicator_business')}</span>
<Building className="h-8 w-8" />
</motion.div>
@@ -675,13 +682,13 @@ export default function SwipeCategorizationView({
{currentTransaction.receipt_id && (
<Badge variant="secondary" className="gap-1">
<ReceiptIcon className="h-3 w-3" />
Kvitto
{t('badge_receipt')}
</Badge>
)}
{currentTransaction.document_id && (
<Badge variant="secondary" className="gap-1">
<Paperclip className="h-3 w-3" />
Bilaga
{t('badge_attachment')}
</Badge>
)}
<Badge>{currentTransaction.currency}</Badge>
@@ -706,18 +713,18 @@ export default function SwipeCategorizationView({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-success">
<FileText className="h-5 w-5" />
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
<span className="font-semibold text-sm">{t('invoice_match_title')}</span>
</div>
<Badge variant="outline" className="text-success border-success">
Match
{t('invoice_match_badge')}
</Badge>
</div>
<div className="text-sm">
<p className="font-medium">
Faktura {currentTransaction.potential_invoice.invoice_number}
{t('invoice_label', { number: currentTransaction.potential_invoice.invoice_number ?? '' })}
</p>
<p className="text-muted-foreground">
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
{currentTransaction.potential_invoice.customer?.name || t('unknown_customer')}
</p>
<p className="font-medium text-success">
{formatCurrency(
@@ -776,14 +783,14 @@ export default function SwipeCategorizationView({
disabled={isProcessing}
>
<Link2 className="mr-2 h-4 w-4" />
Matcha med Faktura {currentTransaction.potential_invoice.invoice_number}
{t('match_invoice_btn', { number: currentTransaction.potential_invoice.invoice_number ?? '' })}
</Button>
)}
{/* Suggested categories - shown as quick-select buttons */}
{suggestions && suggestions[currentTransaction.id] && suggestions[currentTransaction.id].length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">Föreslagna kategorier</p>
<p className="text-xs text-muted-foreground text-center">{t('suggested_categories')}</p>
<div className="grid grid-cols-2 gap-2">
{suggestions[currentTransaction.id].map((suggestion) => (
<Button
@@ -818,7 +825,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing}
>
<Building className="mr-2 h-4 w-4" />
Bokför
{t('book')}
</Button>
{/* Skip button - always visible */}
@@ -829,7 +836,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
{t('skip')}
</Button>
</div>
</div>
+41 -38
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -26,35 +27,35 @@ const GROUP_ORDER: TemplateGroup[] = [
'financial', 'private_transfers', 'equipment',
]
const GROUP_LABELS: Record<TemplateGroup, string> = {
premises: 'Lokalkostnader',
vehicle: 'Fordon',
it_software: 'IT & Programvara',
office_supplies: 'Kontorsmaterial',
marketing: 'Marknadsföring',
travel: 'Resor & Transport',
representation: 'Representation',
insurance: 'Försäkringar',
professional_services: 'Professionella tjänster',
bank_finance: 'Bank & Finans',
telecom: 'Telekom & Internet',
education: 'Utbildning',
personnel: 'Personal',
revenue: 'Intäkter',
financial: 'Finansiella poster',
private_transfers: 'Privata transaktioner',
equipment: 'Inventarier & Utrustning',
const GROUP_LABEL_KEYS: Record<TemplateGroup, string> = {
premises: 'group_premises',
vehicle: 'group_vehicle',
it_software: 'group_it_software',
office_supplies: 'group_office_supplies',
marketing: 'group_marketing',
travel: 'group_travel',
representation: 'group_representation',
insurance: 'group_insurance',
professional_services: 'group_professional_services',
bank_finance: 'group_bank_finance',
telecom: 'group_telecom',
education: 'group_education',
personnel: 'group_personnel',
revenue: 'group_revenue',
financial: 'group_financial',
private_transfers: 'group_private_transfers',
equipment: 'group_equipment',
}
function getVatLabel(template: BookingTemplate): string | null {
function getVatLabelKey(template: BookingTemplate): string | null {
if (!template.vat_treatment) return null
switch (template.vat_treatment) {
case 'standard_25': return '25% moms'
case 'reduced_12': return '12% moms'
case 'reduced_6': return '6% moms'
case 'reverse_charge': return 'Omvänd moms'
case 'export': return 'Momsfri (export)'
case 'exempt': return 'Momsfri'
case 'standard_25': return 'vat_standard_25'
case 'reduced_12': return 'vat_reduced_12'
case 'reduced_6': return 'vat_reduced_6'
case 'reverse_charge': return 'vat_reverse_charge'
case 'export': return 'vat_export'
case 'exempt': return 'vat_exempt'
default: return null
}
}
@@ -77,7 +78,8 @@ interface TemplateCardProps {
}
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
const vatLabel = getVatLabel(template)
const t = useTranslations('tx_template_picker')
const vatLabelKey = getVatLabelKey(template)
return (
<button
@@ -98,7 +100,7 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp
<span className="text-xs font-mono text-muted-foreground">
D: {formatAccountWithName(template.debit_account)} &middot; K: {formatAccountWithName(template.credit_account)}
</span>
{vatLabel && (
{vatLabelKey && (
<Badge
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
@@ -107,13 +109,13 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp
: ''
}`}
>
{vatLabel}
{t(vatLabelKey)}
</Badge>
)}
{template.requires_vat_registration_data && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-warning/30 text-warning-foreground gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Kräver momsreg.nr
{t('requires_vat_reg')}
</Badge>
)}
</div>
@@ -149,6 +151,7 @@ export default function TemplatePicker({
onSelectCounterparty,
selectedTemplateId,
}: TemplatePickerProps) {
const t = useTranslations('tx_template_picker')
const [searchQuery, setSearchQuery] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [libraryTemplates, setLibraryTemplates] = useState<BookingTemplate[]>([])
@@ -265,7 +268,7 @@ export default function TemplatePicker({
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Sök mall..."
placeholder={t('search_placeholder')}
className="pl-9 h-9"
/>
</div>
@@ -276,7 +279,7 @@ export default function TemplatePicker({
{searchResults !== null ? (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
{searchResults.length === 0 ? t('no_results') : t('n_results', { count: searchResults.length })}
</p>
<div className="space-y-1.5">
{searchResults.map((t) => (
@@ -296,7 +299,7 @@ export default function TemplatePicker({
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Building2 className="h-3 w-3" />
Mina mallar
{t('my_templates')}
</p>
<div className="space-y-1.5">
{relevantLibraryTemplates.map((t) => (
@@ -315,7 +318,7 @@ export default function TemplatePicker({
{/* Counterparty templates — learned from history */}
{hasCounterparty && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Tidigare motparter</p>
<p className="text-xs font-medium text-muted-foreground mb-2">{t('previous_counterparties')}</p>
<div className="space-y-1.5">
{counterpartySuggestions.slice(0, 3).map((s) => (
<button
@@ -354,7 +357,7 @@ export default function TemplatePicker({
{/* Suggested templates */}
{hasSuggestions && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
<p className="text-xs font-medium text-muted-foreground mb-2">{t('suggested')}</p>
<div className="space-y-1.5">
{resolvedSuggestions.slice(0, 5).map((s) => {
// Find the full template object
@@ -377,12 +380,12 @@ export default function TemplatePicker({
{/* Common templates grouped */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Vanliga mallar</p>
<p className="text-xs font-medium text-muted-foreground mb-2">{t('common_templates')}</p>
<div className="space-y-3">
{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
{t(GROUP_LABEL_KEYS[group])}
</p>
<div className="space-y-1.5">
{commonGrouped.get(group)!.map((t) => (
@@ -409,7 +412,7 @@ export default function TemplatePicker({
className="w-full justify-between text-xs text-muted-foreground h-8"
onClick={() => setShowAdvanced(!showAdvanced)}
>
Fler mallar ({allAdvanced.length})
{t('more_templates', { count: allAdvanced.length })}
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
@@ -421,7 +424,7 @@ export default function TemplatePicker({
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
{t(GROUP_LABEL_KEYS[group])}
</p>
<div className="space-y-1.5">
{advancedGrouped.get(group)!.map((t) => (
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -21,7 +22,7 @@ interface TransactionBookingDialogProps {
onBooked: (transactionId: string, journalEntryId: string) => void
}
function buildInitialLines(transaction: TransactionWithInvoice): FormLine[] {
function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] {
const sekAmount = Math.round(Math.abs(resolveSekAmount(
transaction.amount,
transaction.amount_sek,
@@ -44,7 +45,7 @@ function buildInitialLines(transaction: TransactionWithInvoice): FormLine[] {
account_number: '1930',
debit_amount: isExpense ? '' : amountStr,
credit_amount: isExpense ? amountStr : '',
line_description: 'Företagskonto',
line_description: bankLineDescription,
...currencyMeta,
}
@@ -64,6 +65,7 @@ export default function TransactionBookingDialog({
transaction,
onBooked,
}: TransactionBookingDialogProps) {
const t = useTranslations('tx_booking_dialog')
const { toast } = useToast()
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
@@ -90,8 +92,8 @@ export default function TransactionBookingDialog({
}
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('doc_link_failed_title'),
description: t('doc_link_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
@@ -112,9 +114,9 @@ export default function TransactionBookingDialog({
}}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
Skapa en verifikation för transaktionen
{t('description')}
</DialogDescription>
</DialogHeader>
@@ -152,10 +154,10 @@ export default function TransactionBookingDialog({
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag (valfritt)</span>
<span className="font-medium">{t('doc_label')}</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
</span>
)}
</div>
@@ -179,7 +181,7 @@ export default function TransactionBookingDialog({
<JournalEntryForm
key={transaction.id}
embedded
initialLines={buildInitialLines(transaction)}
initialLines={buildInitialLines(transaction, t('bank_line_description'))}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
+26 -22
View File
@@ -1,10 +1,11 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { format } from 'date-fns'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,16 +14,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Loader2 } from 'lucide-react'
import type { CreateTransactionInput, Currency } from '@/types'
const schema = z.object({
date: z.string().min(1, 'Datum krävs'),
description: z.string().min(1, 'Beskrivning krävs'),
amount: z.number().refine((n) => n !== 0, 'Belopp måste anges'),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface TransactionFormProps {
onSubmit: (data: CreateTransactionInput) => Promise<void>
isLoading: boolean
@@ -31,6 +22,19 @@ interface TransactionFormProps {
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export default function TransactionForm({ onSubmit, isLoading }: TransactionFormProps) {
const t = useTranslations('tx_form')
const schema = useMemo(
() =>
z.object({
date: z.string().min(1, t('date_required')),
description: z.string().min(1, t('description_required')),
amount: z.number().refine((n) => n !== 0, t('amount_required')),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
notes: z.string().optional(),
}),
[t]
)
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
@@ -67,14 +71,14 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Datum *</Label>
<Label htmlFor="date">{t('date_label')}</Label>
<Input id="date" type="date" {...register('date')} />
{errors.date && (
<p className="text-sm text-destructive">{errors.date.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency">Valuta</Label>
<Label htmlFor="currency">{t('currency_label')}</Label>
<Controller
name="currency"
control={control}
@@ -97,10 +101,10 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
</div>
<div className="space-y-2">
<Label htmlFor="description">Beskrivning *</Label>
<Label htmlFor="description">{t('description_label')}</Label>
<Input
id="description"
placeholder="T.ex. Adobe Creative Cloud"
placeholder={t('description_placeholder')}
{...register('description')}
/>
{errors.description && (
@@ -109,27 +113,27 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
</div>
<div className="space-y-2">
<Label htmlFor="amount">Belopp * (negativt för utgift)</Label>
<Label htmlFor="amount">{t('amount_label')}</Label>
<Input
id="amount"
type="number"
step="0.01"
placeholder="-500"
placeholder={t('amount_placeholder')}
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && (
<p className="text-sm text-destructive">{errors.amount.message}</p>
)}
<p className="text-xs text-muted-foreground">
Ange positivt belopp för intäkter, negativt för kostnader
{t('amount_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder="Valfria anteckningar..."
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
@@ -138,10 +142,10 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
'Spara transaktion'
t('save')
)}
</Button>
</form>
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -13,6 +14,7 @@ import {
ArrowUpRight,
ArrowDownRight,
ArrowLeftRight,
ChevronRight,
Check,
Landmark,
Link2,
@@ -60,6 +62,7 @@ export default function TransactionHistoryList({
isLoadingMore,
onLoadMore,
}: TransactionHistoryListProps) {
const t = useTranslations('tx_history')
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<HistoryFilter>('all')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
@@ -108,7 +111,7 @@ export default function TransactionHistoryList({
<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 transaktioner..."
placeholder={t('search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@@ -123,7 +126,7 @@ export default function TransactionHistoryList({
className="h-9"
onClick={() => setFilter(f)}
>
{f === 'all' ? 'Alla' : f === 'business' ? 'Företag' : 'Privat'}
{f === 'all' ? t('filter_all') : f === 'business' ? t('filter_business') : t('filter_private')}
</Button>
))}
</div>
@@ -131,7 +134,7 @@ export default function TransactionHistoryList({
{showSourceFilter && (
<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>
{(['all', 'bank', 'skatteverket'] as const).map((s) => (
<button
key={s}
@@ -144,7 +147,7 @@ export default function TransactionHistoryList({
)}
>
{s === 'skatteverket' && <Landmark className="h-3 w-3" />}
{s === 'all' ? 'Alla' : s === 'bank' ? 'Bank' : 'Skatteverket'}
{s === 'all' ? t('source_all') : s === 'bank' ? t('source_bank') : t('source_skatteverket')}
</button>
))}
</div>
@@ -155,11 +158,9 @@ export default function TransactionHistoryList({
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ArrowLeftRight className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga transaktioner</h3>
<h3 className="text-lg font-medium">{t('empty_title')}</h3>
<p className="text-muted-foreground text-center mt-1">
{searchTerm
? 'Inga transaktioner matchar din sökning'
: 'Inga transaktioner att visa med valt filter'}
{searchTerm ? t('empty_search') : t('empty_filter')}
</p>
</CardContent>
</Card>
@@ -193,10 +194,10 @@ export default function TransactionHistoryList({
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Laddar...
{t('loading_more')}
</>
) : (
'Ladda fler'
t('load_more')
)}
</Button>
</div>
@@ -218,6 +219,7 @@ function BankHistoryRow({
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
}) {
const t = useTranslations('tx_history')
// Viewers must not see write affordances. CorrectionAffordance opens a
// dialog that stages a storno + correction journal entry; the API path
// already 403s for viewers but rendering the trigger creates a confusing
@@ -259,31 +261,31 @@ function BankHistoryRow({
<Badge variant={transaction.is_business ? 'default' : 'secondary'}>
{transaction.is_business
? getCategoryDisplayName(transaction.category)
: 'Privat'}
: t('private_badge')}
</Badge>
</>
)}
{transaction.invoice_id && (
<>
<span>·</span>
<Badge variant="outline" className="text-primary border-primary">
<Badge variant="outline" className="border-primary/40 text-primary">
<Link2 className="h-3 w-3 mr-1" />
Kopplad till faktura
{t('linked_to_invoice')}
</Badge>
</>
)}
{transaction.journal_entry_id ? (
<>
<span>·</span>
<Badge variant="outline" className="text-success border-success">
<Badge variant="outline" className="border-success/40 text-success">
<Check className="h-3 w-3 mr-1" />
Bokförd
{t('posted')}
</Badge>
<Link
href={`/bookkeeping/${transaction.journal_entry_id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Visa verifikation
{t('view_voucher')}
</Link>
{canWrite && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
@@ -294,7 +296,7 @@ function BankHistoryRow({
disabled={isLoading}
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{isLoading ? 'Hämtar…' : 'Skapa ändringsverifikation'}
{isLoading ? t('fetching') : t('create_correction')}
</button>
)}
</CorrectionAffordance>
@@ -305,10 +307,11 @@ function BankHistoryRow({
<span>·</span>
<button
type="button"
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
className="group/post inline-flex items-center gap-1 rounded-md border border-warning/60 bg-background px-2.5 py-1 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
onClick={() => onOpenCategoryDialog(transaction)}
>
Ej bokförd
{t('not_posted')}
<ChevronRight className="h-3 w-3 transition-transform group-hover/post:translate-x-0.5" />
</button>
</>
)}
@@ -317,50 +320,41 @@ function BankHistoryRow({
<span>·</span>
<button
type="button"
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
className="inline-flex items-center rounded-md border border-primary/40 bg-transparent px-2.5 py-0.5 text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
onClick={() => onOpenMatchDialog(transaction)}
>
<FileText className="h-3 w-3 mr-1" />
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
{t('possible_match_invoice', { number: transaction.potential_invoice.invoice_number ?? '' })}
</button>
</>
)}
{!transaction.journal_entry_id && onDelete && (
<>
<span>·</span>
<button
type="button"
onClick={() => onDelete(transaction.id)}
aria-label={t('delete_aria')}
className="inline-flex items-center gap-1 rounded-md border border-destructive/30 bg-transparent px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="h-3 w-3" />
{t('delete')}
</button>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{!transaction.journal_entry_id && (
<Button
size="sm"
variant="default"
className="h-10 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
Bokför
</Button>
)}
{!transaction.journal_entry_id && onDelete && (
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(transaction.id)}
aria-label="Ta bort transaktion"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<div className="text-right">
<p className="font-medium tabular-nums">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
<div className="text-right tabular-nums shrink-0">
<p className="font-medium tabular-nums">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
)}
</div>
</div>
</CardContent>
@@ -377,6 +371,7 @@ function SkattekontoHistoryRow({
onBokfor?: (row: StoredSkattekontoTransaction) => void
onMatch?: (row: StoredSkattekontoTransaction) => void
}) {
const t = useTranslations('tx_history')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
const isBooked = !!row.journal_entry_id
@@ -404,70 +399,71 @@ function SkattekontoHistoryRow({
<span>·</span>
<Badge variant="outline" className="gap-1">
<Landmark className="h-3 w-3" />
Skatteverket
{t('skv_badge')}
</Badge>
{isBooked ? (
<>
<span>·</span>
<Badge variant="outline" className="text-success border-success">
<Badge variant="outline" className="border-success/40 text-success">
<Check className="h-3 w-3 mr-1" />
Bokförd
{t('posted')}
</Badge>
{row.journal_entry_id && (
<Link
href={`/bookkeeping/${row.journal_entry_id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t('view_voucher')}
</Link>
)}
</>
) : row.match_suggestion ? (
<>
<span>·</span>
<Badge variant="outline" className="border-warning text-warning">
Möjlig dublett
</Badge>
<button
type="button"
className="group/post inline-flex items-center gap-1 rounded-md border border-warning/60 bg-background px-2.5 py-1 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
onClick={() => onMatch?.(row)}
>
{t('possible_duplicate')}
<ChevronRight className="h-3 w-3 transition-transform group-hover/post:translate-x-0.5" />
</button>
</>
) : (
<>
<span>·</span>
<Badge variant="outline">Ej bokförd</Badge>
<button
type="button"
className="group/post inline-flex items-center gap-1 rounded-md border border-warning/60 bg-background px-2.5 py-1 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
onClick={() => onBokfor?.(row)}
>
{t('not_posted')}
<ChevronRight className="h-3 w-3 transition-transform group-hover/post:translate-x-0.5" />
</button>
{onMatch && (
<button
type="button"
onClick={() => onMatch(row)}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t('match')}
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{!isBooked && onMatch && (
<Button
size="sm"
variant={row.match_suggestion ? 'default' : 'outline'}
className="h-10 text-xs"
onClick={() => onMatch(row)}
>
<Link2 className="mr-1 h-3 w-3" />
{row.match_suggestion ? 'Koppla' : 'Matcha'}
</Button>
)}
{!isBooked && !row.match_suggestion && onBokfor && (
<Button
size="sm"
variant="default"
className="h-10 text-xs"
onClick={() => onBokfor(row)}
>
Bokför
</Button>
)}
{isBooked && (
<Button asChild size="sm" variant="ghost" className="h-10 text-xs">
<Link href={`/bookkeeping/${row.journal_entry_id}`}>Visa verifikat</Link>
</Button>
)}
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
<div className="text-right tabular-nums shrink-0">
<p
className={cn(
'font-medium tabular-nums',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
</div>
</CardContent>
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { motion } from 'framer-motion'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -8,7 +9,6 @@ import { Checkbox } from '@/components/ui/checkbox'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { AlertCircle, ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
@@ -57,6 +57,7 @@ export default function TransactionInboxCard({
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
@@ -98,17 +99,13 @@ export default function TransactionInboxCard({
)}
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
>
<CardContent className="py-4">
<CardContent className="py-3">
{skvCounterpartDate && (
<div className="mb-3 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-2 text-xs">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-warning" />
<p className="min-w-0">
<span className="font-medium">Möjlig 1930↔1630-överföring.</span>{' '}
Det finns en skattekonto-händelse den{' '}
<span className="tabular-nums">{skvCounterpartDate}</span> som
matchar — bokför detta verifikat först, koppla sedan
skattekonto-raden mot samma verifikat istället för att bokföra
två gånger.
<span className="font-medium">{t('skv_counterpart_label')}</span>{' '}
{t('skv_counterpart_body', { date: skvCounterpartDate })}
</p>
</div>
)}
@@ -158,7 +155,7 @@ export default function TransactionInboxCard({
{/* Inline action buttons - only shown when not in batch mode */}
{!isBatchMode && (
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t">
<div className="flex flex-wrap items-center gap-2 mt-2">
{/* Primary action: invoice match or top suggestion */}
{hasInvoiceMatch ? (
<Button
@@ -173,7 +170,7 @@ export default function TransactionInboxCard({
) : (
<FileText className="mr-1.5 h-3 w-3 flex-shrink-0" />
)}
Matcha Faktura {transaction.potential_invoice!.invoice_number}
{t('match_invoice_btn', { number: transaction.potential_invoice!.invoice_number ?? '' })}
</Button>
) : hasSupplierInvoiceMatch ? (
<Button
@@ -188,7 +185,7 @@ export default function TransactionInboxCard({
) : (
<FileText className="mr-1.5 h-3 w-3 flex-shrink-0" />
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
{t('match_supplier_invoice_btn', { number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '' })}
</Button>
) : templateSuggestions && templateSuggestions.length > 0 ? (
<>
@@ -199,8 +196,8 @@ export default function TransactionInboxCard({
<Button
key={ts.template_id}
size="sm"
variant={idx === 0 ? 'default' : 'outline'}
className="h-auto py-1.5 text-xs"
variant={idx === 0 ? 'secondary' : 'outline'}
className="h-9 w-44 text-xs"
onClick={() => {
if (onOpenTemplateReview && (isCounterparty || tmpl)) {
onOpenTemplateReview(transaction, ts.template_id)
@@ -210,20 +207,10 @@ export default function TransactionInboxCard({
}}
disabled={isProcessing || isDisabled}
>
<div className="flex flex-col items-start">
<div className="flex items-center">
{isProcessing && idx === 0 ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{ts.name_sv}
</div>
<span className="opacity-70 font-normal text-[10px]">
{isCounterparty
? `${ts.description_sv}`
: getAccountName(tmpl?.debit_account || ts.debit_account)
}
</span>
</div>
{isProcessing && idx === 0 ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin flex-shrink-0" />
) : null}
<span className="truncate">{ts.name_sv}</span>
</Button>
)
})}
@@ -231,22 +218,17 @@ export default function TransactionInboxCard({
) : topSuggestion ? (
<Button
size="sm"
variant="default"
className="h-9 text-xs"
variant="secondary"
className="h-9 w-44 text-xs"
onClick={() => handleSuggestionClick(topSuggestion)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
<Loader2 className="mr-1.5 h-3 w-3 animate-spin flex-shrink-0" />
) : null}
{topSuggestion.label}
{topSuggestion.account && (
<span className="ml-1 opacity-70 font-normal">
({formatAccountWithName(topSuggestion.account)})
</span>
)}
<span className="truncate">{topSuggestion.label}</span>
{topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 flex-shrink-0">
{Math.round(topSuggestion.confidence * 100)}%
</Badge>
)}
@@ -256,12 +238,12 @@ export default function TransactionInboxCard({
{/* Open category dialog / template picker */}
<Button
size="sm"
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'default' : 'outline'}
className="h-9 text-xs"
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'secondary' : 'outline'}
className="h-9 w-44 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
Välj mall...
{t('choose_template_btn')}
</Button>
{/* Delete button — available for all unbooked transactions */}
@@ -272,7 +254,7 @@ export default function TransactionInboxCard({
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() => onDelete(transaction.id)}
disabled={isProcessing || isDisabled}
aria-label="Ta bort transaktion"
aria-label={t('delete_aria')}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
@@ -31,25 +32,26 @@ export default function TransactionStatusBar({
onToggleBatchMode,
}: TransactionStatusBarProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('transactions')
return (
<div className="space-y-4">
{/* Header with title + actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Transaktioner</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('page_title')}</h1>
{uncategorizedCount > 0 && mode === 'inbox' && (
<p className="text-muted-foreground mt-1">
<span className="text-foreground font-semibold">{uncategorizedCount}</span> att bokföra
<span className="text-foreground font-semibold">{uncategorizedCount}</span> {t('subtitle_to_post')}
{invoiceMatchCount > 0 && (
<span className="ml-2">
· <FileText className="inline h-3.5 w-3.5 text-primary" />{' '}
<span className="text-foreground font-semibold">{invoiceMatchCount} fakturamatchningar</span>
<span className="text-foreground font-semibold">{t('subtitle_matches', { count: invoiceMatchCount })}</span>
</span>
)}
</p>
)}
{mode === 'history' && (
<p className="text-muted-foreground">Alla dina transaktioner</p>
<p className="text-muted-foreground">{t('history_subtitle')}</p>
)}
</div>
@@ -57,7 +59,7 @@ export default function TransactionStatusBar({
<Button variant="outline" size="sm" asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera
{t('action_import')}
</Link>
</Button>
{mode === 'inbox' && uncategorizedCount > 0 && (
@@ -69,7 +71,7 @@ export default function TransactionStatusBar({
disabled={isLoadingSuggestions}
>
<Wand className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : 'Gå igenom alla'}
{isLoadingSuggestions ? t('action_review_loading') : t('action_review_all')}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
@@ -77,7 +79,7 @@ export default function TransactionStatusBar({
onClick={onToggleBatchMode}
>
<CheckSquare className="mr-2 h-4 w-4" />
{isBatchMode ? 'Avsluta' : 'Välj flera'}
{isBatchMode ? t('action_select_multi_end') : t('action_select_multi_start')}
</Button>
</>
)}
@@ -85,14 +87,14 @@ export default function TransactionStatusBar({
size="sm"
onClick={onOpenCreateDialog}
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 transaktion
{t('action_new_transaction')}
</Button>
</div>
</div>
@@ -105,7 +107,7 @@ export default function TransactionStatusBar({
className="h-8 rounded-md"
onClick={() => onModeChange('inbox')}
>
Att bokföra
{t('mode_inbox')}
{uncategorizedCount > 0 && (
<Badge
variant={mode === 'inbox' ? 'secondary' : 'outline'}
@@ -121,7 +123,7 @@ export default function TransactionStatusBar({
className="h-8 rounded-md"
onClick={() => onModeChange('history')}
>
Alla transaktioner
{t('mode_history')}
</Button>
</div>
</div>
@@ -2,6 +2,7 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
@@ -18,6 +19,7 @@ export default function VatTreatmentSelect({
onValueChange,
disabled,
}: VatTreatmentSelectProps) {
const t = useTranslations('tx_categories')
return (
<Select
value={value}
@@ -42,10 +44,10 @@ export default function VatTreatmentSelect({
</SelectPrimitive.ItemIndicator>
</span>
<div>
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
{opt.description && (
<SelectPrimitive.ItemText>{t(opt.labelKey)}</SelectPrimitive.ItemText>
{opt.descriptionKey && (
<p className="text-xs text-muted-foreground mt-0.5 font-normal">
{opt.description}
{t(opt.descriptionKey)}
</p>
)}
</div>
+31 -26
View File
@@ -27,50 +27,55 @@ export type MatchInvoiceHandler = (
invoiceId: string
) => Promise<boolean>
// Category option type
// Category option type. `label` retains the Swedish text for back-compat and
// non-React consumers; `labelKey` is the next-intl key under the `tx_categories`
// namespace that React components should prefer.
export interface CategoryOption {
value: TransactionCategory
label: string
labelKey: string
account?: string
}
// Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [
{ value: 'expense_representation', label: 'Representation', account: '6071' },
{ value: 'expense_equipment', label: 'Utrustning', account: '5410' },
{ value: 'expense_software', label: 'Programvara', account: '5420' },
{ value: 'expense_consumables', label: 'Material', account: '5460' },
{ value: 'expense_travel', label: 'Resor', account: '5800' },
{ value: 'expense_office', label: 'Kontor', account: '6110' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel', account: '5611' },
{ value: 'expense_telecom', label: 'Telefon & internet', account: '6200' },
{ value: 'expense_marketing', label: 'Marknadsföring', account: '5910' },
{ value: 'expense_professional_services', label: 'Konsulter', account: '6530' },
{ value: 'expense_education', label: 'Utbildning', account: '6991' },
{ value: 'expense_bank_fees', label: 'Bankavgift', account: '6570' },
{ value: 'expense_card_fees', label: 'Kortavgift', account: '6570' },
{ value: 'expense_currency_exchange', label: 'Valutaväxling', account: '7960' },
{ value: 'expense_other', label: 'Övrigt', account: '6991' },
{ value: 'expense_representation', label: 'Representation', labelKey: 'expense_representation', account: '6071' },
{ value: 'expense_equipment', label: 'Utrustning', labelKey: 'expense_equipment', account: '5410' },
{ value: 'expense_software', label: 'Programvara', labelKey: 'expense_software', account: '5420' },
{ value: 'expense_consumables', label: 'Material', labelKey: 'expense_consumables', account: '5460' },
{ value: 'expense_travel', label: 'Resor', labelKey: 'expense_travel', account: '5800' },
{ value: 'expense_office', label: 'Kontor', labelKey: 'expense_office', account: '6110' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel', labelKey: 'expense_vehicle', account: '5611' },
{ value: 'expense_telecom', label: 'Telefon & internet', labelKey: 'expense_telecom', account: '6200' },
{ value: 'expense_marketing', label: 'Marknadsföring', labelKey: 'expense_marketing', account: '5910' },
{ value: 'expense_professional_services', label: 'Konsulter', labelKey: 'expense_professional_services', account: '6530' },
{ value: 'expense_education', label: 'Utbildning', labelKey: 'expense_education', account: '6991' },
{ value: 'expense_bank_fees', label: 'Bankavgift', labelKey: 'expense_bank_fees', account: '6570' },
{ value: 'expense_card_fees', label: 'Kortavgift', labelKey: 'expense_card_fees', account: '6570' },
{ value: 'expense_currency_exchange', label: 'Valutaväxling', labelKey: 'expense_currency_exchange', account: '7960' },
{ value: 'expense_other', label: 'Övrigt', labelKey: 'expense_other', account: '6991' },
]
export const INCOME_CATEGORIES: CategoryOption[] = [
{ value: 'income_services', label: 'Tjänster', account: '3001' },
{ value: 'income_products', label: 'Produkter', account: '3001' },
{ value: 'income_other', label: 'Övrigt', account: '3900' },
{ value: 'income_services', label: 'Tjänster', labelKey: 'income_services', account: '3001' },
{ value: 'income_products', label: 'Produkter', labelKey: 'income_products', account: '3001' },
{ value: 'income_other', label: 'Övrigt', labelKey: 'income_other', account: '3900' },
]
export interface VatTreatmentOption {
value: VatTreatment | 'none'
label: string
labelKey: string
description?: string
descriptionKey?: string
}
export const VAT_TREATMENT_OPTIONS: VatTreatmentOption[] = [
{ value: 'standard_25', label: 'Moms 25%' },
{ value: 'reduced_12', label: 'Moms 12%', description: 'Livsmedel, hotell, camping' },
{ value: 'reduced_6', label: 'Moms 6%', description: 'Böcker, tidningar, kollektivtrafik' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet', description: 'Köparen redovisar momsen (EU-tjänster m.m.)' },
{ value: 'export', label: 'Export', description: 'Försäljning utanför EU (behåller avdragsrätt)' },
{ value: 'exempt', label: 'Momsfri', description: 'Undantaget enligt ML (vård, utbildning, finans)' },
{ value: 'none', label: 'Ingen moms', description: 'Ej momspliktigt (t.ex. lön, privata uttag)' },
{ value: 'standard_25', label: 'Moms 25%', labelKey: 'vat_standard_25' },
{ value: 'reduced_12', label: 'Moms 12%', labelKey: 'vat_reduced_12', description: 'Livsmedel, hotell, camping', descriptionKey: 'vat_reduced_12_desc' },
{ value: 'reduced_6', label: 'Moms 6%', labelKey: 'vat_reduced_6', description: 'Böcker, tidningar, kollektivtrafik', descriptionKey: 'vat_reduced_6_desc' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet', labelKey: 'vat_reverse_charge', description: 'Köparen redovisar momsen (EU-tjänster m.m.)', descriptionKey: 'vat_reverse_charge_desc' },
{ value: 'export', label: 'Export', labelKey: 'vat_export', description: 'Försäljning utanför EU (behåller avdragsrätt)', descriptionKey: 'vat_export_desc' },
{ value: 'exempt', label: 'Momsfri', labelKey: 'vat_exempt', description: 'Undantaget enligt ML (vård, utbildning, finans)', descriptionKey: 'vat_exempt_desc' },
{ value: 'none', label: 'Ingen moms', labelKey: 'vat_none', description: 'Ej momspliktigt (t.ex. lön, privata uttag)', descriptionKey: 'vat_none_desc' },
]
+34 -25
View File
@@ -2,6 +2,7 @@
import * as React from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
@@ -32,7 +33,7 @@ interface EmptyStateProps {
}
/**
* EmptyState - Visar ett vänligt meddelande när det inte finns någon data
* EmptyState — friendly placeholder shown when there is no data.
*/
export function EmptyState({
icon: Icon,
@@ -47,6 +48,7 @@ export function EmptyState({
className,
children,
}: EmptyStateProps) {
const t = useTranslations('empty')
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
{Icon && (
@@ -61,8 +63,8 @@ export function EmptyState({
{supportHint && (
<div className="mb-6">
<SupportLink variant="muted" subject="Behöver hjälp att komma igång">
Behöver du hjälp? Kontakta support
<SupportLink variant="muted" subject={t('support_hint_subject')}>
{t('support_hint_label')}
</SupportLink>
</div>
)}
@@ -95,27 +97,29 @@ export function EmptyState({
)
}
// Förkonfigurerade tomma tillstånd för vanliga sidor
// Preset empty states for common pages
export function EmptyInvoices() {
const t = useTranslations('empty')
return (
<EmptyState
icon={Receipt}
title="Inga fakturor ännu"
description="Skapa din första faktura på under 60 sekunder. Vi fyller i dina uppgifter automatiskt."
actionLabel="Skapa faktura"
title={t('preset_invoices_title')}
description={t('preset_invoices_description')}
actionLabel={t('preset_invoices_action')}
actionHref="/invoices/new"
/>
)
}
export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
const t = useTranslations('empty')
return (
<EmptyState
icon={Users}
title="Inga kunder ännu"
description="Lägg till dina kunder för att enkelt skapa fakturor och hålla koll på betalningar."
actionLabel="Lägg till kund"
title={t('preset_customers_title')}
description={t('preset_customers_description')}
actionLabel={t('preset_customers_action')}
actionHref={onAction ? undefined : '/customers/new'}
onAction={onAction}
/>
@@ -123,12 +127,13 @@ export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
}
export function EmptyTransactions() {
const t = useTranslations('empty')
return (
<EmptyState
icon={ArrowLeftRight}
title="Inga transaktioner"
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få koll på ekonomin."
actionLabel="Importera transaktioner"
title={t('preset_transactions_title')}
description={t('preset_transactions_description')}
actionLabel={t('preset_transactions_action')}
actionHref="/import"
supportHint
/>
@@ -136,34 +141,37 @@ export function EmptyTransactions() {
}
export function EmptyReceipts() {
const t = useTranslations('empty')
return (
<EmptyState
icon={Camera}
title="Inga kvitton"
description="Ta en bild på ett kvitto för automatisk avläsning och bokföring. Vi sköter resten!"
actionLabel="Skanna kvitto"
title={t('preset_receipts_title')}
description={t('preset_receipts_description')}
actionLabel={t('preset_receipts_action')}
actionHref="/receipts/scan"
/>
)
}
export function EmptyDeadlines() {
const t = useTranslations('empty')
return (
<EmptyState
icon={Calendar}
title="Inga kommande deadlines"
description="Bra jobbat! Du har inga omedelbara deadlines att ta hand om."
title={t('preset_deadlines_title')}
description={t('preset_deadlines_description')}
/>
)
}
export function NoBankConnected() {
const t = useTranslations('empty')
return (
<EmptyState
icon={Building2}
title="Inga transaktioner importerade"
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
actionLabel="Importera transaktioner"
title={t('preset_no_bank_title')}
description={t('preset_no_bank_description')}
actionLabel={t('preset_no_bank_action')}
actionHref="/import"
supportHint
/>
@@ -171,14 +179,15 @@ export function NoBankConnected() {
}
export function EmptyReports() {
const t = useTranslations('empty')
return (
<EmptyState
icon={FileText}
title="Inga rapporter tillgängliga"
description="Rapporter genereras automatiskt när du har tillräckligt med data. Börja med att skapa fakturor eller importera transaktioner."
actionLabel="Skapa faktura"
title={t('preset_reports_title')}
description={t('preset_reports_description')}
actionLabel={t('preset_reports_action')}
actionHref="/invoices/new"
secondaryActionLabel="Importera transaktioner"
secondaryActionLabel={t('preset_reports_secondary')}
secondaryActionHref="/import"
/>
)
+12 -15
View File
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getBranding } from '@/lib/branding/service'
@@ -21,41 +22,37 @@ interface RetentionNoticeProps {
* hides it from the UI and anonymizes PII where applicable.
*/
export function RetentionNotice({ variant, className }: RetentionNoticeProps) {
const t = useTranslations('retention_notice')
const { appName } = getBranding()
const copy =
variant === 'company'
? {
title: 'Bokföringen behålls i 7 år',
title: t('company_title'),
body: (
<>
Enligt bokföringslagen (BFL 7 kap. 2§) sparas räkenskapsinformation i 7 år.
När du raderar företaget döljs det i {appName.toLowerCase()}, men verifikationer, dokument och
bokföring behålls säkert tills lagkravet löpt ut. Du kan{' '}
{t('company_body_prefix', { appName: appName.toLowerCase() })}
<Link
href="/settings/backup"
className="underline underline-offset-2 hover:text-foreground"
>
ladda ner en säkerhetsbackup
</Link>{' '}
innan du fortsätter.
{t('company_body_link')}
</Link>
{t('company_body_suffix')}
</>
),
}
: {
title: 'Kontoraderingen är permanent',
title: t('account_title'),
body: (
<>
Ditt konto avidentifieras och du loggas ut från alla enheter.
Räkenskapsinformation från företag du ägt behålls säkert i 7 år enligt BFL 7
kap. 2§. Du kan inte skapa ett nytt konto med samma e-postadress — kontakta
support om du vill återaktivera kontot i framtiden. Ladda gärna ner en{' '}
{t('account_body_prefix')}
<Link
href="/settings/backup"
className="underline underline-offset-2 hover:text-foreground"
>
säkerhetsbackup
</Link>{' '}
innan du fortsätter.
{t('account_body_link')}
</Link>
{t('account_body_suffix')}
</>
),
}
+14 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Mail, Loader2, Send } from 'lucide-react'
import {
@@ -31,6 +32,7 @@ export function SupportLink({
children,
className,
}: SupportLinkProps) {
const t = useTranslations('support_link')
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [isSending, setIsSending] = useState(false)
@@ -57,8 +59,8 @@ export function SupportLink({
}, 2000)
} else {
toast({
title: 'Kunde inte skicka',
description: result.error || 'Försök igen.',
title: t('send_failed_title'),
description: result.error || t('send_failed_fallback'),
variant: 'destructive',
})
}
@@ -82,7 +84,7 @@ export function SupportLink({
)}
>
<Mail className="h-3 w-3" />
{children ?? 'Kontakta support'}
{children ?? t('default_label')}
</button>
) : (
<button
@@ -92,7 +94,7 @@ export function SupportLink({
className
)}
>
{children ?? 'Kontakta support'}
{children ?? t('default_label')}
</button>
)
@@ -101,9 +103,9 @@ export function SupportLink({
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Kontakta support</DialogTitle>
<DialogTitle>{t('dialog_title')}</DialogTitle>
<DialogDescription>
Beskriv ditt ärende så återkommer vi så snart vi kan.
{t('dialog_description')}
</DialogDescription>
</DialogHeader>
@@ -112,21 +114,21 @@ export function SupportLink({
<div className="p-3 rounded-full bg-success/10">
<Send className="h-6 w-6 text-success" />
</div>
<p className="text-sm font-medium">Tack! Vi har mottagit ditt meddelande.</p>
<p className="text-sm font-medium">{t('thanks')}</p>
</div>
) : (
<form onSubmit={handleSubmit}>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Beskriv vad du behöver hjälp med..."
placeholder={t('placeholder')}
className="min-h-[120px] resize-none"
maxLength={5000}
disabled={isSending}
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1.5">
{message.length}/5000 tecken
{t('char_count', { count: message.length })}
</p>
<DialogFooter className="mt-4">
<Button
@@ -135,7 +137,7 @@ export function SupportLink({
onClick={() => setOpen(false)}
disabled={isSending}
>
Avbryt
{t('cancel')}
</Button>
<Button
type="submit"
@@ -144,12 +146,12 @@ export function SupportLink({
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
{t('sending')}
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Skicka
{t('send')}
</>
)}
</Button>