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:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -35,36 +36,31 @@ interface ReferenceAccount extends BASReferenceAccount {
|
||||
is_system_account: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLASS_LABELS: Record<number, string> = {
|
||||
1: 'Tillgångar',
|
||||
2: 'Eget kapital och skulder',
|
||||
3: 'Rörelseintäkter',
|
||||
4: 'Varuinköp och material',
|
||||
5: 'Övriga externa kostnader',
|
||||
6: 'Övriga externa kostnader',
|
||||
7: 'Personalkostnader och avskrivningar',
|
||||
8: 'Finansiella poster och resultat',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
asset: 'Tillgång',
|
||||
liability: 'Skuld',
|
||||
equity: 'EK',
|
||||
revenue: 'Intakt',
|
||||
expense: 'Kostnad',
|
||||
untaxed_reserves: 'Ob. reserver',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChartOfAccountsManager() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
|
||||
const classLabel = (cls: number): string => {
|
||||
const key = `class_${cls}` as const
|
||||
try {
|
||||
return t(key)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (type: string): string => {
|
||||
const key = `type_${type}` as const
|
||||
try {
|
||||
return t(key)
|
||||
} catch {
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
// View state
|
||||
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
|
||||
@@ -142,17 +138,17 @@ export default function ChartOfAccountsManager() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: !account.is_active }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
|
||||
if (!res.ok) throw new Error(t('toast_update_failed'))
|
||||
await refreshAll()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte uppdatera kontot', variant: 'destructive' })
|
||||
toast({ title: t('toast_update_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setTogglingAccount(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(account: BASAccount) {
|
||||
const confirmed = window.confirm(`Vill du ta bort konto ${account.account_number} ${account.account_name}?`)
|
||||
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
|
||||
if (!confirmed) return
|
||||
setDeletingAccount(account.account_number)
|
||||
try {
|
||||
@@ -161,13 +157,13 @@ export default function ChartOfAccountsManager() {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Kunde inte ta bort kontot')
|
||||
throw new Error(data.error || t('toast_delete_failed'))
|
||||
}
|
||||
toast({ title: 'Konto borttaget', description: `${account.account_number} ${account.account_name}` })
|
||||
toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` })
|
||||
await refreshAll()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
|
||||
title: err instanceof Error ? err.message : t('toast_delete_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -183,14 +179,14 @@ export default function ChartOfAccountsManager() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_numbers: [accountNumber] }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Kunde inte aktivera kontot')
|
||||
if (!res.ok) throw new Error(t('toast_activate_failed'))
|
||||
const { activated } = await res.json()
|
||||
if (activated > 0) {
|
||||
toast({ title: 'Konto aktiverat', description: `Konto ${accountNumber} har lagts till i din kontoplan` })
|
||||
toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) })
|
||||
}
|
||||
await refreshAll()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte aktivera kontot', variant: 'destructive' })
|
||||
toast({ title: t('toast_activate_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setActivatingAccounts((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -271,7 +267,7 @@ export default function ChartOfAccountsManager() {
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
Laddar kontoplan...
|
||||
{t('loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -290,14 +286,14 @@ export default function ChartOfAccountsManager() {
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="my-accounts">
|
||||
Mina konton
|
||||
{t('tab_my_accounts')}
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{accounts.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bas-catalog">
|
||||
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||
BAS-katalog
|
||||
{t('tab_bas_catalog')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -305,7 +301,7 @@ export default function ChartOfAccountsManager() {
|
||||
{view === 'my-accounts' && (
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Eget konto
|
||||
{t('add_own')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -316,7 +312,7 @@ export default function ChartOfAccountsManager() {
|
||||
onCheckedChange={setHideK2Excluded}
|
||||
className="scale-75"
|
||||
/>
|
||||
<span className="text-muted-foreground">Dölj K2-undantagna</span>
|
||||
<span className="text-muted-foreground">{t('hide_k2_excluded')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -325,7 +321,7 @@ export default function ChartOfAccountsManager() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök konto (nummer eller namn)..."
|
||||
placeholder={t('search_placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
@@ -355,7 +351,7 @@ export default function ChartOfAccountsManager() {
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-left">
|
||||
Klass {cls}: {CLASS_LABELS[classNum] || ''}
|
||||
{t('class_heading', { cls, label: classLabel(classNum) })}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeCount}/{classAccounts.length}
|
||||
@@ -368,11 +364,11 @@ export default function ChartOfAccountsManager() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
<th className="py-2 w-24 text-center">Typ</th>
|
||||
<th className="py-2 w-16 text-center">Aktiv</th>
|
||||
<th className="py-2 w-24">{t('col_account')}</th>
|
||||
<th className="py-2">{t('col_name')}</th>
|
||||
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
||||
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
||||
<th className="py-2 w-16 text-center">{t('col_active')}</th>
|
||||
<th className="py-2 w-20 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -392,7 +388,7 @@ export default function ChartOfAccountsManager() {
|
||||
{account.account_name}
|
||||
{account.is_system_account && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||
System
|
||||
{t('system_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
@@ -404,7 +400,7 @@ export default function ChartOfAccountsManager() {
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[account.account_type] || account.account_type}
|
||||
{typeLabel(account.account_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
@@ -455,7 +451,7 @@ export default function ChartOfAccountsManager() {
|
||||
{filteredAccounts.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
{searchQuery ? 'Inga konton matchar sökningen' : 'Inga konton i kontoplanen'}
|
||||
{searchQuery ? t('no_matches') : t('no_accounts')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -485,10 +481,10 @@ export default function ChartOfAccountsManager() {
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-left">
|
||||
Klass {cls}: {CLASS_LABELS[classNum] || ''}
|
||||
{t('class_heading', { cls, label: classLabel(classNum) })}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activatedCount}/{classAccounts.length} aktiva
|
||||
{t('active_count_label', { active: activatedCount, total: classAccounts.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
@@ -498,11 +494,11 @@ export default function ChartOfAccountsManager() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
<th className="py-2 w-24 text-center">Typ</th>
|
||||
<th className="py-2 w-28 text-right">Status</th>
|
||||
<th className="py-2 w-24">{t('col_account')}</th>
|
||||
<th className="py-2">{t('col_name')}</th>
|
||||
<th className="py-2 w-20 text-center">{t('col_sru')}</th>
|
||||
<th className="py-2 w-24 text-center">{t('col_type')}</th>
|
||||
<th className="py-2 w-28 text-right">{t('col_status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -533,14 +529,14 @@ export default function ChartOfAccountsManager() {
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABELS[account.account_type] || account.account_type}
|
||||
{typeLabel(account.account_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{account.is_activated ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Aktiverat
|
||||
{t('activated')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
@@ -555,7 +551,7 @@ export default function ChartOfAccountsManager() {
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Lägg till
|
||||
{t('add')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
@@ -572,7 +568,7 @@ export default function ChartOfAccountsManager() {
|
||||
{filteredReference.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga konton matchar sökningen
|
||||
{t('no_matches')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Info } from 'lucide-react'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
@@ -12,14 +13,17 @@ interface Props {
|
||||
chain: JournalEntry[]
|
||||
}
|
||||
|
||||
function getRole(entry: JournalEntry): { label: string; color: string } {
|
||||
if (entry.source_type === 'storno') {
|
||||
return { label: 'Storno', color: 'bg-destructive' }
|
||||
function useGetRole() {
|
||||
const t = useTranslations('journal_correction')
|
||||
return (entry: JournalEntry): { label: string; color: string } => {
|
||||
if (entry.source_type === 'storno') {
|
||||
return { label: t('role_storno'), color: 'bg-destructive' }
|
||||
}
|
||||
if (entry.source_type === 'correction') {
|
||||
return { label: t('role_correction'), color: 'bg-primary' }
|
||||
}
|
||||
return { label: t('role_original'), color: 'bg-muted-foreground' }
|
||||
}
|
||||
if (entry.source_type === 'correction') {
|
||||
return { label: 'Rättelse', color: 'bg-primary' }
|
||||
}
|
||||
return { label: 'Original', color: 'bg-muted-foreground' }
|
||||
}
|
||||
|
||||
function getTotal(entry: JournalEntry): number {
|
||||
@@ -28,6 +32,8 @@ function getTotal(entry: JournalEntry): number {
|
||||
}
|
||||
|
||||
export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
const t = useTranslations('journal_correction')
|
||||
const getRole = useGetRole()
|
||||
if (chain.length === 0) return null
|
||||
|
||||
// Combine current entry isn't in chain — chain is "other" entries
|
||||
@@ -38,14 +44,11 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Ändringskedja</h3>
|
||||
<h3 className="text-sm font-medium">{t('title')}</h3>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Bokförda verifikationer kan inte ändras direkt. Istället skapas en stornoverifikation
|
||||
som nollställer den ursprungliga, och en ny rättelsepost med de korrekta uppgifterna.
|
||||
</p>
|
||||
<p>{t('info')}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-0">
|
||||
@@ -76,7 +79,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={false} />
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Aktuell
|
||||
{t('current')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
|
||||
|
||||
@@ -40,6 +40,22 @@ function isImageType(type: string): boolean {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a Swedish error message from the structured error envelope
|
||||
* returned by /api/documents. Falls back to message_en or null if the
|
||||
* shape is unexpected.
|
||||
*/
|
||||
function extractErrorMessage(err: unknown): string | null {
|
||||
if (typeof err === 'string') return err
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { message?: unknown; message_en?: unknown; code?: unknown }
|
||||
if (typeof e.message === 'string' && e.message.length > 0) return e.message
|
||||
if (typeof e.message_en === 'string' && e.message_en.length > 0) return e.message_en
|
||||
if (typeof e.code === 'string') return e.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function DocumentUploadZone({
|
||||
files,
|
||||
onFilesChange,
|
||||
@@ -64,15 +80,39 @@ export default function DocumentUploadZone({
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const result = await res.json()
|
||||
|
||||
if (result.error) {
|
||||
return { ...file, status: 'error', error: result.error }
|
||||
// Try to parse JSON, but tolerate non-JSON responses (auth redirect HTML, 502 etc.)
|
||||
let result: { data?: { id?: string }; error?: unknown } = {}
|
||||
try {
|
||||
result = await res.json()
|
||||
} catch {
|
||||
console.warn('[DocumentUploadZone] Non-JSON response', {
|
||||
status: res.status,
|
||||
fileName: file.fileName,
|
||||
})
|
||||
const reason = res.status === 401 || res.status === 403
|
||||
? 'Din session har gått ut. Ladda om sidan och logga in igen.'
|
||||
: `Servern svarade ${res.status}.`
|
||||
return { ...file, status: 'error', error: reason }
|
||||
}
|
||||
|
||||
if (!res.ok || result.error) {
|
||||
const errMessage = extractErrorMessage(result.error) || `Uppladdning misslyckades (${res.status})`
|
||||
console.warn('[DocumentUploadZone] Upload error', {
|
||||
status: res.status,
|
||||
error: result.error,
|
||||
fileName: file.fileName,
|
||||
})
|
||||
return { ...file, status: 'error', error: errMessage }
|
||||
}
|
||||
|
||||
return { ...file, status: 'uploaded', id: result.data?.id }
|
||||
} catch {
|
||||
return { ...file, status: 'error', error: 'Uppladdning misslyckades' }
|
||||
} catch (err) {
|
||||
console.error('[DocumentUploadZone] Upload threw', {
|
||||
error: err,
|
||||
fileName: file.fileName,
|
||||
})
|
||||
return { ...file, status: 'error', error: 'Uppladdning misslyckades — nätverksfel' }
|
||||
}
|
||||
}, [journalEntryId])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
@@ -43,6 +44,7 @@ export default function JournalEntryAttachments({
|
||||
journalEntryId,
|
||||
onCountChange,
|
||||
}: JournalEntryAttachmentsProps) {
|
||||
const t = useTranslations('journal_attachments')
|
||||
const [documents, setDocuments] = useState<DocumentRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
|
||||
@@ -122,7 +124,7 @@ export default function JournalEntryAttachments({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
Laddar underlag...
|
||||
{t('loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -131,7 +133,7 @@ export default function JournalEntryAttachments({
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Underlag {documents.length > 0 && `(${documents.length})`}
|
||||
{t('title')} {documents.length > 0 && `(${documents.length})`}
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -140,7 +142,7 @@ export default function JournalEntryAttachments({
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till underlag
|
||||
{t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +161,7 @@ export default function JournalEntryAttachments({
|
||||
{/* Document list */}
|
||||
{documents.length === 0 && !showUpload ? (
|
||||
<p className="text-sm text-muted-foreground py-1">
|
||||
Inga underlag bifogade.
|
||||
{t('empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -199,7 +201,7 @@ export default function JournalEntryAttachments({
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
|
||||
onClick={() => handleDownload(doc.id)}
|
||||
title="Ladda ner"
|
||||
title={t('download')}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -77,6 +78,7 @@ export default function JournalEntryForm({
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('journal_form')
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
|
||||
@@ -379,16 +381,16 @@ export default function JournalEntryForm({
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: 'Underlag kunde inte bifogas',
|
||||
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
|
||||
title: t('toast_attach_failed_title'),
|
||||
description: t('toast_attach_failed_description', { count: linkFailCount }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Verifikation skapad',
|
||||
description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`,
|
||||
title: t('toast_created_title'),
|
||||
description: t('toast_created_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }),
|
||||
})
|
||||
setShowReview(false)
|
||||
setDescription('')
|
||||
@@ -408,7 +410,7 @@ export default function JournalEntryForm({
|
||||
} else {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte skapa verifikation',
|
||||
title: t('toast_create_failed'),
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -442,8 +444,8 @@ export default function JournalEntryForm({
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Utkast sparat',
|
||||
description: 'Utkastet kan bokföras från bokföringssidan.',
|
||||
title: t('toast_draft_saved_title'),
|
||||
description: t('toast_draft_saved_description'),
|
||||
})
|
||||
setDescription('')
|
||||
setNotes('')
|
||||
@@ -462,7 +464,7 @@ export default function JournalEntryForm({
|
||||
} else {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte spara utkast',
|
||||
title: t('toast_save_draft_failed'),
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -483,10 +485,10 @@ export default function JournalEntryForm({
|
||||
: 'sm:grid-cols-[1fr_auto_1fr_3.5rem]'
|
||||
}`}>
|
||||
<div>
|
||||
<Label>Räkenskapsår</Label>
|
||||
<Label>{t('fiscal_year')}</Label>
|
||||
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Välj period" />
|
||||
<SelectValue placeholder={t('fiscal_year_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periods.map((p) => (
|
||||
@@ -499,7 +501,7 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
{!(embedded && initialDate) && (
|
||||
<div>
|
||||
<Label>Datum</Label>
|
||||
<Label>{t('date')}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={entryDate}
|
||||
@@ -508,19 +510,19 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Beskrivning</Label>
|
||||
<Label>{t('description')}</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Verifikationstext..."
|
||||
placeholder={t('description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={embedded ? 'hidden' : 'col-span-full'}>
|
||||
<Label>Intern anteckning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
|
||||
<Label>{t('internal_note')} <span className="text-muted-foreground font-normal">{t('internal_note_optional')}</span></Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="T.ex. anledning till bokning, referens till mejl, etc."
|
||||
placeholder={t('internal_note_placeholder')}
|
||||
className="mt-1 resize-none"
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
@@ -528,7 +530,7 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div>
|
||||
<Label>Serie</Label>
|
||||
<Label>{t('series')}</Label>
|
||||
<Input
|
||||
value={voucherSeries}
|
||||
onChange={(e) => {
|
||||
@@ -554,8 +556,8 @@ export default function JournalEntryForm({
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-warning-foreground">
|
||||
<p className="font-medium">Inget räkenskapsår matchar datumet {entryDate}</p>
|
||||
<p className="mt-0.5">Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.</p>
|
||||
<p className="font-medium">{t('no_period_warning', { date: entryDate })}</p>
|
||||
<p className="mt-0.5">{t('no_period_help')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -564,7 +566,7 @@ export default function JournalEntryForm({
|
||||
className="shrink-0"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Skapa räkenskapsår
|
||||
{t('create_period')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -572,7 +574,7 @@ export default function JournalEntryForm({
|
||||
{/* Currency section */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-24">
|
||||
<Label className="text-xs text-muted-foreground">Valuta</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('currency')}</Label>
|
||||
<Select value={entryCurrency} onValueChange={(v) => {
|
||||
setEntryCurrency(v as Currency)
|
||||
if (v === 'SEK') {
|
||||
@@ -594,7 +596,7 @@ export default function JournalEntryForm({
|
||||
<>
|
||||
<div className="w-40">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Omräkningskurs (1 {entryCurrency} = ? SEK)
|
||||
{t('exchange_rate_label', { currency: entryCurrency })}
|
||||
</Label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
@@ -613,7 +615,7 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Belopp i {entryCurrency}
|
||||
{t('amount_in_currency_label', { currency: entryCurrency })}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -659,11 +661,11 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Radtext..."
|
||||
placeholder={t('line_description_placeholder')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Debet</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('col_debit')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
@@ -676,7 +678,7 @@ export default function JournalEntryForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Kredit</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('col_credit')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
@@ -694,13 +696,13 @@ export default function JournalEntryForm({
|
||||
|
||||
{/* Mobile totals */}
|
||||
<div className="flex justify-between items-center px-1 pt-2 font-semibold text-sm">
|
||||
<span>Summa</span>
|
||||
<span>{t('sum')}</span>
|
||||
<div className="flex gap-4">
|
||||
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
|
||||
D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
{t('sum_d', { amount: totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
|
||||
</span>
|
||||
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
|
||||
K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
{t('sum_k', { amount: totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -713,7 +715,7 @@ export default function JournalEntryForm({
|
||||
className="flex-1"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
{t('add_line')}
|
||||
</Button>
|
||||
<BookingTemplatePicker
|
||||
onApply={handleTemplateApply}
|
||||
@@ -727,10 +729,10 @@ export default function JournalEntryForm({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-28">Konto</th>
|
||||
<th className="py-2 px-1">Beskrivning</th>
|
||||
<th className="py-2 w-32 px-1 text-right">Debet</th>
|
||||
<th className="py-2 w-32 px-1 text-right">Kredit</th>
|
||||
<th className="py-2 w-28">{t('col_account')}</th>
|
||||
<th className="py-2 px-1">{t('col_description')}</th>
|
||||
<th className="py-2 w-32 px-1 text-right">{t('col_debit')}</th>
|
||||
<th className="py-2 w-32 px-1 text-right">{t('col_credit')}</th>
|
||||
<th className="py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -748,7 +750,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Radtext..."
|
||||
placeholder={t('line_description_placeholder')}
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
@@ -793,7 +795,7 @@ export default function JournalEntryForm({
|
||||
<tfoot>
|
||||
<tr className="font-semibold">
|
||||
<td colSpan={2} className="py-2 px-1">
|
||||
Summa
|
||||
{t('sum')}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-1 text-right ${
|
||||
@@ -821,7 +823,7 @@ export default function JournalEntryForm({
|
||||
onClick={addLine}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
{t('add_line')}
|
||||
</Button>
|
||||
<BookingTemplatePicker
|
||||
onApply={handleTemplateApply}
|
||||
@@ -833,7 +835,7 @@ export default function JournalEntryForm({
|
||||
{/* Document attachments */}
|
||||
{!embedded && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Underlag</Label>
|
||||
<Label className="mb-2 block">{t('attachments_label')}</Label>
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
@@ -843,7 +845,7 @@ export default function JournalEntryForm({
|
||||
|
||||
{!isBalanced && totalDebit > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Differens: {formatCurrency(Math.abs(totalDebit - totalCredit))}
|
||||
{t('difference', { amount: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -853,31 +855,31 @@ export default function JournalEntryForm({
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : 'Sparar som utkast utan att tilldela verifikationsnummer'}
|
||||
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Spara som utkast
|
||||
{t('save_draft')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
Granska & skapa
|
||||
{t('review_and_create')}
|
||||
</Button>
|
||||
</div>
|
||||
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
{!description && <p>Ange en beskrivning</p>}
|
||||
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
|
||||
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
|
||||
{isUploading && <p>Vänta tills filerna laddats upp</p>}
|
||||
{!description && <p>{t('validation_description')}</p>}
|
||||
{!selectedPeriod && <p>{t('validation_period')}</p>}
|
||||
{periodMismatch === 'no_period' && <p>{t('validation_no_matching_period')}</p>}
|
||||
{isUploading && <p>{t('validation_uploading')}</p>}
|
||||
{incompleteLineCount > 0 && (
|
||||
<p>Alla rader med belopp måste ha ett konto (och tvärtom)</p>
|
||||
<p>{t('validation_incomplete_lines')}</p>
|
||||
)}
|
||||
{submittableLines.length < 2 && incompleteLineCount === 0 && (
|
||||
<p>Minst två rader med konto och belopp krävs</p>
|
||||
<p>{t('validation_min_lines')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -897,10 +899,10 @@ export default function JournalEntryForm({
|
||||
isSubmitting={isSubmitting}
|
||||
title={
|
||||
!embedded && nextVoucherNumber != null
|
||||
? `Granska verifikation (${voucherSeries}${nextVoucherNumber})`
|
||||
: 'Granska verifikation'
|
||||
? t('review_title_with_voucher', { voucher: `${voucherSeries}${nextVoucherNumber}` })
|
||||
: t('review_title')
|
||||
}
|
||||
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
|
||||
warningText={embedded ? '' : t('review_warning')}
|
||||
>
|
||||
<JournalEntryReviewContent
|
||||
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
|
||||
@@ -926,12 +928,12 @@ export default function JournalEntryForm({
|
||||
setShowReview(true)
|
||||
}}
|
||||
isSubmitting={false}
|
||||
title="Underlag saknas"
|
||||
warningText="Inget underlag bifogat. Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan."
|
||||
confirmLabel="Bokför utan underlag"
|
||||
title={t('no_doc_dialog_title')}
|
||||
warningText={t('no_doc_dialog_warning')}
|
||||
confirmLabel={t('no_doc_confirm')}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Granska uppgifterna innan du bekräftar.
|
||||
{t('no_doc_body')}
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
|
||||
@@ -952,7 +954,7 @@ export default function JournalEntryForm({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ny verifikation</CardTitle>
|
||||
<CardTitle>{t('card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formContent}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -39,6 +40,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const t = useTranslations('journal_list')
|
||||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||||
const [committingId, setCommittingId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -156,15 +158,15 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
if (res.ok) {
|
||||
const posted = result.data
|
||||
toast({
|
||||
title: 'Verifikat bokfört',
|
||||
description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`,
|
||||
title: t('toast_posted_title'),
|
||||
description: t('toast_posted_description', { voucher: `${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''}` }),
|
||||
})
|
||||
await fetchEntries()
|
||||
} else {
|
||||
toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
||||
toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' })
|
||||
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
|
||||
} finally {
|
||||
setCommittingId(null)
|
||||
}
|
||||
@@ -175,7 +177,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Laddar verifikationer...</p>
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -188,9 +190,9 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<div className="p-4 rounded-full bg-muted mb-4">
|
||||
<BookOpen className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">Inga verifikationer</h3>
|
||||
<h3 className="text-lg font-medium mb-1">{t('empty_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken "Ny verifikation".
|
||||
{t('empty_description')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -218,7 +220,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
onCheckedChange={setShowMissingOnly}
|
||||
/>
|
||||
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
|
||||
Visa saknade underlag
|
||||
{t('show_missing')}
|
||||
</Label>
|
||||
{showMissingOnly && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -232,16 +234,16 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">Datum, nyast först</SelectItem>
|
||||
<SelectItem value="date_asc">Datum, äldst först</SelectItem>
|
||||
<SelectItem value="voucher_asc">Verifikat, A1 först</SelectItem>
|
||||
<SelectItem value="voucher_desc">Verifikat, senaste först</SelectItem>
|
||||
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
|
||||
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Från YYYY-MM-DD"
|
||||
placeholder={t('date_from_placeholder')}
|
||||
value={dateFromInput}
|
||||
onChange={(e) => setDateFromInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -260,7 +262,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Till YYYY-MM-DD"
|
||||
placeholder={t('date_to_placeholder')}
|
||||
value={dateToInput}
|
||||
onChange={(e) => setDateToInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -283,14 +285,14 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
className="h-8 text-xs shrink-0"
|
||||
onClick={applyDateFilter}
|
||||
>
|
||||
Filtrera
|
||||
{t('filter')}
|
||||
</Button>
|
||||
{(dateFrom || dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
|
||||
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
|
||||
title="Rensa datumfilter"
|
||||
title={t('clear_date_filter')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -331,9 +333,9 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title="Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året)."
|
||||
title={t('out_of_period_tooltip')}
|
||||
>
|
||||
Efterföljande
|
||||
{t('out_of_period_label')}
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
@@ -341,13 +343,13 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)}
|
||||
<span className="flex-1 truncate">{entry.description}</span>
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{attachmentCounts[entry.id]}</span>
|
||||
</span>
|
||||
) : (
|
||||
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
|
||||
<span className="mr-1" title="Underlag saknas">
|
||||
<span className="mr-1" title={t('missing_attachment_tooltip')}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
|
||||
</span>
|
||||
)
|
||||
@@ -375,9 +377,9 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title="Bokförd i ett senare räkenskapsår, men avser det valda året."
|
||||
title={t('out_of_period_tooltip_mobile')}
|
||||
>
|
||||
Efterföljande
|
||||
{t('out_of_period_label')}
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
@@ -385,13 +387,13 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground" title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{attachmentCounts[entry.id]}</span>
|
||||
</span>
|
||||
) : (
|
||||
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
|
||||
<span title="Underlag saknas">
|
||||
<span title={t('missing_attachment_tooltip')}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
|
||||
</span>
|
||||
)
|
||||
@@ -405,7 +407,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
|
||||
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
@@ -427,7 +429,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)}
|
||||
<div className="flex justify-between items-center pt-1 border-t text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'}
|
||||
{Number(line.debit_amount) > 0 ? t('debit') : t('credit')}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="font-mono tabular-nums font-medium">
|
||||
@@ -447,11 +449,11 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
})}
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-sm font-semibold space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Summa debet</span>
|
||||
<span>{t('sum_debit')}</span>
|
||||
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Summa kredit</span>
|
||||
<span>{t('sum_credit')}</span>
|
||||
<span className="font-mono tabular-nums">{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,14 +479,14 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => handleCommit(entry.id)}
|
||||
disabled={!canWrite || committingId === entry.id}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Bokför
|
||||
{t('post')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
|
||||
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
|
||||
<Link href={`/bookkeeping/${entry.id}`}>{t('show_details')}</Link>
|
||||
</Button>
|
||||
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
||||
<Button
|
||||
@@ -493,7 +495,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCorrectionEntry(entry)}
|
||||
>
|
||||
Skapa ändringsverifikation
|
||||
{t('create_correction')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -503,7 +505,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Kopiera
|
||||
{t('copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -532,10 +534,10 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Föregående
|
||||
{t('previous')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground self-center">
|
||||
Sida {page + 1} av {Math.ceil(count / pageSize)}
|
||||
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -543,7 +545,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
disabled={(page + 1) * pageSize >= count}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Nästa
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary' },
|
||||
posted: { label: 'Bokförd', variant: 'success' },
|
||||
reversed: { label: 'Omförd', variant: 'warning' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary' },
|
||||
type BadgeVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
|
||||
|
||||
const statusVariants: Record<string, BadgeVariant> = {
|
||||
draft: 'secondary',
|
||||
posted: 'success',
|
||||
reversed: 'warning',
|
||||
cancelled: 'secondary',
|
||||
}
|
||||
|
||||
const sourceTypeBadges: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
storno: { label: 'Storno', variant: 'destructive' },
|
||||
correction: { label: 'Rättelse', variant: 'default' },
|
||||
const sourceTypeVariants: Record<string, BadgeVariant> = {
|
||||
storno: 'destructive',
|
||||
correction: 'default',
|
||||
}
|
||||
|
||||
export const sourceTypeLabels: Record<string, string> = {
|
||||
manual: 'Manuell',
|
||||
bank_transaction: 'Banktransaktion',
|
||||
invoice_created: 'Faktura skapad',
|
||||
invoice_paid: 'Fakturabetalning',
|
||||
credit_note: 'Kreditfaktura',
|
||||
salary_payment: 'Lön',
|
||||
opening_balance: 'Ingående balans',
|
||||
year_end: 'Årsbokslut',
|
||||
storno: 'Storno',
|
||||
correction: 'Rättelse',
|
||||
import: 'Import',
|
||||
system: 'System',
|
||||
supplier_invoice_registered: 'Leverantörsfaktura',
|
||||
supplier_invoice_paid: 'Leverantörsbetalning',
|
||||
supplier_invoice_cash_payment: 'Kontantbetalning',
|
||||
currency_revaluation: 'Valutaomvärdering',
|
||||
const SOURCE_TYPES = [
|
||||
'manual',
|
||||
'bank_transaction',
|
||||
'invoice_created',
|
||||
'invoice_paid',
|
||||
'credit_note',
|
||||
'salary_payment',
|
||||
'opening_balance',
|
||||
'year_end',
|
||||
'storno',
|
||||
'correction',
|
||||
'import',
|
||||
'system',
|
||||
'supplier_invoice_registered',
|
||||
'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment',
|
||||
'currency_revaluation',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Hook returning the translated source-type label map. Use this in client
|
||||
* components that need to render the human-readable label for a source_type.
|
||||
*/
|
||||
export function useSourceTypeLabels(): Record<string, string> {
|
||||
const t = useTranslations('journal_status')
|
||||
const out: Record<string, string> = {}
|
||||
for (const key of SOURCE_TYPES) {
|
||||
out[key] = t(`source_label_${key}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -38,19 +56,32 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function JournalEntryStatusBadge({ entry, showStatus = true }: Props) {
|
||||
const status = statusConfig[entry.status]
|
||||
const sourceType = sourceTypeBadges[entry.source_type]
|
||||
const t = useTranslations('journal_status')
|
||||
const statusVariant = statusVariants[entry.status]
|
||||
const sourceVariant = sourceTypeVariants[entry.source_type]
|
||||
|
||||
const statusLabelKey =
|
||||
entry.status === 'draft' ? 'status_draft'
|
||||
: entry.status === 'posted' ? 'status_posted'
|
||||
: entry.status === 'reversed' ? 'status_reversed'
|
||||
: entry.status === 'cancelled' ? 'status_cancelled'
|
||||
: null
|
||||
|
||||
const sourceLabelKey =
|
||||
entry.source_type === 'storno' ? 'source_storno'
|
||||
: entry.source_type === 'correction' ? 'source_correction'
|
||||
: null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{showStatus && status && (
|
||||
<Badge variant={status.variant} className="text-[10px] px-1.5 py-0">
|
||||
{status.label}
|
||||
{showStatus && statusVariant && statusLabelKey && (
|
||||
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0">
|
||||
{t(statusLabelKey)}
|
||||
</Badge>
|
||||
)}
|
||||
{sourceType && (
|
||||
<Badge variant={sourceType.variant} className="text-[10px] px-1.5 py-0">
|
||||
{sourceType.label}
|
||||
{sourceVariant && sourceLabelKey && (
|
||||
<Badge variant={sourceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{t(sourceLabelKey)}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -59,14 +60,16 @@ export function FiscalYearSelector({
|
||||
value,
|
||||
onChange,
|
||||
includeAllOption = true,
|
||||
label = 'Räkenskapsår',
|
||||
label,
|
||||
hideFuturePeriods = false,
|
||||
onReady,
|
||||
className,
|
||||
}: Props) {
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('fiscal_year')
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const effectiveLabel = label === null ? null : (label ?? t('label'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
@@ -149,24 +152,24 @@ export function FiscalYearSelector({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className={`flex items-center gap-2 ${label ? 'mt-1' : ''}`}>
|
||||
{effectiveLabel && <Label>{effectiveLabel}</Label>}
|
||||
<div className={`flex items-center gap-2 ${effectiveLabel ? 'mt-1' : ''}`}>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={handleChange}
|
||||
disabled={!loaded || periods.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[280px]">
|
||||
<SelectValue placeholder={loaded ? 'Välj räkenskapsår' : 'Laddar…'} />
|
||||
<SelectValue placeholder={loaded ? t('placeholder') : t('loading')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{includeAllOption && (
|
||||
<SelectItem value={ALL_YEARS_VALUE}>Alla räkenskapsår</SelectItem>
|
||||
<SelectItem value={ALL_YEARS_VALUE}>{t('all_years')}</SelectItem>
|
||||
)}
|
||||
{periods.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} ({p.period_start} — {p.period_end})
|
||||
{p.locked_at ? ' — låst' : p.is_closed ? ' — stängt' : ''}
|
||||
{p.locked_at ? t('suffix_locked') : p.is_closed ? t('suffix_closed') : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -175,14 +178,10 @@ export function FiscalYearSelector({
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-xs font-normal shrink-0"
|
||||
title={
|
||||
lockState === 'locked'
|
||||
? 'Räkenskapsåret är låst — ingen bokföring kan ändras eller läggas till'
|
||||
: 'Räkenskapsåret är stängt (årsbokslut upprättat) — kan återöppnas av admin'
|
||||
}
|
||||
title={lockState === 'locked' ? t('tooltip_locked') : t('tooltip_closed')}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{lockState === 'locked' ? 'Låst' : 'Stängt'}
|
||||
{lockState === 'locked' ? t('badge_locked') : t('badge_closed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,30 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { CreateCustomerInput, CustomerType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Namn krävs'),
|
||||
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
|
||||
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
default_payment_terms: z.number().min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
interface CustomerFormProps {
|
||||
onSubmit: (data: CreateCustomerInput) => Promise<void>
|
||||
isLoading: boolean
|
||||
initialData?: Partial<FormData>
|
||||
initialData?: Partial<CreateCustomerInput>
|
||||
}
|
||||
|
||||
export default function CustomerForm({
|
||||
@@ -45,18 +28,36 @@ export default function CustomerForm({
|
||||
}: CustomerFormProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('form_customer')
|
||||
const [isValidatingVat, setIsValidatingVat] = useState(false)
|
||||
const [vatValidationResult, setVatValidationResult] = useState<{
|
||||
valid: boolean
|
||||
name?: string
|
||||
} | null>(null)
|
||||
|
||||
const schema = useMemo(() => z.object({
|
||||
name: z.string().min(1, t('name_required')),
|
||||
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
|
||||
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
default_payment_terms: z.number().min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
}), [t])
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -101,19 +102,19 @@ export default function CustomerForm({
|
||||
|
||||
if (result.valid && result.name) {
|
||||
toast({
|
||||
title: 'VAT-nummer verifierat',
|
||||
description: `Företag: ${result.name}`,
|
||||
title: t('vat_verified_title'),
|
||||
description: t('vat_verified_description', { name: result.name }),
|
||||
})
|
||||
} else if (!result.valid) {
|
||||
toast({
|
||||
title: 'Verifiering misslyckades',
|
||||
description: result.error || 'VAT-numret kunde inte verifieras',
|
||||
title: t('vat_failed_title'),
|
||||
description: result.error || t('vat_failed_default'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte verifiera VAT-nummer',
|
||||
title: t('vat_error_title'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -132,35 +133,35 @@ export default function CustomerForm({
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Customer Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Kundtyp *</Label>
|
||||
<Label>{t('type_label')}</Label>
|
||||
<Controller
|
||||
name="customer_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kundtyp" />
|
||||
<SelectValue placeholder={t('type_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Privatperson (Sverige)</SelectItem>
|
||||
<SelectItem value="swedish_business">Svenskt företag eller organisation</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
|
||||
<SelectItem value="individual">{t('type_individual')}</SelectItem>
|
||||
<SelectItem value="swedish_business">{t('type_swedish_business')}</SelectItem>
|
||||
<SelectItem value="eu_business">{t('type_eu_business')}</SelectItem>
|
||||
<SelectItem value="non_eu_business">{t('type_non_eu_business')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kundtypen påverkar hur moms hanteras på fakturor
|
||||
{t('type_hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Namn *</Label>
|
||||
<Label htmlFor="name">{t('name_label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Företagsnamn eller personnamn"
|
||||
placeholder={t('name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
@@ -171,11 +172,11 @@ export default function CustomerForm({
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="namn@foretag.se"
|
||||
placeholder={t('email_placeholder')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
@@ -183,10 +184,10 @@ export default function CustomerForm({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Label htmlFor="phone">{t('phone_label')}</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="+46 70 123 45 67"
|
||||
placeholder={t('phone_placeholder')}
|
||||
{...register('phone')}
|
||||
/>
|
||||
</div>
|
||||
@@ -194,37 +195,37 @@ export default function CustomerForm({
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">Adress</h3>
|
||||
<h3 className="font-medium">{t('address_section')}</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Label htmlFor="address_line1">{t('street_label')}</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
placeholder="Storgatan 1"
|
||||
placeholder={t('street_placeholder')}
|
||||
{...register('address_line1')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Label htmlFor="postal_code">{t('postal_label')}</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
placeholder="123 45"
|
||||
placeholder={t('postal_placeholder')}
|
||||
{...register('postal_code')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Label htmlFor="city">{t('city_label')}</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Stockholm"
|
||||
placeholder={t('city_placeholder')}
|
||||
{...register('city')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Land</Label>
|
||||
<Label htmlFor="country">{t('country_label')}</Label>
|
||||
<Input
|
||||
id="country"
|
||||
placeholder="Sweden"
|
||||
placeholder={t('country_placeholder')}
|
||||
{...register('country')}
|
||||
/>
|
||||
</div>
|
||||
@@ -234,24 +235,24 @@ export default function CustomerForm({
|
||||
{/* Business info */}
|
||||
{customerType !== 'individual' && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Företagsuppgifter</h3>
|
||||
<h3 className="font-medium">{t('business_section')}</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Label htmlFor="org_number">{t('org_number_label')}</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
placeholder="XXXXXX-XXXX"
|
||||
placeholder={t('org_number_placeholder')}
|
||||
{...register('org_number')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vat_number">VAT-nummer (momsreg.nr)</Label>
|
||||
<Label htmlFor="vat_number">{t('vat_label')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="vat_number"
|
||||
placeholder={customerType === 'eu_business' ? 'DE123456789' : 'SE123456789001'}
|
||||
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
|
||||
{...register('vat_number')}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -269,14 +270,14 @@ export default function CustomerForm({
|
||||
) : vatValidationResult?.valid === false ? (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
'Verifiera'
|
||||
t('vat_verify')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{customerType === 'eu_business' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifiera VAT-numret för att kunna fakturera med omvänd skattskyldighet (0% moms)
|
||||
{t('vat_hint_eu')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -286,7 +287,7 @@ export default function CustomerForm({
|
||||
|
||||
{/* Payment terms */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
|
||||
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
|
||||
<Input
|
||||
id="payment_terms"
|
||||
type="number"
|
||||
@@ -296,10 +297,10 @@ export default function CustomerForm({
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Label htmlFor="notes">{t('notes_label')}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Interna anteckningar om kunden..."
|
||||
placeholder={t('notes_placeholder')}
|
||||
{...register('notes')}
|
||||
/>
|
||||
</div>
|
||||
@@ -309,20 +310,20 @@ export default function CustomerForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('submit_saving')}
|
||||
</>
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Spara kund
|
||||
{t('submit_save')}
|
||||
</>
|
||||
) : (
|
||||
'Spara kund'
|
||||
t('submit_save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { switchCompany } from '@/lib/company/actions'
|
||||
@@ -10,6 +11,7 @@ import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CompanySwitcher() {
|
||||
const { company, companies, isSandbox } = useCompany()
|
||||
const t = useTranslations('company_switcher')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -115,7 +117,7 @@ export default function CompanySwitcher() {
|
||||
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-[13px] text-muted-foreground truncate">Lägg till företag</span>
|
||||
<span className="text-[13px] text-muted-foreground truncate">{t('add_company')}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -130,9 +132,9 @@ export default function CompanySwitcher() {
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">Företag</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-[0.06em] leading-none mb-1">{t('company_label')}</p>
|
||||
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
|
||||
{company?.name || 'Min verksamhet'}
|
||||
{company?.name || t('default_company_name')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
@@ -149,7 +151,7 @@ export default function CompanySwitcher() {
|
||||
{hasMultiple && (
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] px-1.5">
|
||||
Företag
|
||||
{t('company_label')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -196,7 +198,7 @@ export default function CompanySwitcher() {
|
||||
className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Lägg till företag
|
||||
{t('add_company')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
||||
@@ -45,6 +46,7 @@ interface DashboardContentProps {
|
||||
|
||||
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
const t = useTranslations('dashboard')
|
||||
|
||||
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
|
||||
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
|
||||
@@ -96,9 +98,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Förfallna fakturor</p>
|
||||
<p className="font-medium text-sm">{t('overdue_invoices')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.overdueInvoicesCount} st
|
||||
{t('overdue_invoices_count', { count: summary.overdueInvoicesCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,9 +118,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Obetalda fakturor</p>
|
||||
<p className="font-medium text-sm">{t('unpaid_invoices')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
{t('unpaid_invoices_detail', {
|
||||
count: summary.unpaidInvoicesCount - summary.overdueInvoicesCount,
|
||||
amount: formatCurrency(summary.unpaidInvoicesTotal),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,9 +141,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Transaktioner</p>
|
||||
<p className="font-medium text-sm">{t('transactions')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedCount} obokförda
|
||||
{t('uncategorized_count', { count: summary.uncategorizedCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,9 +161,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Saknade underlag</p>
|
||||
<p className="font-medium text-sm">{t('missing_underlag')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.missingUnderlagCount} verifikationer utan underlag
|
||||
{t('missing_underlag_detail', { count: summary.missingUnderlagCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,9 +181,9 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Gamla transaktioner</p>
|
||||
<p className="font-medium text-sm">{t('stale_transactions')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.staleUncategorizedCount} transaktioner äldre än 14 dagar saknar bokföring
|
||||
{t('stale_transactions_detail', { count: summary.staleUncategorizedCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,9 +202,11 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="flex items-center gap-3">
|
||||
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Banksamtycke löper ut</p>
|
||||
<p className="font-medium text-sm">{t('bank_consent_expiring')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
|
||||
{conn.days_left === 1
|
||||
? t('bank_consent_detail_one', { bank: conn.bank_name, days: conn.days_left })
|
||||
: t('bank_consent_detail_other', { bank: conn.bank_name, days: conn.days_left })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,7 +233,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('result')}</p>
|
||||
<p className={cn(
|
||||
'font-display text-xl font-medium tabular-nums leading-tight',
|
||||
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
|
||||
@@ -235,7 +242,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.ytd.net)} i år
|
||||
{formatCurrency(summary.ytd.net)} {t('this_year_short')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -244,12 +251,12 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('to_be_paid')}</p>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
{summary.unpaidInvoicesCount}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
@@ -261,7 +268,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
{summary.bankBalance !== null ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.bankBalance)}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
|
||||
@@ -273,10 +280,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('bank_balance')}</p>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-primary">Koppla bank</p>
|
||||
<p className="text-sm font-medium text-primary">{t('connect_bank')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -284,17 +291,17 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('todo')}</p>
|
||||
<div role="status" aria-live="polite">
|
||||
{todoCount > 0 ? (
|
||||
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
|
||||
{todoCount}
|
||||
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
|
||||
{t('units') && <span className="text-sm ml-0.5 text-muted-foreground font-normal">{t('units')}</span>}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<p className="text-sm font-medium text-success">Allt klart!</p>
|
||||
<p className="text-sm font-medium text-success">{t('all_done')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -303,19 +310,19 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resultat — intäkter / kostnader (always visible) */}
|
||||
{/* Result — revenue / expenses (always visible) */}
|
||||
<section>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('revenue')}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -323,14 +330,14 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('expenses')}</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('this_month')}</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-xs text-muted-foreground">{t('this_year_block')}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -338,10 +345,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Att hantera */}
|
||||
{/* Alerts */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
|
||||
<h2 className="font-display text-lg font-medium mb-4">{t('alerts_title')}</h2>
|
||||
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
|
||||
{visibleAlerts}
|
||||
</div>
|
||||
@@ -352,7 +359,7 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
aria-controls="alerts-list"
|
||||
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
{showAllAlerts ? 'Visa färre' : `Visa alla (${alertItems.length})`}
|
||||
{showAllAlerts ? t('show_less') : t('show_all', { count: alertItems.length })}
|
||||
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
|
||||
@@ -37,6 +38,8 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
void _ENABLED_EXTENSION_IDS
|
||||
|
||||
interface ExtensionNavItem {
|
||||
href: string
|
||||
label: string
|
||||
@@ -52,52 +55,74 @@ interface DashboardNavProps {
|
||||
extensionNavItems?: ExtensionNavItem[]
|
||||
}
|
||||
|
||||
type NavLabelKey =
|
||||
| 'dashboard'
|
||||
| 'kpi'
|
||||
| 'invoice_inbox'
|
||||
| 'invoices'
|
||||
| 'customers'
|
||||
| 'supplier_invoices'
|
||||
| 'suppliers'
|
||||
| 'review'
|
||||
| 'transactions'
|
||||
| 'bookkeeping'
|
||||
| 'assets'
|
||||
| 'reports'
|
||||
| 'import'
|
||||
| 'salary'
|
||||
| 'employees'
|
||||
| 'help'
|
||||
| 'settings'
|
||||
|
||||
type GroupKey = 'main' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' | 'övrigt'
|
||||
|
||||
interface NavItem {
|
||||
href: string
|
||||
label: string
|
||||
labelKey: NavLabelKey
|
||||
icon: typeof LayoutDashboard
|
||||
group: string
|
||||
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
|
||||
hidden?: boolean // Temporarily hide from sidebar
|
||||
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
|
||||
devBadge?: boolean // Shows a "Dev" badge to indicate dev-only feature
|
||||
betaBadge?: boolean // Clickable; shows a "Beta" badge to indicate feature in testing
|
||||
group: GroupKey
|
||||
modes?: EntityType[]
|
||||
hidden?: boolean
|
||||
comingSoon?: boolean
|
||||
devBadge?: boolean
|
||||
betaBadge?: boolean
|
||||
}
|
||||
|
||||
// All nav items for sidebar and mobile drawer
|
||||
const navItems: NavItem[] = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
|
||||
{ href: '/e/general/invoice-inbox', label: 'Dokumentinkorg', icon: Inbox, group: 'main', betaBadge: true },
|
||||
// AR — Accounts Receivable
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
|
||||
// AP — Accounts Payable
|
||||
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: Wallet, group: 'inköp' },
|
||||
// Temporarily hidden pending module rework (see feedback #49)
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
|
||||
// General accounting
|
||||
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
||||
{ href: '/assets', label: 'Anläggningstillgångar', icon: Package, group: 'redovisning' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
|
||||
// Personal — enabled in production with a "Beta" badge while we validate the
|
||||
// end-to-end salary + AGI flow with real customers.
|
||||
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
|
||||
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
|
||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
||||
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'main' },
|
||||
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'main', betaBadge: true },
|
||||
{ href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' },
|
||||
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
|
||||
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
|
||||
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp', hidden: true },
|
||||
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'redovisning' },
|
||||
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'redovisning' },
|
||||
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
|
||||
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
|
||||
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
|
||||
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
|
||||
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', modes: ['aktiebolag'], betaBadge: true },
|
||||
{ href: '/help', labelKey: 'help', icon: HelpCircle, group: 'övrigt' },
|
||||
{ href: '/settings', labelKey: 'settings', icon: Settings, group: 'övrigt' },
|
||||
]
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
main: 'Huvudmeny',
|
||||
försäljning: 'Försäljning',
|
||||
inköp: 'Inköp',
|
||||
personal: 'Personal',
|
||||
redovisning: 'Redovisning',
|
||||
övrigt: 'Övrigt',
|
||||
// Map known extension hrefs to nav translation keys so sidebar labels translate.
|
||||
// Extensions whose manifest label happens to be English-ready can stay null.
|
||||
function extensionLabelKey(href: string): string | null {
|
||||
if (href === '/e/general/tic') return 'ext_tic'
|
||||
if (href === '/e/general/invoice-inbox') return 'ext_invoice_inbox'
|
||||
return null
|
||||
}
|
||||
|
||||
const groupLabelKey: Record<GroupKey, string> = {
|
||||
main: 'group_main',
|
||||
försäljning: 'group_sales',
|
||||
inköp: 'group_purchases',
|
||||
redovisning: 'group_accounting',
|
||||
personal: 'group_personnel',
|
||||
övrigt: 'group_other',
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
|
||||
@@ -105,22 +130,19 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// When the user has no active company (e.g. just archived their last
|
||||
// one), every company-scoped route is unreachable. We keep them visible
|
||||
// so the sidebar doesn't collapse, but render them as disabled.
|
||||
// Only /settings remains navigable — from there the user can either
|
||||
// create a new company or delete their account.
|
||||
const hasCompany = !!company
|
||||
const ALWAYS_ENABLED = new Set(['/settings'])
|
||||
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
|
||||
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
|
||||
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
|
||||
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
|
||||
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
|
||||
|
||||
const openMobileMenu = () => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
@@ -140,8 +162,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
if (href === '/') {
|
||||
return pathname === '/'
|
||||
}
|
||||
// For parent routes that have a sibling sub-route in the nav (e.g. /salary vs /salary/employees),
|
||||
// only match the parent for exact or non-overlapping sub-paths
|
||||
if (href === '/salary') {
|
||||
return pathname === '/salary' || pathname.startsWith('/salary/runs')
|
||||
}
|
||||
@@ -159,12 +179,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
|
||||
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
|
||||
|
||||
// Filter nav items by entity type, hidden flag, and conditional visibility
|
||||
const filteredItems = navItems.filter(item => {
|
||||
if (item.hidden) return false
|
||||
if (hiddenNavHrefs.has(item.href)) return false
|
||||
if (item.modes && !item.modes.includes(entityType)) return false
|
||||
// Only show Granskning when there are pending operations
|
||||
if (item.href === '/pending' && pendingOperationsCount === 0) return false
|
||||
return true
|
||||
})
|
||||
@@ -172,20 +190,30 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
const mainItems = filteredItems.filter(i => i.group === 'main')
|
||||
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
|
||||
|
||||
// Groups rendered as distinct sidebar sections (AR, AP, Accounting)
|
||||
const sidebarGroups = [
|
||||
const sidebarGroups: { key: GroupKey; items: NavItem[]; spacing: string }[] = [
|
||||
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
|
||||
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
|
||||
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' },
|
||||
{ key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' },
|
||||
] as const
|
||||
|
||||
const mobileNavItems = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
|
||||
]
|
||||
|
||||
const mobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
|
||||
{ href: '/', labelKey: 'dashboard', icon: LayoutDashboard },
|
||||
{ href: '/invoices', labelKey: 'invoices', icon: Receipt },
|
||||
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
|
||||
]
|
||||
|
||||
const renderBadge = (item: NavItem | { comingSoon?: boolean; devBadge?: boolean; betaBadge?: boolean }, position: 'sidebar' | 'mobile') => {
|
||||
const baseClass =
|
||||
position === 'sidebar'
|
||||
? 'ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
||||
: 'rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5'
|
||||
if (item.comingSoon) return <span className={baseClass}>{tNav('badge_coming_soon')}</span>
|
||||
if (item.devBadge) return <span className={baseClass}>{tNav('badge_dev')}</span>
|
||||
if (item.betaBadge) return <span className={baseClass}>{tNav('badge_beta')}</span>
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
@@ -198,11 +226,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
</div>
|
||||
|
||||
{/* Navigation with group headers */}
|
||||
<nav className="px-3" aria-label="Huvudnavigation">
|
||||
{/* Huvudmeny group */}
|
||||
<nav className="px-3" aria-label={tNav('main_navigation')}>
|
||||
{/* Main group */}
|
||||
<div className="mb-6">
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
||||
{groupLabels.main}
|
||||
{tNav('group_main')}
|
||||
</p>
|
||||
<div className="space-y-px">
|
||||
{mainItems.map((item) => {
|
||||
@@ -215,20 +243,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.comingSoon ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Kommer snart
|
||||
</span>
|
||||
) : item.devBadge ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Dev
|
||||
</span>
|
||||
) : item.betaBadge ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Beta
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">{tNav(item.labelKey)}</span>
|
||||
{renderBadge(item, 'sidebar')}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -251,7 +267,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
title={tNav('needs_company_tooltip')}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
@@ -264,7 +280,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (
|
||||
<div key={key} className={spacing}>
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
||||
{groupLabels[key]}
|
||||
{tNav(groupLabelKey[key])}
|
||||
</p>
|
||||
<div className="space-y-px">
|
||||
{items.map((item) => {
|
||||
@@ -276,26 +292,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
: null
|
||||
const decorBadge = renderBadge(item, 'sidebar')
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.comingSoon ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Kommer snart
|
||||
</span>
|
||||
) : item.devBadge ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Dev
|
||||
</span>
|
||||
) : item.betaBadge ? (
|
||||
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Beta
|
||||
</span>
|
||||
) : badge !== null && (
|
||||
<span className="flex-1">{tNav(item.labelKey)}</span>
|
||||
{decorBadge ? decorBadge : badge !== null && (
|
||||
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
@@ -322,7 +327,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title={item.comingSoon ? 'Kommer snart' : 'Lägg till ett företag för att aktivera'}
|
||||
title={item.comingSoon ? tNav('badge_coming_soon') : tNav('needs_company_tooltip')}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
@@ -338,7 +343,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<span>{groupLabels.övrigt}</span>
|
||||
<span>{tNav('group_other')}</span>
|
||||
<ChevronDown className={cn(
|
||||
"h-3 w-3 transition-transform duration-200",
|
||||
isOvrigtExpanded && "rotate-180"
|
||||
@@ -349,15 +354,16 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
{extensionNavItems.map((item) => {
|
||||
const Icon = resolveIcon(item.icon)
|
||||
const active = isActive(item.href)
|
||||
// Extension nav items are always company-scoped.
|
||||
const enabled = hasCompany
|
||||
const labelTranslationKey = extensionLabelKey(item.href)
|
||||
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -380,7 +386,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
title={tNav('needs_company_tooltip')}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
@@ -396,7 +402,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
{tNav(item.labelKey)}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -419,7 +425,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
key={item.href}
|
||||
className={baseClass}
|
||||
aria-disabled="true"
|
||||
title="Lägg till ett företag för att aktivera"
|
||||
title={tNav('needs_company_tooltip')}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
@@ -442,14 +448,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
|
||||
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
|
||||
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label="Mobilnavigation">
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
||||
<div className="flex items-center justify-around h-16 px-2">
|
||||
{mobileNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
@@ -475,7 +481,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
active && "font-medium"
|
||||
)}>{item.label}</span>
|
||||
)}>{tNav(item.labelKey)}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -501,11 +507,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
{/* Menu button */}
|
||||
<button
|
||||
onClick={openMobileMenu}
|
||||
aria-label="Öppna meny"
|
||||
aria-label={tNav('open_menu')}
|
||||
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
|
||||
>
|
||||
<Menu className="h-5 w-5 mb-1" />
|
||||
<span>Meny</span>
|
||||
<span>{tNav('menu')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -532,7 +538,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
)}
|
||||
style={{ maxHeight: '85dvh', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="dialog"
|
||||
aria-label="Navigeringsmeny"
|
||||
aria-label={tNav('navigation_menu')}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1 sticky top-0 bg-card rounded-t-2xl">
|
||||
@@ -549,7 +555,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-1"
|
||||
onClick={closeMobileMenu}
|
||||
aria-label="Stäng meny"
|
||||
aria-label={tNav('close_menu')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -566,20 +572,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{item.label}</span>
|
||||
{item.comingSoon ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Kommer snart
|
||||
</span>
|
||||
) : item.devBadge ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Dev
|
||||
</span>
|
||||
) : item.betaBadge ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Beta
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
||||
{renderBadge(item, 'mobile')}
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -614,7 +608,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav(groupLabelKey[key])}</span>
|
||||
<div className="flex-1 h-px bg-border/30" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
@@ -627,23 +621,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
: null
|
||||
const decorBadge = renderBadge(item, 'mobile')
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{item.label}</span>
|
||||
{item.comingSoon ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Kommer snart
|
||||
</span>
|
||||
) : item.devBadge ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Dev
|
||||
</span>
|
||||
) : item.betaBadge ? (
|
||||
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
|
||||
Beta
|
||||
</span>
|
||||
) : badge !== null && (
|
||||
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
||||
{decorBadge ? decorBadge : badge !== null && (
|
||||
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
@@ -682,7 +665,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
|
||||
{/* Övrigt divider */}
|
||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">Övrigt</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{tNav('group_other')}</span>
|
||||
<div className="flex-1 h-px bg-border/30" />
|
||||
</div>
|
||||
|
||||
@@ -692,10 +675,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
const Icon = resolveIcon(item.icon)
|
||||
const active = isActive(item.href)
|
||||
const enabled = hasCompany
|
||||
const labelTranslationKey = extensionLabelKey(item.href)
|
||||
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -731,7 +716,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-sm">{tNav(item.labelKey)}</span>
|
||||
</>
|
||||
)
|
||||
const baseClass = cn(
|
||||
@@ -777,7 +762,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
}}
|
||||
>
|
||||
<LogOut className="mr-3 h-[18px] w-[18px]" />
|
||||
{isSandbox ? 'Avsluta sandbox' : 'Logga ut'}
|
||||
{isSandbox ? tNav('logout_sandbox') : tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createCompanyFromOnboarding } from '@/lib/company/actions'
|
||||
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -19,19 +20,23 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
||||
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
|
||||
|
||||
const STEP_INFO = [
|
||||
{ title: 'Företagsform', subtitle: 'Välj din företagsform för att komma igång.' },
|
||||
{ title: 'Uppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.' },
|
||||
{ title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.' },
|
||||
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' },
|
||||
]
|
||||
type TFn = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function translatePeriodError(msg: string): string {
|
||||
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
|
||||
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
|
||||
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
|
||||
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
|
||||
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
|
||||
function buildStepInfo(t: TFn) {
|
||||
return [
|
||||
{ title: t('step1_title'), subtitle: t('step1_subtitle') },
|
||||
{ title: t('step2_title'), subtitle: t('step2_subtitle') },
|
||||
{ title: t('step3_title'), subtitle: t('step3_subtitle') },
|
||||
{ title: t('step4_title'), subtitle: t('step4_subtitle') },
|
||||
]
|
||||
}
|
||||
|
||||
function translatePeriodError(msg: string, t: TFn): string {
|
||||
if (msg.includes('end must be after')) return t('period_error_end_after_start')
|
||||
if (msg.includes('start must be the 1st')) return t('period_error_start_first')
|
||||
if (msg.includes('end must be the last day')) return t('period_error_end_last_day')
|
||||
if (msg.includes('exceeds maximum 18 months')) return t('period_error_max_18')
|
||||
return t('period_error_invalid')
|
||||
}
|
||||
|
||||
const LOG = '[welcome-onboarding]'
|
||||
@@ -63,6 +68,8 @@ export default function WelcomeOnboarding({
|
||||
}: WelcomeOnboardingProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('onboarding')
|
||||
const STEP_INFO = buildStepInfo(t)
|
||||
|
||||
const [started, setStarted] = useState(skipWelcome ?? false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -76,7 +83,7 @@ export default function WelcomeOnboarding({
|
||||
const totalSteps = 4
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
|
||||
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
|
||||
|
||||
const handleNext = async (stepData: Partial<CompanySettings>) => {
|
||||
// Reset org_number/company_name only on a genuine change (user going back
|
||||
@@ -99,8 +106,8 @@ export default function WelcomeOnboarding({
|
||||
const periodResult = computeFiscalPeriod(mergedSettings)
|
||||
if (periodResult.error) {
|
||||
toast({
|
||||
title: 'Ogiltigt räkenskapsår',
|
||||
description: translatePeriodError(periodResult.error),
|
||||
title: t('toast_invalid_fiscal_year'),
|
||||
description: translatePeriodError(periodResult.error, t),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -120,8 +127,8 @@ export default function WelcomeOnboarding({
|
||||
const periodResult = computeFiscalPeriod(mergedSettings)
|
||||
if (periodResult.error) {
|
||||
toast({
|
||||
title: 'Ogiltigt räkenskapsår',
|
||||
description: translatePeriodError(periodResult.error),
|
||||
title: t('toast_invalid_fiscal_year'),
|
||||
description: translatePeriodError(periodResult.error, t),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -141,16 +148,16 @@ export default function WelcomeOnboarding({
|
||||
|
||||
if (result.error || !result.companyId) {
|
||||
logError('create company action failed', { error: result.error })
|
||||
let title = 'Fel'
|
||||
let description: string = result.error || 'Kunde inte skapa företag. Försök igen.'
|
||||
let title = t('toast_error_title')
|
||||
let description: string = result.error || t('toast_create_failed')
|
||||
let backToStep2 = false
|
||||
if (result.error === 'org_number_exists') {
|
||||
title = 'Företaget finns redan'
|
||||
description = `Det här företaget finns redan i ${branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.`
|
||||
title = t('toast_company_exists_title')
|
||||
description = t('toast_company_exists_description', { appName: branding.appName.toLowerCase() })
|
||||
backToStep2 = true
|
||||
} else if (result.error === 'org_number_invalid') {
|
||||
title = 'Ogiltigt organisationsnummer'
|
||||
description = 'Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.'
|
||||
title = t('toast_org_invalid_title')
|
||||
description = t('toast_org_invalid_description')
|
||||
backToStep2 = true
|
||||
}
|
||||
toast({
|
||||
@@ -167,14 +174,14 @@ export default function WelcomeOnboarding({
|
||||
|
||||
console.log(LOG, 'onboarding completed', result.companyId)
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Ditt företag är nu redo.',
|
||||
title: t('toast_welcome_title'),
|
||||
description: t('toast_company_ready'),
|
||||
})
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logError('create company action threw', { error: message })
|
||||
toast({ title: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
|
||||
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -194,13 +201,13 @@ export default function WelcomeOnboarding({
|
||||
<div className="flex flex-col items-start justify-center min-h-[60vh] animate-fade-in">
|
||||
<p className="text-muted-foreground/50 text-sm mb-2">{greeting}</p>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight leading-[1.05] mb-10">
|
||||
Välkommen till {branding.appName}
|
||||
{t('welcome_title', { appName: branding.appName })}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setStarted(true)}
|
||||
className="px-5 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium hover:bg-foreground/85 transition-colors duration-150 active:scale-[0.98]"
|
||||
>
|
||||
{hasExistingCompanies ? 'Lägg till ett företag' : 'Lägg till ditt första företag'}
|
||||
{hasExistingCompanies ? t('add_a_company') : t('add_first_company')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -214,7 +221,7 @@ export default function WelcomeOnboarding({
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">
|
||||
{hasExistingCompanies ? 'Lägg till ett företag.' : 'Lägg till ditt första företag för att komma igång.'}
|
||||
{hasExistingCompanies ? t('add_company_subtitle') : t('add_first_company_subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -236,7 +243,7 @@ export default function WelcomeOnboarding({
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-white/60" />
|
||||
<span className="text-xs text-white/40 tracking-wide uppercase">Nytt företag</span>
|
||||
<span className="text-xs text-white/40 tracking-wide uppercase">{t('new_company')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STEP_INFO.map((_, i) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,6 +30,7 @@ interface TaxTodoWidgetProps {
|
||||
|
||||
export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('tax_todo')
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
// Filter to only tax deadlines needing attention
|
||||
@@ -62,10 +64,10 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
if (diffDays === 0) return 'Idag'
|
||||
if (diffDays === 1) return 'Imorgon'
|
||||
if (diffDays < 0) return `${Math.abs(diffDays)} dagar sedan`
|
||||
if (diffDays <= 7) return `Om ${diffDays} dagar`
|
||||
if (diffDays === 0) return t('today')
|
||||
if (diffDays === 1) return t('tomorrow')
|
||||
if (diffDays < 0) return t('days_ago', { count: Math.abs(diffDays) })
|
||||
if (diffDays <= 7) return t('in_days', { count: diffDays })
|
||||
|
||||
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
@@ -86,14 +88,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Status uppdaterad',
|
||||
description: `Markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
|
||||
title: t('toast_status_updated'),
|
||||
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
|
||||
})
|
||||
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -115,14 +117,14 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
Att göra - Skatt
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{overdueCount > 0 && (
|
||||
<Badge variant="destructive">{overdueCount} försenad</Badge>
|
||||
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
|
||||
)}
|
||||
{actionNeededCount > 0 && (
|
||||
<Badge variant="warning">{actionNeededCount} snart</Badge>
|
||||
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +195,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
'Påbörja'
|
||||
t('start')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -212,7 +214,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Inskickad
|
||||
{t('submitted')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -225,13 +227,13 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
|
||||
{sortedDeadlines.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
+{sortedDeadlines.length - 5} fler uppgifter
|
||||
{t('more_tasks', { count: sortedDeadlines.length - 5 })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between">
|
||||
Visa alla deadlines
|
||||
{t('view_all')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -21,6 +22,7 @@ interface UpcomingDeadlinesWidgetProps {
|
||||
|
||||
export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChange }: UpcomingDeadlinesWidgetProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('upcoming_deadlines')
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
// Get upcoming deadlines (next 7 days) + any needing attention
|
||||
@@ -51,10 +53,10 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Idag'
|
||||
return t('today')
|
||||
}
|
||||
if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return 'Imorgon'
|
||||
return t('tomorrow')
|
||||
}
|
||||
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
@@ -75,15 +77,15 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Status uppdaterad',
|
||||
description: `Deadline markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
|
||||
title: t('toast_status_updated'),
|
||||
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
|
||||
})
|
||||
|
||||
// Notify parent component
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
title: error instanceof Error ? error.message : t('toast_status_update_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -107,14 +109,14 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Kommande deadlines
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{overdueCount > 0 && (
|
||||
<Badge variant="destructive">{overdueCount} försenad</Badge>
|
||||
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
|
||||
)}
|
||||
{actionNeededCount > 0 && (
|
||||
<Badge variant="warning">{actionNeededCount} åtgärd</Badge>
|
||||
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,11 +149,11 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(deadline.due_date)}
|
||||
{deadline.due_time && ` kl. ${deadline.due_time.slice(0, 5)}`}
|
||||
{deadline.due_time && ` ${t('time_prefix')} ${deadline.due_time.slice(0, 5)}`}
|
||||
</p>
|
||||
{deadline.tax_deadline_type && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
Skatt
|
||||
{t('tax_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,7 +174,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
className="h-9 px-2.5"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'submitted')}
|
||||
title="Markera som inskickad"
|
||||
title={t('mark_as_submitted')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -189,7 +191,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
className="h-9 px-2.5"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
|
||||
title="Markera som bekräftad"
|
||||
title={t('mark_as_confirmed')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -205,7 +207,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between mt-2">
|
||||
Visa alla deadlines
|
||||
{t('view_all')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ExtensionCategory } from '@/lib/extensions/types'
|
||||
|
||||
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
|
||||
accounting: { label: 'Bokföring & Skatt', className: 'bg-destructive/10 text-destructive border-destructive/30' },
|
||||
reports: { label: 'Branschrapporter', className: 'bg-primary/10 text-primary border-primary/30' },
|
||||
import: { label: 'Smart Import', className: 'bg-success/10 text-success border-success/30' },
|
||||
operations: { label: 'Verktyg', className: 'bg-muted text-muted-foreground border-border' },
|
||||
const CATEGORY_CONFIG: Record<ExtensionCategory, { labelKey: string; className: string }> = {
|
||||
accounting: { labelKey: 'category_accounting', className: 'bg-destructive/10 text-destructive border-destructive/30' },
|
||||
reports: { labelKey: 'category_reports', className: 'bg-primary/10 text-primary border-primary/30' },
|
||||
import: { labelKey: 'category_import', className: 'bg-success/10 text-success border-success/30' },
|
||||
operations: { labelKey: 'category_operations', className: 'bg-muted text-muted-foreground border-border' },
|
||||
}
|
||||
|
||||
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
|
||||
const t = useTranslations('extensions')
|
||||
const config = CATEGORY_CONFIG[category]
|
||||
return (
|
||||
<Badge variant="outline" className={cn('text-[10px] font-medium', config.className)}>
|
||||
{config.label}
|
||||
{t(config.labelKey)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { extensionNameKey, extensionDescriptionKey } from '@/lib/extensions/i18n'
|
||||
import type { ExtensionDefinition } from '@/lib/extensions/types'
|
||||
import CategoryBadge from './CategoryBadge'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
|
||||
export default async function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
|
||||
const t = await getTranslations('extensions')
|
||||
|
||||
const nameKey = extensionNameKey(extension.slug)
|
||||
const descriptionKey = extensionDescriptionKey(extension.slug)
|
||||
const name = nameKey ? t(nameKey) : extension.name
|
||||
const description = descriptionKey ? t(descriptionKey) : extension.description
|
||||
|
||||
const Icon = resolveIcon(extension.icon)
|
||||
|
||||
@@ -20,10 +28,10 @@ export default function ExtensionCard({ extension }: { extension: ExtensionDefin
|
||||
href={`/extensions/${extension.sector}/${extension.slug}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{extension.name}
|
||||
{name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{extension.description}
|
||||
{description}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CategoryBadge category={extension.category} />
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { sectorNameKey, sectorDescriptionKey } from '@/lib/extensions/i18n'
|
||||
import type { Sector } from '@/lib/extensions/types'
|
||||
|
||||
export default function SectorCard({ sector }: { sector: Sector }) {
|
||||
|
||||
export default async function SectorCard({ sector }: { sector: Sector }) {
|
||||
const t = await getTranslations('extensions')
|
||||
|
||||
const nameKey = sectorNameKey(sector.slug)
|
||||
const descriptionKey = sectorDescriptionKey(sector.slug)
|
||||
const name = nameKey ? t(nameKey) : sector.name
|
||||
const description = descriptionKey ? t(descriptionKey) : sector.description
|
||||
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
|
||||
return (
|
||||
@@ -17,11 +25,11 @@ export default function SectorCard({ sector }: { sector: Sector }) {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{sector.name}
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{sector.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{sector.extensions.length} tillägg
|
||||
{t('extension_count', { count: sector.extensions.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -50,15 +51,15 @@ function formatPeriod(start: number, end: number): string {
|
||||
return `${fmt(s)} – ${fmt(e)}`
|
||||
}
|
||||
|
||||
function timeAgo(isoDate: string): string {
|
||||
function timeAgo(isoDate: string, t: (key: string, values?: Record<string, string | number>) => string): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'just nu'
|
||||
if (minutes < 60) return `${minutes} min sedan`
|
||||
if (minutes < 1) return t('time_just_now')
|
||||
if (minutes < 60) return t('time_minutes_ago', { n: minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} tim sedan`
|
||||
if (hours < 24) return t('time_hours_ago', { n: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} dag${days > 1 ? 'ar' : ''} sedan`
|
||||
return t('time_days_ago', { n: days })
|
||||
}
|
||||
|
||||
// Mirrors the live layout (two cards: company info + financials) so the
|
||||
@@ -122,6 +123,7 @@ function ProfileSkeleton() {
|
||||
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('tic_workspace')
|
||||
const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [noOrgNumber, setNoOrgNumber] = useState(false)
|
||||
@@ -147,7 +149,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
// Get org_number from company settings
|
||||
const settingsRes = await fetch('/api/settings')
|
||||
if (!settingsRes.ok) {
|
||||
toast({ title: 'Kunde inte hämta inställningar', variant: 'destructive' })
|
||||
toast({ title: t('toast_settings_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data: settings } = await settingsRes.json()
|
||||
@@ -164,7 +166,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json()
|
||||
toast({ title: error ?? 'Kunde inte hämta företagsprofil', variant: 'destructive' })
|
||||
toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' })
|
||||
setFetchFailed(true)
|
||||
return
|
||||
}
|
||||
@@ -173,12 +175,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
setProfile(data)
|
||||
await save('company_profile', data)
|
||||
} catch {
|
||||
toast({ title: 'Ett oväntat fel inträffade', variant: 'destructive' })
|
||||
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
|
||||
setFetchFailed(true)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [save, toast])
|
||||
}, [save, toast, t])
|
||||
|
||||
// Auto-fetch on first visit when no cached data
|
||||
useEffect(() => {
|
||||
@@ -196,13 +198,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Settings className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Inget organisationsnummer
|
||||
{t('no_org_number_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">
|
||||
Ange organisationsnummer under Inställningar för att visa företagsprofilen.
|
||||
{t('no_org_number_description')}
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" asChild>
|
||||
<Link href="/settings">Gå till Inställningar</Link>
|
||||
<Link href="/settings">{t('go_to_settings')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -217,17 +219,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<XCircle className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Kunde inte hämta företagsprofil
|
||||
{t('fetch_failed_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">
|
||||
Kontrollera att organisationsnumret i inställningarna är korrekt och försök igen.
|
||||
{t('fetch_failed_description')}
|
||||
</p>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/settings">Inställningar</Link>
|
||||
<Link href="/settings">{t('settings')}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={fetchProfile} disabled={isFetching}>
|
||||
Försök igen
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,9 +240,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
|
||||
const isActive = profile.activityStatus !== 'ceased'
|
||||
const registrations = [
|
||||
profile.registration.fTax && 'F-skatt',
|
||||
profile.registration.vat && 'Moms',
|
||||
profile.registration.payroll && 'Arbetsgivare',
|
||||
profile.registration.fTax && t('reg_f_tax'),
|
||||
profile.registration.vat && t('reg_vat'),
|
||||
profile.registration.payroll && t('reg_employer'),
|
||||
].filter((label): label is string => Boolean(label))
|
||||
|
||||
return (
|
||||
@@ -256,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<CardDescription>
|
||||
{profile.orgNumber} · {profile.legalEntityType}
|
||||
{!isActive && (
|
||||
<span className="ml-2 text-destructive">· Avregistrerat</span>
|
||||
<span className="ml-2 text-destructive">· {t('deregistered')}</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -285,13 +287,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
)}
|
||||
{registrations.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Registrerat för</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">{t('registered_for')}</p>
|
||||
<p className="text-xs text-muted-foreground">{registrations.join(' · ')}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.sniCodes.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">{t('sni_codes')}</p>
|
||||
<div className="space-y-0.5">
|
||||
{profile.sniCodes
|
||||
.filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i)
|
||||
@@ -306,7 +308,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
)}
|
||||
{profile.bankAccounts.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Bankuppgifter</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">{t('bank_accounts')}</p>
|
||||
<div className="space-y-0.5">
|
||||
{profile.bankAccounts.map((ba, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
@@ -319,7 +321,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
)}
|
||||
{profile.purpose && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Verksamhet</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">{t('purpose')}</p>
|
||||
<p className="text-xs text-muted-foreground">{profile.purpose}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -327,18 +329,18 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<div className="pt-2 border-t">
|
||||
{profile.employeeRange && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Anställda: {profile.employeeRange}
|
||||
{t('employees_range', { range: profile.employeeRange })}
|
||||
</p>
|
||||
)}
|
||||
{profile.turnoverRange && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Omsättning: {profile.turnoverRange}
|
||||
{t('turnover_range', { range: profile.turnoverRange })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-xs text-muted-foreground/70">
|
||||
Uppdaterad {timeAgo(profile.fetchedAt)}
|
||||
{t('updated_ago', { ago: timeAgo(profile.fetchedAt, t) })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -346,7 +348,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
{/* Financials card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Senaste bokslut</CardTitle>
|
||||
<CardTitle className="text-base">{t('latest_closing')}</CardTitle>
|
||||
{profile.financials && (
|
||||
<CardDescription>
|
||||
{formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)}
|
||||
@@ -356,31 +358,31 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<CardContent>
|
||||
{profile.financials ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FinancialCell label="Omsättning" value={formatKSEK(profile.financials.netSalesK)} />
|
||||
<FinancialCell label={t('net_sales')} value={formatKSEK(profile.financials.netSalesK)} />
|
||||
<FinancialCell
|
||||
label="Rörelseresultat"
|
||||
label={t('operating_profit')}
|
||||
value={formatKSEK(profile.financials.operatingProfitK)}
|
||||
negative={(profile.financials.operatingProfitK ?? 0) < 0}
|
||||
/>
|
||||
<FinancialCell label="Totala tillgångar" value={formatKSEK(profile.financials.totalAssetsK)} />
|
||||
<FinancialCell label={t('total_assets')} value={formatKSEK(profile.financials.totalAssetsK)} />
|
||||
<FinancialCell
|
||||
label="Anställda"
|
||||
label={t('employees')}
|
||||
value={profile.financials.numberOfEmployees !== null
|
||||
? String(profile.financials.numberOfEmployees)
|
||||
: '—'}
|
||||
/>
|
||||
<FinancialCell
|
||||
label="Rörelsemarginal"
|
||||
label={t('operating_margin')}
|
||||
value={formatPercent(profile.financials.operatingMargin)}
|
||||
negative={(profile.financials.operatingMargin ?? 0) < 0}
|
||||
/>
|
||||
<FinancialCell
|
||||
label="Soliditet"
|
||||
label={t('equity_ratio')}
|
||||
value={formatPercent(profile.financials.equityAssetsRatio)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('no_financials')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -390,17 +392,17 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
{profile.financialReports.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Årsredovisningar</CardTitle>
|
||||
<CardTitle className="text-base">{t('annual_reports')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead>Titel</TableHead>
|
||||
<TableHead>Inlämnad</TableHead>
|
||||
<TableHead>Reviderad</TableHead>
|
||||
<TableHead>Revisionsutlåtande</TableHead>
|
||||
<TableHead>{t('col_period')}</TableHead>
|
||||
<TableHead>{t('col_title')}</TableHead>
|
||||
<TableHead>{t('col_filed')}</TableHead>
|
||||
<TableHead>{t('col_audited')}</TableHead>
|
||||
<TableHead>{t('col_audit_opinion')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -14,47 +15,6 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
|
||||
const bankSetupSchema = z.object({
|
||||
bank_name: z.string().max(100).optional().or(z.literal('')),
|
||||
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4–5 siffror').optional().or(z.literal('')),
|
||||
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6–12 siffror').optional().or(z.literal('')),
|
||||
bankgiro: z.string().optional().or(z.literal('')),
|
||||
iban: z.string().optional().or(z.literal('')),
|
||||
bic: z.string().optional().or(z.literal('')),
|
||||
invoice_prefix: z.string().optional().or(z.literal('')),
|
||||
next_invoice_number: z.string().optional().or(z.literal('')),
|
||||
}).refine(
|
||||
(data) => {
|
||||
const hasAccount = !!data.clearing_number && !!data.account_number
|
||||
const hasBankgiro = !!data.bankgiro
|
||||
return hasAccount || hasBankgiro
|
||||
},
|
||||
{ message: 'Ange antingen kontonummer (clearing + konto) eller bankgiro', path: ['clearing_number'] }
|
||||
).refine(
|
||||
(data) => {
|
||||
// If one of clearing/account is filled, both must be
|
||||
if (data.clearing_number && !data.account_number) return false
|
||||
if (!data.clearing_number && data.account_number) return false
|
||||
return true
|
||||
},
|
||||
{ message: 'Ange både clearingnummer och kontonummer', path: ['account_number'] }
|
||||
).refine(
|
||||
(data) => {
|
||||
if (!data.bankgiro) return true
|
||||
return validateBankgiroNumber(data.bankgiro)
|
||||
},
|
||||
{ message: 'Ogiltigt bankgironummer (7–8 siffror med kontrollsiffra)', path: ['bankgiro'] }
|
||||
).refine(
|
||||
(data) => {
|
||||
if (!data.next_invoice_number) return true
|
||||
const num = parseInt(data.next_invoice_number, 10)
|
||||
return !isNaN(num) && num >= 1
|
||||
},
|
||||
{ message: 'Startnummer måste vara ett positivt heltal', path: ['next_invoice_number'] }
|
||||
)
|
||||
|
||||
type BankSetupData = z.infer<typeof bankSetupSchema>
|
||||
|
||||
interface BankDetailsSetupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -63,10 +23,68 @@ interface BankDetailsSetupDialogProps {
|
||||
|
||||
export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankDetailsSetupDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('invoice_bank_setup')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showInternational, setShowInternational] = useState(false)
|
||||
const [bankName, setBankName] = useState('')
|
||||
|
||||
const bankSetupSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
bank_name: z.string().max(100).optional().or(z.literal('')),
|
||||
clearing_number: z
|
||||
.string()
|
||||
.regex(/^\d{4,5}$/, t('validation_clearing_format'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
account_number: z
|
||||
.string()
|
||||
.regex(/^\d{6,12}$/, t('validation_account_format'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
bankgiro: z.string().optional().or(z.literal('')),
|
||||
iban: z.string().optional().or(z.literal('')),
|
||||
bic: z.string().optional().or(z.literal('')),
|
||||
invoice_prefix: z.string().optional().or(z.literal('')),
|
||||
next_invoice_number: z.string().optional().or(z.literal('')),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const hasAccount = !!data.clearing_number && !!data.account_number
|
||||
const hasBankgiro = !!data.bankgiro
|
||||
return hasAccount || hasBankgiro
|
||||
},
|
||||
{ message: t('validation_either_required'), path: ['clearing_number'] },
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.clearing_number && !data.account_number) return false
|
||||
if (!data.clearing_number && data.account_number) return false
|
||||
return true
|
||||
},
|
||||
{ message: t('validation_both_required'), path: ['account_number'] },
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.bankgiro) return true
|
||||
return validateBankgiroNumber(data.bankgiro)
|
||||
},
|
||||
{ message: t('validation_bankgiro_invalid'), path: ['bankgiro'] },
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.next_invoice_number) return true
|
||||
const num = parseInt(data.next_invoice_number, 10)
|
||||
return !isNaN(num) && num >= 1
|
||||
},
|
||||
{ message: t('validation_start_number_positive'), path: ['next_invoice_number'] },
|
||||
),
|
||||
[t],
|
||||
)
|
||||
|
||||
type BankSetupData = z.infer<typeof bankSetupSchema>
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -119,18 +137,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte spara')
|
||||
throw new Error(result.error || t('save_failed_fallback'))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Betalningsuppgifter sparade',
|
||||
description: 'Du kan ändra dem senare i Inställningar.',
|
||||
title: t('saved_title'),
|
||||
description: t('saved_description'),
|
||||
})
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
title: t('save_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('save_failed_fallback'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -150,17 +168,18 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-xl tracking-tight">Betalningsuppgifter</DialogTitle>
|
||||
<DialogTitle className="font-display text-xl tracking-tight">{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dessa uppgifter visas på dina fakturor. Du kan ändra dem senare i{' '}
|
||||
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">Inställningar</a>.
|
||||
{t('description_prefix')}
|
||||
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">{t('settings_link')}</a>
|
||||
{t('description_suffix')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
|
||||
{/* Bank name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Label htmlFor="bank_name">{t('bank_label')}</Label>
|
||||
<BankNameCombobox
|
||||
value={bankName}
|
||||
onChange={setBankName}
|
||||
@@ -170,10 +189,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
{/* Clearing + Account number */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="8000"
|
||||
placeholder={t('clearing_placeholder')}
|
||||
maxLength={5}
|
||||
inputMode="numeric"
|
||||
{...register('clearing_number')}
|
||||
@@ -181,10 +200,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Label htmlFor="account_number">{t('account_label')}</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="12345678"
|
||||
placeholder={t('account_placeholder')}
|
||||
maxLength={12}
|
||||
inputMode="numeric"
|
||||
{...register('account_number')}
|
||||
@@ -201,10 +220,10 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
|
||||
{/* Bankgiro */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
placeholder="123-4567"
|
||||
placeholder={t('bankgiro_placeholder')}
|
||||
maxLength={9}
|
||||
{...register('bankgiro')}
|
||||
onBlur={(e) => {
|
||||
@@ -232,23 +251,23 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Internationella betalningar
|
||||
{t('international_toggle')}
|
||||
</button>
|
||||
{showInternational && (
|
||||
<div className="space-y-3 pt-3 animate-in slide-in-from-top-1 duration-150">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Label htmlFor="iban">{t('iban_label')}</Label>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE12 3456 7890 1234 5678 9012"
|
||||
placeholder={t('iban_placeholder')}
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
<Label htmlFor="bic">{t('bic_label')}</Label>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="NDEASESS"
|
||||
placeholder={t('bic_placeholder')}
|
||||
maxLength={11}
|
||||
{...register('bic')}
|
||||
/>
|
||||
@@ -262,19 +281,19 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
{/* Invoice prefix + starting number */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Label htmlFor="invoice_prefix">{t('invoice_prefix_label')}</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
placeholder={t('invoice_prefix_placeholder')}
|
||||
maxLength={10}
|
||||
{...register('invoice_prefix')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Startnummer</Label>
|
||||
<Label htmlFor="next_invoice_number">{t('next_invoice_number_label')}</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
placeholder="1"
|
||||
placeholder={t('next_invoice_number_placeholder')}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
{...register('next_invoice_number')}
|
||||
@@ -282,7 +301,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Prefix "F-" med startnummer 1 ger F-2026001. Lämna tomt för standard.
|
||||
{t('prefix_hint')}
|
||||
</p>
|
||||
{errors.next_invoice_number && (
|
||||
<p className="text-sm text-destructive -mt-2">{errors.next_invoice_number.message}</p>
|
||||
@@ -292,7 +311,7 @@ export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankD
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Spara & fortsätt
|
||||
{t('save_and_continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -48,12 +49,13 @@ export function InvoiceReviewContent({
|
||||
numberPreview,
|
||||
oreRounding,
|
||||
}: InvoiceReviewContentProps) {
|
||||
const t = useTranslations('invoice_review')
|
||||
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
|
||||
const customerTypeLabel: Record<string, string> = {
|
||||
individual: 'Privatperson',
|
||||
swedish_business: 'Svenskt företag eller organisation',
|
||||
eu_business: 'EU-företag',
|
||||
non_eu_business: 'Utanför EU',
|
||||
individual: t('customer_type_individual'),
|
||||
swedish_business: t('customer_type_swedish_business'),
|
||||
eu_business: t('customer_type_eu_business'),
|
||||
non_eu_business: t('customer_type_non_eu_business'),
|
||||
}
|
||||
|
||||
// Calculate per-rate VAT breakdown
|
||||
@@ -71,7 +73,7 @@ export function InvoiceReviewContent({
|
||||
<div className="space-y-4">
|
||||
{numberPreview && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Tilldelas fakturanummer{' '}
|
||||
{t('assigned_number_prefix')}{' '}
|
||||
<span className="font-medium tabular-nums text-foreground">{numberPreview}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -89,11 +91,11 @@ export function InvoiceReviewContent({
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<span className="text-muted-foreground">{t('invoice_date')}</span>
|
||||
<p className="font-medium">{invoiceDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<span className="text-muted-foreground">{t('due_date')}</span>
|
||||
<p className="font-medium">{dueDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,12 +105,12 @@ export function InvoiceReviewContent({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-16 text-right">Antal</th>
|
||||
<th className="py-2 w-16 text-center">Enhet</th>
|
||||
<th className="py-2 w-24 text-right">À-pris</th>
|
||||
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2">{t('th_description')}</th>
|
||||
<th className="py-2 w-16 text-right">{t('th_quantity')}</th>
|
||||
<th className="py-2 w-16 text-center">{t('th_unit')}</th>
|
||||
<th className="py-2 w-24 text-right">{t('th_unit_price')}</th>
|
||||
{showVatColumn && <th className="py-2 w-16 text-right">{t('th_vat')}</th>}
|
||||
<th className="py-2 w-28 text-right">{t('th_amount')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -135,7 +137,7 @@ export function InvoiceReviewContent({
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
|
||||
{showVatColumn && <span className="text-xs">({item.vat_rate ?? 0}% moms)</span>}
|
||||
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
|
||||
</div>
|
||||
<p className="text-right font-medium">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
@@ -147,7 +149,7 @@ export function InvoiceReviewContent({
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span className="text-muted-foreground">{t('subtotal')}</span>
|
||||
<span>{formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
{Array.from(vatByRate.entries())
|
||||
@@ -155,25 +157,25 @@ export function InvoiceReviewContent({
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, vat]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
|
||||
<span>{formatCurrency(vat, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span>{formatCurrency(0, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{rounding.applies && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Öresavrundning</span>
|
||||
<span className="text-muted-foreground">{t('ore_rounding')}</span>
|
||||
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-xl sm:text-2xl">
|
||||
<span>Totalt</span>
|
||||
<span>{t('total')}</span>
|
||||
<span>{formatCurrency(rounding.displayed, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +185,7 @@ export function InvoiceReviewContent({
|
||||
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{yourReference && (
|
||||
<div>
|
||||
<span>Er referens:</span>
|
||||
<span>{t('your_reference')}</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{yourReference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
@@ -195,7 +197,7 @@ export function InvoiceReviewContent({
|
||||
)}
|
||||
{ourReference && (
|
||||
<div>
|
||||
<span>Vår referens:</span>
|
||||
<span>{t('our_reference')}</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ourReference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
@@ -205,7 +207,7 @@ export function InvoiceReviewContent({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{notes && <p>Anteckning: {notes}</p>}
|
||||
{notes && <p>{t('notes_prefix', { notes })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,12 +39,6 @@ interface DuplicateCandidate {
|
||||
match_confidence: number
|
||||
}
|
||||
|
||||
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
|
||||
ocr_exact: 'Exakt OCR-träff',
|
||||
name_amount_fuzzy: 'Sannolik träff',
|
||||
amount_only: 'Möjlig träff',
|
||||
}
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
@@ -68,6 +63,13 @@ export default function PaymentBookingDialog({
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('invoice_payment_dialog')
|
||||
|
||||
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
|
||||
ocr_exact: t('match_reason_ocr_exact'),
|
||||
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
|
||||
amount_only: t('match_reason_amount_only'),
|
||||
}
|
||||
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [lines, setLines] = useState<FormLine[]>([])
|
||||
@@ -90,11 +92,11 @@ export default function PaymentBookingDialog({
|
||||
try {
|
||||
// Fetch accounts
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (!accountsRes.ok) throw new Error('Kunde inte ladda kontoplanen')
|
||||
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
|
||||
const accountsData = await accountsRes.json()
|
||||
const fetchedAccounts: BASAccount[] = accountsData.data || []
|
||||
|
||||
if (!company?.id) throw new Error('Inget aktivt företag')
|
||||
if (!company?.id) throw new Error(t('no_active_company'))
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
@@ -103,7 +105,7 @@ export default function PaymentBookingDialog({
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (settingsError) throw new Error('Kunde inte ladda företagsinställningar')
|
||||
if (settingsError) throw new Error(t('load_settings_failed'))
|
||||
if (cancelled) return
|
||||
|
||||
setAccounts(fetchedAccounts)
|
||||
@@ -135,8 +137,8 @@ export default function PaymentBookingDialog({
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: 'Kunde inte ladda bokföringsdialog',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
title: t('load_dialog_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
onOpenChange(false)
|
||||
@@ -220,7 +222,7 @@ export default function PaymentBookingDialog({
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
const error = new Error('Kunde inte markera som betald') as Error & { body?: unknown; status?: number }
|
||||
const error = new Error(t('mark_paid_failed')) as Error & { body?: unknown; status?: number }
|
||||
error.body = data
|
||||
error.status = response.status
|
||||
throw error
|
||||
@@ -231,7 +233,7 @@ export default function PaymentBookingDialog({
|
||||
} catch (error) {
|
||||
const anyErr = error as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Bokföring misslyckades',
|
||||
title: t('booking_failed_title'),
|
||||
description: getErrorMessage(anyErr.body ?? error, { context: 'invoice', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -252,11 +254,11 @@ export default function PaymentBookingDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[680px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bokför betalning{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}</DialogTitle>
|
||||
<DialogTitle>{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<> ({formatCurrency(invoice.total_sek)} SEK)</>
|
||||
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -264,11 +266,11 @@ export default function PaymentBookingDialog({
|
||||
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Möjlig dubblettbetalning</p>
|
||||
<p className="text-sm font-medium">{t('duplicate_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{duplicateCandidates.length === 1
|
||||
? 'En inkommande banktransaktion ser ut att vara denna betalning. Länka den istället för att skapa en ny verifikation, eller bokför ändå om du är säker.'
|
||||
: `${duplicateCandidates.length} inkommande banktransaktioner ser ut att kunna vara denna betalning. Länka rätt transaktion istället för att skapa en ny verifikation, eller bokför ändå om du är säker.`}
|
||||
? t('duplicate_one')
|
||||
: t('duplicate_many', { count: duplicateCandidates.length })}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
@@ -305,7 +307,7 @@ export default function PaymentBookingDialog({
|
||||
onClick={() => handleLinkExisting(c.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
Länka transaktion
|
||||
{t('link_transaction')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
@@ -320,7 +322,7 @@ export default function PaymentBookingDialog({
|
||||
<div className="space-y-4">
|
||||
{/* Payment date */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-date">Betalningsdatum</Label>
|
||||
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
@@ -356,7 +358,7 @@ export default function PaymentBookingDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Debet</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('debit_label')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -369,7 +371,7 @@ export default function PaymentBookingDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Kredit</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('credit_label')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -385,7 +387,7 @@ export default function PaymentBookingDialog({
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="w-full">
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> Lägg till rad
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> {t('add_row')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -393,9 +395,9 @@ export default function PaymentBookingDialog({
|
||||
<div className="hidden sm:block space-y-2">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_120px_120px_32px] gap-2 text-xs font-medium text-muted-foreground px-1">
|
||||
<span>Konto</span>
|
||||
<span className="text-right">Debet</span>
|
||||
<span className="text-right">Kredit</span>
|
||||
<span>{t('account_label')}</span>
|
||||
<span className="text-right">{t('debit_label')}</span>
|
||||
<span className="text-right">{t('credit_label')}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
@@ -449,7 +451,7 @@ export default function PaymentBookingDialog({
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Lägg till rad
|
||||
{t('add_row')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -458,11 +460,11 @@ export default function PaymentBookingDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
{isBalanced ? (
|
||||
<Badge variant="secondary" className="bg-success/10 text-success">
|
||||
Debet = Kredit
|
||||
{t('balanced_badge')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
Obalanserad ({formatCurrency(Math.abs(totalDebit - totalCredit))})
|
||||
{t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -475,7 +477,7 @@ export default function PaymentBookingDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
|
||||
Avbryt
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
||||
<Button
|
||||
@@ -484,7 +486,7 @@ export default function PaymentBookingDialog({
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Bokför ändå
|
||||
{t('book_anyway')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -493,7 +495,7 @@ export default function PaymentBookingDialog({
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Bekräfta & bokför
|
||||
{t('confirm_and_book')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -43,6 +44,7 @@ export default function SendInvoiceDialog({
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('invoice_send_dialog')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
@@ -60,7 +62,7 @@ export default function SendInvoiceDialog({
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
if (!company?.id) throw new Error('Inget aktivt företag')
|
||||
if (!company?.id) throw new Error(t('no_active_company'))
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings, error } = await supabase
|
||||
@@ -69,7 +71,7 @@ export default function SendInvoiceDialog({
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error('Kunde inte ladda företagsinställningar')
|
||||
if (error) throw new Error(t('company_settings_failed'))
|
||||
if (cancelled) return
|
||||
|
||||
// Fetch fiscal period for the invoice date
|
||||
@@ -90,8 +92,8 @@ export default function SendInvoiceDialog({
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: 'Kunde inte ladda inställningar',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
title: t('load_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
onOpenChange(false)
|
||||
@@ -145,7 +147,7 @@ export default function SendInvoiceDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte skicka fakturan')
|
||||
throw new Error(data.error || t('send_failed_fallback'))
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
@@ -153,23 +155,23 @@ export default function SendInvoiceDialog({
|
||||
if (mode === 'email') {
|
||||
onOpenChange(false)
|
||||
toast({
|
||||
title: 'Faktura skickad',
|
||||
description: data.message || `Fakturan har skickats till ${invoice.customer.email}`,
|
||||
title: t('send_success_title'),
|
||||
description: data.message || t('send_success_default', { email: invoice.customer.email ?? '' }),
|
||||
})
|
||||
} else {
|
||||
// For manual send, just close — no email to confirm
|
||||
onOpenChange(false)
|
||||
toast({
|
||||
title: 'Faktura markerad som skickad',
|
||||
title: t('mark_success_title'),
|
||||
description: accountingMethod === 'accrual'
|
||||
? 'Bokföringsverifikationen har skapats.'
|
||||
? t('mark_success_voucher_created')
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte skicka faktura',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
title: t('send_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -188,15 +190,15 @@ export default function SendInvoiceDialog({
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}{invoice.invoice_number ? ` — ${invoice.invoice_number}` : ''}
|
||||
{mode === 'email' ? t('title_email') : t('title_manual')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<> ({formatCurrency(invoice.total_sek)} SEK)</>
|
||||
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
|
||||
)}
|
||||
{mode === 'email' && invoice.customer.email && (
|
||||
<> till {invoice.customer.email}</>
|
||||
<>{t('description_to_email', { email: invoice.customer.email })}</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -210,12 +212,15 @@ export default function SendInvoiceDialog({
|
||||
{showJournalPreview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Följande bokföringsverifikation skapas automatiskt:
|
||||
{t('journal_preview_intro')}
|
||||
</p>
|
||||
<JournalEntryReviewContent
|
||||
periodName={periodName}
|
||||
entryDate={invoice.invoice_date}
|
||||
description={`Försäljning faktura${invoice.invoice_number ? ` ${invoice.invoice_number}` : ''}${invoice.customer.name ? `, ${invoice.customer.name}` : ''}`}
|
||||
description={t('voucher_description', {
|
||||
numberSpace: invoice.invoice_number ? ` ${invoice.invoice_number}` : '',
|
||||
customerSuffix: invoice.customer.name ? `, ${invoice.customer.name}` : '',
|
||||
})}
|
||||
lines={proposedLines}
|
||||
totalDebit={totalDebit}
|
||||
totalCredit={totalCredit}
|
||||
@@ -226,10 +231,10 @@ export default function SendInvoiceDialog({
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accountingMethod === 'cash'
|
||||
? 'Kontantmetoden — bokföring sker vid betalning, inte vid fakturering.'
|
||||
? t('explain_cash')
|
||||
: mode === 'email'
|
||||
? `Fakturan skickas till ${invoice.customer.email}.`
|
||||
: 'Fakturan markeras som skickad.'}
|
||||
? t('explain_email', { email: invoice.customer.email ?? '' })
|
||||
: t('explain_manual')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -242,7 +247,7 @@ export default function SendInvoiceDialog({
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
Avbryt
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
@@ -256,7 +261,7 @@ export default function SendInvoiceDialog({
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{mode === 'email' ? 'Skicka faktura' : 'Markera som skickad'}
|
||||
{mode === 'email' ? t('send_invoice') : t('mark_as_sent')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -22,16 +23,17 @@ const SEGMENT_COLORS = [
|
||||
]
|
||||
|
||||
export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const { class4, class5, class6, class7 } = composition
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ name: 'Varor (klass 4)', value: class4 },
|
||||
{ name: 'Drift & lokaler (klass 5)', value: class5 },
|
||||
{ name: 'Övriga externa (klass 6)', value: class6 },
|
||||
{ name: 'Personal (klass 7)', value: class7 },
|
||||
{ name: t('expense_mix_class4'), value: class4 },
|
||||
{ name: t('expense_mix_class5'), value: class5 },
|
||||
{ name: t('expense_mix_class6'), value: class6 },
|
||||
{ name: t('expense_mix_class7'), value: class7 },
|
||||
].filter((s) => s.value > 0),
|
||||
[class4, class5, class6, class7]
|
||||
[class4, class5, class6, class7, t]
|
||||
)
|
||||
|
||||
const total = class4 + class5 + class6 + class7
|
||||
@@ -44,12 +46,12 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Kostnader per klass</CardTitle>
|
||||
<CardTitle className="text-base">{t('expense_mix_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-muted-foreground">
|
||||
Inga bokförda kostnader ännu
|
||||
{t('expense_mix_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex flex-col items-center">
|
||||
@@ -81,7 +83,7 @@ export function KPIExpenseMixChart({ composition }: KPIExpenseMixChartProps) {
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[180px] flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Totalt
|
||||
{t('expense_mix_total')}
|
||||
</span>
|
||||
<span
|
||||
className="font-display text-lg font-medium tabular-nums"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -11,75 +12,73 @@ interface KPIHeroCardsProps {
|
||||
preferences?: KPIPreferences
|
||||
}
|
||||
|
||||
function getKPIValue(
|
||||
function getKPISubtitleKey(
|
||||
report: KPIReport,
|
||||
id: string
|
||||
): { value: number | null; subtitle: string } {
|
||||
id: string,
|
||||
): { key: string; args?: Record<string, string | number> } {
|
||||
switch (id) {
|
||||
case 'netResult':
|
||||
return { value: report.netResult, subtitle: 'netto' }
|
||||
return { key: 'sub_netto' }
|
||||
case 'cashPosition':
|
||||
return { value: report.cashPosition, subtitle: 'likvida medel' }
|
||||
return { key: 'sub_likvida_medel' }
|
||||
case 'outstandingReceivables':
|
||||
return {
|
||||
value: report.outstandingReceivables,
|
||||
subtitle:
|
||||
report.overdueReceivables > 0
|
||||
? `varav förfallet: ${formatCurrency(report.overdueReceivables)}`
|
||||
: 'utestående',
|
||||
if (report.overdueReceivables > 0) {
|
||||
return { key: 'sub_overdue', args: { amount: formatCurrency(report.overdueReceivables) } }
|
||||
}
|
||||
return { key: 'sub_utestaende' }
|
||||
case 'vatLiability':
|
||||
return {
|
||||
value: report.vatLiability,
|
||||
subtitle:
|
||||
report.vatLiability > 0
|
||||
? 'att betala'
|
||||
: report.vatLiability < 0
|
||||
? 'att återfå'
|
||||
: 'jämnt',
|
||||
}
|
||||
if (report.vatLiability > 0) return { key: 'sub_att_betala' }
|
||||
if (report.vatLiability < 0) return { key: 'sub_att_aterfa' }
|
||||
return { key: 'sub_jamnt' }
|
||||
case 'grossMargin':
|
||||
return { value: report.grossMargin, subtitle: 'av intäkter' }
|
||||
return { key: 'sub_av_intakter' }
|
||||
case 'expenseRatio':
|
||||
return { value: report.expenseRatio, subtitle: 'av intäkter' }
|
||||
return { key: 'sub_av_intakter' }
|
||||
case 'avgPaymentDays':
|
||||
return { value: report.avgPaymentDays, subtitle: 'snitt' }
|
||||
return { key: 'sub_snitt' }
|
||||
default:
|
||||
return { value: null, subtitle: '' }
|
||||
return { key: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function formatKPIValue(value: number | null, format: string, id: string): string {
|
||||
function getKPIValue(report: KPIReport, id: string): number | null {
|
||||
switch (id) {
|
||||
case 'netResult': return report.netResult
|
||||
case 'cashPosition': return report.cashPosition
|
||||
case 'outstandingReceivables': return report.outstandingReceivables
|
||||
case 'vatLiability': return report.vatLiability
|
||||
case 'grossMargin': return report.grossMargin
|
||||
case 'expenseRatio': return report.expenseRatio
|
||||
case 'avgPaymentDays': return report.avgPaymentDays
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatKPIValue(value: number | null, format: string, id: string, daysSuffix: string): string {
|
||||
if (value === null) return '—'
|
||||
if (format === 'currency') {
|
||||
if (id === 'vatLiability') return formatCurrency(Math.abs(value))
|
||||
return formatCurrency(value)
|
||||
}
|
||||
if (format === 'percentage') return `${value}%`
|
||||
if (format === 'days') return `${value} dagar`
|
||||
if (format === 'days') return `${value} ${daysSuffix}`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getValueColor(
|
||||
value: number | null,
|
||||
colorLogic: string
|
||||
): string {
|
||||
function getValueColor(value: number | null, colorLogic: string): string {
|
||||
if (value === null) return 'text-muted-foreground'
|
||||
if (colorLogic === 'neutral') return ''
|
||||
if (colorLogic === 'positive-good') {
|
||||
return value >= 0
|
||||
? 'text-[hsl(var(--chart-1))]'
|
||||
: 'text-[hsl(var(--chart-2))]'
|
||||
return value >= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
|
||||
}
|
||||
if (colorLogic === 'negative-good') {
|
||||
return value <= 0
|
||||
? 'text-[hsl(var(--chart-1))]'
|
||||
: 'text-[hsl(var(--chart-2))]'
|
||||
return value <= 0 ? 'text-[hsl(var(--chart-1))]' : 'text-[hsl(var(--chart-2))]'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const prefs = preferences ?? getDefaultPreferences()
|
||||
|
||||
const visibleDefs = prefs.kpiOrder
|
||||
@@ -90,7 +89,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Inga nyckeltal valda. Klicka på "Anpassa" för att välja vilka som ska visas.
|
||||
{t('empty_no_kpis_chosen')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -106,8 +105,9 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
return (
|
||||
<div className={`grid ${gridCols} gap-4`}>
|
||||
{visibleDefs.map((def) => {
|
||||
const { value, subtitle } = getKPIValue(report, def.id)
|
||||
const formatted = formatKPIValue(value, def.format, def.id)
|
||||
const value = getKPIValue(report, def.id)
|
||||
const sub = getKPISubtitleKey(report, def.id)
|
||||
const formatted = formatKPIValue(value, def.format, def.id, t('value_days_suffix'))
|
||||
const color = getValueColor(value, def.colorLogic)
|
||||
const hasOverride =
|
||||
prefs.accountOverrides[def.id] &&
|
||||
@@ -115,18 +115,18 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<p className="text-foreground/90">{def.description}</p>
|
||||
<p className="text-foreground/90">{t(`def_${def.id}_description`)}</p>
|
||||
<div>
|
||||
<span className="font-medium">Formel: </span>
|
||||
<span className="font-mono">{def.formula}</span>
|
||||
<span className="font-medium">{t('tooltip_formula')} </span>
|
||||
<span className="font-mono">{t(`def_${def.id}_formula`)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Konton: </span>
|
||||
{def.accountDescription}
|
||||
<span className="font-medium">{t('tooltip_accounts')} </span>
|
||||
{t(`def_${def.id}_accounts`)}
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<div className="text-primary">
|
||||
<span className="font-medium">Anpassade: </span>
|
||||
<span className="font-medium">{t('tooltip_overrides')} </span>
|
||||
<span className="font-mono">
|
||||
{prefs.accountOverrides[def.id].join(', ')}
|
||||
</span>
|
||||
@@ -144,7 +144,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
maxWidth="320px"
|
||||
iconClassName="h-3 w-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{def.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(`def_${def.id}_label`)}</p>
|
||||
</InfoTooltip>
|
||||
<p
|
||||
className={`font-display text-2xl font-medium tabular-nums tracking-tight mt-2 ${color}`}
|
||||
@@ -152,7 +152,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
{formatted}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
{sub.key ? t(sub.key, sub.args) : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Settings2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -23,6 +24,8 @@ interface KPISettingsDialogProps {
|
||||
}
|
||||
|
||||
export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDialogProps) {
|
||||
const t = useTranslations('kpi')
|
||||
const tCommon = useTranslations('common')
|
||||
const [draft, setDraft] = useState<KPIPreferences>(preferences)
|
||||
const [expandedKpi, setExpandedKpi] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -78,14 +81,14 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Anpassa
|
||||
{t('customize')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Anpassa nyckeltal</DialogTitle>
|
||||
<DialogTitle>{t('settings_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Välj vilka nyckeltal som visas och justera beräkningarna.
|
||||
{t('settings_subtitle')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -120,10 +123,10 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{def.label}
|
||||
{t(`def_${def.id}_label`)}
|
||||
{hasOverride && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
||||
(anpassad)
|
||||
{t('settings_custom_suffix')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -137,26 +140,26 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 ml-5.5 space-y-2.5 text-xs text-muted-foreground">
|
||||
<p>{def.description}</p>
|
||||
<p>{t(`def_${def.id}_description`)}</p>
|
||||
<div>
|
||||
<p className="font-medium text-foreground/80 mb-0.5">
|
||||
Formel
|
||||
{t('settings_formula_label')}
|
||||
</p>
|
||||
<p className="font-mono text-[11px] bg-muted/50 rounded px-2 py-1">
|
||||
{def.formula}
|
||||
{t(`def_${def.id}_formula`)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground/80 mb-0.5">
|
||||
Konton
|
||||
{t('settings_accounts_label')}
|
||||
</p>
|
||||
<p>{def.accountDescription}</p>
|
||||
<p>{t(`def_${def.id}_accounts`)}</p>
|
||||
</div>
|
||||
|
||||
{def.customizableAccounts && (
|
||||
<div className="pt-1">
|
||||
<label className="font-medium text-foreground/80 block mb-1">
|
||||
Anpassa konton
|
||||
{t('settings_customize_accounts')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -168,8 +171,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-mono tabular-nums placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/70">
|
||||
Ange kontonummer separerade med komma (t.ex.{' '}
|
||||
{def.defaultAccounts.slice(0, 3).join(', ')})
|
||||
{t('settings_account_hint', { example: def.defaultAccounts.slice(0, 3).join(', ') })}
|
||||
</p>
|
||||
{hasOverride && (
|
||||
<button
|
||||
@@ -177,7 +179,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
onClick={() => clearAccountOverride(def.id)}
|
||||
className="mt-1 text-[10px] text-primary hover:underline"
|
||||
>
|
||||
Återställ till standard
|
||||
{t('settings_reset_field')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -196,16 +198,16 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Återställ allt
|
||||
{t('settings_reset_all')}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Avbryt
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Sparar...' : 'Spara'}
|
||||
{saving ? tCommon('saving') : tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -19,15 +20,16 @@ interface KPITopSuppliersChartProps {
|
||||
const BAR_COLOR = 'hsl(var(--chart-1))'
|
||||
|
||||
export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Största leverantörer</CardTitle>
|
||||
<CardTitle className="text-base">{t('top_suppliers_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{suppliers.length === 0 ? (
|
||||
<div className="flex h-[200px] items-center justify-center text-center text-sm text-muted-foreground px-4">
|
||||
Inga registrerade leverantörsfakturor under perioden
|
||||
{t('top_suppliers_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, suppliers.length * 32)}>
|
||||
@@ -55,7 +57,7 @@ export function KPITopSuppliersChart({ suppliers }: KPITopSuppliersChartProps) {
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), 'Spend']}
|
||||
formatter={(value) => [formatCurrency(Number(value)), t('top_suppliers_spend')]}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
borderRadius: '8px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Area,
|
||||
XAxis,
|
||||
@@ -19,12 +20,18 @@ interface KPITrendChartProps {
|
||||
}
|
||||
|
||||
export function KPITrendChart({ months }: KPITrendChartProps) {
|
||||
const t = useTranslations('kpi')
|
||||
if (months.length === 0) return null
|
||||
|
||||
const seriesLabel = (key: string) =>
|
||||
key === 'income' ? t('trend_legend_income')
|
||||
: key === 'expenses' ? t('trend_legend_expenses')
|
||||
: t('trend_legend_net')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Intäkter, kostnader & resultat per månad</CardTitle>
|
||||
<CardTitle className="text-base">{t('trend_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
@@ -43,11 +50,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
formatCurrency(Number(value)),
|
||||
name === 'income'
|
||||
? 'Intäkter'
|
||||
: name === 'expenses'
|
||||
? 'Kostnader'
|
||||
: 'Resultat',
|
||||
seriesLabel(String(name)),
|
||||
]}
|
||||
contentStyle={{
|
||||
fontSize: '12px',
|
||||
@@ -56,15 +59,7 @@ export function KPITrendChart({ months }: KPITrendChartProps) {
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) =>
|
||||
value === 'income'
|
||||
? 'Intäkter'
|
||||
: value === 'expenses'
|
||||
? 'Kostnader'
|
||||
: 'Resultat'
|
||||
}
|
||||
/>
|
||||
<Legend formatter={(value: string) => seriesLabel(value)} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,6 +41,8 @@ type SetupState =
|
||||
| { kind: 'opening'; companyId: string }
|
||||
| { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' }
|
||||
|
||||
// Swedish legal entity names (Aktiebolag, Enskild firma, etc.) are statutory
|
||||
// terms — kept in Swedish in both locales.
|
||||
function humanEntityType(t: string | null | undefined): string {
|
||||
if (!t) return ''
|
||||
if (t === 'aktiebolag') return 'Aktiebolag'
|
||||
@@ -74,11 +77,12 @@ export default function BankIdCompanyPicker({
|
||||
}: BankIdCompanyPickerProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('select_company')
|
||||
const [setup, setSetup] = useState<SetupState>({ kind: 'idle' })
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
|
||||
const greeting = hour < 5 ? t('greeting_night') : hour < 10 ? t('greeting_morning') : hour < 14 ? t('greeting_hello') : hour < 18 ? t('greeting_afternoon') : t('greeting_evening')
|
||||
|
||||
const busy = setup.kind !== 'idle' || isPending
|
||||
|
||||
@@ -127,8 +131,8 @@ export default function BankIdCompanyPicker({
|
||||
// Route to the manual wizard with the known fields pre-filled instead.
|
||||
if (!lookup) {
|
||||
toast({
|
||||
title: 'Kunde inte hämta företagsuppgifter',
|
||||
description: 'Fyll i resterande uppgifter manuellt.',
|
||||
title: t('toast_lookup_failed_title'),
|
||||
description: t('toast_lookup_failed_description'),
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`)
|
||||
@@ -139,8 +143,8 @@ export default function BankIdCompanyPicker({
|
||||
// Under BFL 2 kap, bokföringsskyldighet ends when a company is struck off.
|
||||
if (lookup.isCeased) {
|
||||
toast({
|
||||
title: 'Företaget är avregistrerat',
|
||||
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
|
||||
title: t('toast_company_ceased_title'),
|
||||
description: t('toast_company_ceased_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
@@ -168,8 +172,8 @@ export default function BankIdCompanyPicker({
|
||||
|
||||
if (result.error === 'org_number_exists') {
|
||||
toast({
|
||||
title: 'Företaget finns redan',
|
||||
description: 'Be en befintlig administratör att bjuda in dig.',
|
||||
title: t('toast_company_exists_title'),
|
||||
description: t('toast_company_exists_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
@@ -181,8 +185,8 @@ export default function BankIdCompanyPicker({
|
||||
// above, but the server-side guard catches any race where TIC's
|
||||
// cached result differs between the two calls.
|
||||
toast({
|
||||
title: 'Företaget är avregistrerat',
|
||||
description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.',
|
||||
title: t('toast_company_ceased_title'),
|
||||
description: t('toast_company_ceased_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
@@ -191,8 +195,8 @@ export default function BankIdCompanyPicker({
|
||||
|
||||
if (result.error === 'org_number_invalid') {
|
||||
toast({
|
||||
title: 'Ogiltigt organisationsnummer',
|
||||
description: 'Fortsätt med manuell uppsättning.',
|
||||
title: t('toast_org_invalid_title'),
|
||||
description: t('toast_org_invalid_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
@@ -202,15 +206,15 @@ export default function BankIdCompanyPicker({
|
||||
|
||||
if (result.error || !result.companyId) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa företag',
|
||||
description: result.error ?? 'Försök igen eller lägg till manuellt.',
|
||||
title: t('toast_create_failed_title'),
|
||||
description: result.error ?? t('toast_create_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSetup({ kind: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.' })
|
||||
toast({ title: t('toast_welcome_title'), description: t('toast_company_ready') })
|
||||
window.location.assign('/')
|
||||
})
|
||||
}
|
||||
@@ -224,21 +228,21 @@ export default function BankIdCompanyPicker({
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">Sätter upp ditt företag…</p>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">{t('setting_up')}</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Org.nr {setup.orgNumber}</p>
|
||||
<p className="font-medium text-sm">{t('org_nr_prefix', { orgNumber: setup.orgNumber })}</p>
|
||||
<ul className="mt-3 space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
{lookupDone
|
||||
? <Check className="h-4 w-4 text-sage" />
|
||||
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
<span className={cn(!lookupDone && 'text-muted-foreground')}>
|
||||
Hämtar uppgifter från Bolagsverket
|
||||
{t('progress_lookup')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
@@ -246,7 +250,7 @@ export default function BankIdCompanyPicker({
|
||||
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
|
||||
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
|
||||
Skapar företag och kontoplan
|
||||
{t('progress_provision')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -264,7 +268,7 @@ export default function BankIdCompanyPicker({
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1.5">
|
||||
Välj ett företag att öppna eller lägg till ett nytt.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -274,7 +278,7 @@ export default function BankIdCompanyPicker({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.
|
||||
{t('enrichment_stale')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +287,7 @@ export default function BankIdCompanyPicker({
|
||||
{memberCompanies.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
|
||||
Dina företag i {branding.appName.toLowerCase()}
|
||||
{t('section_your_companies', { appName: branding.appName.toLowerCase() })}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{memberCompanies.map((c) => {
|
||||
@@ -330,7 +334,7 @@ export default function BankIdCompanyPicker({
|
||||
{ticCompanies.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
|
||||
Företag kopplade till ditt BankID
|
||||
{t('section_bankid_companies')}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{ticCompanies.map(({ role, status }) => {
|
||||
@@ -351,11 +355,11 @@ export default function BankIdCompanyPicker({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
|
||||
Finns redan i {branding.appName.toLowerCase()}
|
||||
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||
Be en befintlig administratör att bjuda in dig.
|
||||
{t('ask_admin_invite')}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -382,7 +386,7 @@ export default function BankIdCompanyPicker({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
|
||||
Sätts upp manuellt
|
||||
{t('setup_manually')}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -422,7 +426,7 @@ export default function BankIdCompanyPicker({
|
||||
|
||||
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga företag hittades. Lägg till ditt första företag nedan.
|
||||
{t('no_companies_found')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -432,7 +436,7 @@ export default function BankIdCompanyPicker({
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-background px-3 text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
eller
|
||||
{t('or_separator')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +450,7 @@ export default function BankIdCompanyPicker({
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Lägg till företag manuellt
|
||||
{t('add_company_manually')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
@@ -31,6 +32,7 @@ export default function NewUserChecklist({
|
||||
className,
|
||||
hasSkatteverketConnected,
|
||||
}: NewUserChecklistProps) {
|
||||
const t = useTranslations('new_user_checklist')
|
||||
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
|
||||
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
@@ -41,11 +43,10 @@ export default function NewUserChecklist({
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 md:mb-12">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
Välkommen till {branding.appName.toLowerCase()}
|
||||
{t('welcome', { appName: branding.appName.toLowerCase() })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto mt-3">
|
||||
Börja med att hämta din bokföring, sedan kopplar du banken.
|
||||
Ingenting ändras i ditt nuvarande system.
|
||||
{t('intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +57,7 @@ export default function NewUserChecklist({
|
||||
1
|
||||
</span>
|
||||
<h2 className="font-display text-base font-medium tracking-tight">
|
||||
Hämta din bokföring
|
||||
{t('step1_title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -72,10 +73,10 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
Hämta från annat system
|
||||
{t('migrate_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed underline decoration-foreground/20 underline-offset-2">
|
||||
Inget ändras i ditt befintliga system.
|
||||
{t('migrate_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 sm:gap-2 mt-2.5 sm:mt-3">
|
||||
{([
|
||||
@@ -107,10 +108,10 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
Importera SIE-fil
|
||||
{t('sie_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
Exportera en SIE4-fil från ditt nuvarande bokföringsprogram och ladda upp den här.
|
||||
{t('sie_description')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
|
||||
@@ -126,7 +127,7 @@ export default function NewUserChecklist({
|
||||
2
|
||||
</span>
|
||||
<h2 className="font-display text-base font-medium tracking-tight">
|
||||
Koppla din bank
|
||||
{t('step2_title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -141,12 +142,12 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
Anslut ditt bankkonto
|
||||
{t('bank_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
{hasBanking
|
||||
? 'Koppla via PSD2 — transaktioner synkas automatiskt varje dag.'
|
||||
: 'Importera kontoutdrag från din bank — CSV, OFX och de flesta svenska banker.'}
|
||||
? t('bank_description_psd2')
|
||||
: t('bank_description_file')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
|
||||
@@ -175,9 +176,9 @@ export default function NewUserChecklist({
|
||||
: '3'}
|
||||
</span>
|
||||
<h2 className="font-display text-base font-medium tracking-tight">
|
||||
Anslut Skatteverket
|
||||
{t('step3_title')}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">— valfritt</span>
|
||||
<span className="text-xs text-muted-foreground">{t('optional_suffix')}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-0 sm:ml-10">
|
||||
@@ -189,10 +190,10 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base text-emerald-900 dark:text-emerald-200">
|
||||
Skatteverket anslutet
|
||||
{t('skatteverket_connected_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
Du kan nu skicka momsdeklaration och AGI direkt, samt se saldot på skattekontot.
|
||||
{t('skatteverket_connected_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,10 +213,10 @@ export default function NewUserChecklist({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
|
||||
Anslut till Skatteverket med BankID
|
||||
{t('skatteverket_connect_title')}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-1.5 leading-relaxed">
|
||||
Skicka momsdeklaration och arbetsgivardeklaration direkt, och hämta saldot på skattekontot — utan att lämna {branding.appName.toLowerCase()}.
|
||||
{t('skatteverket_connect_description', { appName: branding.appName.toLowerCase() })}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
|
||||
@@ -230,7 +231,7 @@ export default function NewUserChecklist({
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
<span className="text-xs text-muted-foreground">eller</span>
|
||||
<span className="text-xs text-muted-foreground">{t('or_separator')}</span>
|
||||
<div className="flex-1 h-px bg-border/60" />
|
||||
</div>
|
||||
|
||||
@@ -239,7 +240,7 @@ export default function NewUserChecklist({
|
||||
onClick={onFreshStart}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5 group"
|
||||
>
|
||||
Jag startar en ny verksamhet utan tidigare bokföring
|
||||
{t('fresh_start')}
|
||||
<ArrowRight className="h-3.5 w-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -247,7 +248,7 @@ export default function NewUserChecklist({
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground/50">
|
||||
Din data är krypterad och lagras säkert i Sverige
|
||||
{t('security_note')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -14,30 +15,33 @@ interface Step1Props {
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
const entityOptions: {
|
||||
value: EntityType | string
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Building2
|
||||
disabled?: boolean
|
||||
}[] = [
|
||||
{
|
||||
value: 'enskild_firma',
|
||||
label: 'Enskild firma',
|
||||
description: 'Du driver verksamhet i eget namn med F-skattsedel',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: 'aktiebolag',
|
||||
label: 'Aktiebolag',
|
||||
description: 'Du har ett registrerat AB med organisationsnummer',
|
||||
icon: Building2,
|
||||
},
|
||||
]
|
||||
|
||||
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
|
||||
const t = useTranslations('onboarding')
|
||||
const [selected, setSelected] = useState<EntityType | undefined>(initialData.entity_type)
|
||||
|
||||
// "Enskild firma" and "Aktiebolag" are statutory legal entity types — kept
|
||||
// in Swedish in both locales.
|
||||
const entityOptions: {
|
||||
value: EntityType | string
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Building2
|
||||
disabled?: boolean
|
||||
}[] = [
|
||||
{
|
||||
value: 'enskild_firma',
|
||||
label: 'Enskild firma',
|
||||
description: t('step1_ef_description'),
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: 'aktiebolag',
|
||||
label: 'Aktiebolag',
|
||||
description: t('step1_ab_description'),
|
||||
icon: Building2,
|
||||
},
|
||||
]
|
||||
|
||||
const handleNext = () => {
|
||||
if (!selected) {
|
||||
const msg = 'step 1: fortsätt clicked without entity type selected'
|
||||
@@ -85,7 +89,7 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{option.disabled && (
|
||||
<Badge variant="secondary" className="text-xs">Kommer snart</Badge>
|
||||
<Badge variant="secondary" className="text-xs">{t('coming_soon')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
@@ -114,11 +118,11 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
{t('continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -52,6 +53,7 @@ export default function Step2CompanyDetails({
|
||||
isSaving,
|
||||
orgNumberLocked,
|
||||
}: Step2Props) {
|
||||
const t = useTranslations('onboarding')
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -137,12 +139,12 @@ export default function Step2CompanyDetails({
|
||||
return
|
||||
}
|
||||
if (res.status === 404) {
|
||||
setLookupError('Inget företag hittades med det organisationsnumret.')
|
||||
setLookupError(t('step2_lookup_not_found'))
|
||||
onTicLookup?.(null)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
|
||||
setLookupError(t('step2_lookup_failed'))
|
||||
onTicLookup?.(null)
|
||||
return
|
||||
}
|
||||
@@ -163,7 +165,7 @@ export default function Step2CompanyDetails({
|
||||
})
|
||||
.catch((err) => {
|
||||
if ((err as Error).name === 'AbortError') return
|
||||
setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.')
|
||||
setLookupError(t('step2_lookup_failed'))
|
||||
onTicLookup?.(null)
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -184,13 +186,13 @@ export default function Step2CompanyDetails({
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grunduppgifter</CardTitle>
|
||||
<CardTitle>{t('step2_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{ticEnabled
|
||||
? 'Ange organisationsnummer så hämtas övriga uppgifter automatiskt.'
|
||||
? t('step2_card_desc_tic')
|
||||
: isAB
|
||||
? 'Ange bolagets registrerade namn och organisationsnummer.'
|
||||
: 'Ange namn på din verksamhet.'}
|
||||
? t('step2_card_desc_ab')
|
||||
: t('step2_card_desc_ef')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -201,11 +203,11 @@ export default function Step2CompanyDetails({
|
||||
})} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">
|
||||
Organisationsnummer *
|
||||
{t('step2_org_number_label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
|
||||
placeholder={isAB ? 'XXXXXX-XXXX' : t('step2_org_number_placeholder_ef')}
|
||||
{...register('org_number')}
|
||||
readOnly={orgNumberLocked}
|
||||
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
|
||||
@@ -215,13 +217,13 @@ export default function Step2CompanyDetails({
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAB
|
||||
? 'Obligatoriskt för aktiebolag'
|
||||
: 'Vid enskild firma är orgnummer samma som ditt personnummer'}
|
||||
? t('step2_org_help_ab')
|
||||
: t('step2_org_help_ef')}
|
||||
</p>
|
||||
{ticEnabled && isLooking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Hämtar företagsuppgifter...
|
||||
{t('step2_fetching_details')}
|
||||
</div>
|
||||
)}
|
||||
{ticEnabled && lookupDone && !lookupDone.isCeased && (
|
||||
@@ -233,7 +235,7 @@ export default function Step2CompanyDetails({
|
||||
{ticEnabled && lookupDone?.isCeased && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{lookupDone.companyName} — företaget är avregistrerat
|
||||
{t('step2_ceased_inline', { companyName: lookupDone.companyName })}
|
||||
</div>
|
||||
)}
|
||||
{ticEnabled && lookupError && (
|
||||
@@ -243,7 +245,7 @@ export default function Step2CompanyDetails({
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Det här företaget finns redan i {branding.appName.toLowerCase()}. Be en befintlig administratör att bjuda in dig.
|
||||
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -251,11 +253,11 @@ export default function Step2CompanyDetails({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">
|
||||
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
|
||||
{isAB ? t('step2_company_name_ab') : t('step2_company_name_ef')}
|
||||
</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
|
||||
placeholder={isAB ? t('step2_company_name_placeholder_ab') : t('step2_company_name_placeholder_ef')}
|
||||
{...register('company_name')}
|
||||
/>
|
||||
{errors.company_name && (
|
||||
@@ -264,21 +266,21 @@ export default function Step2CompanyDetails({
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Adress (för fakturor)</h3>
|
||||
<h3 className="font-medium mb-4">{t('step2_address_heading')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Label htmlFor="address_line1">{t('step2_street_address')}</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
placeholder="Storgatan 1"
|
||||
placeholder={t('step2_street_placeholder')}
|
||||
{...register('address_line1')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Label htmlFor="postal_code">{t('step2_postal_code')}</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
placeholder="123 45"
|
||||
@@ -286,7 +288,7 @@ export default function Step2CompanyDetails({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Label htmlFor="city">{t('step2_city')}</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Stockholm"
|
||||
@@ -306,7 +308,7 @@ export default function Step2CompanyDetails({
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -316,11 +318,11 @@ export default function Step2CompanyDetails({
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
{t('continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -150,6 +151,7 @@ export default function Step3TaxRegistration({
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step3Props) {
|
||||
const t = useTranslations('onboarding')
|
||||
const isEF = entityType === 'enskild_firma'
|
||||
const { toast } = useToast()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
@@ -215,7 +217,7 @@ export default function Step3TaxRegistration({
|
||||
)
|
||||
if (validation.error) {
|
||||
toast({
|
||||
title: 'Räkenskapsåret är inte giltigt',
|
||||
title: t('step3_invalid_period'),
|
||||
description: validation.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -242,10 +244,10 @@ export default function Step3TaxRegistration({
|
||||
? `${parseDateParts(firstEnd).day} ${monthNames[parseDateParts(firstEnd).month - 1].toLowerCase()} ${parseDateParts(firstEnd).year}`
|
||||
: monthNames[endMonth - 1].toLowerCase()
|
||||
const ok = await confirm({
|
||||
title: 'Är du säker på brutet räkenskapsår?',
|
||||
description: `Du har valt ett räkenskapsår som inte följer kalenderåret (slutar ${endLabel}). De flesta svenska företag använder kalenderår (1 januari – 31 december). Du kan ändra detta senare i inställningarna, men endast innan du har bokfört något.`,
|
||||
confirmLabel: 'Ja, fortsätt',
|
||||
cancelLabel: 'Ändra val',
|
||||
title: t('step3_broken_year_title'),
|
||||
description: t('step3_broken_year_description', { endLabel }),
|
||||
confirmLabel: t('step3_broken_year_confirm'),
|
||||
cancelLabel: t('step3_broken_year_cancel'),
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -267,9 +269,9 @@ export default function Step3TaxRegistration({
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>F-skatt och räkenskapsår</CardTitle>
|
||||
<CardTitle>{t('step3_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa uppgifter används för att beräkna din skattesituation.
|
||||
{t('step3_card_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -279,8 +281,8 @@ export default function Step3TaxRegistration({
|
||||
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 3 validation failed', extra: { fields } }) }).catch(() => {})
|
||||
// Show first validation error to user
|
||||
const firstError = Object.values(errs)[0]
|
||||
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
|
||||
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
|
||||
const message = firstError?.message || t('check_all_fields')
|
||||
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
|
||||
})} className="space-y-6">
|
||||
{/* F-skatt */}
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -299,19 +301,19 @@ export default function Step3TaxRegistration({
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är F-skatt?</p>
|
||||
<p>F-skatt betyder att du själv ansvarar för att betala skatt och avgifter till Skatteverket varje månad.</p>
|
||||
<p className="text-xs text-muted-foreground">De flesta som driver företag har F-skatt. Utan F-skatt måste dina kunder göra skatteavdrag på dina fakturor.</p>
|
||||
<p className="font-medium">{t('step3_fskatt_tip_title')}</p>
|
||||
<p>{t('step3_fskatt_tip_body')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('step3_fskatt_tip_note')}</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="f_skatt" className="cursor-pointer">
|
||||
Jag har F-skattsedel
|
||||
{t('step3_fskatt_label')}
|
||||
</Label>
|
||||
</InfoTooltip>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
F-skatt innebär att du själv ansvarar för att betala in skatt och avgifter.
|
||||
{t('step3_fskatt_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,16 +323,16 @@ export default function Step3TaxRegistration({
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Räkenskapsår</p>
|
||||
<p>Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).</p>
|
||||
<p className="font-medium">{t('step3_fy_tip_title')}</p>
|
||||
<p>{t('step3_fy_tip_body')}</p>
|
||||
{isEF && (
|
||||
<p className="text-xs text-muted-foreground">Enskild firma måste använda kalenderår enligt BFL 3 kap.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('step3_fy_tip_ef_note')}</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label className="text-base font-medium">Vilket räkenskapsår bokför du för?</Label>
|
||||
<Label className="text-base font-medium">{t('step3_fy_question')}</Label>
|
||||
</InfoTooltip>
|
||||
|
||||
{/* Toggle: First year vs Ongoing */}
|
||||
@@ -350,8 +352,8 @@ export default function Step3TaxRegistration({
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Första räkenskapsåret</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Nystartat företag</p>
|
||||
<p className="font-medium text-sm">{t('step3_first_fy_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_first_fy_subtitle')}</p>
|
||||
</div>
|
||||
{field.value && (
|
||||
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
|
||||
@@ -372,8 +374,8 @@ export default function Step3TaxRegistration({
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Annat räkenskapsår</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Pågående verksamhet</p>
|
||||
<p className="font-medium text-sm">{t('step3_other_fy_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('step3_other_fy_subtitle')}</p>
|
||||
</div>
|
||||
{!field.value && (
|
||||
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
|
||||
@@ -405,7 +407,7 @@ export default function Step3TaxRegistration({
|
||||
// Reset end when start changes — its valid options depend on start
|
||||
if (endField.value) endField.onChange('')
|
||||
}}
|
||||
startHelpText="Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag."
|
||||
startHelpText={t('step3_start_help')}
|
||||
endDate={endField.value || ''}
|
||||
entityType={entityType}
|
||||
endDateSlot={
|
||||
@@ -413,7 +415,7 @@ export default function Step3TaxRegistration({
|
||||
{/* AB: end month selector */}
|
||||
{!isEF && parsedStart && (
|
||||
<div className="space-y-2">
|
||||
<Label>Räkenskapsåret slutar (månad)</Label>
|
||||
<Label>{t('step3_fy_end_month_label')}</Label>
|
||||
<Select
|
||||
value={abEndMonth.toString()}
|
||||
onValueChange={(v) => {
|
||||
@@ -424,7 +426,7 @@ export default function Step3TaxRegistration({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
<SelectValue placeholder={t('step3_select_month')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
@@ -440,13 +442,13 @@ export default function Step3TaxRegistration({
|
||||
{/* End date selector (options depend on entity type + start) */}
|
||||
{parsedStart && firstYearEndOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Slutdatum</Label>
|
||||
<Label>{t('step3_end_date_label')}</Label>
|
||||
<Select
|
||||
value={endField.value || ''}
|
||||
onValueChange={(v) => { if (v) endField.onChange(v) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj slutdatum" />
|
||||
<SelectValue placeholder={t('step3_select_end_date')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{firstYearEndOptions.map((opt) => (
|
||||
@@ -464,7 +466,7 @@ export default function Step3TaxRegistration({
|
||||
|
||||
{parsedStart && firstYearEndOptions.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
|
||||
{t('step3_no_valid_end')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -486,14 +488,14 @@ export default function Step3TaxRegistration({
|
||||
<div className="space-y-2">
|
||||
{isEF ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium">Kalenderår (januari-december)</p>
|
||||
<p className="text-sm font-medium">{t('step3_calendar_year')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Enskild firma måste använda kalenderår enligt BFL 3 kap.
|
||||
{t('step3_ef_calendar_required')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>När slutar företagets räkenskapsår?</Label>
|
||||
<Label>{t('step3_when_fy_ends')}</Label>
|
||||
<Controller
|
||||
name="fiscal_year_end_month"
|
||||
control={control}
|
||||
@@ -503,7 +505,7 @@ export default function Step3TaxRegistration({
|
||||
onValueChange={(v) => { if (v) field.onChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
<SelectValue placeholder={t('step3_select_month')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
@@ -516,21 +518,21 @@ export default function Step3TaxRegistration({
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
|
||||
{t('step3_calendar_or_broken')}
|
||||
</p>
|
||||
|
||||
{fiscalYearEndMonth && (
|
||||
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
Ditt räkenskapsår
|
||||
{t('step3_your_fy')}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fiscalYearEndMonth === 12
|
||||
? `1 januari \u2013 31 december (kalenderår)`
|
||||
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">12 månader</p>
|
||||
<p className="text-xs text-muted-foreground">{t('step3_twelve_months')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -547,17 +549,17 @@ export default function Step3TaxRegistration({
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
{t('continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -56,6 +57,7 @@ export default function Step4VatAccounting({
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step4Props) {
|
||||
const t = useTranslations('onboarding')
|
||||
const { toast } = useToast()
|
||||
|
||||
const {
|
||||
@@ -105,9 +107,9 @@ export default function Step4VatAccounting({
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moms och bokföringsmetod</CardTitle>
|
||||
<CardTitle>{t('step4_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Ange din momsregistrering och välj bokföringsmetod.
|
||||
{t('step4_card_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -116,8 +118,8 @@ export default function Step4VatAccounting({
|
||||
console.error('[onboarding] step 4 validation failed:', fields, errs)
|
||||
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
|
||||
const firstError = Object.values(errs)[0]
|
||||
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
|
||||
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
|
||||
const message = firstError?.message || t('check_all_fields')
|
||||
toast({ title: t('missing_fields'), description: String(message), variant: 'destructive' })
|
||||
})} className="space-y-6">
|
||||
{/* VAT section */}
|
||||
<div className="space-y-4">
|
||||
@@ -125,14 +127,14 @@ export default function Step4VatAccounting({
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Behöver jag momsregistrera mig?</p>
|
||||
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
|
||||
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
|
||||
<p className="font-medium">{t('step4_vat_tip_title')}</p>
|
||||
<p>{t('step4_vat_tip_body')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('step4_vat_tip_note')}</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<span>Momsregistrering</span>
|
||||
<span>{t('step4_vat_heading')}</span>
|
||||
</InfoTooltip>
|
||||
</h3>
|
||||
|
||||
@@ -150,10 +152,10 @@ export default function Step4VatAccounting({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vat_registered" className="cursor-pointer">
|
||||
Jag är momsregistrerad
|
||||
{t('step4_vat_registered_label')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
|
||||
{t('step4_vat_registered_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,14 +163,14 @@ export default function Step4VatAccounting({
|
||||
{vatRegistered && (
|
||||
<div className="space-y-4 pl-0 sm:pl-7">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
|
||||
<Label htmlFor="vat_number">{t('step4_vat_number_label')}</Label>
|
||||
<Input
|
||||
id="vat_number"
|
||||
placeholder="SE123456789001"
|
||||
{...register('vat_number')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: SE + organisationsnummer + 01
|
||||
{t('step4_vat_number_format')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -176,18 +178,18 @@ export default function Step4VatAccounting({
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Hur ofta rapporterar du moms?</p>
|
||||
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
|
||||
<p className="font-medium">{t('step4_vat_period_tip_title')}</p>
|
||||
<p>{t('step4_vat_period_tip_body')}</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
|
||||
<li>1-40 miljoner = Kvartal</li>
|
||||
<li>Över 40 miljoner = Månad</li>
|
||||
<li>{t('step4_vat_period_bracket_low')}</li>
|
||||
<li>{t('step4_vat_period_bracket_mid')}</li>
|
||||
<li>{t('step4_vat_period_bracket_high')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label>Momsredovisningsperiod</Label>
|
||||
<Label>{t('step4_vat_period_label')}</Label>
|
||||
</InfoTooltip>
|
||||
<Controller
|
||||
name="moms_period"
|
||||
@@ -198,18 +200,18 @@ export default function Step4VatAccounting({
|
||||
onValueChange={(v) => { if (v) field.onChange(v) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj period" />
|
||||
<SelectValue placeholder={t('step4_select_period')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
<SelectItem value="yearly">År</SelectItem>
|
||||
<SelectItem value="monthly">{t('step4_period_monthly')}</SelectItem>
|
||||
<SelectItem value="quarterly">{t('step4_period_quarterly')}</SelectItem>
|
||||
<SelectItem value="yearly">{t('step4_period_yearly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
|
||||
{t('step4_period_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,10 +222,10 @@ export default function Step4VatAccounting({
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
|
||||
content={t('step4_method_tip')}
|
||||
side="right"
|
||||
>
|
||||
<Label>Bokföringsmetod</Label>
|
||||
<Label>{t('step4_method_label')}</Label>
|
||||
</InfoTooltip>
|
||||
<Controller
|
||||
name="accounting_method"
|
||||
@@ -237,7 +239,7 @@ export default function Step4VatAccounting({
|
||||
onCheckedChange={(checked) => { if (checked) field.onChange('accrual') }}
|
||||
/>
|
||||
<Label htmlFor="method_accrual" className="cursor-pointer">
|
||||
Faktureringsmetoden
|
||||
{t('step4_method_accrual')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -247,7 +249,7 @@ export default function Step4VatAccounting({
|
||||
onCheckedChange={(checked) => { if (checked) field.onChange('cash') }}
|
||||
/>
|
||||
<Label htmlFor="method_cash" className="cursor-pointer">
|
||||
Kontantmetoden
|
||||
{t('step4_method_cash')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,16 +258,15 @@ export default function Step4VatAccounting({
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
|
||||
{accountingMethod === 'accrual' ? t('step4_method_accrual') : t('step4_method_cash')}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accountingMethod === 'accrual'
|
||||
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
|
||||
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
|
||||
? t('step4_method_accrual_desc')
|
||||
: t('step4_method_cash_desc')}
|
||||
</p>
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
|
||||
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
|
||||
3 MSEK (BFL 5 kap. 2 §).
|
||||
{t('step4_cash_limit_note')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,17 +280,17 @@ export default function Step4VatAccounting({
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
{t('back')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
{t('continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Select,
|
||||
@@ -14,53 +15,53 @@ import type { EntityType } from '@/types'
|
||||
|
||||
interface ReportItem {
|
||||
value: string
|
||||
label: string
|
||||
labelKey: string
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
interface ReportCategory {
|
||||
label: string
|
||||
labelKey: string
|
||||
items: ReportItem[]
|
||||
}
|
||||
|
||||
const CATEGORIES: ReportCategory[] = [
|
||||
{
|
||||
label: 'Löpande',
|
||||
labelKey: 'group_interim',
|
||||
items: [
|
||||
{ value: 'resultatrapport', label: 'Resultatrapport' },
|
||||
{ value: 'balansrapport', label: 'Balansrapport' },
|
||||
{ value: 'trial-balance', label: 'Saldobalans' },
|
||||
{ value: 'resultatrapport', labelKey: 'name_resultatrapport' },
|
||||
{ value: 'balansrapport', labelKey: 'name_balansrapport' },
|
||||
{ value: 'trial-balance', labelKey: 'name_trial_balance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bokslut',
|
||||
labelKey: 'group_year_end',
|
||||
items: [
|
||||
{ value: 'income-statement', label: 'Resultaträkning' },
|
||||
{ value: 'balance-sheet', label: 'Balansräkning' },
|
||||
{ value: 'income-statement', labelKey: 'name_income_statement' },
|
||||
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Skatt & moms',
|
||||
labelKey: 'group_tax_vat',
|
||||
items: [
|
||||
{ value: 'vat-declaration', label: 'Momsdeklaration' },
|
||||
{ value: 'periodisk-sammanstallning', label: 'Periodisk sammanställning' },
|
||||
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
|
||||
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
|
||||
{ value: 'vat-declaration', labelKey: 'name_vat_declaration' },
|
||||
{ value: 'periodisk-sammanstallning', labelKey: 'name_periodisk_sammanstallning' },
|
||||
{ value: 'ne-declaration', labelKey: 'name_ne_declaration', entityType: 'enskild_firma' },
|
||||
{ value: 'ink2-declaration', labelKey: 'name_ink2_declaration', entityType: 'aktiebolag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Huvudböcker',
|
||||
labelKey: 'group_ledgers',
|
||||
items: [
|
||||
{ value: 'huvudbok', label: 'Huvudbok' },
|
||||
{ value: 'grundbok', label: 'Grundbok' },
|
||||
{ value: 'kundreskontra', label: 'Kundreskontra' },
|
||||
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
|
||||
{ value: 'huvudbok', labelKey: 'name_huvudbok' },
|
||||
{ value: 'grundbok', labelKey: 'name_grundbok' },
|
||||
{ value: 'kundreskontra', labelKey: 'name_kundreskontra' },
|
||||
{ value: 'supplier-ledger', labelKey: 'name_supplier_ledger' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Avstämning',
|
||||
labelKey: 'group_reconciliation',
|
||||
items: [
|
||||
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
|
||||
{ value: 'bank-reconciliation', labelKey: 'name_bank_reconciliation' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -72,6 +73,7 @@ interface ReportsNavProps {
|
||||
}
|
||||
|
||||
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
const t = useTranslations('reports')
|
||||
const filtered = CATEGORIES
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
@@ -89,11 +91,11 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filtered.map(cat => (
|
||||
<SelectGroup key={cat.label}>
|
||||
<SelectLabel>{cat.label}</SelectLabel>
|
||||
<SelectGroup key={cat.labelKey}>
|
||||
<SelectLabel>{t(cat.labelKey)}</SelectLabel>
|
||||
{cat.items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -105,13 +107,13 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
{/* Desktop: vertical left rail */}
|
||||
<nav
|
||||
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
|
||||
aria-label="Rapportkategorier"
|
||||
aria-label={t('categories_aria')}
|
||||
>
|
||||
<ul className="space-y-6">
|
||||
{filtered.map(cat => (
|
||||
<li key={cat.label}>
|
||||
<li key={cat.labelKey}>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
|
||||
{cat.label}
|
||||
{t(cat.labelKey)}
|
||||
</p>
|
||||
<ul className="space-y-px">
|
||||
{cat.items.map(item => {
|
||||
@@ -129,7 +131,7 @@ export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -25,6 +26,7 @@ interface Blocker {
|
||||
}
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const t = useTranslations('settings_account_danger')
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([])
|
||||
@@ -77,7 +79,7 @@ export function AccountDangerZone() {
|
||||
const body = await response.json()
|
||||
// Precondition tripped mid-flow — refresh the list and show inline.
|
||||
setBlockers(body.blockers ?? [])
|
||||
setError(body.error || 'Du måste radera eller överlåta dina företag först.')
|
||||
setError(body.error || t('delete_failed_blockers'))
|
||||
setIsDeleting(false)
|
||||
setShowDialog(false)
|
||||
return
|
||||
@@ -85,12 +87,12 @@ export function AccountDangerZone() {
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte radera kontot')
|
||||
throw new Error(body.error || t('delete_failed_default'))
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Kunde inte radera kontot')
|
||||
setError(err instanceof Error ? err.message : t('delete_failed_default'))
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
@@ -102,14 +104,14 @@ export function AccountDangerZone() {
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
Radera konto
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
{hasBlockers && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Företag du äger</p>
|
||||
<p className="text-sm font-medium">{t('blockers_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Radera eller överlåt alla företag innan du raderar kontot.
|
||||
{t('blockers_description')}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{blockers.map((b) => (
|
||||
@@ -119,7 +121,7 @@ export function AccountDangerZone() {
|
||||
>
|
||||
<span className="text-sm font-medium">{b.name}</span>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/settings/company">Hantera</Link>
|
||||
<Link href="/settings/company">{t('blockers_manage')}</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
@@ -130,8 +132,8 @@ export function AccountDangerZone() {
|
||||
<RetentionNotice variant="account" />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
{t('support_question')}{' '}
|
||||
<SupportLink variant="inline" subject={t('support_subject')} />
|
||||
</p>
|
||||
|
||||
{error && !showDialog && (
|
||||
@@ -142,7 +144,7 @@ export function AccountDangerZone() {
|
||||
<Button variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
{t('export_sie')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -151,7 +153,7 @@ export function AccountDangerZone() {
|
||||
onClick={() => setShowDialog(true)}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
Radera mitt konto
|
||||
{t('delete_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -169,16 +171,17 @@ export function AccountDangerZone() {
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogTitle>{t('dialog_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ditt konto avidentifieras och du loggas ut från alla enheter.
|
||||
Du kan inte skapa ett nytt konto med samma e-postadress — kontakta support
|
||||
om du vill återaktivera kontot i framtiden.
|
||||
{t('dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">
|
||||
Skriv din e-postadress (<strong>{email}</strong>) för att bekräfta
|
||||
{t.rich('confirm_label', {
|
||||
email: email ?? '',
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
@@ -200,7 +203,7 @@ export function AccountDangerZone() {
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -214,10 +217,10 @@ export function AccountDangerZone() {
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
{t('deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Radera konto'
|
||||
t('delete_confirm_button')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,14 +28,14 @@ const connectorName = branding.appName.toLowerCase()
|
||||
|
||||
type ScopeEntry = {
|
||||
scope: ApiKeyScope
|
||||
label: string
|
||||
labelKey: string
|
||||
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
|
||||
tools: number
|
||||
}
|
||||
|
||||
type ScopeGroup = {
|
||||
domain: string
|
||||
label: string
|
||||
labelKey: string
|
||||
read: ScopeEntry | null
|
||||
write: ScopeEntry | null
|
||||
}
|
||||
@@ -42,86 +43,86 @@ type ScopeGroup = {
|
||||
const SCOPE_GROUPS: ScopeGroup[] = [
|
||||
{
|
||||
domain: 'transactions',
|
||||
label: 'Transaktioner',
|
||||
read: { scope: 'transactions:read', label: 'Läs — lista transaktioner, mallar, kategorier, inbox', tools: 8 },
|
||||
write: { scope: 'transactions:write', label: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning', tools: 8 },
|
||||
labelKey: 'group_transactions',
|
||||
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
|
||||
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
|
||||
},
|
||||
{
|
||||
domain: 'customers',
|
||||
label: 'Kunder',
|
||||
read: { scope: 'customers:read', label: 'Läs — lista kunder', tools: 1 },
|
||||
write: { scope: 'customers:write', label: 'Skriv — skapa kunder', tools: 1 },
|
||||
labelKey: 'group_customers',
|
||||
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
|
||||
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
|
||||
},
|
||||
{
|
||||
domain: 'invoices',
|
||||
label: 'Fakturor',
|
||||
read: { scope: 'invoices:read', label: 'Läs — lista fakturor', tools: 1 },
|
||||
write: { scope: 'invoices:write', label: 'Skriv — skapa, skicka, markera betald/skickad, kreditera, konvertera', tools: 6 },
|
||||
labelKey: 'group_invoices',
|
||||
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
|
||||
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
|
||||
},
|
||||
{
|
||||
domain: 'suppliers',
|
||||
label: 'Leverantörer',
|
||||
read: { scope: 'suppliers:read', label: 'Läs — lista leverantörer och leverantörsfakturor', tools: 2 },
|
||||
write: { scope: 'suppliers:write', label: 'Skriv — godkänn, kreditera, skapa leverantörsfaktura från inbox', tools: 3 },
|
||||
labelKey: 'group_suppliers',
|
||||
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
|
||||
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'reports',
|
||||
label: 'Rapporter',
|
||||
read: { scope: 'reports:read', label: 'Läs — kontoplan, huvudbok, BR, RR, moms, KPI, reskontra, perioder, bankavstämning, SIE-export', tools: 18 },
|
||||
labelKey: 'group_reports',
|
||||
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'bookkeeping',
|
||||
label: 'Bokföring',
|
||||
labelKey: 'group_bookkeeping',
|
||||
read: null,
|
||||
write: { scope: 'bookkeeping:write', label: 'Skriv — stänga/låsa perioder, IB, bokslut, SIE-import, verifikat, korrigeringar (alla stagas)', tools: 11 },
|
||||
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
|
||||
},
|
||||
{
|
||||
domain: 'payroll',
|
||||
label: 'Löner',
|
||||
read: { scope: 'payroll:read', label: 'Läs — lista anställda, lönekörningar, lönejournal', tools: 3 },
|
||||
write: { scope: 'payroll:write', label: 'Skriv — skapa lönekörning, beräkna, generera AGI', tools: 3 },
|
||||
labelKey: 'group_payroll',
|
||||
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
|
||||
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'pending_operations',
|
||||
label: 'Stagade operationer',
|
||||
read: { scope: 'pending_operations:read', label: 'Läs — lista pending_operations som väntar på godkännande', tools: 1 },
|
||||
write: { scope: 'pending_operations:approve', label: 'Godkänn — committa eller avvisa staged ops via API (ersätter web-UI:s granskning)', tools: 2 },
|
||||
labelKey: 'group_pending_operations',
|
||||
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
|
||||
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
|
||||
},
|
||||
{
|
||||
domain: 'documents',
|
||||
label: 'Dokument (REST API)',
|
||||
read: { scope: 'documents:read', label: 'Läs — lista och hämta dokumentbilagor', tools: 0 },
|
||||
write: { scope: 'documents:write', label: 'Skriv — ladda upp och koppla dokument till verifikationer', tools: 0 },
|
||||
labelKey: 'group_documents',
|
||||
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
|
||||
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'companies',
|
||||
label: 'Företag (REST API)',
|
||||
read: { scope: 'companies:read', label: 'Läs — företagsprofiler nyckeln har åtkomst till', tools: 0 },
|
||||
labelKey: 'group_companies',
|
||||
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'events',
|
||||
label: 'Händelser (REST API)',
|
||||
read: { scope: 'events:read', label: 'Läs — polla event_log som webhook-fallback', tools: 0 },
|
||||
labelKey: 'group_events',
|
||||
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'webhooks',
|
||||
label: 'Webhooks (REST API)',
|
||||
labelKey: 'group_webhooks',
|
||||
read: null,
|
||||
write: { scope: 'webhooks:manage', label: 'Hantera — skapa, lista, uppdatera, radera prenumerationer', tools: 0 },
|
||||
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'operations',
|
||||
label: 'Operationer (REST API)',
|
||||
read: { scope: 'operations:read', label: 'Läs — status för långkörande operationer (import, bokslut, omvärdering)', tools: 0 },
|
||||
labelKey: 'group_operations',
|
||||
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'compliance',
|
||||
label: 'Compliance (REST API)',
|
||||
read: { scope: 'compliance:read', label: 'Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet', tools: 0 },
|
||||
labelKey: 'group_compliance',
|
||||
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
]
|
||||
@@ -146,7 +147,7 @@ interface ApiKey {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function CopyBlock({ text }: { text: string }) {
|
||||
function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
@@ -169,7 +170,7 @@ function CopyBlock({ text }: { text: string }) {
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={handleCopy}
|
||||
aria-label="Kopiera"
|
||||
aria-label={copyAriaLabel}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
@@ -190,9 +191,11 @@ function ScopeCard({
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const dashIdx = entry.label.indexOf(' — ')
|
||||
const verb = dashIdx > 0 ? entry.label.slice(0, dashIdx) : entry.label
|
||||
const description = dashIdx > 0 ? entry.label.slice(dashIdx + 3) : ''
|
||||
const t = useTranslations('settings_api_keys')
|
||||
const label = t(entry.labelKey)
|
||||
const dashIdx = label.indexOf(' — ')
|
||||
const verb = dashIdx > 0 ? label.slice(0, dashIdx) : label
|
||||
const description = dashIdx > 0 ? label.slice(dashIdx + 3) : ''
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -211,7 +214,7 @@ function ScopeCard({
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{entry.tools > 0 ? `${entry.tools} verktyg` : 'REST'}
|
||||
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
@@ -224,6 +227,7 @@ function ScopeCard({
|
||||
}
|
||||
|
||||
export function ApiKeysPanel() {
|
||||
const t = useTranslations('settings_api_keys')
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
|
||||
|
||||
@@ -246,11 +250,11 @@ export function ApiKeysPanel() {
|
||||
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
}, [toast, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys()
|
||||
@@ -262,7 +266,7 @@ export function ApiKeysPanel() {
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newKeyName || 'MCP-nyckel', scopes: Array.from(newKeyScopes) }),
|
||||
body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes) }),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
@@ -278,7 +282,7 @@ export function ApiKeysPanel() {
|
||||
setNewKeyScopes(new Set(ALL_SCOPES))
|
||||
fetchKeys()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
|
||||
toast({ title: t('toast_create_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@@ -286,18 +290,18 @@ export function ApiKeysPanel() {
|
||||
|
||||
async function handleRevoke(id: string, name: string) {
|
||||
const ok = await confirmRevoke({
|
||||
title: 'Återkalla API-nyckel',
|
||||
description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`,
|
||||
confirmLabel: 'Återkalla',
|
||||
title: t('revoke_dialog_title'),
|
||||
description: t('revoke_dialog_description', { name }),
|
||||
confirmLabel: t('revoke_confirm'),
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' })
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id))
|
||||
toast({ title: 'Nyckel återkallad' })
|
||||
toast({ title: t('toast_revoked') })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
|
||||
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +330,9 @@ export function ApiKeysPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API-nycklar</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Hantera nycklar för MCP-klienter (Claude, Cursor) och andra integrationer.
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -337,7 +341,7 @@ export function ApiKeysPanel() {
|
||||
disabled={keys.length >= 10}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Skapa nyckel
|
||||
{t('create_key')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -349,9 +353,9 @@ export function ApiKeysPanel() {
|
||||
) : keys.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Key className="h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Inga API-nycklar ännu.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Skapa en nyckel för att koppla din MCP-klient.
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -368,10 +372,10 @@ export function ApiKeysPanel() {
|
||||
<p className="text-sm font-medium truncate">{key.name}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scopeCount === ALL_SCOPES.length
|
||||
? 'Alla behörigheter'
|
||||
? t('all_permissions')
|
||||
: scopeCount === 0
|
||||
? 'Inga behörigheter'
|
||||
: `${scopeCount} behörigheter`}
|
||||
? t('no_permissions')
|
||||
: t('permissions_count', { count: scopeCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
@@ -379,12 +383,12 @@ export function ApiKeysPanel() {
|
||||
{key.key_prefix}...
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Skapad {formatDate(key.created_at)}
|
||||
{t('created')} {formatDate(key.created_at)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.last_used_at
|
||||
? `Använd ${formatDate(key.last_used_at)}`
|
||||
: 'Aldrig använd'}
|
||||
? t('used_on', { date: formatDate(key.last_used_at) })
|
||||
: t('never_used')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,7 +396,7 @@ export function ApiKeysPanel() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(key.id, key.name)}
|
||||
aria-label={`Återkalla ${key.name}`}
|
||||
aria-label={t('revoke_aria', { name: key.name })}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -407,27 +411,29 @@ export function ApiKeysPanel() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anslut MCP-klient</CardTitle>
|
||||
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-sm font-medium">Claude.ai</p>
|
||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">Rekommenderat</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">{t('recommended_badge')}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Gå till <strong>Settings → Integrations → Add Integration</strong> och klistra in MCP-serverns URL.
|
||||
Du loggas in via ditt {connectorName}-konto — ingen API-nyckel behövs.
|
||||
{t.rich('claude_ai_instructions', {
|
||||
connectorName,
|
||||
path: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
<CopyBlock text={mcpUrl} />
|
||||
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Claude Code / Cursor</p>
|
||||
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Kör i terminalen — loggar in via webbläsaren:
|
||||
{t('terminal_runs_browser_login')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} />
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
@@ -437,14 +443,16 @@ export function ApiKeysPanel() {
|
||||
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
|
||||
Anslut med API-nyckel istället
|
||||
{t('connect_with_api_key')}
|
||||
</button>
|
||||
{showApiKeyMethods && (
|
||||
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Claude Desktop</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar → Developer):
|
||||
{t.rich('claude_desktop_instructions', {
|
||||
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
<CopyBlock text={`{
|
||||
"mcpServers": {
|
||||
@@ -456,17 +464,17 @@ export function ApiKeysPanel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}`} />
|
||||
}`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
|
||||
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Kör i terminalen med en API-nyckel:
|
||||
{t('terminal_with_api_key')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
|
||||
--url ${mcpUrl} \\
|
||||
--header "Authorization: Bearer gnubok_sk_..."`} />
|
||||
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,17 +486,17 @@ export function ApiKeysPanel() {
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl p-4 sm:max-w-3xl sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa API-nyckel</DialogTitle>
|
||||
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ge nyckeln ett namn så du vet vad den används till.
|
||||
{t('create_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key-name">Namn</Label>
|
||||
<Label htmlFor="key-name">{t('name_label')}</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="t.ex. Claude Desktop"
|
||||
placeholder={t('name_placeholder')}
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
@@ -497,19 +505,19 @@ export function ApiKeysPanel() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Behörigheter</Label>
|
||||
<Label>{t('permissions_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Välj vad nyckeln ska ha åtkomst till.
|
||||
{t('permissions_help')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{newKeyScopes.size} av {ALL_SCOPES.length} valda
|
||||
{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{SCOPE_GROUPS.map((group) => (
|
||||
<div key={group.domain} className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{group.label}</h4>
|
||||
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
|
||||
<div className="space-y-2 px-2">
|
||||
{group.read && (
|
||||
<ScopeCard
|
||||
@@ -555,11 +563,11 @@ export function ApiKeysPanel() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
Avbryt
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
|
||||
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Skapa
|
||||
{t('create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -577,9 +585,9 @@ export function ApiKeysPanel() {
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Din nya API-nyckel</DialogTitle>
|
||||
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Kopiera nyckeln nu. Den visas bara en gång.
|
||||
{t('new_key_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
@@ -605,7 +613,7 @@ export function ApiKeysPanel() {
|
||||
setNewKeyValue('')
|
||||
setCopied(false)
|
||||
}}>
|
||||
Klar
|
||||
{t('done')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -28,6 +29,7 @@ interface EstimateResponse {
|
||||
const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download'
|
||||
|
||||
export function BackupDownloadForm() {
|
||||
const t = useTranslations('settings_backup_download')
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
|
||||
@@ -132,16 +134,16 @@ export function BackupDownloadForm() {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
|
||||
toast({
|
||||
title: 'Arkivet är för stort för direktnedladdning',
|
||||
title: t('toast_too_large_title'),
|
||||
description: sizeMb
|
||||
? `Ditt arkiv är cirka ${sizeMb} MB. Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.`
|
||||
: 'Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.',
|
||||
? t('toast_too_large_with_size', { size: sizeMb })
|
||||
: t('toast_too_large_generic'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte skapa arkivet')
|
||||
throw new Error(body.error || t('error_create_archive'))
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
@@ -164,17 +166,17 @@ export function BackupDownloadForm() {
|
||||
setLastDownloadedAt(now)
|
||||
}
|
||||
|
||||
toast({ title: 'Säkerhetsbackup skapad', description: filename })
|
||||
toast({ title: t('toast_backup_created'), description: filename })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa säkerhetsbackup',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
title: t('toast_backup_failed'),
|
||||
description: err instanceof Error ? err.message : t('toast_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast])
|
||||
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t])
|
||||
|
||||
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
|
||||
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId)
|
||||
@@ -183,31 +185,33 @@ export function BackupDownloadForm() {
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skapa backup</CardTitle>
|
||||
<CardTitle>{t('create_backup_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Omfattning</Label>
|
||||
<Label>{t('scope_label')}</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<ScopeRadio
|
||||
checked={scope === 'all'}
|
||||
onChange={() => setScope('all')}
|
||||
label="Hela historiken"
|
||||
description="Alla räkenskapsår och verifikationer"
|
||||
label={t('scope_all_label')}
|
||||
description={t('scope_all_desc')}
|
||||
recommendedLabel={t('recommended')}
|
||||
recommended
|
||||
/>
|
||||
<ScopeRadio
|
||||
checked={scope === 'period'}
|
||||
onChange={() => setScope('period')}
|
||||
label="En period"
|
||||
description="Välj ett specifikt räkenskapsår"
|
||||
label={t('scope_period_label')}
|
||||
description={t('scope_period_desc')}
|
||||
recommendedLabel={t('recommended')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === 'period' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-period">Räkenskapsår</Label>
|
||||
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
|
||||
<select
|
||||
id="backup-period"
|
||||
value={selectedPeriodId}
|
||||
@@ -215,7 +219,7 @@ export function BackupDownloadForm() {
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
disabled={periods.length === 0}
|
||||
>
|
||||
{periods.length === 0 && <option value="">Inga räkenskapsår</option>}
|
||||
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.period_start} – {p.period_end}
|
||||
@@ -227,10 +231,9 @@ export function BackupDownloadForm() {
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="include-documents">Inkludera kvitton och underlag</Label>
|
||||
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-prose">
|
||||
Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en.
|
||||
Stäng av för en mindre backup med bara bokföringsdata.
|
||||
{t('include_docs_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -244,21 +247,19 @@ export function BackupDownloadForm() {
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
{isLoadingEstimate ? (
|
||||
<span>Beräknar storlek…</span>
|
||||
<span>{t('calculating_size')}</span>
|
||||
) : estimate ? (
|
||||
<span>
|
||||
Uppskattad storlek: <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
|
||||
{' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'})
|
||||
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
|
||||
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
|
||||
</span>
|
||||
) : (
|
||||
<span>Storlek beräknas när omfattning är vald.</span>
|
||||
<span>{t('size_will_calculate')}</span>
|
||||
)}
|
||||
</div>
|
||||
{isOverLimit && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Arkivet är större än {formatBytes(estimate!.size_limit_bytes)} och kan inte laddas ner
|
||||
direkt. Välj en enskild period eller stäng av bilagor tills vidare —
|
||||
automatisk molnsynkronisering kommer i senare version.
|
||||
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -268,18 +269,18 @@ export function BackupDownloadForm() {
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar backup…
|
||||
{t('creating_backup')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Skapa och ladda ner
|
||||
{t('create_and_download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{lastDownloadedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Senaste nedladdning: {formatDate(lastDownloadedAt)}
|
||||
{t('last_download')}: {formatDate(lastDownloadedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -293,13 +294,12 @@ export function BackupDownloadForm() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="h-4 w-4 text-muted-foreground" />
|
||||
Molnsynkronisering
|
||||
{t('cloud_sync_title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground max-w-prose">
|
||||
Aktivera tillägget “Molnsynkronisering” för att koppla Google
|
||||
Drive och ladda upp säkerhetsbackupen med ett klick.
|
||||
{t('cloud_sync_disabled_help')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -314,9 +314,10 @@ interface ScopeRadioProps {
|
||||
label: string
|
||||
description: string
|
||||
recommended?: boolean
|
||||
recommendedLabel: string
|
||||
}
|
||||
|
||||
function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) {
|
||||
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -329,7 +330,7 @@ function ScopeRadio({ checked, onChange, label, description, recommended }: Scop
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
{recommended && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
|
||||
Rekommenderas
|
||||
{recommendedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -14,6 +15,7 @@ interface BankDetailsFormProps {
|
||||
}
|
||||
|
||||
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
const t = useTranslations('settings_bank_details_form')
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
@@ -23,22 +25,22 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bankuppgifter
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas på dina fakturor
|
||||
{t('subheading')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<Label>{t('bank_label')}</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
@@ -52,13 +54,13 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setClearingError(null); return }
|
||||
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
|
||||
setClearingError(!/^\d{4,5}$/.test(val) ? t('clearing_error') : null)
|
||||
}}
|
||||
/>
|
||||
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Label htmlFor="account_number">{t('account_number_label')}</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
@@ -72,7 +74,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setAccountNumberError(null); return }
|
||||
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
|
||||
setAccountNumberError(!/^\d{6,12}$/.test(val) ? t('account_number_error') : null)
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
|
||||
@@ -81,7 +83,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
@@ -94,7 +96,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer')
|
||||
setBankgiroError(t('bankgiro_error'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -102,11 +104,11 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="swish">Swish</Label>
|
||||
<Label htmlFor="swish">{t('swish_label')}</Label>
|
||||
<Input
|
||||
id="swish"
|
||||
name="swish"
|
||||
placeholder="123 XXX XX XX eller 07X XXX XX XX"
|
||||
placeholder={t('swish_placeholder')}
|
||||
defaultValue={settings.swish || ''}
|
||||
onBlur={(e) => {
|
||||
const val = normaliseSwish(e.target.value)
|
||||
@@ -115,7 +117,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
e.target.value = val
|
||||
setSwishError(null)
|
||||
} else {
|
||||
setSwishError('Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)')
|
||||
setSwishError(t('swish_error'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
@@ -17,6 +18,7 @@ interface BankIdIdentity {
|
||||
}
|
||||
|
||||
export function BankIdSettings() {
|
||||
const t = useTranslations('settings_bankid')
|
||||
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLinking, setIsLinking] = useState(false)
|
||||
@@ -45,20 +47,20 @@ export function BankIdSettings() {
|
||||
const handleLinkComplete = async (result: BankIdResult) => {
|
||||
if (result.error) {
|
||||
const message = result.error === 'already_linked'
|
||||
? 'Detta BankID ar redan kopplat till ett annat konto.'
|
||||
: 'Kunde inte koppla BankID.'
|
||||
? t('toast_already_linked')
|
||||
: t('toast_link_failed')
|
||||
toast({ title: message, variant: 'destructive' })
|
||||
setIsLinking(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'BankID kopplat till ditt konto' })
|
||||
toast({ title: t('toast_linked') })
|
||||
setIsLinking(false)
|
||||
fetchIdentity()
|
||||
}
|
||||
|
||||
const handleUnlink = async () => {
|
||||
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
|
||||
if (!confirm(t('confirm_unlink'))) return
|
||||
|
||||
setIsUnlinking(true)
|
||||
try {
|
||||
@@ -66,9 +68,9 @@ export function BankIdSettings() {
|
||||
if (!res.ok) throw new Error('Unlink failed')
|
||||
|
||||
setIdentity(null)
|
||||
toast({ title: 'BankID bortkopplat' })
|
||||
toast({ title: t('toast_unlinked') })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
|
||||
toast({ title: t('toast_unlink_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsUnlinking(false)
|
||||
}
|
||||
@@ -88,8 +90,8 @@ export function BankIdSettings() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Koppla BankID</CardTitle>
|
||||
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
|
||||
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
|
||||
<CardDescription>{t('link_bankid_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
|
||||
@@ -107,12 +109,10 @@ export function BankIdSettings() {
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
BankID
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{identity
|
||||
? 'Ditt konto ar kopplat till BankID.'
|
||||
: 'Koppla BankID for sakrare inloggning.'}
|
||||
{identity ? t('linked_description') : t('not_linked_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -123,7 +123,7 @@ export function BankIdSettings() {
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
Kopplat {formatDateLong(identity.linked_at)}
|
||||
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -133,7 +133,7 @@ export function BankIdSettings() {
|
||||
disabled={isUnlinking}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
|
||||
{isUnlinking ? t('unlinking') : t('unlink_button')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -141,7 +141,7 @@ export function BankIdSettings() {
|
||||
variant="outline"
|
||||
onClick={() => setIsLinking(true)}
|
||||
>
|
||||
Koppla BankID
|
||||
{t('link_button')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -21,16 +22,17 @@ import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
all: 'Alla',
|
||||
enskild_firma: 'Enskild firma',
|
||||
aktiebolag: 'Aktiebolag',
|
||||
}
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
all: t('entity_all'),
|
||||
enskild_firma: t('entity_enskild_firma'),
|
||||
aktiebolag: t('entity_aktiebolag'),
|
||||
}
|
||||
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
@@ -44,11 +46,11 @@ export function BookingTemplatesPanel() {
|
||||
const json = await res.json()
|
||||
if (json.data) setTemplates(json.data)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
}, [toast, t])
|
||||
|
||||
useEffect(() => { fetchTemplates() }, [fetchTemplates])
|
||||
|
||||
@@ -61,11 +63,11 @@ export function BookingTemplatesPanel() {
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
toast({ title: 'Mall borttagen' })
|
||||
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
|
||||
toast({ title: t('toast_deleted') })
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
@@ -82,7 +84,7 @@ export function BookingTemplatesPanel() {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
|
||||
toast({ title: t('toast_export_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +101,13 @@ export function BookingTemplatesPanel() {
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
|
||||
toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
|
||||
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
|
||||
fetchTemplates()
|
||||
} catch {
|
||||
toast({ title: 'Importfel', description: 'Ogiltig fil', variant: 'destructive' })
|
||||
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
|
||||
} finally {
|
||||
// Reset input so same file can be imported again
|
||||
if (importRef.current) importRef.current.value = ''
|
||||
@@ -113,29 +115,29 @@ export function BookingTemplatesPanel() {
|
||||
}
|
||||
|
||||
// Group templates by scope
|
||||
const systemTemplates = templates.filter((t) => t.is_system)
|
||||
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
|
||||
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
|
||||
const systemTemplates = templates.filter((tt) => tt.is_system)
|
||||
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
|
||||
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Bokföringsmallar</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Exportera
|
||||
{t('export')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
Importera
|
||||
{t('import')}
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
@@ -148,14 +150,15 @@ export function BookingTemplatesPanel() {
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ny mall
|
||||
{t('new_template')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa bokföringsmall</DialogTitle>
|
||||
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateTemplateForm
|
||||
entityLabels={ENTITY_LABELS}
|
||||
onCreated={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
@@ -174,14 +177,14 @@ export function BookingTemplatesPanel() {
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
Inga mallar hittades.
|
||||
{t('empty_state')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* System templates */}
|
||||
{systemTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title="Standardmallar"
|
||||
title={t('section_system')}
|
||||
icon={Globe}
|
||||
templates={systemTemplates}
|
||||
expandedId={expandedId}
|
||||
@@ -189,13 +192,14 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={false}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team templates */}
|
||||
{teamTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title="Teammallar"
|
||||
title={t('section_team')}
|
||||
icon={Users}
|
||||
templates={teamTemplates}
|
||||
expandedId={expandedId}
|
||||
@@ -203,13 +207,14 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Company templates */}
|
||||
{companyTemplates.length > 0 && (
|
||||
<TemplateSection
|
||||
title="Företagsmallar"
|
||||
title={t('section_company')}
|
||||
icon={Building2}
|
||||
templates={companyTemplates}
|
||||
expandedId={expandedId}
|
||||
@@ -217,6 +222,7 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,6 +241,7 @@ function TemplateSection({
|
||||
deletingId,
|
||||
onDelete,
|
||||
canDelete,
|
||||
entityLabels,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
@@ -244,7 +251,9 @@ function TemplateSection({
|
||||
deletingId: string | null
|
||||
onDelete: (id: string) => void
|
||||
canDelete: boolean
|
||||
entityLabels: Record<string, string>
|
||||
}) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -253,28 +262,28 @@ function TemplateSection({
|
||||
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{templates.map((t) => {
|
||||
const isExpanded = expandedId === t.id
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
key={tt.id}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : t.id)}
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{t.name}</span>
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{TEMPLATE_CATEGORY_LABELS[t.category]}
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
</Badge>
|
||||
{t.entity_type !== 'all' && (
|
||||
{tt.entity_type !== 'all' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{ENTITY_LABELS[t.entity_type]}
|
||||
{entityLabels[tt.entity_type]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,12 +294,12 @@ function TemplateSection({
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(t.id)
|
||||
onDelete(tt.id)
|
||||
}}
|
||||
disabled={deletingId === t.id}
|
||||
disabled={deletingId === tt.id}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
{deletingId === t.id ? (
|
||||
{deletingId === tt.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -300,31 +309,31 @@ function TemplateSection({
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
{t.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
|
||||
{tt.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 w-14">Konto</th>
|
||||
<th className="text-left py-1">Beskrivning</th>
|
||||
<th className="text-center py-1 w-16">Typ</th>
|
||||
<th className="text-right py-1 w-12">Debet</th>
|
||||
<th className="text-right py-1 w-12">Kredit</th>
|
||||
<th className="text-left py-1 w-14">{t('th_account')}</th>
|
||||
<th className="text-left py-1">{t('th_description')}</th>
|
||||
<th className="text-center py-1 w-16">{t('th_type')}</th>
|
||||
<th className="text-right py-1 w-12">{t('th_debit')}</th>
|
||||
<th className="text-right py-1 w-12">{t('th_credit')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.lines.map((line: BookingTemplateLibraryLine, i: number) => (
|
||||
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1 font-mono">{line.account}</td>
|
||||
<td className="py-1">{line.label}</td>
|
||||
<td className="py-1 text-center">
|
||||
{line.type === 'vat' && line.vat_rate
|
||||
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
|
||||
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
|
||||
? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) })
|
||||
: line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')}
|
||||
</td>
|
||||
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
|
||||
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
|
||||
<td className="py-1 text-right">{line.side === 'debit' ? t('debit_short') : ''}</td>
|
||||
<td className="py-1 text-right">{line.side === 'credit' ? t('credit_short') : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -339,7 +348,8 @@ function TemplateSection({
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void; entityLabels: Record<string, string> }) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
const { toast } = useToast()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
@@ -371,7 +381,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name || lines.some((l) => !l.account || !l.label)) {
|
||||
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
|
||||
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -384,10 +394,10 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
|
||||
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: 'Mall skapad' })
|
||||
toast({ title: t('toast_created') })
|
||||
onCreated()
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -397,16 +407,16 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>Namn</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
|
||||
<Label>{t('name_label')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('name_placeholder')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
|
||||
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Kategori</Label>
|
||||
<Label>{t('category_label')}</Label>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -417,11 +427,11 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Företagstyp</Label>
|
||||
<Label>{t('entity_type_label')}</Label>
|
||||
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
|
||||
{Object.entries(entityLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -430,36 +440,36 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Rader</Label>
|
||||
<Label>{t('lines_label')}</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={line.account}
|
||||
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="Konto"
|
||||
placeholder={t('account_placeholder')}
|
||||
className="w-20 font-mono"
|
||||
maxLength={4}
|
||||
/>
|
||||
<Input
|
||||
value={line.label}
|
||||
onChange={(e) => updateLine(i, 'label', e.target.value)}
|
||||
placeholder="Beskrivning"
|
||||
placeholder={t('description_short_placeholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
|
||||
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debit">Debet</SelectItem>
|
||||
<SelectItem value="credit">Kredit</SelectItem>
|
||||
<SelectItem value="debit">{t('debit_label')}</SelectItem>
|
||||
<SelectItem value="credit">{t('credit_label')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business">Kostnad</SelectItem>
|
||||
<SelectItem value="vat">Moms</SelectItem>
|
||||
<SelectItem value="settlement">Betalning</SelectItem>
|
||||
<SelectItem value="business">{t('type_cost')}</SelectItem>
|
||||
<SelectItem value="vat">{t('type_vat')}</SelectItem>
|
||||
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -476,14 +486,14 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
{t('add_line')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Skapa mall
|
||||
{t('create_button')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -18,6 +19,7 @@ interface CalendarFeedWithUrls extends CalendarFeed {
|
||||
}
|
||||
|
||||
export function CalendarFeedSettings() {
|
||||
const t = useTranslations('settings_calendar_feed')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -29,6 +31,7 @@ export function CalendarFeedSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFeed = async () => {
|
||||
@@ -57,12 +60,12 @@ export function CalendarFeedSettings() {
|
||||
setFeed(data)
|
||||
|
||||
toast({
|
||||
title: 'Kalenderfeed skapad',
|
||||
description: 'Du kan nu koppla kalendern till Apple Calendar eller Google Calendar.',
|
||||
title: t('toast_feed_created_title'),
|
||||
description: t('toast_feed_created_description'),
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte skapa kalenderfeed.',
|
||||
title: t('toast_create_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -88,9 +91,9 @@ export function CalendarFeedSettings() {
|
||||
|
||||
const { data } = await response.json()
|
||||
setFeed(data)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte uppdatera inställning.',
|
||||
title: t('toast_update_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -100,9 +103,9 @@ export function CalendarFeedSettings() {
|
||||
|
||||
const regenerateToken = async () => {
|
||||
const ok = await confirmAction({
|
||||
title: 'Skapa ny kalender-länk',
|
||||
description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
|
||||
confirmLabel: 'Skapa ny länk',
|
||||
title: t('regen_dialog_title'),
|
||||
description: t('regen_dialog_description'),
|
||||
confirmLabel: t('regen_confirm'),
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -122,12 +125,12 @@ export function CalendarFeedSettings() {
|
||||
setFeed(data)
|
||||
|
||||
toast({
|
||||
title: 'Ny länk skapad',
|
||||
description: 'Den gamla länken fungerar inte längre.',
|
||||
title: t('toast_new_link_title'),
|
||||
description: t('toast_new_link_description'),
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte skapa ny länk.',
|
||||
title: t('toast_regen_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -141,12 +144,12 @@ export function CalendarFeedSettings() {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
toast({
|
||||
title: 'Kopierad',
|
||||
description: 'Länken har kopierats till urklipp.',
|
||||
title: t('toast_copied_title'),
|
||||
description: t('toast_copied_description'),
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte kopiera länken.',
|
||||
title: t('toast_copy_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -172,27 +175,27 @@ export function CalendarFeedSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Kalendersynkronisering
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Synka dina deadlines med Apple Calendar, Google Calendar eller Outlook
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4 py-4">
|
||||
<p className="text-muted-foreground">
|
||||
Skapa en kalenderfeed för att se dina deadlines i din vanliga kalenderapp.
|
||||
{t('empty_intro')}
|
||||
</p>
|
||||
<Button onClick={createFeed} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar...
|
||||
{t('creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Aktivera kalendersynk
|
||||
{t('activate_sync')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -209,10 +212,10 @@ export function CalendarFeedSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Kalendersynkronisering
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Prenumerera på din kalender i Apple Calendar, Google Calendar eller Outlook
|
||||
{t('subscribe_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -220,7 +223,7 @@ export function CalendarFeedSettings() {
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={openWebcal} className="flex-1">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Lägg till i Apple Calendar
|
||||
{t('add_to_apple_calendar')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
|
||||
{copied ? (
|
||||
@@ -233,7 +236,7 @@ export function CalendarFeedSettings() {
|
||||
|
||||
{/* URL display */}
|
||||
<div className="space-y-2">
|
||||
<Label>Kalenderlänk (för Google Calendar m.fl.)</Label>
|
||||
<Label>{t('calendar_link_label')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={feed.httpsUrl}
|
||||
@@ -242,14 +245,14 @@ export function CalendarFeedSettings() {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.
|
||||
{t('calendar_link_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{feed.last_accessed_at && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Senast hämtad:</span>
|
||||
<span>{t('last_fetched')}</span>
|
||||
<span>
|
||||
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
@@ -259,7 +262,7 @@ export function CalendarFeedSettings() {
|
||||
})}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{feed.access_count} gånger
|
||||
{t('times_count', { count: feed.access_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -275,17 +278,17 @@ export function CalendarFeedSettings() {
|
||||
{isRegenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar ny länk...
|
||||
{t('creating_new_link')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Skapa ny länk
|
||||
{t('create_new_link')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ogiltigförklarar den gamla länken. Använd om någon obehörig fått tag i länken.
|
||||
{t('regen_help')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -294,17 +297,17 @@ export function CalendarFeedSettings() {
|
||||
{/* Content settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Innehåll i kalendern</CardTitle>
|
||||
<CardTitle>{t('content_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Välj vilka händelser som ska visas i din kalender
|
||||
{t('content_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-tax">Skattedeadlines</Label>
|
||||
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moms, F-skatt, deklarationer
|
||||
{t('tax_deadlines_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -319,9 +322,9 @@ export function CalendarFeedSettings() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-invoices">Fakturor</Label>
|
||||
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Förfallodatum för fakturor
|
||||
{t('invoices_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -33,6 +34,7 @@ const branding = getBranding()
|
||||
* capabilities.bankIdLinked boolean fetched from the user profile.
|
||||
*/
|
||||
export function CompanyDangerZone() {
|
||||
const t = useTranslations('settings_company')
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { company, role } = useCompany()
|
||||
@@ -57,10 +59,10 @@ export function CompanyDangerZone() {
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte radera företaget')
|
||||
throw new Error(body.error || t('danger_delete_failed_default'))
|
||||
}
|
||||
|
||||
toast({ title: 'Företaget raderades', description: company.name })
|
||||
toast({ title: t('danger_deleted_title'), description: company.name })
|
||||
// Stay inside settings. If the user had another company, the dashboard
|
||||
// layout will resolve it and /settings/account still renders as
|
||||
// normal. If this was their last company, the layout falls into the
|
||||
@@ -69,8 +71,8 @@ export function CompanyDangerZone() {
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte radera företaget',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
title: t('danger_delete_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('danger_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsDeleting(false)
|
||||
@@ -81,7 +83,7 @@ export function CompanyDangerZone() {
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
Radera företag
|
||||
{t('danger_heading')}
|
||||
</h2>
|
||||
|
||||
<RetentionNotice variant="company" />
|
||||
@@ -92,7 +94,7 @@ export function CompanyDangerZone() {
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Radera företag
|
||||
{t('danger_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -107,15 +109,17 @@ export function CompanyDangerZone() {
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera {company.name}</DialogTitle>
|
||||
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Företaget döljs från {branding.appName.toLowerCase()}. Bokföringen behålls säkert i 7 år enligt BFL.
|
||||
Skriv företagets namn exakt för att bekräfta.
|
||||
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-delete-confirm">
|
||||
Skriv <strong>{company.name}</strong> för att bekräfta
|
||||
{t.rich('danger_confirm_label', {
|
||||
companyName: company.name,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="company-delete-confirm"
|
||||
@@ -134,7 +138,7 @@ export function CompanyDangerZone() {
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
{t('danger_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -144,10 +148,10 @@ export function CompanyDangerZone() {
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
{t('danger_deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Radera företag'
|
||||
t('danger_button')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
@@ -9,26 +10,27 @@ interface CompanyInfoFormProps {
|
||||
}
|
||||
|
||||
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Företagsuppgifter
|
||||
{t('company_info_heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Label htmlFor="company_name">{t('company_name_label')}</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings.company_name || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).
|
||||
{t('company_name_help')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Label htmlFor="org_number">{t('org_number_label')}</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
@@ -36,13 +38,13 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Label htmlFor="address_line1">{t('address_label')}</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
@@ -52,7 +54,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Label htmlFor="postal_code">{t('postal_code_label')}</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
@@ -60,7 +62,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Label htmlFor="city">{t('city_label')}</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
@@ -71,7 +73,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Label htmlFor="phone">{t('phone_label')}</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
@@ -80,7 +82,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
@@ -91,7 +93,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Webbplats</Label>
|
||||
<Label htmlFor="website">{t('website_label')}</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -31,16 +32,17 @@ interface CompanyInvitation {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'Ägare',
|
||||
admin: 'Admin',
|
||||
member: 'Medlem',
|
||||
viewer: 'Läsbehörighet',
|
||||
}
|
||||
|
||||
export function CompanyMembersSection() {
|
||||
const t = useTranslations('settings_company')
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
owner: t('members_role_owner'),
|
||||
admin: t('members_role_admin'),
|
||||
member: t('members_role_member'),
|
||||
viewer: t('members_role_viewer'),
|
||||
}
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [members, setMembers] = useState<CompanyMemberItem[]>([])
|
||||
const [invitations, setInvitations] = useState<CompanyInvitation[]>([])
|
||||
@@ -94,16 +96,16 @@ export function CompanyMembersSection() {
|
||||
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
|
||||
}
|
||||
toast({
|
||||
title: 'Inbjudan skickad',
|
||||
title: t('members_invite_sent_title'),
|
||||
description: data.data.inviteUrl
|
||||
? 'Länk loggad i konsolen (F12)'
|
||||
: `E-post skickad till ${email}.`,
|
||||
? t('members_invite_sent_dev_url')
|
||||
: t('members_invite_sent_description', { email }),
|
||||
})
|
||||
setInviteEmail('')
|
||||
setInviteRole('viewer')
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
|
||||
toast({ title: t('members_invite_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
@@ -120,10 +122,10 @@ export function CompanyMembersSection() {
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Medlem borttagen' })
|
||||
toast({ title: t('members_removed') })
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
|
||||
toast({ title: t('members_remove_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
@@ -140,10 +142,10 @@ export function CompanyMembersSection() {
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Inbjudan återkallad' })
|
||||
toast({ title: t('members_invite_revoked') })
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
|
||||
toast({ title: t('members_invite_revoke_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
@@ -163,19 +165,19 @@ export function CompanyMembersSection() {
|
||||
{canInvite && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bjud in till {company?.name}</CardTitle>
|
||||
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
|
||||
<CardDescription>
|
||||
Personen får tillgång till enbart detta företag.
|
||||
{t('members_invite_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="company-invite-email" className="sr-only">E-postadress</Label>
|
||||
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
|
||||
<Input
|
||||
id="company-invite-email"
|
||||
type="email"
|
||||
placeholder="namn@example.com"
|
||||
placeholder={t('members_invite_email_placeholder')}
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isSending}
|
||||
@@ -187,9 +189,9 @@ export function CompanyMembersSection() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">Läsbehörighet</SelectItem>
|
||||
<SelectItem value="member">Medlem</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
|
||||
<SelectItem value="member">{t('members_role_member')}</SelectItem>
|
||||
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
|
||||
@@ -198,7 +200,7 @@ export function CompanyMembersSection() {
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Bjud in
|
||||
{t('members_invite_button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -212,10 +214,10 @@ export function CompanyMembersSection() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Medlemmar
|
||||
{t('members_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{members.length} {members.length === 1 ? 'medlem' : 'medlemmar'} i {company?.name}
|
||||
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -232,16 +234,16 @@ export function CompanyMembersSection() {
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.email}
|
||||
{member.is_current_user && (
|
||||
<span className="text-muted-foreground font-normal ml-1">(du)</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ROLE_LABELS[member.role] || member.role}
|
||||
{roleLabels[member.role] || member.role}
|
||||
</span>
|
||||
{member.source === 'team' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Team
|
||||
{t('members_team_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -272,7 +274,7 @@ export function CompanyMembersSection() {
|
||||
{invitations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Väntande inbjudningar</CardTitle>
|
||||
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border/40">
|
||||
@@ -287,14 +289,14 @@ export function CompanyMembersSection() {
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
Går ut {formatDateLong(inv.expires_at)}
|
||||
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{ROLE_LABELS[inv.role] || inv.role}
|
||||
{roleLabels[inv.role] || inv.role}
|
||||
</Badge>
|
||||
{canInvite && (
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -10,22 +11,6 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { CategorizationTemplate } from '@/types'
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
sie_import: 'SIE-import',
|
||||
user_approved: 'Godkänd',
|
||||
auto_learned: 'Automatisk',
|
||||
sni_default: 'Standard',
|
||||
}
|
||||
|
||||
const VAT_LABELS: Record<string, string> = {
|
||||
standard_25: '25%',
|
||||
reduced_12: '12%',
|
||||
reduced_6: '6%',
|
||||
reverse_charge: 'Omvänd',
|
||||
export: 'Export',
|
||||
exempt: 'Momsfri',
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
@@ -42,8 +27,25 @@ function confidenceColor(c: number): string {
|
||||
}
|
||||
|
||||
export function CounterpartyTemplatesPanel() {
|
||||
const t = useTranslations('settings_counterparty_templates')
|
||||
const { toast } = useToast()
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
sie_import: t('source_sie_import'),
|
||||
user_approved: t('source_user_approved'),
|
||||
auto_learned: t('source_auto_learned'),
|
||||
sni_default: t('source_sni_default'),
|
||||
}
|
||||
|
||||
const VAT_LABELS: Record<string, string> = {
|
||||
standard_25: '25%',
|
||||
reduced_12: '12%',
|
||||
reduced_6: '6%',
|
||||
reverse_charge: t('vat_reverse_charge'),
|
||||
export: t('vat_export'),
|
||||
exempt: t('vat_exempt'),
|
||||
}
|
||||
|
||||
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
@@ -57,11 +59,11 @@ export function CounterpartyTemplatesPanel() {
|
||||
setTemplates(json.data)
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
}, [toast, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
@@ -76,14 +78,14 @@ export function CounterpartyTemplatesPanel() {
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
|
||||
if (expandedId === id) setExpandedId(null)
|
||||
toast({ title: 'Mall borttagen' })
|
||||
toast({ title: t('toast_deleted') })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
@@ -93,10 +95,9 @@ export function CounterpartyTemplatesPanel() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bokföringsmallar</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Inlärda mönster som automatiskt föreslår kontering baserat på motpart.
|
||||
Mallar skapas från SIE-import och när du godkänner kategoriseringar.
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -107,54 +108,54 @@ export function CounterpartyTemplatesPanel() {
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Inga mallar ännu.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mallar skapas automatiskt när du kategoriserar transaktioner eller importerar en SIE-fil.
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{templates.map((t) => {
|
||||
const isExpanded = expandedId === t.id
|
||||
const isMultiLine = t.line_pattern && t.line_pattern.length > 0
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
|
||||
|
||||
return (
|
||||
<div key={t.id} className="rounded-md border overflow-hidden">
|
||||
<div key={tt.id} className="rounded-md border overflow-hidden">
|
||||
{/* Clickable summary row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : t.id)}
|
||||
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{formatCounterpartyName(t.counterparty_name)}</p>
|
||||
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{SOURCE_LABELS[t.source] || t.source}
|
||||
{SOURCE_LABELS[tt.source] || tt.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
|
||||
{isMultiLine ? (
|
||||
<span className="font-mono">
|
||||
{t.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
||||
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono">{t.debit_account}</span>
|
||||
<span className="font-mono">{tt.debit_account}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span className="font-mono">{t.credit_account}</span>
|
||||
<span className="font-mono">{tt.credit_account}</span>
|
||||
</>
|
||||
)}
|
||||
{t.vat_treatment && (
|
||||
{tt.vat_treatment && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
|
||||
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{t.occurrence_count} ggr</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
|
||||
{Math.round(Number(t.confidence) * 100)}%
|
||||
<span>{t('times_count', { count: tt.occurrence_count })}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,17 +167,17 @@ export function CounterpartyTemplatesPanel() {
|
||||
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
|
||||
{/* Account lines */}
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Kontering</p>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
|
||||
{isMultiLine ? (
|
||||
<div className="space-y-1">
|
||||
{t.line_pattern!.map((lp, i) => (
|
||||
{tt.line_pattern!.map((lp, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">
|
||||
{lp.side === 'debit' ? 'Debet' : 'Kredit'}
|
||||
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
|
||||
</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
|
||||
{lp.type === 'vat' && lp.vat_rate && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.vat_rate * 100)}% moms)</span>
|
||||
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
|
||||
)}
|
||||
{lp.ratio !== undefined && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
|
||||
@@ -187,12 +188,12 @@ export function CounterpartyTemplatesPanel() {
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Debet</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(t.debit_account)}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('debit_label')}</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Kredit</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(t.credit_account)}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('credit_label')}</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -200,30 +201,30 @@ export function CounterpartyTemplatesPanel() {
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
{t.vat_treatment && (
|
||||
{tt.vat_treatment && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Antal bokföringar</span>
|
||||
<span className="tabular-nums">{t.occurrence_count}</span>
|
||||
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
|
||||
<span className="tabular-nums">{tt.occurrence_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Säkerhet</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
|
||||
{Math.round(Number(t.confidence) * 100)}%
|
||||
<span className="text-muted-foreground">{t('confidence_label')}</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
||||
{Math.round(Number(tt.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Senast använd</span>
|
||||
<span>{formatDate(t.last_seen_date)}</span>
|
||||
<span className="text-muted-foreground">{t('last_seen_label')}</span>
|
||||
<span>{formatDate(tt.last_seen_date)}</span>
|
||||
</div>
|
||||
{t.counterparty_aliases && t.counterparty_aliases.length > 1 && (
|
||||
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-muted-foreground">Alias</span>
|
||||
<span className="text-right truncate ml-4">{t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''}</span>
|
||||
<span className="text-muted-foreground">{t('aliases_label')}</span>
|
||||
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,16 +234,16 @@ export function CounterpartyTemplatesPanel() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(t.id)}
|
||||
disabled={deletingId === t.id}
|
||||
onClick={() => handleDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="text-destructive hover:text-destructive text-xs h-7"
|
||||
>
|
||||
{deletingId === t.id ? (
|
||||
{deletingId === tt.id ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
Ta bort mall
|
||||
{t('delete_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -34,6 +35,7 @@ function isCalendarYear(period: { period_start: string; period_end: string }): b
|
||||
}
|
||||
|
||||
export function FiscalPeriodEditor() {
|
||||
const t = useTranslations('settings_company')
|
||||
const { company, role } = useCompany()
|
||||
const { toast } = useToast()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
@@ -59,7 +61,7 @@ export function FiscalPeriodEditor() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) throw new Error('Kunde inte hämta räkenskapsår')
|
||||
if (!res.ok) throw new Error(t('fp_load_error_periods'))
|
||||
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
||||
if (!data || data.length === 0) {
|
||||
if (!cancelled) {
|
||||
@@ -72,7 +74,7 @@ export function FiscalPeriodEditor() {
|
||||
const first = sorted[0]
|
||||
|
||||
const countRes = await fetch(`/api/bookkeeping/fiscal-periods/${first.id}/entry-count`)
|
||||
if (!countRes.ok) throw new Error('Kunde inte hämta verifikationsantal')
|
||||
if (!countRes.ok) throw new Error(t('fp_load_error_entry_count'))
|
||||
const { data: countData } = (await countRes.json()) as { data: { posted_count: number } }
|
||||
|
||||
if (cancelled) return
|
||||
@@ -82,7 +84,7 @@ export function FiscalPeriodEditor() {
|
||||
setEndDate(first.period_end)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Okänt fel')
|
||||
setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
@@ -93,7 +95,7 @@ export function FiscalPeriodEditor() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company])
|
||||
}, [company, t])
|
||||
|
||||
const validation = validateFirstPeriod(
|
||||
startDate,
|
||||
@@ -114,10 +116,15 @@ export function FiscalPeriodEditor() {
|
||||
if (!isDirty) return
|
||||
|
||||
const ok = await confirm({
|
||||
title: 'Ändra första räkenskapsåret?',
|
||||
description: `Detta ändrar ditt första räkenskapsår från ${formatSwedishDate(period.period_start)} – ${formatSwedishDate(period.period_end)} till ${formatSwedishDate(startDate)} – ${formatSwedishDate(endDate)}. Ändringen är bara tillåten eftersom inga verifikationer är bokförda ännu. Fortsätt?`,
|
||||
confirmLabel: 'Ja, ändra räkenskapsår',
|
||||
cancelLabel: 'Avbryt',
|
||||
title: t('fp_confirm_title'),
|
||||
description: t('fp_confirm_description', {
|
||||
oldStart: formatSwedishDate(period.period_start),
|
||||
oldEnd: formatSwedishDate(period.period_end),
|
||||
newStart: formatSwedishDate(startDate),
|
||||
newEnd: formatSwedishDate(endDate),
|
||||
}),
|
||||
confirmLabel: t('fp_confirm_yes'),
|
||||
cancelLabel: t('fp_confirm_cancel'),
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -128,8 +135,8 @@ export function FiscalPeriodEditor() {
|
||||
const endYear = parseDateParts(endDate).year
|
||||
const newName =
|
||||
startYear === endYear
|
||||
? `Räkenskapsår ${startYear}`
|
||||
: `Räkenskapsår ${startYear}/${endYear}`
|
||||
? t('fp_year_label_single', { year: startYear })
|
||||
: t('fp_year_label_range', { startYear, endYear })
|
||||
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -141,17 +148,17 @@ export function FiscalPeriodEditor() {
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || 'Kunde inte ändra räkenskapsår')
|
||||
throw new Error(body.error || t('fp_update_failed_title'))
|
||||
}
|
||||
setPeriod(body.data as FiscalPeriod)
|
||||
toast({
|
||||
title: 'Räkenskapsår uppdaterat',
|
||||
title: t('fp_updated_title'),
|
||||
description: `${formatSwedishDate(body.data.period_start)} – ${formatSwedishDate(body.data.period_end)}`,
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte ändra räkenskapsår',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
title: t('fp_update_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('fp_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -170,22 +177,22 @@ export function FiscalPeriodEditor() {
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Första räkenskapsår
|
||||
{t('fp_heading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Om du valde fel räkenskapsår vid uppstart kan du justera det här — så länge du inte har bokfört någon verifikation ännu.
|
||||
{t('fp_intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Hämtar räkenskapsår...
|
||||
{t('fp_loading')}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<p className="text-sm text-destructive">{loadError}</p>
|
||||
) : !period ? (
|
||||
<p className="text-sm text-muted-foreground">Inget räkenskapsår hittades.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
|
||||
) : isBlocked ? (
|
||||
<BlockedState
|
||||
period={period}
|
||||
@@ -196,10 +203,10 @@ export function FiscalPeriodEditor() {
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
|
||||
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Ändra med omsorg.</p>
|
||||
<p className="font-medium">{t('fp_warning_title')}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Ändringen påverkar öppningsbalanser och rapporter.
|
||||
{isEF && ' Enskild firma måste använda kalenderår enligt BFL 3 kap.'}
|
||||
{t('fp_warning_body')}
|
||||
{isEF && t('fp_warning_ef_suffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,10 +216,10 @@ export function FiscalPeriodEditor() {
|
||||
onStartDateChange={setStartDate}
|
||||
endDate={endDate}
|
||||
entityType={company?.entity_type}
|
||||
summaryTitle="Föreslaget räkenskapsår"
|
||||
summaryTitle={t('fp_summary_title')}
|
||||
endDateSlot={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fp_end">Slutdatum</Label>
|
||||
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
|
||||
<Input
|
||||
id="fp_end"
|
||||
type="date"
|
||||
@@ -220,7 +227,7 @@ export function FiscalPeriodEditor() {
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Måste vara sista dagen i en månad.
|
||||
{t('fp_end_date_help')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -233,7 +240,7 @@ export function FiscalPeriodEditor() {
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Återställ
|
||||
{t('fp_reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -249,10 +256,10 @@ export function FiscalPeriodEditor() {
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('fp_saving')}
|
||||
</>
|
||||
) : (
|
||||
'Spara ändring'
|
||||
t('fp_save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -271,32 +278,32 @@ function BlockedState({
|
||||
period: FiscalPeriod
|
||||
postedCount: number
|
||||
}) {
|
||||
const t = useTranslations('settings_company')
|
||||
const reason = period.locked_at
|
||||
? 'Räkenskapsåret är låst.'
|
||||
? t('fp_blocked_reason_locked')
|
||||
: period.is_closed
|
||||
? 'Räkenskapsåret är stängt.'
|
||||
: `${postedCount} bokförd${postedCount === 1 ? '' : 'a'} verifikation${postedCount === 1 ? '' : 'er'} finns redan i perioden.`
|
||||
? t('fp_blocked_reason_closed')
|
||||
: t('fp_blocked_reason_posted', { count: postedCount })
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Räkenskapsåret kan inte längre ändras</p>
|
||||
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Första räkenskapsåret:{' '}
|
||||
{t('fp_blocked_first_year')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatSwedishDate(period.period_start)} – {formatSwedishDate(period.period_end)}
|
||||
</span>
|
||||
{isCalendarYear(period) ? ' (kalenderår)' : ' (brutet räkenskapsår)'}
|
||||
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
|
||||
</p>
|
||||
<p>
|
||||
Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt.
|
||||
Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.
|
||||
{t('fp_blocked_explainer')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -10,24 +11,25 @@ interface InvoiceSettingsFormProps {
|
||||
}
|
||||
|
||||
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
const t = useTranslations('settings_invoice_form')
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Fakturainställningar
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
placeholder={t('prefix_placeholder')}
|
||||
defaultValue={settings.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
@@ -37,7 +39,7 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
@@ -49,16 +51,16 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
placeholder={t('default_notes_placeholder')}
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås automatiskt vid ny faktura.
|
||||
{t('default_notes_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -12,6 +13,7 @@ interface LogoUploadProps {
|
||||
}
|
||||
|
||||
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
const { toast } = useToast()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -23,11 +25,11 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
|
||||
function validateAndUpload(file: File) {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast({ title: 'Otillåten filtyp', description: 'PNG, JPG, SVG eller WebP.', variant: 'destructive' })
|
||||
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
|
||||
toast({ title: t('logo_too_large'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
handleUpload(file)
|
||||
@@ -48,15 +50,15 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Uppladdning misslyckades')
|
||||
throw new Error(result.error || t('logo_upload_failed_default'))
|
||||
}
|
||||
|
||||
setPreview(result.data.logo_url)
|
||||
onUpdate(result.data.logo_url)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda upp',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
title: t('logo_upload_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('logo_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -75,7 +77,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
onUpdate(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
|
||||
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
@@ -107,10 +109,10 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Logotyp
|
||||
{t('logo_heading')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
|
||||
{t('logo_help')}
|
||||
</p>
|
||||
|
||||
{preview ? (
|
||||
@@ -119,7 +121,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Företagslogotyp"
|
||||
alt={t('logo_alt')}
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
@@ -131,7 +133,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
|
||||
Byt logotyp
|
||||
{t('logo_change')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -141,7 +143,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
|
||||
Ta bort
|
||||
{t('logo_remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +166,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
|
||||
)}
|
||||
<Label className="text-sm text-muted-foreground cursor-pointer">
|
||||
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
|
||||
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
|
||||
</Label>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -26,6 +27,7 @@ interface OAuthClient {
|
||||
}
|
||||
|
||||
export function OAuthClientsPanel() {
|
||||
const t = useTranslations('settings_oauth_clients')
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
|
||||
|
||||
@@ -44,11 +46,11 @@ export function OAuthClientsPanel() {
|
||||
setClients(json.data.filter((c: OAuthClient) => !c.revoked_at))
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte hämta OAuth-klienter', variant: 'destructive' })
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
}, [toast, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients()
|
||||
@@ -61,14 +63,14 @@ export function OAuthClientsPanel() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: clientName.trim() || 'OAuth-klient',
|
||||
client_name: clientName.trim() || t('default_client_name'),
|
||||
redirect_uri: redirectUri.trim(),
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: json.error ?? 'Kunde inte registrera redirect URI', variant: 'destructive' })
|
||||
toast({ title: json.error ?? t('toast_register_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ export function OAuthClientsPanel() {
|
||||
setRedirectUri('')
|
||||
fetchClients()
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte registrera redirect URI', variant: 'destructive' })
|
||||
toast({ title: t('toast_register_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@@ -85,30 +87,26 @@ export function OAuthClientsPanel() {
|
||||
|
||||
async function handleRevoke(id: string, name: string) {
|
||||
const ok = await confirmRevoke({
|
||||
title: 'Återkalla OAuth-klient',
|
||||
description: `"${name}" tas bort från allowlist. Pågående auth-flöden slutar fungera direkt; redan utfärdade API-nycklar fortsätter att gälla tills de återkallas separat.`,
|
||||
confirmLabel: 'Återkalla',
|
||||
title: t('revoke_dialog_title'),
|
||||
description: t('revoke_dialog_description', { name }),
|
||||
confirmLabel: t('revoke_confirm'),
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/settings/oauth-clients/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
// Surface the server error rather than optimistically pretending the
|
||||
// revocation succeeded — a silent fail leaves the row in the
|
||||
// allowlist while the UI says it's gone, which is the opposite of
|
||||
// what the user expected.
|
||||
const body = await res.json().catch(() => ({}))
|
||||
toast({
|
||||
title: body?.error || 'Kunde inte återkalla klient',
|
||||
title: body?.error || t('toast_revoke_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setClients((prev) => prev.filter((c) => c.id !== id))
|
||||
toast({ title: 'Klient återkallad' })
|
||||
toast({ title: t('toast_revoked') })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte återkalla klient', variant: 'destructive' })
|
||||
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,15 +124,14 @@ export function OAuthClientsPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>OAuth-klienter</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
Registrera redirect-URI:er för egenutvecklade MCP-klienter. Claude.ai och localhost
|
||||
är redan godkända som standard — registrera bara här om du bygger en egen app.
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Registrera URI
|
||||
{t('register_uri')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -146,9 +143,9 @@ export function OAuthClientsPanel() {
|
||||
) : clients.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Globe className="h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Inga egna OAuth-klienter registrerade.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Bygger du en agent som ska ansluta via OAuth? Registrera dess callback-URI här.
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -165,7 +162,7 @@ export function OAuthClientsPanel() {
|
||||
{c.redirect_uri}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Registrerad {formatDate(c.created_at)}
|
||||
{t('registered_on')} {formatDate(c.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +170,7 @@ export function OAuthClientsPanel() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(c.id, c.client_name)}
|
||||
aria-label={`Återkalla ${c.client_name}`}
|
||||
aria-label={t('revoke_aria', { name: c.client_name })}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -188,29 +185,26 @@ export function OAuthClientsPanel() {
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registrera redirect URI</DialogTitle>
|
||||
<DialogTitle>{t('register_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bara för egenbyggda MCP-klienter med en publik HTTPS-callback. Lägg{' '}
|
||||
<span className="font-medium">inte</span> till{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">localhost</code>{' '}
|
||||
eller{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">claude.ai</code>{' '}
|
||||
— de fungerar redan utan registrering. URI:n jämförs ord-för-ord mot
|
||||
redirect_uri-parametern i OAuth-flödet.
|
||||
{t.rich('register_dialog_description', {
|
||||
bold: (chunks) => <span className="font-medium">{chunks}</span>,
|
||||
code: (chunks) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{chunks}</code>,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-name">Klientnamn</Label>
|
||||
<Label htmlFor="client-name">{t('client_name_label')}</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
placeholder="t.ex. Min bokföringsagent"
|
||||
placeholder={t('client_name_placeholder')}
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirect-uri">Redirect URI</Label>
|
||||
<Label htmlFor="redirect-uri">{t('redirect_uri_label')}</Label>
|
||||
<Input
|
||||
id="redirect-uri"
|
||||
type="url"
|
||||
@@ -223,11 +217,11 @@ export function OAuthClientsPanel() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
Avbryt
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !redirectUri.trim()}>
|
||||
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Registrera
|
||||
{t('register')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -13,6 +14,7 @@ interface PdfPrintSettingsProps {
|
||||
}
|
||||
|
||||
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
|
||||
const t = useTranslations('settings_pdf_print')
|
||||
const { toast } = useToast()
|
||||
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
|
||||
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
|
||||
@@ -27,9 +29,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
toast({ title: t('toast_save_failed'), variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
const savePosition = useCallback(async (value: 'header' | 'footer') => {
|
||||
try {
|
||||
@@ -41,9 +43,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ invoice_company_name_position: value })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
toast({ title: t('toast_save_failed'), variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
const saveText = useCallback(async (field: string, value: string) => {
|
||||
try {
|
||||
@@ -55,21 +57,21 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
toast({ title: t('toast_save_failed'), variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utskrift & PDF
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Öresavrundning</Label>
|
||||
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
|
||||
<Label>{t('ore_rounding_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ore_rounding ?? true}
|
||||
@@ -79,8 +81,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa OCR-referens</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
|
||||
<Label>{t('show_ocr_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_ocr ?? true}
|
||||
@@ -90,8 +92,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa bankgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
|
||||
<Label>{t('show_bankgiro_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_bankgiro ?? true}
|
||||
@@ -101,8 +103,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa plusgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
|
||||
<Label>{t('show_plusgiro_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_plusgiro ?? true}
|
||||
@@ -112,8 +114,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa Swish</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa Swish-nummer på fakturautskrift</p>
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_swish ?? true}
|
||||
@@ -123,8 +125,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa logga</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
|
||||
<Label>{t('show_logo_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_logo ?? true}
|
||||
@@ -135,8 +137,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa företagsnamn i faktura</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa företagsnamn i fakturan</p>
|
||||
<Label>{t('show_company_name_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_company_name ?? true}
|
||||
@@ -145,10 +147,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
</div>
|
||||
{(settings.invoice_show_company_name ?? true) && (
|
||||
<div className="flex items-center justify-between pl-0">
|
||||
<p className="text-xs text-muted-foreground">Placering</p>
|
||||
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Placering av företagsnamn"
|
||||
aria-label={t('placement_aria_label')}
|
||||
className="inline-flex rounded-md border border-border/60 p-0.5"
|
||||
>
|
||||
{(['header', 'footer'] as const).map((pos) => {
|
||||
@@ -166,7 +168,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
: 'text-muted-foreground hover:text-foreground')
|
||||
}
|
||||
>
|
||||
{pos === 'header' ? 'Huvud' : 'Sidfot'}
|
||||
{pos === 'header' ? t('placement_header') : t('placement_footer')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -178,11 +180,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
|
||||
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
|
||||
placeholder={t('late_fee_placeholder')}
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
@@ -190,11 +192,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
|
||||
<Label htmlFor="invoice_credit_terms_text">{t('credit_terms_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Betalning sker till angivet bankgiro."
|
||||
placeholder={t('credit_terms_placeholder')}
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -10,15 +11,16 @@ interface PeriodLockingSettingsProps {
|
||||
}
|
||||
|
||||
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
|
||||
const t = useTranslations('settings_period_locking')
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Periodlåsning
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
|
||||
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
|
||||
<Input
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
@@ -26,12 +28,12 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifikationer med datum före detta datum kan inte skapas eller ändras.
|
||||
{t('locked_through_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
|
||||
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label>
|
||||
<Select
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
@@ -40,14 +42,14 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
|
||||
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="none">{t('auto_lock_none')}</SelectItem>
|
||||
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
|
||||
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
|
||||
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Låser automatiskt perioder efter valt antal dagar.
|
||||
{t('auto_lock_help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -19,6 +20,7 @@ const mfaRequired = isMfaRequired()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
|
||||
export function SecuritySettings() {
|
||||
const t = useTranslations('settings_security')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false)
|
||||
@@ -59,8 +61,8 @@ export function SecuritySettings() {
|
||||
|
||||
if (!strong) {
|
||||
toast({
|
||||
title: 'Lösenordet är för svagt',
|
||||
description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.',
|
||||
title: t('toast_weak_password_title'),
|
||||
description: t('toast_weak_password_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsChangingPassword(false)
|
||||
@@ -69,8 +71,8 @@ export function SecuritySettings() {
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({
|
||||
title: 'Lösenorden matchar inte',
|
||||
description: 'Kontrollera att du skrev samma lösenord i båda fälten.',
|
||||
title: t('toast_mismatch_title'),
|
||||
description: t('toast_mismatch_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsChangingPassword(false)
|
||||
@@ -87,24 +89,24 @@ export function SecuritySettings() {
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
toast({
|
||||
title: 'Kunde inte uppdatera lösenord',
|
||||
description: body.error || 'Försök igen senare.',
|
||||
title: t('toast_update_failed_title'),
|
||||
description: body.error || t('toast_update_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Lösenord uppdaterat',
|
||||
description: 'Ditt lösenord har ändrats.',
|
||||
title: t('toast_password_updated_title'),
|
||||
description: t('toast_password_updated_description'),
|
||||
})
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setHasPassword(true)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Något gick fel',
|
||||
description: 'Försök igen senare.',
|
||||
title: t('toast_generic_error_title'),
|
||||
description: t('toast_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -121,7 +123,7 @@ export function SecuritySettings() {
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Kunde inte inaktivera 2FA',
|
||||
title: t('toast_unenroll_failed_title'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -129,15 +131,15 @@ export function SecuritySettings() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Tvåfaktorsautentisering inaktiverad',
|
||||
description: '2FA har tagits bort från ditt konto.',
|
||||
title: t('toast_mfa_disabled_title'),
|
||||
description: t('toast_mfa_disabled_description'),
|
||||
})
|
||||
setHasMfa(false)
|
||||
setMfaFactorId(null)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Något gick fel',
|
||||
description: 'Försök igen senare.',
|
||||
title: t('toast_generic_error_title'),
|
||||
description: t('toast_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -182,21 +184,21 @@ export function SecuritySettings() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
Ändra lösenord
|
||||
{t('change_password_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.
|
||||
{t('change_password_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password">Nytt lösenord</Label>
|
||||
<Label htmlFor="new_password">{t('new_password_label')}</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Minst 8 tecken"
|
||||
placeholder={t('new_password_placeholder')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
@@ -205,12 +207,12 @@ export function SecuritySettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_new_password">Bekräfta nytt lösenord</Label>
|
||||
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
|
||||
<Input
|
||||
id="confirm_new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Upprepa lösenordet"
|
||||
placeholder={t('confirm_password_placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
@@ -222,10 +224,10 @@ export function SecuritySettings() {
|
||||
{isChangingPassword ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'Uppdatera lösenord'
|
||||
t('update_password_button')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -239,27 +241,26 @@ export function SecuritySettings() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Tvåfaktorsautentisering (2FA)
|
||||
{t('mfa_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod
|
||||
utöver ditt lösenord.
|
||||
{t('mfa_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingMfa ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Laddar...
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : hasMfa ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900">
|
||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">2FA är aktiverad</p>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">{t('mfa_active_title')}</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
Ditt konto skyddas med tvåfaktorsautentisering.
|
||||
{t('mfa_active_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,19 +273,19 @@ export function SecuritySettings() {
|
||||
{isUnenrolling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Inaktiverar...
|
||||
{t('disabling')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
Inaktivera 2FA
|
||||
{t('disable_mfa')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{mfaRequired && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras.
|
||||
{t('mfa_required_note')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -293,9 +294,9 @@ export function SecuritySettings() {
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border">
|
||||
<ShieldOff className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">2FA är inte aktiverad</p>
|
||||
<p className="font-medium">{t('mfa_inactive_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vi rekommenderar att du aktiverar tvåfaktorsautentisering.
|
||||
{t('mfa_inactive_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,14 +308,14 @@ export function SecuritySettings() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Sätt ett lösenord först
|
||||
{t('set_password_first')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Aktivera 2FA
|
||||
{t('enable_mfa')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Check, Lock } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -17,6 +18,7 @@ interface SettingsFormWrapperProps {
|
||||
}
|
||||
|
||||
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -71,7 +73,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
throw new Error(messages.join(' • '))
|
||||
}
|
||||
}
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
throw new Error(result.error || t('wrapper_save_failed_default'))
|
||||
}
|
||||
|
||||
onSuccess?.(result.data ?? updates)
|
||||
@@ -79,14 +81,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
timerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
title: t('wrapper_save_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('wrapper_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}, [onSave, toast])
|
||||
}, [onSave, toast, t])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
@@ -96,27 +98,27 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Sparat
|
||||
{t('wrapper_saved')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || !canWrite}
|
||||
size="sm"
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sparar...
|
||||
{t('wrapper_saving')}
|
||||
</>
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-3.5 w-3.5" />
|
||||
Spara ändringar
|
||||
{t('wrapper_save_changes')}
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
t('wrapper_save_changes')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -17,6 +18,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('settings_nav')
|
||||
|
||||
const hasCompany = !!company
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
@@ -24,18 +26,18 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ href: '/settings/company', label: 'Företag', show: hasCompany },
|
||||
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
|
||||
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
|
||||
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
|
||||
{ href: '/settings/team', label: 'Lag', show: false },
|
||||
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ href: '/settings/skatteverket', label: 'Skatteverket', show: hasCompany && !isSandbox && hasSkatteverketExtension },
|
||||
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
|
||||
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
|
||||
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
|
||||
{ href: '/settings/account', label: 'Konto', show: true },
|
||||
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
{ href: '/settings/company', label: t('company'), show: hasCompany },
|
||||
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
|
||||
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
|
||||
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
|
||||
{ href: '/settings/team', label: t('team'), show: false },
|
||||
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
|
||||
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
|
||||
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
|
||||
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
|
||||
{ href: '/settings/account', label: t('account'), show: true },
|
||||
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
|
||||
].filter(item => item.show)
|
||||
|
||||
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
|
||||
@@ -61,7 +63,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
{/* Desktop: horizontal tabs with bottom border */}
|
||||
<nav
|
||||
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
|
||||
aria-label="Inställningar"
|
||||
aria-label={t('aria_label')}
|
||||
>
|
||||
<ul className="flex gap-0 -mb-px">
|
||||
{items.map(item => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -21,21 +22,22 @@ type Status =
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
momsdeklaration: 'Momsdeklaration',
|
||||
inkforetag: 'Företagsinformation',
|
||||
skahmst: 'Skattekonto – saldo & transaktioner',
|
||||
skattekonto: 'Skattekonto',
|
||||
agd: 'Arbetsgivardeklaration',
|
||||
}
|
||||
|
||||
export function SkatteverketConnectPanel() {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
const { toast } = useToast()
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [disconnecting, setDisconnecting] = useState(false)
|
||||
|
||||
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
momsdeklaration: t('scope_momsdeklaration'),
|
||||
inkforetag: t('scope_inkforetag'),
|
||||
skahmst: t('scope_skahmst'),
|
||||
skattekonto: t('scope_skattekonto'),
|
||||
agd: t('scope_agd'),
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -68,12 +70,12 @@ export function SkatteverketConnectPanel() {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) throw new Error('Frånkoppling misslyckades')
|
||||
toast({ title: 'Skatteverket frånkopplad' })
|
||||
if (!res.ok) throw new Error(t('disconnect_failed'))
|
||||
toast({ title: t('toast_disconnected') })
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla från',
|
||||
title: t('toast_disconnect_failed'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -86,7 +88,7 @@ export function SkatteverketConnectPanel() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-sm text-muted-foreground">
|
||||
Hämtar status…
|
||||
{t('loading_status')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -97,7 +99,7 @@ export function SkatteverketConnectPanel() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Skatteverket</CardTitle>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -105,23 +107,20 @@ export function SkatteverketConnectPanel() {
|
||||
{status?.disabled && (
|
||||
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.</p>
|
||||
<p>{t('disabled_message')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
|
||||
arbetsgivardeklaration och hämta saldot på skattekontot.
|
||||
{t('connect_intro')}
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
|
||||
På Skatteverkets samtyckessida visas en av behörigheterna som{' '}
|
||||
<span className="font-mono">skahmst (Rubrik saknas)</span> — det är
|
||||
scope-namnet för skattekontots saldo och transaktioner (Skattekonto
|
||||
HuvudMan STatus). Skatteverket har inte publicerat en svensk
|
||||
beskrivning för den ännu. Det är ofarligt att godkänna.
|
||||
{t.rich('skahmst_note', {
|
||||
code: (chunks) => <span className="font-mono">{chunks}</span>,
|
||||
})}
|
||||
</div>
|
||||
<Button onClick={startConnect} disabled={status?.disabled}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut med BankID
|
||||
{t('connect_with_bankid')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -139,13 +138,13 @@ export function SkatteverketConnectPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Skatteverket
|
||||
{t('title')}
|
||||
{status.expired ? (
|
||||
<Badge variant="destructive">Utgången</Badge>
|
||||
<Badge variant="destructive">{t('expired')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Ansluten
|
||||
{t('connected')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
@@ -155,27 +154,27 @@ export function SkatteverketConnectPanel() {
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Token utgår</dt>
|
||||
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{expiresAtDate.toLocaleString('sv-SE')}
|
||||
{!status.expired && expiresInMinutes > 0 && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
(om {expiresInMinutes} min)
|
||||
{t('expires_in_minutes', { minutes: expiresInMinutes })}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Förnyelse</dt>
|
||||
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
|
||||
<dd className="font-medium">
|
||||
{status.canRefresh ? 'Förnyas automatiskt' : 'Förnyelse uttömd — anslut igen'}
|
||||
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Behörigheter
|
||||
{t('permissions_label')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopes.map(s => (
|
||||
@@ -186,15 +185,12 @@ export function SkatteverketConnectPanel() {
|
||||
</div>
|
||||
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
|
||||
<p className="mt-3 text-sm text-foreground">
|
||||
Behörigheten för Skattekonto saknas — koppla från och anslut igen
|
||||
för att aktivera saldo- och transaktionsvyn.
|
||||
{t('missing_skattekonto')}
|
||||
</p>
|
||||
)}
|
||||
{!scopes.includes('agd') && (
|
||||
<p className="mt-3 text-sm text-foreground">
|
||||
Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla
|
||||
från och anslut igen för att kunna skicka AGI direkt från {`gnubok`}.
|
||||
Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.
|
||||
{t('missing_agd')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,7 +198,7 @@ export function SkatteverketConnectPanel() {
|
||||
{status.disabled && (
|
||||
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.</p>
|
||||
<p>{t('disabled_filings_message')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -210,7 +206,7 @@ export function SkatteverketConnectPanel() {
|
||||
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
|
||||
<Button onClick={startConnect} disabled={status.disabled}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut igen
|
||||
{t('reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -219,7 +215,7 @@ export function SkatteverketConnectPanel() {
|
||||
disabled={disconnecting}
|
||||
>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
{disconnecting ? 'Kopplar från…' : 'Koppla från'}
|
||||
{disconnecting ? t('disconnecting') : t('disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -228,11 +224,12 @@ export function SkatteverketConnectPanel() {
|
||||
}
|
||||
|
||||
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
if (disabled) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
Avstängd
|
||||
{t('env_disabled')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -240,14 +237,14 @@ function EnvironmentBadge({ environment, disabled }: { environment?: Environment
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:border-amber-600 dark:text-amber-400">
|
||||
<FlaskConical className="mr-1 h-3 w-3" />
|
||||
Testmiljö
|
||||
{t('env_test')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (environment === 'prod') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-400 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400">
|
||||
Produktion
|
||||
{t('env_prod')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{search}"
|
||||
{t('no_search_results', { term: search })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} · 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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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')}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user