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