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

* feat: add user locale preference to user_preferences table

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

chore: declare CSS module support in TypeScript

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

* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
+22 -19
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
@@ -25,6 +26,7 @@ interface Blocker {
}
export function AccountDangerZone() {
const t = useTranslations('settings_account_danger')
const router = useRouter()
const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([])
@@ -77,7 +79,7 @@ export function AccountDangerZone() {
const body = await response.json()
// Precondition tripped mid-flow — refresh the list and show inline.
setBlockers(body.blockers ?? [])
setError(body.error || 'Du måste radera eller överlåta dina företag först.')
setError(body.error || t('delete_failed_blockers'))
setIsDeleting(false)
setShowDialog(false)
return
@@ -85,12 +87,12 @@ export function AccountDangerZone() {
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera kontot')
throw new Error(body.error || t('delete_failed_default'))
}
router.push('/login')
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte radera kontot')
setError(err instanceof Error ? err.message : t('delete_failed_default'))
setIsDeleting(false)
}
}
@@ -102,14 +104,14 @@ export function AccountDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera konto
{t('heading')}
</h2>
{hasBlockers && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<p className="text-sm font-medium">Företag du äger</p>
<p className="text-sm font-medium">{t('blockers_title')}</p>
<p className="text-sm text-muted-foreground">
Radera eller överlåt alla företag innan du raderar kontot.
{t('blockers_description')}
</p>
<ul className="space-y-2">
{blockers.map((b) => (
@@ -119,7 +121,7 @@ export function AccountDangerZone() {
>
<span className="text-sm font-medium">{b.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">Hantera</Link>
<Link href="/settings/company">{t('blockers_manage')}</Link>
</Button>
</li>
))}
@@ -130,8 +132,8 @@ export function AccountDangerZone() {
<RetentionNotice variant="account" />
<p className="text-sm text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
{t('support_question')}{' '}
<SupportLink variant="inline" subject={t('support_subject')} />
</p>
{error && !showDialog && (
@@ -142,7 +144,7 @@ export function AccountDangerZone() {
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
{t('export_sie')}
</Link>
</Button>
<Button
@@ -151,7 +153,7 @@ export function AccountDangerZone() {
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
Radera mitt konto
{t('delete_button')}
</Button>
</div>
</section>
@@ -169,16 +171,17 @@ export function AccountDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogTitle>{t('dialog_title')}</DialogTitle>
<DialogDescription>
Ditt konto avidentifieras och du loggas ut från alla enheter.
Du kan inte skapa ett nytt konto med samma e-postadress — kontakta support
om du vill återaktivera kontot i framtiden.
{t('dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">
Skriv din e-postadress (<strong>{email}</strong>) för att bekräfta
{t.rich('confirm_label', {
email: email ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="delete-confirm"
@@ -200,7 +203,7 @@ export function AccountDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('cancel')}
</Button>
<Button
variant="destructive"
@@ -214,10 +217,10 @@ export function AccountDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('deleting')}
</>
) : (
'Radera konto'
t('delete_confirm_button')
)}
</Button>
</DialogFooter>
+99 -91
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -27,14 +28,14 @@ const connectorName = branding.appName.toLowerCase()
type ScopeEntry = {
scope: ApiKeyScope
label: string
labelKey: string
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
tools: number
}
type ScopeGroup = {
domain: string
label: string
labelKey: string
read: ScopeEntry | null
write: ScopeEntry | null
}
@@ -42,86 +43,86 @@ type ScopeGroup = {
const SCOPE_GROUPS: ScopeGroup[] = [
{
domain: 'transactions',
label: 'Transaktioner',
read: { scope: 'transactions:read', label: 'Läs — lista transaktioner, mallar, kategorier, inbox', tools: 8 },
write: { scope: 'transactions:write', label: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning', tools: 8 },
labelKey: 'group_transactions',
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
},
{
domain: 'customers',
label: 'Kunder',
read: { scope: 'customers:read', label: 'Läs — lista kunder', tools: 1 },
write: { scope: 'customers:write', label: 'Skriv — skapa kunder', tools: 1 },
labelKey: 'group_customers',
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
},
{
domain: 'invoices',
label: 'Fakturor',
read: { scope: 'invoices:read', label: 'Läs — lista fakturor', tools: 1 },
write: { scope: 'invoices:write', label: 'Skriv — skapa, skicka, markera betald/skickad, kreditera, konvertera', tools: 6 },
labelKey: 'group_invoices',
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
},
{
domain: 'suppliers',
label: 'Leverantörer',
read: { scope: 'suppliers:read', label: 'Läs — lista leverantörer och leverantörsfakturor', tools: 2 },
write: { scope: 'suppliers:write', label: 'Skriv — godkänn, kreditera, skapa leverantörsfaktura från inbox', tools: 3 },
labelKey: 'group_suppliers',
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
},
{
domain: 'reports',
label: 'Rapporter',
read: { scope: 'reports:read', label: 'Läs — kontoplan, huvudbok, BR, RR, moms, KPI, reskontra, perioder, bankavstämning, SIE-export', tools: 18 },
labelKey: 'group_reports',
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
write: null,
},
{
domain: 'bookkeeping',
label: 'Bokföring',
labelKey: 'group_bookkeeping',
read: null,
write: { scope: 'bookkeeping:write', label: 'Skriv — stänga/låsa perioder, IB, bokslut, SIE-import, verifikat, korrigeringar (alla stagas)', tools: 11 },
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
},
{
domain: 'payroll',
label: 'Löner',
read: { scope: 'payroll:read', label: 'Läs — lista anställda, lönekörningar, lönejournal', tools: 3 },
write: { scope: 'payroll:write', label: 'Skriv — skapa lönekörning, beräkna, generera AGI', tools: 3 },
labelKey: 'group_payroll',
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
},
{
domain: 'pending_operations',
label: 'Stagade operationer',
read: { scope: 'pending_operations:read', label: 'Läs — lista pending_operations som väntar på godkännande', tools: 1 },
write: { scope: 'pending_operations:approve', label: 'Godkänn — committa eller avvisa staged ops via API (ersätter web-UI:s granskning)', tools: 2 },
labelKey: 'group_pending_operations',
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
},
{
domain: 'documents',
label: 'Dokument (REST API)',
read: { scope: 'documents:read', label: 'Läs — lista och hämta dokumentbilagor', tools: 0 },
write: { scope: 'documents:write', label: 'Skriv — ladda upp och koppla dokument till verifikationer', tools: 0 },
labelKey: 'group_documents',
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
},
{
domain: 'companies',
label: 'Företag (REST API)',
read: { scope: 'companies:read', label: 'Läs — företagsprofiler nyckeln har åtkomst till', tools: 0 },
labelKey: 'group_companies',
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 },
write: null,
},
{
domain: 'events',
label: 'Händelser (REST API)',
read: { scope: 'events:read', label: 'Läs — polla event_log som webhook-fallback', tools: 0 },
labelKey: 'group_events',
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
write: null,
},
{
domain: 'webhooks',
label: 'Webhooks (REST API)',
labelKey: 'group_webhooks',
read: null,
write: { scope: 'webhooks:manage', label: 'Hantera — skapa, lista, uppdatera, radera prenumerationer', tools: 0 },
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
},
{
domain: 'operations',
label: 'Operationer (REST API)',
read: { scope: 'operations:read', label: 'Läs — status för långkörande operationer (import, bokslut, omvärdering)', tools: 0 },
labelKey: 'group_operations',
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
write: null,
},
{
domain: 'compliance',
label: 'Compliance (REST API)',
read: { scope: 'compliance:read', label: 'Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet', tools: 0 },
labelKey: 'group_compliance',
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 0 },
write: null,
},
]
@@ -146,7 +147,7 @@ interface ApiKey {
created_at: string
}
function CopyBlock({ text }: { text: string }) {
function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
@@ -169,7 +170,7 @@ function CopyBlock({ text }: { text: string }) {
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
aria-label="Kopiera"
aria-label={copyAriaLabel}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
@@ -190,9 +191,11 @@ function ScopeCard({
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const dashIdx = entry.label.indexOf(' — ')
const verb = dashIdx > 0 ? entry.label.slice(0, dashIdx) : entry.label
const description = dashIdx > 0 ? entry.label.slice(dashIdx + 3) : ''
const t = useTranslations('settings_api_keys')
const label = t(entry.labelKey)
const dashIdx = label.indexOf(' — ')
const verb = dashIdx > 0 ? label.slice(0, dashIdx) : label
const description = dashIdx > 0 ? label.slice(dashIdx + 3) : ''
return (
<label
@@ -211,7 +214,7 @@ function ScopeCard({
/>
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{entry.tools > 0 ? `${entry.tools} verktyg` : 'REST'}
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
</span>
</div>
{description && (
@@ -224,6 +227,7 @@ function ScopeCard({
}
export function ApiKeysPanel() {
const t = useTranslations('settings_api_keys')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -246,11 +250,11 @@ export function ApiKeysPanel() {
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchKeys()
@@ -262,7 +266,7 @@ export function ApiKeysPanel() {
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName || 'MCP-nyckel', scopes: Array.from(newKeyScopes) }),
body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes) }),
})
const json = await res.json()
@@ -278,7 +282,7 @@ export function ApiKeysPanel() {
setNewKeyScopes(new Set(ALL_SCOPES))
fetchKeys()
} catch {
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
toast({ title: t('toast_create_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -286,18 +290,18 @@ export function ApiKeysPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla API-nyckel',
description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' })
setKeys((prev) => prev.filter((k) => k.id !== id))
toast({ title: 'Nyckel återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -326,9 +330,9 @@ export function ApiKeysPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>API-nycklar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Hantera nycklar för MCP-klienter (Claude, Cursor) och andra integrationer.
{t('description')}
</CardDescription>
</div>
<Button
@@ -337,7 +341,7 @@ export function ApiKeysPanel() {
disabled={keys.length >= 10}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Skapa nyckel
{t('create_key')}
</Button>
</div>
</CardHeader>
@@ -349,9 +353,9 @@ export function ApiKeysPanel() {
) : keys.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Key className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga API-nycklar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Skapa en nyckel för att koppla din MCP-klient.
{t('empty_help')}
</p>
</div>
) : (
@@ -368,10 +372,10 @@ export function ApiKeysPanel() {
<p className="text-sm font-medium truncate">{key.name}</p>
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? 'Alla behörigheter'
? t('all_permissions')
: scopeCount === 0
? 'Inga behörigheter'
: `${scopeCount} behörigheter`}
? t('no_permissions')
: t('permissions_count', { count: scopeCount })}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
@@ -379,12 +383,12 @@ export function ApiKeysPanel() {
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
Skapad {formatDate(key.created_at)}
{t('created')} {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? `Använd ${formatDate(key.last_used_at)}`
: 'Aldrig använd'}
? t('used_on', { date: formatDate(key.last_used_at) })
: t('never_used')}
</span>
</div>
</div>
@@ -392,7 +396,7 @@ export function ApiKeysPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={`Återkalla ${key.name}`}
aria-label={t('revoke_aria', { name: key.name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -407,27 +411,29 @@ export function ApiKeysPanel() {
<Card>
<CardHeader>
<CardTitle className="text-base">Anslut MCP-klient</CardTitle>
<CardTitle className="text-base">{t('connect_mcp_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-sm font-medium">Claude.ai</p>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">Rekommenderat</Badge>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">{t('recommended_badge')}</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
Gå till <strong>Settings &rarr; Integrations &rarr; Add Integration</strong> och klistra in MCP-serverns URL.
Du loggas in via ditt {connectorName}-konto — ingen API-nyckel behövs.
{t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl} />
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-2">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-2">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen — loggar in via webbläsaren:
{t('terminal_runs_browser_login')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} />
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
</div>
<div className="border-t pt-4">
@@ -437,14 +443,16 @@ export function ApiKeysPanel() {
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
Anslut med API-nyckel istället
{t('connect_with_api_key')}
</button>
{showApiKeyMethods && (
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<p className="text-xs text-muted-foreground mb-2">
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar &rarr; Developer):
{t.rich('claude_desktop_instructions', {
code: (chunks) => <code className="text-xs">{chunks}</code>,
})}
</p>
<CopyBlock text={`{
"mcpServers": {
@@ -456,17 +464,17 @@ export function ApiKeysPanel() {
}
}
}
}`} />
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
<p className="text-sm font-medium mb-1">{t('claude_code_cursor')}</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen med en API-nyckel:
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url ${mcpUrl} \\
--header "Authorization: Bearer gnubok_sk_..."`} />
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
)}
@@ -478,17 +486,17 @@ export function ApiKeysPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl p-4 sm:max-w-3xl sm:p-6">
<DialogHeader>
<DialogTitle>Skapa API-nyckel</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
<DialogDescription>
Ge nyckeln ett namn så du vet vad den används till.
{t('create_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="key-name">Namn</Label>
<Label htmlFor="key-name">{t('name_label')}</Label>
<Input
id="key-name"
placeholder="t.ex. Claude Desktop"
placeholder={t('name_placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
@@ -497,19 +505,19 @@ export function ApiKeysPanel() {
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="space-y-1">
<Label>Behörigheter</Label>
<Label>{t('permissions_label')}</Label>
<p className="text-xs text-muted-foreground">
Välj vad nyckeln ska ha åtkomst till.
{t('permissions_help')}
</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{newKeyScopes.size} av {ALL_SCOPES.length} valda
{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SCOPE_GROUPS.map((group) => (
<div key={group.domain} className="space-y-2">
<h4 className="text-sm font-medium">{group.label}</h4>
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
<div className="space-y-2 px-2">
{group.read && (
<ScopeCard
@@ -555,11 +563,11 @@ export function ApiKeysPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Skapa
{t('create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -577,9 +585,9 @@ export function ApiKeysPanel() {
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Din nya API-nyckel</DialogTitle>
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
<DialogDescription>
Kopiera nyckeln nu. Den visas bara en gång.
{t('new_key_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="relative">
@@ -605,7 +613,7 @@ export function ApiKeysPanel() {
setNewKeyValue('')
setCopied(false)
}}>
Klar
{t('done')}
</Button>
</DialogFooter>
</DialogContent>
+35 -34
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -28,6 +29,7 @@ interface EstimateResponse {
const LAST_DOWNLOAD_STORAGE_KEY = 'gnubok:last-backup-download'
export function BackupDownloadForm() {
const t = useTranslations('settings_backup_download')
const { toast } = useToast()
const { company } = useCompany()
@@ -132,16 +134,16 @@ export function BackupDownloadForm() {
const body = await res.json().catch(() => ({}))
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
toast({
title: 'Arkivet är för stort för direktnedladdning',
title: t('toast_too_large_title'),
description: sizeMb
? `Ditt arkiv är cirka ${sizeMb} MB. Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.`
: 'Exportera en period i taget tills vidare — automatisk molnsynkronisering kommer i senare version.',
? t('toast_too_large_with_size', { size: sizeMb })
: t('toast_too_large_generic'),
variant: 'destructive',
})
return
}
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte skapa arkivet')
throw new Error(body.error || t('error_create_archive'))
}
const blob = await res.blob()
@@ -164,17 +166,17 @@ export function BackupDownloadForm() {
setLastDownloadedAt(now)
}
toast({ title: 'Säkerhetsbackup skapad', description: filename })
toast({ title: t('toast_backup_created'), description: filename })
} catch (err) {
toast({
title: 'Kunde inte skapa säkerhetsbackup',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('toast_backup_failed'),
description: err instanceof Error ? err.message : t('toast_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast])
}, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t])
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId)
@@ -183,31 +185,33 @@ export function BackupDownloadForm() {
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Skapa backup</CardTitle>
<CardTitle>{t('create_backup_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Omfattning</Label>
<Label>{t('scope_label')}</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<ScopeRadio
checked={scope === 'all'}
onChange={() => setScope('all')}
label="Hela historiken"
description="Alla räkenskapsår och verifikationer"
label={t('scope_all_label')}
description={t('scope_all_desc')}
recommendedLabel={t('recommended')}
recommended
/>
<ScopeRadio
checked={scope === 'period'}
onChange={() => setScope('period')}
label="En period"
description="Välj ett specifikt räkenskapsår"
label={t('scope_period_label')}
description={t('scope_period_desc')}
recommendedLabel={t('recommended')}
/>
</div>
</div>
{scope === 'period' && (
<div className="space-y-2">
<Label htmlFor="backup-period">Räkenskapsår</Label>
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
<select
id="backup-period"
value={selectedPeriodId}
@@ -215,7 +219,7 @@ export function BackupDownloadForm() {
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={periods.length === 0}
>
{periods.length === 0 && <option value="">Inga räkenskapsår</option>}
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
@@ -227,10 +231,9 @@ export function BackupDownloadForm() {
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="include-documents">Inkludera kvitton och underlag</Label>
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
<p className="text-xs text-muted-foreground max-w-prose">
Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en.
Stäng av för en mindre backup med bara bokföringsdata.
{t('include_docs_help')}
</p>
</div>
<Switch
@@ -244,21 +247,19 @@ export function BackupDownloadForm() {
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-3.5 w-3.5" />
{isLoadingEstimate ? (
<span>Beräknar storlek…</span>
<span>{t('calculating_size')}</span>
) : estimate ? (
<span>
Uppskattad storlek: <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? 'bilaga' : 'bilagor'})
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
</span>
) : (
<span>Storlek beräknas när omfattning är vald.</span>
<span>{t('size_will_calculate')}</span>
)}
</div>
{isOverLimit && (
<p className="mt-2 text-xs text-destructive">
Arkivet är större än {formatBytes(estimate!.size_limit_bytes)} och kan inte laddas ner
direkt. Välj en enskild period eller stäng av bilagor tills vidare —
automatisk molnsynkronisering kommer i senare version.
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
</p>
)}
</div>
@@ -268,18 +269,18 @@ export function BackupDownloadForm() {
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar backup…
{t('creating_backup')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Skapa och ladda ner
{t('create_and_download')}
</>
)}
</Button>
{lastDownloadedAt && (
<p className="text-xs text-muted-foreground">
Senaste nedladdning: {formatDate(lastDownloadedAt)}
{t('last_download')}: {formatDate(lastDownloadedAt)}
</p>
)}
</div>
@@ -293,13 +294,12 @@ export function BackupDownloadForm() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Molnsynkronisering
{t('cloud_sync_title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground max-w-prose">
Aktivera tillägget &ldquo;Molnsynkronisering&rdquo; för att koppla Google
Drive och ladda upp säkerhetsbackupen med ett klick.
{t('cloud_sync_disabled_help')}
</p>
</CardContent>
</Card>
@@ -314,9 +314,10 @@ interface ScopeRadioProps {
label: string
description: string
recommended?: boolean
recommendedLabel: string
}
function ScopeRadio({ checked, onChange, label, description, recommended }: ScopeRadioProps) {
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
return (
<button
type="button"
@@ -329,7 +330,7 @@ function ScopeRadio({ checked, onChange, label, description, recommended }: Scop
<span className="font-medium text-sm">{label}</span>
{recommended && (
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
Rekommenderas
{recommendedLabel}
</span>
)}
</div>
+14 -12
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,6 +15,7 @@ interface BankDetailsFormProps {
}
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const t = useTranslations('settings_bank_details_form')
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
@@ -23,22 +25,22 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bankuppgifter
{t('heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas på dina fakturor
{t('subheading')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Label>{t('bank_label')}</Label>
<BankNameCombobox
defaultValue={settings.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Label htmlFor="clearing_number">{t('clearing_label')}</Label>
<Input
id="clearing_number"
name="clearing_number"
@@ -52,13 +54,13 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setClearingError(null); return }
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
setClearingError(!/^\d{4,5}$/.test(val) ? t('clearing_error') : null)
}}
/>
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Label htmlFor="account_number">{t('account_number_label')}</Label>
<Input
id="account_number"
name="account_number"
@@ -72,7 +74,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setAccountNumberError(null); return }
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
setAccountNumberError(!/^\d{6,12}$/.test(val) ? t('account_number_error') : null)
}}
/>
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
@@ -81,7 +83,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Label htmlFor="bankgiro">{t('bankgiro_label')}</Label>
<Input
id="bankgiro"
name="bankgiro"
@@ -94,7 +96,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError('Ogiltigt bankgironummer')
setBankgiroError(t('bankgiro_error'))
}
}}
/>
@@ -102,11 +104,11 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="swish">Swish</Label>
<Label htmlFor="swish">{t('swish_label')}</Label>
<Input
id="swish"
name="swish"
placeholder="123 XXX XX XX eller 07X XXX XX XX"
placeholder={t('swish_placeholder')}
defaultValue={settings.swish || ''}
onBlur={(e) => {
const val = normaliseSwish(e.target.value)
@@ -115,7 +117,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) {
e.target.value = val
setSwishError(null)
} else {
setSwishError('Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)')
setSwishError(t('swish_error'))
}
}}
/>
+15 -15
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
@@ -17,6 +18,7 @@ interface BankIdIdentity {
}
export function BankIdSettings() {
const t = useTranslations('settings_bankid')
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLinking, setIsLinking] = useState(false)
@@ -45,20 +47,20 @@ export function BankIdSettings() {
const handleLinkComplete = async (result: BankIdResult) => {
if (result.error) {
const message = result.error === 'already_linked'
? 'Detta BankID ar redan kopplat till ett annat konto.'
: 'Kunde inte koppla BankID.'
? t('toast_already_linked')
: t('toast_link_failed')
toast({ title: message, variant: 'destructive' })
setIsLinking(false)
return
}
toast({ title: 'BankID kopplat till ditt konto' })
toast({ title: t('toast_linked') })
setIsLinking(false)
fetchIdentity()
}
const handleUnlink = async () => {
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
if (!confirm(t('confirm_unlink'))) return
setIsUnlinking(true)
try {
@@ -66,9 +68,9 @@ export function BankIdSettings() {
if (!res.ok) throw new Error('Unlink failed')
setIdentity(null)
toast({ title: 'BankID bortkopplat' })
toast({ title: t('toast_unlinked') })
} catch {
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
toast({ title: t('toast_unlink_failed'), variant: 'destructive' })
} finally {
setIsUnlinking(false)
}
@@ -88,8 +90,8 @@ export function BankIdSettings() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Koppla BankID</CardTitle>
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
<CardTitle className="text-base">{t('link_bankid_title')}</CardTitle>
<CardDescription>{t('link_bankid_description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
@@ -107,12 +109,10 @@ export function BankIdSettings() {
) : (
<Shield className="h-4 w-4 text-muted-foreground" />
)}
BankID
{t('title')}
</CardTitle>
<CardDescription>
{identity
? 'Ditt konto ar kopplat till BankID.'
: 'Koppla BankID for sakrare inloggning.'}
{identity ? t('linked_description') : t('not_linked_description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -123,7 +123,7 @@ export function BankIdSettings() {
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
Kopplat {formatDateLong(identity.linked_at)}
{t('linked_on', { date: formatDateLong(identity.linked_at) })}
</span>
</div>
<Button
@@ -133,7 +133,7 @@ export function BankIdSettings() {
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
{isUnlinking ? t('unlinking') : t('unlink_button')}
</Button>
</div>
) : (
@@ -141,7 +141,7 @@ export function BankIdSettings() {
variant="outline"
onClick={() => setIsLinking(true)}
>
Koppla BankID
{t('link_button')}
</Button>
)}
</CardContent>
+82 -72
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,16 +22,17 @@ import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
const ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const ENTITY_LABELS: Record<string, string> = {
all: t('entity_all'),
enskild_firma: t('entity_enskild_firma'),
aktiebolag: t('entity_aktiebolag'),
}
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -44,11 +46,11 @@ export function BookingTemplatesPanel() {
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
@@ -61,11 +63,11 @@ export function BookingTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Mall borttagen' })
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
toast({ title: t('toast_deleted') })
} finally {
setDeletingId(null)
}
@@ -82,7 +84,7 @@ export function BookingTemplatesPanel() {
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
toast({ title: t('toast_export_failed'), variant: 'destructive' })
}
}
@@ -99,13 +101,13 @@ export function BookingTemplatesPanel() {
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' })
return
}
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
fetchTemplates()
} catch {
toast({ title: 'Importfel', description: 'Ogiltig fil', variant: 'destructive' })
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
} finally {
// Reset input so same file can be imported again
if (importRef.current) importRef.current.value = ''
@@ -113,29 +115,29 @@ export function BookingTemplatesPanel() {
}
// Group templates by scope
const systemTemplates = templates.filter((t) => t.is_system)
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
const systemTemplates = templates.filter((tt) => tt.is_system)
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
{t('description')}
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Exportera
{t('export')}
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Importera
{t('import')}
</Button>
<input
ref={importRef}
@@ -148,14 +150,15 @@ export function BookingTemplatesPanel() {
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Ny mall
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Skapa bokföringsmall</DialogTitle>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<CreateTemplateForm
entityLabels={ENTITY_LABELS}
onCreated={() => {
setShowCreate(false)
fetchTemplates()
@@ -174,14 +177,14 @@ export function BookingTemplatesPanel() {
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
Inga mallar hittades.
{t('empty_state')}
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title="Standardmallar"
title={t('section_system')}
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
@@ -189,13 +192,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title="Teammallar"
title={t('section_team')}
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
@@ -203,13 +207,14 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title="Företagsmallar"
title={t('section_company')}
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
@@ -217,6 +222,7 @@ export function BookingTemplatesPanel() {
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
entityLabels={ENTITY_LABELS}
/>
)}
</div>
@@ -235,6 +241,7 @@ function TemplateSection({
deletingId,
onDelete,
canDelete,
entityLabels,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
@@ -244,7 +251,9 @@ function TemplateSection({
deletingId: string | null
onDelete: (id: string) => void
canDelete: boolean
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
return (
<div>
<div className="flex items-center gap-2 mb-2">
@@ -253,28 +262,28 @@ function TemplateSection({
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
</div>
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
return (
<div
key={t.id}
key={tt.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : t.id)}
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t.name}</span>
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[t.category]}
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
{t.entity_type !== 'all' && (
{tt.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{ENTITY_LABELS[t.entity_type]}
{entityLabels[tt.entity_type]}
</Badge>
)}
</div>
@@ -285,12 +294,12 @@ function TemplateSection({
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(t.id)
onDelete(tt.id)
}}
disabled={deletingId === t.id}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
@@ -300,31 +309,31 @@ function TemplateSection({
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{t.description && (
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
{tt.description && (
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
)}
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
<th className="text-right py-1 w-12">Debet</th>
<th className="text-right py-1 w-12">Kredit</th>
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{t.lines.map((line: BookingTemplateLibraryLine, i: number) => (
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
{line.type === 'vat' && line.vat_rate
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) })
: line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
<td className="py-1 text-right">{line.side === 'debit' ? t('debit_short') : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? t('credit_short') : ''}</td>
</tr>
))}
</tbody>
@@ -339,7 +348,8 @@ function TemplateSection({
)
}
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void; entityLabels: Record<string, string> }) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState('')
@@ -371,7 +381,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
@@ -384,10 +394,10 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
})
if (!res.ok) {
const json = await res.json()
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
toast({ title: t('toast_created') })
onCreated()
} finally {
setIsSubmitting(false)
@@ -397,16 +407,16 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
<Label>{t('name_label')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('name_placeholder')} />
</div>
<div>
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Kategori</Label>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -417,11 +427,11 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</Select>
</div>
<div>
<Label>Företagstyp</Label>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
@@ -430,36 +440,36 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
</div>
<div>
<Label>Rader</Label>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="Konto"
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder="Beskrivning"
placeholder={t('description_short_placeholder')}
className="flex-1"
/>
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">Kostnad</SelectItem>
<SelectItem value="vat">Moms</SelectItem>
<SelectItem value="settlement">Betalning</SelectItem>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
<Button
@@ -476,14 +486,14 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
))}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
{t('add_line')}
</Button>
</div>
</div>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Skapa mall
{t('create_button')}
</Button>
</form>
)
+41 -38
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -18,6 +19,7 @@ interface CalendarFeedWithUrls extends CalendarFeed {
}
export function CalendarFeedSettings() {
const t = useTranslations('settings_calendar_feed')
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
@@ -29,6 +31,7 @@ export function CalendarFeedSettings() {
useEffect(() => {
fetchFeed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFeed = async () => {
@@ -57,12 +60,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Kalenderfeed skapad',
description: 'Du kan nu koppla kalendern till Apple Calendar eller Google Calendar.',
title: t('toast_feed_created_title'),
description: t('toast_feed_created_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa kalenderfeed.',
title: t('toast_create_failed'),
variant: 'destructive',
})
} finally {
@@ -88,9 +91,9 @@ export function CalendarFeedSettings() {
const { data } = await response.json()
setFeed(data)
} catch (error) {
} catch {
toast({
title: 'Kunde inte uppdatera inställning.',
title: t('toast_update_failed'),
variant: 'destructive',
})
} finally {
@@ -100,9 +103,9 @@ export function CalendarFeedSettings() {
const regenerateToken = async () => {
const ok = await confirmAction({
title: 'Skapa ny kalender-länk',
description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
confirmLabel: 'Skapa ny länk',
title: t('regen_dialog_title'),
description: t('regen_dialog_description'),
confirmLabel: t('regen_confirm'),
variant: 'warning',
})
if (!ok) return
@@ -122,12 +125,12 @@ export function CalendarFeedSettings() {
setFeed(data)
toast({
title: 'Ny länk skapad',
description: 'Den gamla länken fungerar inte längre.',
title: t('toast_new_link_title'),
description: t('toast_new_link_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte skapa ny länk.',
title: t('toast_regen_failed'),
variant: 'destructive',
})
} finally {
@@ -141,12 +144,12 @@ export function CalendarFeedSettings() {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast({
title: 'Kopierad',
description: 'Länken har kopierats till urklipp.',
title: t('toast_copied_title'),
description: t('toast_copied_description'),
})
} catch (error) {
} catch {
toast({
title: 'Kunde inte kopiera länken.',
title: t('toast_copy_failed'),
variant: 'destructive',
})
}
@@ -172,27 +175,27 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Synka dina deadlines med Apple Calendar, Google Calendar eller Outlook
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4 py-4">
<p className="text-muted-foreground">
Skapa en kalenderfeed för att se dina deadlines i din vanliga kalenderapp.
{t('empty_intro')}
</p>
<Button onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
{t('creating')}
</>
) : (
<>
<Calendar className="mr-2 h-4 w-4" />
Aktivera kalendersynk
{t('activate_sync')}
</>
)}
</Button>
@@ -209,10 +212,10 @@ export function CalendarFeedSettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
{t('title')}
</CardTitle>
<CardDescription>
Prenumerera på din kalender i Apple Calendar, Google Calendar eller Outlook
{t('subscribe_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -220,7 +223,7 @@ export function CalendarFeedSettings() {
<div className="flex gap-2">
<Button onClick={openWebcal} className="flex-1">
<Calendar className="mr-2 h-4 w-4" />
Lägg till i Apple Calendar
{t('add_to_apple_calendar')}
</Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
{copied ? (
@@ -233,7 +236,7 @@ export function CalendarFeedSettings() {
{/* URL display */}
<div className="space-y-2">
<Label>Kalenderlänk (för Google Calendar m.fl.)</Label>
<Label>{t('calendar_link_label')}</Label>
<div className="flex gap-2">
<Input
value={feed.httpsUrl}
@@ -242,14 +245,14 @@ export function CalendarFeedSettings() {
/>
</div>
<p className="text-xs text-muted-foreground">
Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.
{t('calendar_link_help')}
</p>
</div>
{/* Stats */}
{feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Senast hämtad:</span>
<span>{t('last_fetched')}</span>
<span>
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
@@ -259,7 +262,7 @@ export function CalendarFeedSettings() {
})}
</span>
<Badge variant="secondary" className="text-xs">
{feed.access_count} gånger
{t('times_count', { count: feed.access_count })}
</Badge>
</div>
)}
@@ -275,17 +278,17 @@ export function CalendarFeedSettings() {
{isRegenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar ny länk...
{t('creating_new_link')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Skapa ny länk
{t('create_new_link')}
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">
Ogiltigförklarar den gamla länken. Använd om någon obehörig fått tag i länken.
{t('regen_help')}
</p>
</div>
</CardContent>
@@ -294,17 +297,17 @@ export function CalendarFeedSettings() {
{/* Content settings */}
<Card>
<CardHeader>
<CardTitle>Innehåll i kalendern</CardTitle>
<CardTitle>{t('content_title')}</CardTitle>
<CardDescription>
Välj vilka händelser som ska visas i din kalender
{t('content_description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-tax">Skattedeadlines</Label>
<Label htmlFor="include-tax">{t('tax_deadlines_label')}</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, deklarationer
{t('tax_deadlines_help')}
</p>
</div>
<Switch
@@ -319,9 +322,9 @@ export function CalendarFeedSettings() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-invoices">Fakturor</Label>
<Label htmlFor="include-invoices">{t('invoices_label')}</Label>
<p className="text-sm text-muted-foreground">
Förfallodatum för fakturor
{t('invoices_help')}
</p>
</div>
<Switch
+17 -13
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
@@ -33,6 +34,7 @@ const branding = getBranding()
* capabilities.bankIdLinked boolean fetched from the user profile.
*/
export function CompanyDangerZone() {
const t = useTranslations('settings_company')
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
@@ -57,10 +59,10 @@ export function CompanyDangerZone() {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera företaget')
throw new Error(body.error || t('danger_delete_failed_default'))
}
toast({ title: 'Företaget raderades', description: company.name })
toast({ title: t('danger_deleted_title'), description: company.name })
// Stay inside settings. If the user had another company, the dashboard
// layout will resolve it and /settings/account still renders as
// normal. If this was their last company, the layout falls into the
@@ -69,8 +71,8 @@ export function CompanyDangerZone() {
router.refresh()
} catch (err) {
toast({
title: 'Kunde inte radera företaget',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('danger_delete_failed_title'),
description: err instanceof Error ? err.message : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
@@ -81,7 +83,7 @@ export function CompanyDangerZone() {
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera företag
{t('danger_heading')}
</h2>
<RetentionNotice variant="company" />
@@ -92,7 +94,7 @@ export function CompanyDangerZone() {
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
Radera företag
{t('danger_button')}
</Button>
</div>
</section>
@@ -107,15 +109,17 @@ export function CompanyDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera {company.name}</DialogTitle>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
Företaget döljs från {branding.appName.toLowerCase()}. Bokföringen behålls säkert i 7 år enligt BFL.
Skriv företagets namn exakt för att bekräfta.
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="company-delete-confirm">
Skriv <strong>{company.name}</strong> för att bekräfta
{t.rich('danger_confirm_label', {
companyName: company.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="company-delete-confirm"
@@ -134,7 +138,7 @@ export function CompanyDangerZone() {
}}
disabled={isDeleting}
>
Avbryt
{t('danger_cancel')}
</Button>
<Button
variant="destructive"
@@ -144,10 +148,10 @@ export function CompanyDangerZone() {
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
{t('danger_deleting')}
</>
) : (
'Radera företag'
t('danger_button')
)}
</Button>
</DialogFooter>
+13 -11
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CompanySettings } from '@/types'
@@ -9,26 +10,27 @@ interface CompanyInfoFormProps {
}
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
const t = useTranslations('settings_company')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsuppgifter
{t('company_info_heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Label htmlFor="company_name">{t('company_name_label')}</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
/>
<p className="text-xs text-muted-foreground">
Visas på fakturor, e-post och deklarationsfiler. För enskild firma är det vanligtvis ditt eget namn (Förnamn Efternamn).
{t('company_name_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
name="org_number"
@@ -36,13 +38,13 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
<p className="text-xs text-muted-foreground">{t('org_number_locked')}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Label htmlFor="address_line1">{t('address_label')}</Label>
<Input
id="address_line1"
name="address_line1"
@@ -52,7 +54,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Label htmlFor="postal_code">{t('postal_code_label')}</Label>
<Input
id="postal_code"
name="postal_code"
@@ -60,7 +62,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
name="city"
@@ -71,7 +73,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
name="phone"
@@ -80,7 +82,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
name="email"
@@ -91,7 +93,7 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="website">Webbplats</Label>
<Label htmlFor="website">{t('website_label')}</Label>
<Input
id="website"
name="website"
+33 -31
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,16 +32,17 @@ interface CompanyInvitation {
created_at: string
}
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
admin: 'Admin',
member: 'Medlem',
viewer: 'Läsbehörighet',
}
export function CompanyMembersSection() {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { company } = useCompany()
const roleLabels: Record<string, string> = {
owner: t('members_role_owner'),
admin: t('members_role_admin'),
member: t('members_role_member'),
viewer: t('members_role_viewer'),
}
const [isLoading, setIsLoading] = useState(true)
const [members, setMembers] = useState<CompanyMemberItem[]>([])
const [invitations, setInvitations] = useState<CompanyInvitation[]>([])
@@ -94,16 +96,16 @@ export function CompanyMembersSection() {
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
}
toast({
title: 'Inbjudan skickad',
title: t('members_invite_sent_title'),
description: data.data.inviteUrl
? 'Länk loggad i konsolen (F12)'
: `E-post skickad till ${email}.`,
? t('members_invite_sent_dev_url')
: t('members_invite_sent_description', { email }),
})
setInviteEmail('')
setInviteRole('viewer')
fetchMembers()
} catch {
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_failed'), variant: 'destructive' })
} finally {
setIsSending(false)
}
@@ -120,10 +122,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Medlem borttagen' })
toast({ title: t('members_removed') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
toast({ title: t('members_remove_failed'), variant: 'destructive' })
} finally {
setRemovingId(null)
}
@@ -140,10 +142,10 @@ export function CompanyMembersSection() {
return
}
toast({ title: 'Inbjudan återkallad' })
toast({ title: t('members_invite_revoked') })
fetchMembers()
} catch {
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
toast({ title: t('members_invite_revoke_failed'), variant: 'destructive' })
} finally {
setRevokingId(null)
}
@@ -163,19 +165,19 @@ export function CompanyMembersSection() {
{canInvite && (
<Card>
<CardHeader>
<CardTitle className="text-base">Bjud in till {company?.name}</CardTitle>
<CardTitle className="text-base">{t('members_invite_title', { companyName: company?.name ?? '' })}</CardTitle>
<CardDescription>
Personen får tillgång till enbart detta företag.
{t('members_invite_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex gap-3">
<div className="flex-1">
<Label htmlFor="company-invite-email" className="sr-only">E-postadress</Label>
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
<Input
id="company-invite-email"
type="email"
placeholder="namn@example.com"
placeholder={t('members_invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
@@ -187,9 +189,9 @@ export function CompanyMembersSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Läsbehörighet</SelectItem>
<SelectItem value="member">Medlem</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">{t('members_role_viewer')}</SelectItem>
<SelectItem value="member">{t('members_role_member')}</SelectItem>
<SelectItem value="admin">{t('members_role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={isSending || !inviteEmail.trim()}>
@@ -198,7 +200,7 @@ export function CompanyMembersSection() {
) : (
<>
<Plus className="h-4 w-4 mr-1.5" />
Bjud in
{t('members_invite_button')}
</>
)}
</Button>
@@ -212,10 +214,10 @@ export function CompanyMembersSection() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Medlemmar
{t('members_title')}
</CardTitle>
<CardDescription>
{members.length} {members.length === 1 ? 'medlem' : 'medlemmar'} i {company?.name}
{t('members_count', { count: members.length, companyName: company?.name ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
@@ -232,16 +234,16 @@ export function CompanyMembersSection() {
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">(du)</span>
<span className="text-muted-foreground font-normal ml-1">{t('members_you')}</span>
)}
</p>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{ROLE_LABELS[member.role] || member.role}
{roleLabels[member.role] || member.role}
</span>
{member.source === 'team' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Team
{t('members_team_badge')}
</Badge>
)}
</div>
@@ -272,7 +274,7 @@ export function CompanyMembersSection() {
{invitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Väntande inbjudningar</CardTitle>
<CardTitle className="text-base">{t('invitations_pending_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border/40">
@@ -287,14 +289,14 @@ export function CompanyMembersSection() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>
Går ut {formatDateLong(inv.expires_at)}
{t('invitations_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[inv.role] || inv.role}
{roleLabels[inv.role] || inv.role}
</Badge>
{canInvite && (
<Button
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,22 +11,6 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import type { CategorizationTemplate } from '@/types'
const SOURCE_LABELS: Record<string, string> = {
sie_import: 'SIE-import',
user_approved: 'Godkänd',
auto_learned: 'Automatisk',
sni_default: 'Standard',
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: 'Omvänd',
export: 'Export',
exempt: 'Momsfri',
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('sv-SE', {
@@ -42,8 +27,25 @@ function confidenceColor(c: number): string {
}
export function CounterpartyTemplatesPanel() {
const t = useTranslations('settings_counterparty_templates')
const { toast } = useToast()
const SOURCE_LABELS: Record<string, string> = {
sie_import: t('source_sie_import'),
user_approved: t('source_user_approved'),
auto_learned: t('source_auto_learned'),
sni_default: t('source_sni_default'),
}
const VAT_LABELS: Record<string, string> = {
standard_25: '25%',
reduced_12: '12%',
reduced_6: '6%',
reverse_charge: t('vat_reverse_charge'),
export: t('vat_export'),
exempt: t('vat_exempt'),
}
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
@@ -57,11 +59,11 @@ export function CounterpartyTemplatesPanel() {
setTemplates(json.data)
}
} catch {
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchTemplates()
@@ -76,14 +78,14 @@ export function CounterpartyTemplatesPanel() {
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
if (expandedId === id) setExpandedId(null)
toast({ title: 'Mall borttagen' })
toast({ title: t('toast_deleted') })
} catch {
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
} finally {
setDeletingId(null)
}
@@ -93,10 +95,9 @@ export function CounterpartyTemplatesPanel() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Bokföringsmallar</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Inlärda mönster som automatiskt föreslår kontering baserat på motpart.
Mallar skapas från SIE-import och när du godkänner kategoriseringar.
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -107,54 +108,54 @@ export function CounterpartyTemplatesPanel() {
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga mallar ännu.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Mallar skapas automatiskt när du kategoriserar transaktioner eller importerar en SIE-fil.
{t('empty_help')}
</p>
</div>
) : (
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
const isMultiLine = t.line_pattern && t.line_pattern.length > 0
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
return (
<div key={t.id} className="rounded-md border overflow-hidden">
<div key={tt.id} className="rounded-md border overflow-hidden">
{/* Clickable summary row */}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : t.id)}
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{formatCounterpartyName(t.counterparty_name)}</p>
<p className="text-sm font-medium truncate">{formatCounterpartyName(tt.counterparty_name)}</p>
<Badge variant="outline" className="text-[10px] shrink-0">
{SOURCE_LABELS[t.source] || t.source}
{SOURCE_LABELS[tt.source] || tt.source}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
{isMultiLine ? (
<span className="font-mono">
{t.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
</span>
) : (
<>
<span className="font-mono">{t.debit_account}</span>
<span className="font-mono">{tt.debit_account}</span>
<span className="text-muted-foreground/50">→</span>
<span className="font-mono">{t.credit_account}</span>
<span className="font-mono">{tt.credit_account}</span>
</>
)}
{t.vat_treatment && (
{tt.vat_treatment && (
<>
<span className="text-muted-foreground/30">·</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span>{t.occurrence_count} ggr</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span>{t('times_count', { count: tt.occurrence_count })}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
</div>
@@ -166,17 +167,17 @@ export function CounterpartyTemplatesPanel() {
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
{/* Account lines */}
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Kontering</p>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">{t('booking_label')}</p>
{isMultiLine ? (
<div className="space-y-1">
{t.line_pattern!.map((lp, i) => (
{tt.line_pattern!.map((lp, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">
{lp.side === 'debit' ? 'Debet' : 'Kredit'}
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
</Badge>
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
{lp.type === 'vat' && lp.vat_rate && (
<span className="text-muted-foreground">({Math.round(lp.vat_rate * 100)}% moms)</span>
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
)}
{lp.ratio !== undefined && (
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
@@ -187,12 +188,12 @@ export function CounterpartyTemplatesPanel() {
) : (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Debet</Badge>
<span className="font-mono">{formatAccountWithName(t.debit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('debit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Kredit</Badge>
<span className="font-mono">{formatAccountWithName(t.credit_account)}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">{t('credit_label')}</Badge>
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
</div>
</div>
)}
@@ -200,30 +201,30 @@ export function CounterpartyTemplatesPanel() {
{/* Metadata */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
{t.vat_treatment && (
{tt.vat_treatment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Antal bokföringar</span>
<span className="tabular-nums">{t.occurrence_count}</span>
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
<span className="tabular-nums">{tt.occurrence_count}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Säkerhet</span>
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
{Math.round(Number(t.confidence) * 100)}%
<span className="text-muted-foreground">{t('confidence_label')}</span>
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
{Math.round(Number(tt.confidence) * 100)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Senast använd</span>
<span>{formatDate(t.last_seen_date)}</span>
<span className="text-muted-foreground">{t('last_seen_label')}</span>
<span>{formatDate(tt.last_seen_date)}</span>
</div>
{t.counterparty_aliases && t.counterparty_aliases.length > 1 && (
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
<div className="col-span-2 flex justify-between">
<span className="text-muted-foreground">Alias</span>
<span className="text-right truncate ml-4">{t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''}</span>
<span className="text-muted-foreground">{t('aliases_label')}</span>
<span className="text-right truncate ml-4">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
</div>
)}
</div>
@@ -233,16 +234,16 @@ export function CounterpartyTemplatesPanel() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(t.id)}
disabled={deletingId === t.id}
onClick={() => handleDelete(tt.id)}
disabled={deletingId === tt.id}
className="text-destructive hover:text-destructive text-xs h-7"
>
{deletingId === t.id ? (
{deletingId === tt.id ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Trash2 className="mr-1.5 h-3 w-3" />
)}
Ta bort mall
{t('delete_button')}
</Button>
</div>
</div>
+42 -35
View File
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -34,6 +35,7 @@ function isCalendarYear(period: { period_start: string; period_end: string }): b
}
export function FiscalPeriodEditor() {
const t = useTranslations('settings_company')
const { company, role } = useCompany()
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
@@ -59,7 +61,7 @@ export function FiscalPeriodEditor() {
setLoadError(null)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) throw new Error('Kunde inte hämta räkenskapsår')
if (!res.ok) throw new Error(t('fp_load_error_periods'))
const { data } = (await res.json()) as { data: FiscalPeriod[] }
if (!data || data.length === 0) {
if (!cancelled) {
@@ -72,7 +74,7 @@ export function FiscalPeriodEditor() {
const first = sorted[0]
const countRes = await fetch(`/api/bookkeeping/fiscal-periods/${first.id}/entry-count`)
if (!countRes.ok) throw new Error('Kunde inte hämta verifikationsantal')
if (!countRes.ok) throw new Error(t('fp_load_error_entry_count'))
const { data: countData } = (await countRes.json()) as { data: { posted_count: number } }
if (cancelled) return
@@ -82,7 +84,7 @@ export function FiscalPeriodEditor() {
setEndDate(first.period_end)
} catch (err) {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : 'Okänt fel')
setLoadError(err instanceof Error ? err.message : t('fp_load_error_unknown'))
}
} finally {
if (!cancelled) setIsLoading(false)
@@ -93,7 +95,7 @@ export function FiscalPeriodEditor() {
return () => {
cancelled = true
}
}, [company])
}, [company, t])
const validation = validateFirstPeriod(
startDate,
@@ -114,10 +116,15 @@ export function FiscalPeriodEditor() {
if (!isDirty) return
const ok = await confirm({
title: 'Ändra första räkenskapsåret?',
description: `Detta ändrar ditt första räkenskapsår från ${formatSwedishDate(period.period_start)} – ${formatSwedishDate(period.period_end)} till ${formatSwedishDate(startDate)} – ${formatSwedishDate(endDate)}. Ändringen är bara tillåten eftersom inga verifikationer är bokförda ännu. Fortsätt?`,
confirmLabel: 'Ja, ändra räkenskapsår',
cancelLabel: 'Avbryt',
title: t('fp_confirm_title'),
description: t('fp_confirm_description', {
oldStart: formatSwedishDate(period.period_start),
oldEnd: formatSwedishDate(period.period_end),
newStart: formatSwedishDate(startDate),
newEnd: formatSwedishDate(endDate),
}),
confirmLabel: t('fp_confirm_yes'),
cancelLabel: t('fp_confirm_cancel'),
variant: 'warning',
})
if (!ok) return
@@ -128,8 +135,8 @@ export function FiscalPeriodEditor() {
const endYear = parseDateParts(endDate).year
const newName =
startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
? t('fp_year_label_single', { year: startYear })
: t('fp_year_label_range', { startYear, endYear })
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -141,17 +148,17 @@ export function FiscalPeriodEditor() {
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body.error || 'Kunde inte ändra räkenskapsår')
throw new Error(body.error || t('fp_update_failed_title'))
}
setPeriod(body.data as FiscalPeriod)
toast({
title: 'Räkenskapsår uppdaterat',
title: t('fp_updated_title'),
description: `${formatSwedishDate(body.data.period_start)} – ${formatSwedishDate(body.data.period_end)}`,
})
} catch (err) {
toast({
title: 'Kunde inte ändra räkenskapsår',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('fp_update_failed_title'),
description: err instanceof Error ? err.message : t('fp_try_again'),
variant: 'destructive',
})
} finally {
@@ -170,22 +177,22 @@ export function FiscalPeriodEditor() {
<section className="space-y-4 border-t border-border/8 pt-8">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Första räkenskapsår
{t('fp_heading')}
</h2>
<p className="text-sm text-muted-foreground">
Om du valde fel räkenskapsår vid uppstart kan du justera det här — så länge du inte har bokfört någon verifikation ännu.
{t('fp_intro')}
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Hämtar räkenskapsår...
{t('fp_loading')}
</div>
) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : !period ? (
<p className="text-sm text-muted-foreground">Inget räkenskapsår hittades.</p>
<p className="text-sm text-muted-foreground">{t('fp_none')}</p>
) : isBlocked ? (
<BlockedState
period={period}
@@ -196,10 +203,10 @@ export function FiscalPeriodEditor() {
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
<Info className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Ändra med omsorg.</p>
<p className="font-medium">{t('fp_warning_title')}</p>
<p className="text-muted-foreground">
Ändringen påverkar öppningsbalanser och rapporter.
{isEF && ' Enskild firma måste använda kalenderår enligt BFL 3 kap.'}
{t('fp_warning_body')}
{isEF && t('fp_warning_ef_suffix')}
</p>
</div>
</div>
@@ -209,10 +216,10 @@ export function FiscalPeriodEditor() {
onStartDateChange={setStartDate}
endDate={endDate}
entityType={company?.entity_type}
summaryTitle="Föreslaget räkenskapsår"
summaryTitle={t('fp_summary_title')}
endDateSlot={
<div className="space-y-2">
<Label htmlFor="fp_end">Slutdatum</Label>
<Label htmlFor="fp_end">{t('fp_end_date_label')}</Label>
<Input
id="fp_end"
type="date"
@@ -220,7 +227,7 @@ export function FiscalPeriodEditor() {
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Måste vara sista dagen i en månad.
{t('fp_end_date_help')}
</p>
</div>
}
@@ -233,7 +240,7 @@ export function FiscalPeriodEditor() {
onClick={handleReset}
disabled={!isDirty || isSaving}
>
Återställ
{t('fp_reset')}
</Button>
<Button
type="button"
@@ -249,10 +256,10 @@ export function FiscalPeriodEditor() {
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('fp_saving')}
</>
) : (
'Spara ändring'
t('fp_save')
)}
</Button>
</div>
@@ -271,32 +278,32 @@ function BlockedState({
period: FiscalPeriod
postedCount: number
}) {
const t = useTranslations('settings_company')
const reason = period.locked_at
? 'Räkenskapsåret är låst.'
? t('fp_blocked_reason_locked')
: period.is_closed
? 'Räkenskapsåret är stängt.'
: `${postedCount} bokförd${postedCount === 1 ? '' : 'a'} verifikation${postedCount === 1 ? '' : 'er'} finns redan i perioden.`
? t('fp_blocked_reason_closed')
: t('fp_blocked_reason_posted', { count: postedCount })
return (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<div className="flex gap-2">
<Lock className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Räkenskapsåret kan inte längre ändras</p>
<p className="text-sm font-medium">{t('fp_blocked_title')}</p>
<p className="text-sm text-muted-foreground">{reason}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
Första räkenskapsåret:{' '}
{t('fp_blocked_first_year')}{' '}
<span className="font-medium text-foreground">
{formatSwedishDate(period.period_start)} &ndash; {formatSwedishDate(period.period_end)}
</span>
{isCalendarYear(period) ? ' (kalenderår)' : ' (brutet räkenskapsår)'}
{isCalendarYear(period) ? t('fp_blocked_calendar_year') : t('fp_blocked_broken_year')}
</p>
<p>
Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt.
Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.
{t('fp_blocked_explainer')}
</p>
</div>
</div>
+10 -8
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
@@ -10,24 +11,25 @@ interface InvoiceSettingsFormProps {
}
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
const t = useTranslations('settings_invoice_form')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Fakturainställningar
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Label htmlFor="invoice_prefix">{t('prefix_label')}</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
placeholder={t('prefix_placeholder')}
defaultValue={settings.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Label htmlFor="next_invoice_number">{t('next_number_label')}</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
@@ -37,7 +39,7 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Label htmlFor="invoice_default_days">{t('default_days_label')}</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
@@ -49,16 +51,16 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
<Label htmlFor="invoice_default_notes">{t('default_notes_label')}</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
placeholder={t('default_notes_placeholder')}
defaultValue={settings.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Föreslås automatiskt vid ny faktura.
{t('default_notes_help')}
</p>
</div>
</section>
+14 -12
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
@@ -12,6 +13,7 @@ interface LogoUploadProps {
}
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -23,11 +25,11 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
function validateAndUpload(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
toast({ title: 'Otillåten filtyp', description: 'PNG, JPG, SVG eller WebP.', variant: 'destructive' })
toast({ title: t('logo_disallowed_type_title'), description: t('logo_disallowed_type_description'), variant: 'destructive' })
return
}
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
handleUpload(file)
@@ -48,15 +50,15 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Uppladdning misslyckades')
throw new Error(result.error || t('logo_upload_failed_default'))
}
setPreview(result.data.logo_url)
onUpdate(result.data.logo_url)
} catch (error) {
toast({
title: 'Kunde inte ladda upp',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('logo_upload_failed_title'),
description: error instanceof Error ? error.message : t('logo_try_again'),
variant: 'destructive',
})
}
@@ -75,7 +77,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
onUpdate(null)
if (inputRef.current) inputRef.current.value = ''
} catch {
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
}
setIsDeleting(false)
@@ -107,10 +109,10 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Logotyp
{t('logo_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
{t('logo_help')}
</p>
{preview ? (
@@ -119,7 +121,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Företagslogotyp"
alt={t('logo_alt')}
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
@@ -131,7 +133,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
disabled={isUploading}
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
Byt logotyp
{t('logo_change')}
</Button>
<Button
variant="ghost"
@@ -141,7 +143,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
className="text-muted-foreground hover:text-destructive"
>
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
Ta bort
{t('logo_remove')}
</Button>
</div>
</div>
@@ -164,7 +166,7 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
)}
<Label className="text-sm text-muted-foreground cursor-pointer">
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</Label>
</button>
)}
+30 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -26,6 +27,7 @@ interface OAuthClient {
}
export function OAuthClientsPanel() {
const t = useTranslations('settings_oauth_clients')
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
@@ -44,11 +46,11 @@ export function OAuthClientsPanel() {
setClients(json.data.filter((c: OAuthClient) => !c.revoked_at))
}
} catch {
toast({ title: 'Kunde inte hämta OAuth-klienter', variant: 'destructive' })
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
}, [toast, t])
useEffect(() => {
fetchClients()
@@ -61,14 +63,14 @@ export function OAuthClientsPanel() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: clientName.trim() || 'OAuth-klient',
client_name: clientName.trim() || t('default_client_name'),
redirect_uri: redirectUri.trim(),
}),
})
const json = await res.json()
if (!res.ok) {
toast({ title: json.error ?? 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: json.error ?? t('toast_register_failed'), variant: 'destructive' })
return
}
@@ -77,7 +79,7 @@ export function OAuthClientsPanel() {
setRedirectUri('')
fetchClients()
} catch {
toast({ title: 'Kunde inte registrera redirect URI', variant: 'destructive' })
toast({ title: t('toast_register_failed'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
@@ -85,30 +87,26 @@ export function OAuthClientsPanel() {
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla OAuth-klient',
description: `"${name}" tas bort från allowlist. Pågående auth-flöden slutar fungera direkt; redan utfärdade API-nycklar fortsätter att gälla tills de återkallas separat.`,
confirmLabel: 'Återkalla',
title: t('revoke_dialog_title'),
description: t('revoke_dialog_description', { name }),
confirmLabel: t('revoke_confirm'),
})
if (!ok) return
try {
const res = await fetch(`/api/settings/oauth-clients/${id}`, { method: 'DELETE' })
if (!res.ok) {
// Surface the server error rather than optimistically pretending the
// revocation succeeded — a silent fail leaves the row in the
// allowlist while the UI says it's gone, which is the opposite of
// what the user expected.
const body = await res.json().catch(() => ({}))
toast({
title: body?.error || 'Kunde inte återkalla klient',
title: body?.error || t('toast_revoke_failed'),
variant: 'destructive',
})
return
}
setClients((prev) => prev.filter((c) => c.id !== id))
toast({ title: 'Klient återkallad' })
toast({ title: t('toast_revoked') })
} catch {
toast({ title: 'Kunde inte återkalla klient', variant: 'destructive' })
toast({ title: t('toast_revoke_failed'), variant: 'destructive' })
}
}
@@ -126,15 +124,14 @@ export function OAuthClientsPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>OAuth-klienter</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
Registrera redirect-URI:er för egenutvecklade MCP-klienter. Claude.ai och localhost
är redan godkända som standard — registrera bara här om du bygger en egen app.
{t('description')}
</CardDescription>
</div>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Registrera URI
{t('register_uri')}
</Button>
</div>
</CardHeader>
@@ -146,9 +143,9 @@ export function OAuthClientsPanel() {
) : clients.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">Inga egna OAuth-klienter registrerade.</p>
<p className="text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground mt-1">
Bygger du en agent som ska ansluta via OAuth? Registrera dess callback-URI här.
{t('empty_help')}
</p>
</div>
) : (
@@ -165,7 +162,7 @@ export function OAuthClientsPanel() {
{c.redirect_uri}
</code>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Registrerad {formatDate(c.created_at)}
{t('registered_on')} {formatDate(c.created_at)}
</span>
</div>
</div>
@@ -173,7 +170,7 @@ export function OAuthClientsPanel() {
variant="ghost"
size="sm"
onClick={() => handleRevoke(c.id, c.client_name)}
aria-label={`Återkalla ${c.client_name}`}
aria-label={t('revoke_aria', { name: c.client_name })}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -188,29 +185,26 @@ export function OAuthClientsPanel() {
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrera redirect URI</DialogTitle>
<DialogTitle>{t('register_dialog_title')}</DialogTitle>
<DialogDescription>
Bara för egenbyggda MCP-klienter med en publik HTTPS-callback. Lägg{' '}
<span className="font-medium">inte</span> till{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">localhost</code>{' '}
eller{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">claude.ai</code>{' '}
— de fungerar redan utan registrering. URI:n jämförs ord-för-ord mot
redirect_uri-parametern i OAuth-flödet.
{t.rich('register_dialog_description', {
bold: (chunks) => <span className="font-medium">{chunks}</span>,
code: (chunks) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{chunks}</code>,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-name">Klientnamn</Label>
<Label htmlFor="client-name">{t('client_name_label')}</Label>
<Input
id="client-name"
placeholder="t.ex. Min bokföringsagent"
placeholder={t('client_name_placeholder')}
value={clientName}
onChange={(e) => setClientName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="redirect-uri">Redirect URI</Label>
<Label htmlFor="redirect-uri">{t('redirect_uri_label')}</Label>
<Input
id="redirect-uri"
type="url"
@@ -223,11 +217,11 @@ export function OAuthClientsPanel() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || !redirectUri.trim()}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Registrera
{t('register')}
</Button>
</DialogFooter>
</DialogContent>
+30 -28
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
@@ -13,6 +14,7 @@ interface PdfPrintSettingsProps {
}
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const t = useTranslations('settings_pdf_print')
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
@@ -27,9 +29,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const savePosition = useCallback(async (value: 'header' | 'footer') => {
try {
@@ -41,9 +43,9 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ invoice_company_name_position: value })
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
const saveText = useCallback(async (field: string, value: string) => {
try {
@@ -55,21 +57,21 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
if (!response.ok) throw new Error()
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast])
}, [onUpdate, toast, t])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utskrift & PDF
{t('heading')}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Öresavrundning</Label>
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
<Label>{t('ore_rounding_label')}</Label>
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
</div>
<Switch
checked={settings.ore_rounding ?? true}
@@ -79,8 +81,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa OCR-referens</Label>
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
<Label>{t('show_ocr_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_ocr_help')}</p>
</div>
<Switch
checked={settings.invoice_show_ocr ?? true}
@@ -90,8 +92,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa bankgiro</Label>
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
<Label>{t('show_bankgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_bankgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_bankgiro ?? true}
@@ -101,8 +103,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa plusgiro</Label>
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
<Label>{t('show_plusgiro_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_plusgiro_help')}</p>
</div>
<Switch
checked={settings.invoice_show_plusgiro ?? true}
@@ -112,8 +114,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa Swish</Label>
<p className="text-xs text-muted-foreground">Visa Swish-nummer på fakturautskrift</p>
<Label>{t('show_swish_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? true}
@@ -123,8 +125,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="flex items-center justify-between">
<div>
<Label>Visa logga</Label>
<p className="text-xs text-muted-foreground">Visa uppladdad logga i fakturahuvudet</p>
<Label>{t('show_logo_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_logo_help')}</p>
</div>
<Switch
checked={settings.invoice_show_logo ?? true}
@@ -135,8 +137,8 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Visa företagsnamn i faktura</Label>
<p className="text-xs text-muted-foreground">Visa företagsnamn i fakturan</p>
<Label>{t('show_company_name_label')}</Label>
<p className="text-xs text-muted-foreground">{t('show_company_name_help')}</p>
</div>
<Switch
checked={settings.invoice_show_company_name ?? true}
@@ -145,10 +147,10 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
{(settings.invoice_show_company_name ?? true) && (
<div className="flex items-center justify-between pl-0">
<p className="text-xs text-muted-foreground">Placering</p>
<p className="text-xs text-muted-foreground">{t('placement_label')}</p>
<div
role="group"
aria-label="Placering av företagsnamn"
aria-label={t('placement_aria_label')}
className="inline-flex rounded-md border border-border/60 p-0.5"
>
{(['header', 'footer'] as const).map((pos) => {
@@ -166,7 +168,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
: 'text-muted-foreground hover:text-foreground')
}
>
{pos === 'header' ? 'Huvud' : 'Sidfot'}
{pos === 'header' ? t('placement_header') : t('placement_footer')}
</button>
)
})}
@@ -178,11 +180,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
<Label htmlFor="invoice_late_fee_text">{t('late_fee_label')}</Label>
<Textarea
id="invoice_late_fee_text"
rows={2}
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
placeholder={t('late_fee_placeholder')}
value={lateFeeText}
onChange={(e) => setLateFeeText(e.target.value)}
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
@@ -190,11 +192,11 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
</div>
<div className="space-y-2">
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
<Label htmlFor="invoice_credit_terms_text">{t('credit_terms_label')}</Label>
<Textarea
id="invoice_credit_terms_text"
rows={2}
placeholder="T.ex. Betalning sker till angivet bankgiro."
placeholder={t('credit_terms_placeholder')}
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
+11 -9
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -10,15 +11,16 @@ interface PeriodLockingSettingsProps {
}
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
const t = useTranslations('settings_period_locking')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodlåsning
{t('heading')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
<Label htmlFor="bookkeeping_locked_through">{t('locked_through_label')}</Label>
<Input
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
@@ -26,12 +28,12 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
defaultValue={settings.bookkeeping_locked_through || ''}
/>
<p className="text-xs text-muted-foreground">
Verifikationer med datum före detta datum kan inte skapas eller ändras.
{t('locked_through_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
<Label htmlFor="auto_lock_period_days">{t('auto_lock_label')}</Label>
<Select
name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
@@ -40,14 +42,14 @@ export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps)
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
<SelectItem value="none">{t('auto_lock_none')}</SelectItem>
<SelectItem value="30">{t('auto_lock_30')}</SelectItem>
<SelectItem value="60">{t('auto_lock_60')}</SelectItem>
<SelectItem value="90">{t('auto_lock_90')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Låser automatiskt perioder efter valt antal dagar.
{t('auto_lock_help')}
</p>
</div>
</div>
+37 -36
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -19,6 +20,7 @@ const mfaRequired = isMfaRequired()
const bankIdEnabled = isBankIdEnabled()
export function SecuritySettings() {
const t = useTranslations('settings_security')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isChangingPassword, setIsChangingPassword] = useState(false)
@@ -59,8 +61,8 @@ export function SecuritySettings() {
if (!strong) {
toast({
title: 'Lösenordet är för svagt',
description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.',
title: t('toast_weak_password_title'),
description: t('toast_weak_password_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -69,8 +71,8 @@ export function SecuritySettings() {
if (newPassword !== confirmPassword) {
toast({
title: 'Lösenorden matchar inte',
description: 'Kontrollera att du skrev samma lösenord i båda fälten.',
title: t('toast_mismatch_title'),
description: t('toast_mismatch_description'),
variant: 'destructive',
})
setIsChangingPassword(false)
@@ -87,24 +89,24 @@ export function SecuritySettings() {
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
toast({
title: 'Kunde inte uppdatera lösenord',
description: body.error || 'Försök igen senare.',
title: t('toast_update_failed_title'),
description: body.error || t('toast_update_failed_description'),
variant: 'destructive',
})
return
}
toast({
title: 'Lösenord uppdaterat',
description: 'Ditt lösenord har ändrats.',
title: t('toast_password_updated_title'),
description: t('toast_password_updated_description'),
})
setNewPassword('')
setConfirmPassword('')
setHasPassword(true)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -121,7 +123,7 @@ export function SecuritySettings() {
if (error) {
toast({
title: 'Kunde inte inaktivera 2FA',
title: t('toast_unenroll_failed_title'),
description: error.message,
variant: 'destructive',
})
@@ -129,15 +131,15 @@ export function SecuritySettings() {
}
toast({
title: 'Tvåfaktorsautentisering inaktiverad',
description: '2FA har tagits bort från ditt konto.',
title: t('toast_mfa_disabled_title'),
description: t('toast_mfa_disabled_description'),
})
setHasMfa(false)
setMfaFactorId(null)
} catch {
toast({
title: 'Något gick fel',
description: 'Försök igen senare.',
title: t('toast_generic_error_title'),
description: t('toast_try_again'),
variant: 'destructive',
})
} finally {
@@ -182,21 +184,21 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
Ändra lösenord
{t('change_password_title')}
</CardTitle>
<CardDescription>
Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.
{t('change_password_description')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="new_password">Nytt lösenord</Label>
<Label htmlFor="new_password">{t('new_password_label')}</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken"
placeholder={t('new_password_placeholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
@@ -205,12 +207,12 @@ export function SecuritySettings() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_new_password">Bekräfta nytt lösenord</Label>
<Label htmlFor="confirm_new_password">{t('confirm_password_label')}</Label>
<Input
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -222,10 +224,10 @@ export function SecuritySettings() {
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
{t('saving')}
</>
) : (
'Uppdatera lösenord'
t('update_password_button')
)}
</Button>
</form>
@@ -239,27 +241,26 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Tvåfaktorsautentisering (2FA)
{t('mfa_title')}
</CardTitle>
<CardDescription>
Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod
utöver ditt lösenord.
{t('mfa_description')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar...
{t('loading')}
</div>
) : hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900">
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-500" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">2FA är aktiverad</p>
<p className="font-medium text-green-900 dark:text-green-100">{t('mfa_active_title')}</p>
<p className="text-sm text-green-700 dark:text-green-400">
Ditt konto skyddas med tvåfaktorsautentisering.
{t('mfa_active_description')}
</p>
</div>
</div>
@@ -272,19 +273,19 @@ export function SecuritySettings() {
{isUnenrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Inaktiverar...
{t('disabling')}
</>
) : (
<>
<ShieldOff className="mr-2 h-4 w-4" />
Inaktivera 2FA
{t('disable_mfa')}
</>
)}
</Button>
)}
{mfaRequired && (
<p className="text-xs text-muted-foreground">
Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras.
{t('mfa_required_note')}
</p>
)}
</div>
@@ -293,9 +294,9 @@ export function SecuritySettings() {
<div className="flex items-center gap-3 p-4 rounded-lg border">
<ShieldOff className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">2FA är inte aktiverad</p>
<p className="font-medium">{t('mfa_inactive_title')}</p>
<p className="text-sm text-muted-foreground">
Vi rekommenderar att du aktiverar tvåfaktorsautentisering.
{t('mfa_inactive_description')}
</p>
</div>
</div>
@@ -307,14 +308,14 @@ export function SecuritySettings() {
)
}
>
Sätt ett lösenord först
{t('set_password_first')}
</Button>
) : (
<Button
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
{t('enable_mfa')}
</Button>
)}
</div>
+11 -9
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
@@ -17,6 +18,7 @@ interface SettingsFormWrapperProps {
}
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const t = useTranslations('settings_company')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false)
@@ -71,7 +73,7 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
throw new Error(messages.join(' • '))
}
}
throw new Error(result.error || 'Kunde inte spara inställningar')
throw new Error(result.error || t('wrapper_save_failed_default'))
}
onSuccess?.(result.data ?? updates)
@@ -79,14 +81,14 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
title: t('wrapper_save_failed_title'),
description: error instanceof Error ? error.message : t('wrapper_try_again'),
variant: 'destructive',
})
}
setIsSaving(false)
}, [onSave, toast])
}, [onSave, toast, t])
return (
<form onSubmit={handleSubmit} className={className}>
@@ -96,27 +98,27 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
{saved && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" />
Sparat
{t('wrapper_saved')}
</span>
)}
<Button
type="submit"
disabled={isSaving || !canWrite}
size="sm"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
title={!canWrite ? t('wrapper_readonly_tooltip') : undefined}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sparar...
{t('wrapper_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-3.5 w-3.5" />
Spara ändringar
{t('wrapper_save_changes')}
</>
) : (
'Spara ändringar'
t('wrapper_save_changes')
)}
</Button>
</div>
+15 -13
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
@@ -17,6 +18,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company } = useCompany()
const t = useTranslations('settings_nav')
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
@@ -24,18 +26,18 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
const items: NavItem[] = [
{ href: '/settings/company', label: 'Företag', show: hasCompany },
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
{ href: '/settings/team', label: 'Lag', show: false },
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: 'Skatteverket', show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
{ href: '/settings/account', label: 'Konto', show: true },
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
{ href: '/settings/company', label: t('company'), show: hasCompany },
{ href: '/settings/invoicing', label: t('invoicing'), show: hasCompany },
{ href: '/settings/bookkeeping', label: t('bookkeeping'), show: hasCompany },
{ href: '/settings/tax', label: t('tax'), show: hasCompany },
{ href: '/settings/team', label: t('team'), show: false },
{ href: '/settings/banking', label: t('banking'), show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/backup', label: t('backup'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
@@ -61,7 +63,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{/* Desktop: horizontal tabs with bottom border */}
<nav
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
aria-label="Inställningar"
aria-label={t('aria_label')}
>
<ul className="flex gap-0 -mb-px">
{items.map(item => {
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -21,21 +22,22 @@ type Status =
disabled?: boolean
}
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: 'Momsdeklaration',
inkforetag: 'Företagsinformation',
skahmst: 'Skattekonto – saldo & transaktioner',
skattekonto: 'Skattekonto',
agd: 'Arbetsgivardeklaration',
}
export function SkatteverketConnectPanel() {
const t = useTranslations('settings_skatteverket_connect')
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
// docs: https://www7.skatteverket.se/portal-wapi/open/apier-och-oppna-data/utvecklarportalen/v1/getFile/tjanstebeskrivning-skattekonto-hamta-huvudmans-saldo-och-transaktioner-v101
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: t('scope_momsdeklaration'),
inkforetag: t('scope_inkforetag'),
skahmst: t('scope_skahmst'),
skattekonto: t('scope_skattekonto'),
agd: t('scope_agd'),
}
async function loadStatus() {
setLoading(true)
try {
@@ -68,12 +70,12 @@ export function SkatteverketConnectPanel() {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) throw new Error('Frånkoppling misslyckades')
toast({ title: 'Skatteverket frånkopplad' })
if (!res.ok) throw new Error(t('disconnect_failed'))
toast({ title: t('toast_disconnected') })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla från',
title: t('toast_disconnect_failed'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
@@ -86,7 +88,7 @@ export function SkatteverketConnectPanel() {
return (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Hämtar status…
{t('loading_status')}
</CardContent>
</Card>
)
@@ -97,7 +99,7 @@ export function SkatteverketConnectPanel() {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Skatteverket</CardTitle>
<CardTitle>{t('title')}</CardTitle>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
</div>
</CardHeader>
@@ -105,23 +107,20 @@ export function SkatteverketConnectPanel() {
{status?.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.</p>
<p>{t('disabled_message')}</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
arbetsgivardeklaration och hämta saldot på skattekontot.
{t('connect_intro')}
</p>
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
På Skatteverkets samtyckessida visas en av behörigheterna som{' '}
<span className="font-mono">skahmst (Rubrik saknas)</span> — det är
scope-namnet för skattekontots saldo och transaktioner (Skattekonto
HuvudMan STatus). Skatteverket har inte publicerat en svensk
beskrivning för den ännu. Det är ofarligt att godkänna.
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</div>
<Button onClick={startConnect} disabled={status?.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut med BankID
{t('connect_with_bankid')}
</Button>
</CardContent>
</Card>
@@ -139,13 +138,13 @@ export function SkatteverketConnectPanel() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Skatteverket
{t('title')}
{status.expired ? (
<Badge variant="destructive">Utgången</Badge>
<Badge variant="destructive">{t('expired')}</Badge>
) : (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
Ansluten
{t('connected')}
</Badge>
)}
</CardTitle>
@@ -155,27 +154,27 @@ export function SkatteverketConnectPanel() {
<CardContent className="space-y-4">
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Token utgår</dt>
<dt className="text-muted-foreground">{t('token_expires_label')}</dt>
<dd className="font-medium tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<span className="ml-2 text-muted-foreground">
(om {expiresInMinutes} min)
{t('expires_in_minutes', { minutes: expiresInMinutes })}
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Förnyelse</dt>
<dt className="text-muted-foreground">{t('refresh_label')}</dt>
<dd className="font-medium">
{status.canRefresh ? 'Förnyas automatiskt' : 'Förnyelse uttömd — anslut igen'}
{status.canRefresh ? t('refresh_auto') : t('refresh_exhausted')}
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
Behörigheter
{t('permissions_label')}
</p>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
@@ -186,15 +185,12 @@ export function SkatteverketConnectPanel() {
</div>
{!scopes.includes('skahmst') && !scopes.includes('skattekonto') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Skattekonto saknas — koppla från och anslut igen
för att aktivera saldo- och transaktionsvyn.
{t('missing_skattekonto')}
</p>
)}
{!scopes.includes('agd') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla
från och anslut igen för att kunna skicka AGI direkt från {`gnubok`}.
Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.
{t('missing_agd')}
</p>
)}
</div>
@@ -202,7 +198,7 @@ export function SkatteverketConnectPanel() {
{status.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.</p>
<p>{t('disabled_filings_message')}</p>
</div>
)}
@@ -210,7 +206,7 @@ export function SkatteverketConnectPanel() {
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button onClick={startConnect} disabled={status.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
{t('reconnect')}
</Button>
)}
<Button
@@ -219,7 +215,7 @@ export function SkatteverketConnectPanel() {
disabled={disconnecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? 'Kopplar från…' : 'Koppla från'}
{disconnecting ? t('disconnecting') : t('disconnect')}
</Button>
</div>
</CardContent>
@@ -228,11 +224,12 @@ export function SkatteverketConnectPanel() {
}
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
const t = useTranslations('settings_skatteverket_connect')
if (disabled) {
return (
<Badge variant="destructive">
<ShieldAlert className="mr-1 h-3 w-3" />
Avstängd
{t('env_disabled')}
</Badge>
)
}
@@ -240,14 +237,14 @@ function EnvironmentBadge({ environment, disabled }: { environment?: Environment
return (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:border-amber-600 dark:text-amber-400">
<FlaskConical className="mr-1 h-3 w-3" />
Testmiljö
{t('env_test')}
</Badge>
)
}
if (environment === 'prod') {
return (
<Badge variant="outline" className="border-emerald-400 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400">
Produktion
{t('env_prod')}
</Badge>
)
}
+46 -42
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -13,25 +14,32 @@ interface TaxSettingsFormProps {
}
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
const t = useTranslations('settings_tax_form')
const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false)
const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true)
const [paysSalaries, setPaysSalaries] = useState(settings.pays_salaries ?? false)
const isEnskildFirma = settings.entity_type === 'enskild_firma'
const months = [
t('month_jan'), t('month_feb'), t('month_mar'), t('month_apr'),
t('month_may'), t('month_jun'), t('month_jul'), t('month_aug'),
t('month_sep'), t('month_oct'), t('month_nov'), t('month_dec'),
]
return (
<div className="space-y-8">
{/* Entity type — read-only */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsform
{t('entity_form_heading')}
</h2>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-sm">
{settings.entity_type === 'aktiebolag' ? 'Aktiebolag' : 'Enskild firma'}
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
</Badge>
<p className="text-xs text-muted-foreground">
Företagsform kan inte ändras. Kontakta support vid behov.
{t('entity_form_help')}
</p>
</div>
</section>
@@ -39,7 +47,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* F-skatt */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Skatt & moms
{t('tax_vat_heading')}
</h2>
<div className="space-y-4">
@@ -51,9 +59,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="f_skatt" value={fSkatt ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="f_skatt" className="cursor-pointer">F-skattsedel</Label>
<Label htmlFor="f_skatt" className="cursor-pointer">{t('f_skatt_label')}</Label>
<p className="text-xs text-muted-foreground">
Godkänd för F-skatt (självständig näringsverksamhet).
{t('f_skatt_help')}
</p>
</div>
</div>
@@ -66,9 +74,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="vat_registered" value={vatRegistered ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">Momsregistrerad</Label>
<Label htmlFor="vat_registered" className="cursor-pointer">{t('vat_registered_label')}</Label>
<p className="text-xs text-muted-foreground">
Obligatoriskt om omsättningen överstiger 120 000 kr per år.
{t('vat_registered_help')}
</p>
</div>
</div>
@@ -76,7 +84,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{vatRegistered && (
<div className="space-y-4 pl-7">
<div className="max-w-xs space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Label htmlFor="vat_number">{t('vat_number_label')}</Label>
<Input
id="vat_number"
name="vat_number"
@@ -84,47 +92,46 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
defaultValue={settings.vat_number || ''}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
{t('vat_number_help')}
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Momsredovisningsperiod</Label>
<Label>{t('moms_period_label')}</Label>
<Select
name="moms_period"
defaultValue={settings.moms_period || undefined}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
<SelectItem value="yearly">{t('period_yearly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Enligt beslut från Skatteverket.
{t('moms_period_help')}
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Period för periodisk sammanställning</Label>
<Label>{t('periodisk_label')}</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
<SelectValue placeholder={t('select_period_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="monthly">{t('period_monthly')}</SelectItem>
<SelectItem value="quarterly">{t('period_quarterly')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Varuförsäljning till EU ska normalt rapporteras månadsvis (35 kap. 2 § SFL).
Kvartal kräver tillstånd från Skatteverket och gäller endast tjänsteförsäljning.
{t('periodisk_help')}
</p>
</div>
</div>
@@ -135,24 +142,24 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Tax contact — required for SKV-filings */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Kontaktperson för skatteärenden
{t('tax_contact_heading')}
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.).
{t('tax_contact_help')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="tax_contact_name">Namn</Label>
<Label htmlFor="tax_contact_name">{t('tax_contact_name_label')}</Label>
<Input
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder="Anna Andersson"
placeholder={t('tax_contact_name_placeholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax_contact_phone">Telefon</Label>
<Label htmlFor="tax_contact_phone">{t('tax_contact_phone_label')}</Label>
<Input
id="tax_contact_phone"
name="tax_contact_phone"
@@ -161,7 +168,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="tax_contact_email">E-post</Label>
<Label htmlFor="tax_contact_email">{t('tax_contact_email_label')}</Label>
<Input
id="tax_contact_email"
name="tax_contact_email"
@@ -176,17 +183,17 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Fiscal year & salaries */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Räkenskapsår & löner
{t('fiscal_year_salaries_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label>Räkenskapsårets startmånad</Label>
<Label>{t('fiscal_year_start_label')}</Label>
{isEnskildFirma ? (
<>
<Input value="Januari" disabled />
<Input value={t('month_jan')} disabled />
<input type="hidden" name="fiscal_year_start_month" value="1" />
<p className="text-xs text-muted-foreground">
Enskild firma måste använda kalenderår (BFL 3 kap.).
{t('fiscal_year_ef_help')}
</p>
</>
) : (
@@ -199,16 +206,13 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
].map((month, i) => (
{months.map((month, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{month}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Ändring påverkar framtida räkenskapsår.
{t('fiscal_year_change_help')}
</p>
</>
)}
@@ -222,9 +226,9 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
/>
<input type="hidden" name="pays_salaries" value={paysSalaries ? 'true' : 'false'} />
<div className="space-y-1">
<Label htmlFor="pays_salaries" className="cursor-pointer">Betalar löner</Label>
<Label htmlFor="pays_salaries" className="cursor-pointer">{t('pays_salaries_label')}</Label>
<p className="text-xs text-muted-foreground">
Påverkar vilka skattedeadlines som visas (arbetsgivardeklaration m.m.).
{t('pays_salaries_help')}
</p>
</div>
</div>
@@ -233,12 +237,12 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
{/* Preliminary tax */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Preliminärskatt
{t('preliminary_tax_heading')}
</h2>
<div className="max-w-xs space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
{t('preliminary_tax_monthly_label')}
</Label>
<Input
id="preliminary_tax_monthly"
@@ -247,7 +251,7 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
defaultValue={settings.preliminary_tax_monthly || ''}
/>
<p className="text-xs text-muted-foreground">
Belopp i SEK som betalas varje månad.
{t('preliminary_tax_monthly_help')}
</p>
</div>
</section>
+14 -9
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2, Users } from 'lucide-react'
@@ -13,17 +14,21 @@ interface TeamMember {
is_current_user: boolean
}
const ROLE_LABELS: Record<string, string> = {
owner: 'Ägare',
admin: 'Admin',
member: 'Medlem',
}
export function TeamPanel() {
const t = useTranslations('settings_team_panel')
const [isLoading, setIsLoading] = useState(true)
const [members, setMembers] = useState<TeamMember[]>([])
const [teamName, setTeamName] = useState('')
const roleLabel = (role: string) => {
switch (role) {
case 'owner': return t('role_owner')
case 'admin': return t('role_admin')
case 'member': return t('role_member')
default: return role
}
}
const fetchMembers = useCallback(async () => {
try {
const res = await fetch('/api/team/members')
@@ -57,7 +62,7 @@ export function TeamPanel() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
{teamName || 'Team'}
{teamName || t('team_fallback')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -74,11 +79,11 @@ export function TeamPanel() {
<p className="text-sm font-medium truncate">
{member.email}
{member.is_current_user && (
<span className="text-muted-foreground font-normal ml-1">(du)</span>
<span className="text-muted-foreground font-normal ml-1">{t('you_suffix')}</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{ROLE_LABELS[member.role] || member.role}
{roleLabel(member.role)}
</p>
</div>
</div>
+9 -7
View File
@@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -18,6 +19,7 @@ interface VoucherSeriesManagerProps {
}
export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProps) {
const t = useTranslations('settings_voucher_series')
const { company } = useCompany()
const [series, setSeries] = useState<VoucherSeries[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -48,7 +50,7 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Verifikationsserier
{t('heading')}
</h2>
{isLoading ? (
@@ -58,22 +60,22 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
</div>
) : seriesEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga verifikationsserier ännu. Serie {defaultSeries || 'A'} skapas automatiskt vid första verifikationen.
{t('empty_state', { series: defaultSeries || 'A' })}
</p>
) : (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
<Label className="text-xs text-muted-foreground">{t('active_series_label')}</Label>
<div className="divide-y divide-border/8">
{seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
<span className="text-sm font-medium tabular-nums">{t('series_prefix')} {letter}</span>
{letter === (defaultSeries || 'A') && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">standard</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{t('default_badge')}</Badge>
)}
</div>
<span className="text-sm text-muted-foreground tabular-nums">
Senaste nr: {lastNum}
{t('latest_number')}: {lastNum}
</span>
</div>
))}
@@ -82,7 +84,7 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
)}
<p className="text-xs text-muted-foreground">
Nya serier skapas automatiskt första gången de används vid bokföring.
{t('footnote')}
</p>
</section>
)