refactor(ui): design-system consistency pass over dense pages + i18n de-bloat (#961)
* refactor(ui): normalize dense pages to the locked design system Sweep of the info-dense surfaces against .claude/rules/design.md; no behavior changes, classNames and primitive adoption only. - Replace hand-rolled h1s with PageHeader (import, suppliers, kpi, salary/employees, skattekonto, settings layout) and drop the one double title (SalarySettingsContent under the settings h1) - Replace hand-rolled empty states with EmptyState (skattekonto, banking/api-keys/oauth/counterparty settings) and hand-rolled pulse divs with Skeleton (deadlines, report view loaders) - Remove semantic colors used as chrome: amber/emerald banners in AGIPanel and SkatteverketPanel, success/warning tints in kassaflodesanalys, arsredovisning and import become neutral surfaces with the tint kept on the icon only - Full-opacity borders everywhere (border-border/30-60, border-destructive/20-40, border-foreground/30, text-destructive/80) - Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to scale values); KPI metric tiles p-6 to p-4 per the tile rule - Remove the mobile Select that duplicated the invoices status Tabs (TabsList already scrolls horizontally); single Tabs now serves both breakpoints - supplier-invoices: shared formatCurrency instead of a local formatAmount helper; skattekonto: formatDate/formatDateLong/ formatDateTime instead of raw dates and toLocaleString - arsredovisning flerarsoversikt converted to the Table primitive with right-aligned tabular-nums cells - Settings: CardTitle text-base on section cards, one heading idiom in AccountSettingsContent, h3 to h2 in CompanyProfileView Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(i18n): trim text bloat and fix an untranslated sv string - Fix invoice_credit.create_failed_fallback: sv catalog carried the English "Failed to create credit note"; now "Kunde inte skapa kreditfaktura". Translate new_user_checklist.step3_title in en - Drop descriptions that paraphrase their own title (design.md forbidden pattern): invoice_detail.credited_description, invoice_credit.original_card_description, invoice_editor customer/notes card descriptions (keys deleted from both catalogs, zero remaining usages); the transaction booking DialogDescription becomes sr-only so screen readers keep it - Trim redundant sentences from settings_salary.info_payroll_scope, settings_backup.intro, ext_cloud_backup_long_description, settings.name_description, salary_payments.open_payments_note and shorten invoice_credit.reason_card_description; statutory BFL/tax prose untouched - Normalize toast punctuation (dimensions/self_billing created_description lose the trailing period like their siblings) - common.delete "Radera" to "Ta bort" (zero live call sites; Radera stays reserved for irreversible account/company deletion) Catalogs verified key-identical (4795 keys each) and JSON-parseable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ca8a89324c
commit
da859d7236
@@ -53,3 +53,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against.
|
||||
[2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests.
|
||||
[2026-07-09] Demo/sandbox users could reach Stripe: an anonymous user on a sandbox company hit POST /api/billing/checkout and created a live Stripe customer (no subscription = no charge; exact tenant/customer IDs kept out of source control, see the incident PR). Root cause: neither billing/checkout nor billing/portal checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users through (they are authenticated, just anonymously). Fix guards BOTH conditions in both routes (is_anonymous is the identity truth; guardSandbox matches the existing lib/sandbox/guard.ts "never charge a token" doctrine), belt-and-suspenders since anon and sandbox happen to co-occur today but are orthogonal. Anon check runs first (in-memory, no DB round trip). Also surfaced isDemo on GET /api/billing/status so the client hides the upgrade CTA instead of showing a button that 403s. Blast radius = exactly one company (no other sandbox/anon tenant had a stripe_customer_id). Left the stray company_subscriptions row + orphan Stripe customer for manual cleanup (prod write / external destructive action, not done unilaterally).
|
||||
[2026-07-09] UI consistency pass: skipped the suppliers/page.tsx card-grid to Table conversion (did PageHeader + space-y-8 only): the suppliers.* i18n namespace has no column-header keys (nothing equivalent to customers.col_name/col_type), the pass forbade new i18n keys, and a data table whose primary name column has no header is worse than the existing card grid.
|
||||
[2026-07-09] supplier-invoices/[id]: replaced the local formatAmount with shared formatCurrency at all direct render sites, but the two i18n-templated amounts (amount_registered_description embeds "kr", remaining_to_pay embeds "{currency}") now use the shared bare-number formatAmount from lib/utils instead: passing formatCurrency output there would double-print the currency, and message files were off-limits.
|
||||
[2026-07-09] common.delete changed "Radera" to "Ta bort": grep proved the key has zero live call sites (every delete dialog uses feature-namespace keys), so this only affects future uses; convention going forward is Ta bort = detach/remove, Radera = irreversible destruction (kept in AccountDangerZone/CompanyDangerZone keys).
|
||||
[2026-07-09] InvoiceEditor customer-card description kept only for the self-billing branch (issuer_card_description adds real info: who issues the invoice); the plain-invoice branch dropped its description as a title paraphrase per design.md forbidden patterns.
|
||||
[2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix.
|
||||
|
||||
@@ -339,7 +339,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight font-mono">
|
||||
<h1 className="text-2xl md:text-3xl tracking-tight font-mono">
|
||||
{formatVoucher(entry)}
|
||||
</h1>
|
||||
<JournalEntryStatusBadge entry={entry} />
|
||||
|
||||
@@ -10,9 +10,11 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning'
|
||||
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
|
||||
@@ -388,7 +390,7 @@ export default function ArsredovisningPage() {
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-description">Verksamhetsbeskrivning</Label>
|
||||
<Textarea
|
||||
id="ar-description"
|
||||
@@ -397,7 +399,7 @@ export default function ArsredovisningPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-events">Väsentliga händelser</Label>
|
||||
<Textarea
|
||||
id="ar-events"
|
||||
@@ -406,7 +408,7 @@ export default function ArsredovisningPage() {
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-rd">Resultatdisposition</Label>
|
||||
<Textarea
|
||||
id="ar-rd"
|
||||
@@ -415,7 +417,7 @@ export default function ArsredovisningPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-agm-date">Datum för årsstämma</Label>
|
||||
<Input
|
||||
id="ar-agm-date"
|
||||
@@ -440,7 +442,7 @@ export default function ArsredovisningPage() {
|
||||
fält visas som "Inga." i PDF:en.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-ltd">
|
||||
Långfristiga skulder förfallande efter mer än fem år (kr)
|
||||
</Label>
|
||||
@@ -457,7 +459,7 @@ export default function ArsredovisningPage() {
|
||||
ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-securities">Ställda säkerheter</Label>
|
||||
<Textarea
|
||||
id="ar-securities"
|
||||
@@ -468,7 +470,7 @@ export default function ArsredovisningPage() {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">ÅRL 5:14 §.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-contingent">Eventualförpliktelser</Label>
|
||||
<Textarea
|
||||
id="ar-contingent"
|
||||
@@ -479,7 +481,7 @@ export default function ArsredovisningPage() {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">ÅRL 5:15 §.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-parent-name">
|
||||
Moderföretag: namn (om koncerntillhörighet)
|
||||
</Label>
|
||||
@@ -495,7 +497,7 @@ export default function ArsredovisningPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-parent-orgnr">Moderföretagets org.nr</Label>
|
||||
<Input
|
||||
id="ar-parent-orgnr"
|
||||
@@ -504,7 +506,7 @@ export default function ArsredovisningPage() {
|
||||
placeholder="556677-8899"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ar-parent-city">Moderföretagets säte</Label>
|
||||
<Input
|
||||
id="ar-parent-city"
|
||||
@@ -551,34 +553,34 @@ export default function ArsredovisningPage() {
|
||||
<CardTitle className="text-base">Flerårsöversikt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground border-b border-border">
|
||||
<th className="py-2">År</th>
|
||||
<th className="py-2 text-right">Nettoomsättning</th>
|
||||
<th className="py-2 text-right">Resultat efter fin.</th>
|
||||
<th className="py-2 text-right">Soliditet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>År</TableHead>
|
||||
<TableHead className="text-right">Nettoomsättning</TableHead>
|
||||
<TableHead className="text-right">Resultat efter fin.</TableHead>
|
||||
<TableHead className="text-right">Soliditet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.forvaltningsberattelse.flerarsoversikt.map((row) => (
|
||||
<tr key={row.year} className="border-b border-border last:border-b-0">
|
||||
<td className="py-2">{row.year}</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{row.net_revenue.toLocaleString('sv-SE')}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{row.result_after_financial.toLocaleString('sv-SE')}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
<TableRow key={row.year}>
|
||||
<TableCell>{row.year}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(row.net_revenue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(row.result_after_financial)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.soliditet_pct === null
|
||||
? '-'
|
||||
: `${row.soliditet_pct.toFixed(1)} %`}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -709,7 +711,7 @@ export default function ArsredovisningPage() {
|
||||
}
|
||||
>
|
||||
{data.warnings.length > 0 && (
|
||||
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground space-y-1">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs space-y-1">
|
||||
<p className="font-medium">Innan inlämning till Bolagsverket:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{data.warnings.map((w, i) => (
|
||||
@@ -718,7 +720,7 @@ export default function ArsredovisningPage() {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<strong>Notis om digital inlämning:</strong> Digital inlämning (iXBRL) av
|
||||
årsredovisning föreslås bli obligatorisk för K2/K3-aktiebolag för
|
||||
räkenskapsår som inleds efter 2025-12-31. Använd avsnittet{' '}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { DeadlineList } from '@/components/deadlines/DeadlineList'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertTriangle, ArrowRight, CalendarClock } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -204,18 +205,18 @@ export default function DeadlinesPage() {
|
||||
<PageHeader title={t('title')} />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-lg border p-4 animate-pulse">
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 space-y-1">
|
||||
<div className="h-5 bg-muted rounded w-8 mx-auto" />
|
||||
<div className="h-3 bg-muted rounded w-6 mx-auto" />
|
||||
<Skeleton className="h-5 w-8 mx-auto" />
|
||||
<Skeleton className="h-3 w-6 mx-auto" />
|
||||
</div>
|
||||
<div className="w-px h-8 bg-muted" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-muted rounded w-48" />
|
||||
<div className="h-3 bg-muted rounded w-24" />
|
||||
<Skeleton className="w-px h-8" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="h-4 bg-muted rounded w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -241,7 +242,7 @@ export default function DeadlinesPage() {
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{t('no_system_deadlines_title')}</span>
|
||||
<span className="text-muted-foreground ml-1.5">
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{t('no_system_deadlines_description')}
|
||||
</span>
|
||||
</p>
|
||||
@@ -259,7 +260,7 @@ export default function DeadlinesPage() {
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{t('overdue_invoices', { count: overdueInvoices.count })}</span>
|
||||
<span className="text-muted-foreground ml-1.5 tabular-nums">
|
||||
<span className="text-muted-foreground ml-2 tabular-nums">
|
||||
{formatCurrency(overdueInvoices.total)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
|
||||
@@ -292,7 +294,7 @@ function BankFileImportWizard() {
|
||||
{/* Overlap warning: active PSD2 means file import will likely create
|
||||
duplicates of transactions the nightly sync already covers. */}
|
||||
{activePsd2Banks.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
@@ -2011,16 +2013,11 @@ export default function ImportPage() {
|
||||
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
|
||||
{view === 'export' ? t('export_title') : t('title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{view === 'export' ? t('export_subtitle') : t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={view === 'export' ? t('export_title') : t('title')}
|
||||
description={view === 'export' ? t('export_subtitle') : undefined}
|
||||
/>
|
||||
|
||||
{mode === null && (
|
||||
<>
|
||||
@@ -2048,7 +2045,7 @@ export default function ImportPage() {
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-colors',
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15'
|
||||
@@ -2060,23 +2057,21 @@ export default function ImportPage() {
|
||||
<Landmark className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
|
||||
{hasBankSync ? (
|
||||
<span className="text-[11px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full leading-none">
|
||||
{t('psd2_recommended')}
|
||||
</span>
|
||||
<Badge variant="success">{t('psd2_recommended')}</Badge>
|
||||
) : (
|
||||
<span className="text-[11px] font-medium text-muted-foreground bg-secondary px-2 py-0.5 rounded-full leading-none">
|
||||
{t('psd2_requires_subscription')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('psd2_description')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2087,7 +2082,7 @@ export default function ImportPage() {
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
className={cn(
|
||||
'group rounded-lg border bg-card p-5 transition-colors',
|
||||
'group rounded-lg border bg-card p-6 transition-colors',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15'
|
||||
@@ -2101,13 +2096,13 @@ export default function ImportPage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('migration_title')}</h3>
|
||||
<p className="text-sm mt-1.5 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
|
||||
<p className="text-sm mt-1 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
|
||||
{t('migration_description')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3.5 ml-[52px]">
|
||||
<div className="flex flex-wrap gap-2 mt-4 ml-[52px]">
|
||||
{([
|
||||
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
|
||||
{ name: 'Visma', logo: '/logos/visma.jpeg' },
|
||||
@@ -2115,7 +2110,7 @@ export default function ImportPage() {
|
||||
{ name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
|
||||
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
|
||||
] as const).map(provider => (
|
||||
<div key={provider.name} className="flex items-center gap-1.5 rounded border border-border/60 bg-muted/30 px-2 py-1">
|
||||
<div key={provider.name} className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
|
||||
<img src={provider.logo} alt={provider.name} className="h-4 w-4 shrink-0 rounded-sm object-contain" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground">{provider.name}</span>
|
||||
</div>
|
||||
@@ -2133,7 +2128,7 @@ export default function ImportPage() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-colors',
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('bank')}
|
||||
@@ -2144,10 +2139,10 @@ export default function ImportPage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('bankfile_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('bankfile_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2.5">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{['CSV', 'OFX', 'SEB', 'Swedbank', 'Nordea'].map(fmt => (
|
||||
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
|
||||
{fmt}
|
||||
@@ -2155,7 +2150,7 @@ export default function ImportPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
|
||||
@@ -2163,7 +2158,7 @@ export default function ImportPage() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-colors',
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('csv_data')}
|
||||
@@ -2174,10 +2169,10 @@ export default function ImportPage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('csv_data_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('csv_data_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2.5">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{[
|
||||
{ key: 'XLSX', label: 'XLSX' },
|
||||
{ key: 'CSV', label: 'CSV' },
|
||||
@@ -2192,7 +2187,7 @@ export default function ImportPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 5. Bokföringsdata (SIE) */}
|
||||
@@ -2200,7 +2195,7 @@ export default function ImportPage() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-colors',
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
|
||||
'cursor-pointer hover:border-foreground/15'
|
||||
)}
|
||||
onClick={() => setMode('sie')}
|
||||
@@ -2211,10 +2206,10 @@ export default function ImportPage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{t('sie_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
|
||||
{t('sie_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2.5">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{['SIE4', '.se'].map(fmt => (
|
||||
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
|
||||
{fmt}
|
||||
@@ -2222,7 +2217,7 @@ export default function ImportPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -179,9 +179,6 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('original_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('original_card_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
|
||||
@@ -1144,9 +1144,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t('credited_description')}
|
||||
</p>
|
||||
<Link href={`/invoices/${creditNote.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
@@ -222,25 +221,8 @@ export default function InvoicesPage() {
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Mobile: dropdown select */}
|
||||
<Select value={activeTab} onValueChange={setActiveTab}>
|
||||
<SelectTrigger className="sm:hidden w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('tab_all')}</SelectItem>
|
||||
<SelectItem value="unpaid">{t('tab_unpaid')}</SelectItem>
|
||||
<SelectItem value="paid">{t('tab_paid')}</SelectItem>
|
||||
<SelectItem value="draft">{t('tab_draft')}</SelectItem>
|
||||
<SelectItem value="proforma">{t('tab_proforma')}</SelectItem>
|
||||
<SelectItem value="delivery_note">{t('tab_delivery_note')}</SelectItem>
|
||||
<SelectItem value="credit">{t('tab_credit')}</SelectItem>
|
||||
<SelectItem value="cancelled">{t('tab_cancelled')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Desktop: tab bar */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="hidden sm:block">
|
||||
<TabsList>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="min-w-0">
|
||||
<TabsList className="w-max max-w-full justify-start">
|
||||
<TabsTrigger value="all">{t('tab_all')}</TabsTrigger>
|
||||
<TabsTrigger value="unpaid">{t('tab_unpaid')}</TabsTrigger>
|
||||
<TabsTrigger value="paid">{t('tab_paid')}</TabsTrigger>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { KPIHeroCards } from '@/components/kpi/KPIHeroCards'
|
||||
@@ -95,16 +96,16 @@ export default function KpiPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('title')}</h1>
|
||||
<div className="flex gap-2">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
saving={isSavingPrefs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiscalYearSelector
|
||||
value={selectedPeriod || null}
|
||||
@@ -143,7 +144,7 @@ function LoadingSkeleton() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-2">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
|
||||
@@ -384,11 +384,11 @@ function InvoicePreview({ data }: { data: Record<string, unknown> }) {
|
||||
)}
|
||||
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span className="font-mono tabular-nums text-right">{formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')}</span>
|
||||
<span className="tabular-nums text-right">{formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')}</span>
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="font-mono tabular-nums text-right">{formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')}</span>
|
||||
<span className="tabular-nums text-right">{formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')}</span>
|
||||
<span className="font-medium">Totalt</span>
|
||||
<span className="font-mono tabular-nums font-medium text-right">{formatCurrency(data.total as number, (data.currency as string) || 'SEK')}</span>
|
||||
<span className="tabular-nums font-medium text-right">{formatCurrency(data.total as number, (data.currency as string) || 'SEK')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -944,7 +944,7 @@ export default function PendingOperationsPage() {
|
||||
const showFilterDot = sourceFilter !== 'all'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={t('subtitle')}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface CashRowProps {
|
||||
|
||||
function CashRow({ label, amount }: CashRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||
<div className="flex items-center justify-between py-1 text-sm">
|
||||
<span className="text-foreground">{label}</span>
|
||||
<span className="tabular-nums text-right">{formatAmount(amount)}</span>
|
||||
</div>
|
||||
@@ -96,9 +96,9 @@ export function KassaflodesanalysClient() {
|
||||
<div className="space-y-8">
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Rapporter
|
||||
</Link>
|
||||
<PageHeader title="Kassaflödesanalys" />
|
||||
@@ -159,7 +159,7 @@ export function KassaflodesanalysClient() {
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="p-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
) : report ? (
|
||||
@@ -265,7 +265,7 @@ export function KassaflodesanalysClient() {
|
||||
{/* Total */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between text-base font-medium">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-lg">Årets kassaflöde</span>
|
||||
<span className="font-display text-lg tabular-nums">
|
||||
{formatAmount(report.total_cash_flow)}
|
||||
@@ -278,8 +278,8 @@ export function KassaflodesanalysClient() {
|
||||
<Card
|
||||
className={
|
||||
report.reconciliation.is_reconciled
|
||||
? 'border-success/40 bg-success/5'
|
||||
: 'border-destructive/60 bg-destructive/5'
|
||||
? 'border-border bg-muted/30'
|
||||
: 'border-destructive'
|
||||
}
|
||||
>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
@@ -313,7 +313,7 @@ export function KassaflodesanalysClient() {
|
||||
amount={report.reconciliation.delta_calculated}
|
||||
/>
|
||||
{!report.reconciliation.is_reconciled && (
|
||||
<div className="flex items-center justify-between border-t border-destructive/40 pt-2 mt-2 text-sm font-medium text-destructive">
|
||||
<div className="flex items-center justify-between border-t border-destructive pt-2 mt-2 text-sm font-medium text-destructive">
|
||||
<span>Avvikelse</span>
|
||||
<span className="tabular-nums text-right">
|
||||
{formatAmount(report.reconciliation.mismatch_amount)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
|
||||
@@ -54,22 +55,24 @@ export default function EmployeesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label={t('back_to_payroll')}><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('registered_count', { count: employees.length })}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label={t('back_to_payroll')}><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={t('registered_count', { count: employees.length })}
|
||||
action={
|
||||
canWrite ? (
|
||||
<Button onClick={openNewEmployee}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_employee')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button onClick={openNewEmployee}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_employee')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { SettingsShell } from '@/components/settings/SettingsShell'
|
||||
|
||||
const TAB_TO_ROUTE: Record<string, string> = {
|
||||
@@ -34,7 +35,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="font-display text-2xl tracking-tight md:text-3xl">{t('aria_label')}</h1>
|
||||
<PageHeader title={t('aria_label')} />
|
||||
<SettingsShell variant="page">{children}</SettingsShell>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
@@ -16,7 +18,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatDateLong,
|
||||
formatDateTime,
|
||||
} from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -263,19 +270,19 @@ export default function SkattekontoPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeading />
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<Landmark className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mb-1 font-medium">Skatteverket är inte anslutet</p>
|
||||
<p className="mb-4 max-w-md text-sm text-muted-foreground">
|
||||
För att se saldo och transaktioner på skattekontot behöver du
|
||||
ansluta med BankID i inställningarna.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/settings/tax">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut Skatteverket
|
||||
</Link>
|
||||
</Button>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={Landmark}
|
||||
title="Skatteverket är inte anslutet"
|
||||
description="För att se saldo och transaktioner på skattekontot behöver du ansluta med BankID i inställningarna."
|
||||
>
|
||||
<Button asChild>
|
||||
<Link href="/settings/tax">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Anslut Skatteverket
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -287,17 +294,17 @@ export default function SkattekontoPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeading />
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<AlertCircle className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mb-1 font-medium">Kunde inte hämta skattekontot</p>
|
||||
<p className="mb-4 max-w-md text-sm text-muted-foreground">
|
||||
Något gick fel när saldo och transaktioner skulle hämtas. Försök
|
||||
igen om en stund.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Försök igen
|
||||
</Button>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Kunde inte hämta skattekontot"
|
||||
description="Något gick fel när saldo och transaktioner skulle hämtas. Försök igen om en stund."
|
||||
>
|
||||
<Button variant="outline" onClick={() => void reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Försök igen
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -401,17 +408,7 @@ export default function SkattekontoPage() {
|
||||
}
|
||||
|
||||
function PageHeading({ right }: { right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl">Skattekonto</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saldo och transaktioner från Skatteverket
|
||||
</p>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
)
|
||||
return <PageHeader title="Skattekonto" action={right} />
|
||||
}
|
||||
|
||||
function BalanceHero({
|
||||
@@ -456,7 +453,7 @@ function BalanceHero({
|
||||
Skatteverket
|
||||
</p>
|
||||
<p
|
||||
className={`font-serif text-4xl tabular-nums ${
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
skvNegative ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
@@ -473,7 +470,7 @@ function BalanceHero({
|
||||
Kronofogden
|
||||
</p>
|
||||
<p
|
||||
className={`font-serif text-4xl tabular-nums ${
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
kfmNegative ? 'text-destructive' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
@@ -514,7 +511,7 @@ function BalanceHero({
|
||||
Saldo per
|
||||
</p>
|
||||
<p className="font-medium tabular-nums">
|
||||
{new Date(data.senastUppdaterad).toLocaleString('sv-SE')}
|
||||
{formatDateLong(data.senastUppdaterad)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,7 +520,7 @@ function BalanceHero({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synkas automatiskt varje natt. Senast synkad{' '}
|
||||
<span className="tabular-nums">
|
||||
{new Date(saldo.lastSyncedAt).toLocaleString('sv-SE')}
|
||||
{formatDateTime(saldo.lastSyncedAt)}
|
||||
</span>
|
||||
. Skatteverket uppdaterar saldot periodvis: datumet ovan ändras
|
||||
inte varje gång du synkroniserar.
|
||||
@@ -584,9 +581,11 @@ function TransactionTable({
|
||||
const isBooked = !!row.journal_entry_id
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">{row.transaktionsdatum}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(row.transaktionsdatum)}</TableCell>
|
||||
{showForfallodatum && (
|
||||
<TableCell className="tabular-nums">{row.forfallodatum ?? '-'}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{row.forfallodatum ? formatDate(row.forfallodatum) : '-'}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{row.transaktionstext}
|
||||
@@ -599,7 +598,7 @@ function TransactionTable({
|
||||
voucher_number: row.match_suggestion.voucher_number,
|
||||
})
|
||||
: 'utkast'}{' '}
|
||||
({row.match_suggestion.entry_date})
|
||||
({formatDate(row.match_suggestion.entry_date)})
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -681,7 +680,7 @@ function MatchDialog({
|
||||
<DialogDescription>
|
||||
{row && (
|
||||
<>
|
||||
{row.transaktionsdatum} • {row.transaktionstext} •{' '}
|
||||
{formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '}
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</span>
|
||||
@@ -723,7 +722,7 @@ function MatchDialog({
|
||||
<TableBody>
|
||||
{candidates.map(c => (
|
||||
<TableRow key={c.journal_entry_id}>
|
||||
<TableCell className="tabular-nums">{c.entry_date}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(c.entry_date)}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{formatVoucher(c)}
|
||||
</TableCell>
|
||||
|
||||
@@ -22,7 +22,7 @@ import Link from 'next/link'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatAmount, formatCurrency } from '@/lib/utils'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
|
||||
|
||||
@@ -56,10 +56,6 @@ interface MarkPaidPreview {
|
||||
accounting_method: 'accrual' | 'cash'
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// A line is periodiserad when both period dates are set: the cost was parked
|
||||
// on the 17xx interim account and dissolves monthly via accrual_schedules.
|
||||
const itemHasAccrual = (item: SupplierInvoiceItem): boolean =>
|
||||
@@ -433,7 +429,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="space-y-8 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
@@ -605,29 +601,29 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
|
||||
</div>
|
||||
{rounding.applies && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('ore_rounding')}</span>
|
||||
<span className="tabular-nums">{formatAmount(rounding.roundingDelta)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatCurrency(rounding.roundingDelta, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-semibold text-base pt-2 border-t">
|
||||
<span>{t('total_label')}</span>
|
||||
<span className="tabular-nums">{formatAmount(rounding.displayed)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatCurrency(rounding.displayed, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-muted-foreground">{t('paid_label')}</span>
|
||||
<span className="tabular-nums text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums text-success">{formatCurrency(invoice.paid_amount, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>{t('remaining_label')}</span>
|
||||
<span className="tabular-nums">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
|
||||
<span className="tabular-nums">{formatCurrency(invoice.remaining_amount, invoice.currency)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -692,11 +688,11 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2">{item.unit}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.unit_price)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatCurrency(item.unit_price, invoice.currency)}</td>
|
||||
<td className="py-2"><AccountNumber number={item.account_number} /></td>
|
||||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.line_total)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(item.vat_amount)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatCurrency(item.line_total, invoice.currency)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatCurrency(item.vat_amount, invoice.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -720,12 +716,12 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatAmount(item.unit_price)}</span>
|
||||
<span className="tabular-nums">{formatAmount(item.line_total)} kr</span>
|
||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(item.line_total, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span><AccountNumber number={item.account_number} /> · {t('vat_inline', { rate: Math.round(item.vat_rate * 100) })}</span>
|
||||
<span className="tabular-nums">{t('vat_amount_inline', { amount: formatAmount(item.vat_amount) })}</span>
|
||||
<span className="tabular-nums">{t('vat_amount_inline', { amount: formatCurrency(item.vat_amount, invoice.currency) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -755,7 +751,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2 tabular-nums">{formatDate(p.payment_date)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(p.amount)} {p.currency}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatCurrency(p.amount, p.currency)}</td>
|
||||
<td className="py-2">
|
||||
{p.journal_entry_id ? (
|
||||
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
|
||||
@@ -775,7 +771,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<div key={p.id} className="border rounded-lg p-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums">{formatDate(p.payment_date)}</span>
|
||||
<span className="font-mono font-medium">{formatAmount(p.amount)} {p.currency}</span>
|
||||
<span className="font-mono font-medium">{formatCurrency(p.amount, p.currency)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{p.journal_entry_id ? (
|
||||
@@ -1096,7 +1092,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabular-nums font-medium">
|
||||
{formatAmount(Math.abs(c.amount))} {invoice.currency}
|
||||
{formatCurrency(Math.abs(c.amount), invoice.currency)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useTranslations } from 'next-intl'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataList, DataListEmpty } from '@/components/ui/data-list'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, FileInput, Lock } from 'lucide-react'
|
||||
@@ -14,13 +15,9 @@ import Link from 'next/link'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
registered: 'secondary',
|
||||
approved: 'default',
|
||||
@@ -134,7 +131,8 @@ export default function SupplierInvoicesPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab}>
|
||||
<DataList>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div>
|
||||
<div className="p-3 border-b border-border">
|
||||
@@ -184,7 +182,7 @@ export default function SupplierInvoicesPage() {
|
||||
<TableBody>
|
||||
{filteredInvoices.map((inv) => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell className="font-mono tabular-nums">{inv.arrival_number}</TableCell>
|
||||
<TableCell className="tabular-nums">{inv.arrival_number}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers/${inv.supplier_id}`} className="hover:underline">
|
||||
{inv.supplier?.name || '-'}
|
||||
@@ -197,8 +195,8 @@ export default function SupplierInvoicesPage() {
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.total)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.remaining_amount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCurrency(inv.total, inv.currency)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCurrency(inv.remaining_amount, inv.currency)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[inv.status] || 'secondary'}>
|
||||
{STATUS_LABEL_KEYS[inv.status] ? t(STATUS_LABEL_KEYS[inv.status]) : inv.status}
|
||||
@@ -209,7 +207,8 @@ export default function SupplierInvoicesPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DataList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Search, Building2, Lock } from 'lucide-react'
|
||||
@@ -113,44 +114,44 @@ export default function SuppliersPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('title')}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ReportExportMenu
|
||||
size="default"
|
||||
items={[
|
||||
{ format: 'xlsx', href: '/api/export/suppliers' },
|
||||
{ format: 'csv', href: '/api/export/suppliers?format=csv' },
|
||||
]}
|
||||
/>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('new_supplier')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('add_supplier')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SupplierForm
|
||||
onSubmit={handleCreateSupplier}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<ReportExportMenu
|
||||
size="default"
|
||||
items={[
|
||||
{ format: 'xlsx', href: '/api/export/suppliers' },
|
||||
{ format: 'csv', href: '/api/export/suppliers?format=csv' },
|
||||
]}
|
||||
/>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('new_supplier')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('add_supplier')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SupplierForm
|
||||
onSubmit={handleCreateSupplier}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{isSelfBilled ? <>{ts('customer_label')}<RequiredMark /></> : <>{t('customer_card_title')}<RequiredMark /></>}</CardTitle>
|
||||
<CardDescription>{isSelfBilled ? ts('issuer_card_description') : t('customer_card_description')}</CardDescription>
|
||||
{isSelfBilled && <CardDescription>{ts('issuer_card_description')}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
@@ -1960,7 +1960,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('notes_card_title')}</CardTitle>
|
||||
<CardDescription>{t('notes_card_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
|
||||
@@ -137,7 +137,7 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
|
||||
return (
|
||||
<Card key={def.id}>
|
||||
<CardContent className="p-6">
|
||||
<CardContent className="p-4">
|
||||
<InfoTooltip
|
||||
content={tooltipContent}
|
||||
side="top"
|
||||
|
||||
@@ -266,7 +266,7 @@ function ExpansionContent({
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={`${line.voucher_series}-${line.voucher_number}-${line.journal_entry_id}`} className="border-t border-border/40">
|
||||
<tr key={`${line.voucher_series}-${line.voucher_number}-${line.journal_entry_id}`} className="border-t border-border">
|
||||
<td className="py-1.5">
|
||||
{line.journal_entry_id ? (
|
||||
<Link
|
||||
|
||||
@@ -630,8 +630,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
</div>
|
||||
)}
|
||||
{success && !error && (
|
||||
<div className="flex items-start gap-2 text-sm text-success bg-success/5 rounded-lg p-3">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<div className="flex items-start gap-2 text-sm rounded-lg border border-border bg-muted/30 p-3">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0 text-success" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -650,13 +650,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-3 ${
|
||||
c.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
: 'border border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
{c.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-warning" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{c.code}</span>
|
||||
@@ -802,13 +802,13 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
className={`flex items-start gap-2 text-sm rounded-lg p-3 ${
|
||||
k.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
: 'border border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
{k.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-warning" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-1.5">{k.kod}</span>
|
||||
@@ -841,7 +841,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
|
||||
{/* Signing link */}
|
||||
{signeringslank && (
|
||||
<div className="rounded-lg border border-success/30 bg-success/5 p-3 space-y-2">
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
|
||||
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Öppna länken nedan och signera med BankID på Skatteverkets sida.
|
||||
|
||||
@@ -117,8 +117,10 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar saldobalans...
|
||||
<CardContent className="p-6 space-y-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -451,8 +453,10 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar resultaträkning...
|
||||
<CardContent className="p-6 space-y-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -717,8 +721,10 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar resultatrapport...
|
||||
<CardContent className="p-6 space-y-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -717,7 +717,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
refresh token or has burned through its 10-refresh budget. The
|
||||
only fix is a fresh BankID round-trip. */}
|
||||
{(status?.expired === true || status?.canRefresh === false) && !readOnly && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">{t('expired_banner_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('expired_banner_description')}
|
||||
@@ -734,7 +734,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
after some users had already connected, so their stored token
|
||||
grants moms/skattekonto but not AGI. */}
|
||||
{missingAgdScope && !readOnly && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t('missing_scope_title')}
|
||||
</p>
|
||||
@@ -784,7 +784,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
treatment so the user understands they must fix errors before
|
||||
BankID signing is even possible. */}
|
||||
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">{t('draft_locked_title')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t('draft_locked_description')}
|
||||
@@ -793,7 +793,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
href={submission.signeringslank}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-amber-900 hover:underline dark:text-amber-200"
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
|
||||
>
|
||||
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
@@ -806,7 +806,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
it would file the old amounts. The "Lås upp" button below releases
|
||||
the SKV lock; the user then re-submits the freshly generated XML. */}
|
||||
{awaitingSigning && draftIsStale && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">{t('stale_draft_title')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t('stale_draft_description', {
|
||||
@@ -849,12 +849,12 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)}
|
||||
|
||||
{kontroller.length > 0 && (
|
||||
<div className="space-y-1 rounded-md border bg-muted/30 p-2.5">
|
||||
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
|
||||
{kontroller.map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-start gap-2 text-xs ${
|
||||
k.status === 'STOPP' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
|
||||
k.status === 'STOPP' ? 'text-destructive' : 'text-warning'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
@@ -878,7 +878,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
status?.expired === true ||
|
||||
status?.canRefresh === false
|
||||
return (
|
||||
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
|
||||
{error}
|
||||
{sessionExpired && !readOnly && (
|
||||
@@ -893,8 +893,8 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)
|
||||
})()}
|
||||
{success && !error && (
|
||||
<div className="rounded-md bg-emerald-50 p-2.5 text-sm text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300">
|
||||
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5" />
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5 text-success" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
|
||||
</thead>
|
||||
<tbody>
|
||||
{entry.lines.map((line, li) => (
|
||||
<tr key={li} className="border-t border-border/30">
|
||||
<tr key={li} className="border-t border-border">
|
||||
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AccountDangerZone() {
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const PROSE =
|
||||
'prose-table:my-2 prose-table:text-xs [&_table]:w-full ' +
|
||||
'[&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium ' +
|
||||
'[&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] ' +
|
||||
'[&_td]:border-b [&_td]:border-border/60 [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
|
||||
'[&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
|
||||
|
||||
// The API returns errors either as a plain string (legacy/validation) or as
|
||||
// the canonical { code, message } envelope — extract something renderable.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -216,7 +217,7 @@ function ScopeCard({
|
||||
className={cn(
|
||||
'flex min-h-[68px] cursor-pointer flex-col gap-1 rounded-md border p-2 transition-colors',
|
||||
checked
|
||||
? 'border-foreground/30 bg-secondary'
|
||||
? 'border-border bg-secondary'
|
||||
: 'border-border hover:bg-secondary/60'
|
||||
)}
|
||||
>
|
||||
@@ -387,7 +388,7 @@ export function ApiKeysPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
@@ -408,13 +409,11 @@ export function ApiKeysPanel() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : 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">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Key}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key) => {
|
||||
|
||||
@@ -185,7 +185,7 @@ export function BackupDownloadForm() {
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('create_backup_title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('create_backup_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -129,7 +129,7 @@ export function BookingTemplatesPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function CompanyDangerZone() {
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive">
|
||||
{t('danger_heading')}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ function cleanSignatory(raw: string): string[] {
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
@@ -94,7 +95,7 @@ export function CounterpartyTemplatesPanel() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
@@ -105,13 +106,11 @@ export function CounterpartyTemplatesPanel() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : 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">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{templates.map((tt) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Plus, Trash2, Globe } from 'lucide-react'
|
||||
|
||||
@@ -124,7 +125,7 @@ export function OAuthClientsPanel() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
@@ -141,13 +142,11 @@ export function OAuthClientsPanel() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : 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">{t('empty_title')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('empty_help')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_help')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
|
||||
@@ -152,7 +152,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('placement_aria_label')}
|
||||
className="inline-flex rounded-md border border-border/60 p-0.5"
|
||||
className="inline-flex rounded-md border border-border p-1"
|
||||
>
|
||||
{(['header', 'footer'] as const).map((pos) => {
|
||||
const active = (settings.invoice_company_name_position ?? 'header') === pos
|
||||
|
||||
@@ -105,7 +105,7 @@ export function SkatteverketConnectPanel() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardTitle className="text-base">{t('title')}</CardTitle>
|
||||
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -154,7 +154,7 @@ export function SkatteverketConnectPanel() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{t('title')}
|
||||
{status.expired ? (
|
||||
<Badge variant="destructive">{t('expired')}</Badge>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -219,53 +218,47 @@ export function AccountSettingsContent() {
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<section className="border-t border-border pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tCommon('account_settings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{tCommon('logout')}</p>
|
||||
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tCommon('account_settings')}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{tCommon('logout')}</p>
|
||||
<p className="text-sm text-muted-foreground">{tCommon('logout_description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{tCommon('logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
|
||||
<section className="border-t border-border pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tSettings('legal_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_privacy')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dpa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_dpa')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{tSettings('legal_title')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_privacy')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/dpa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="font-medium">{tSettings('legal_dpa')}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Delete account: only for non-sandbox */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
@@ -75,7 +76,7 @@ export function BankingSettingsContent() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
@@ -123,18 +124,19 @@ export function BankingSettingsContent() {
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<p className="font-medium mb-1">{t('not_enabled_title')}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
{t('not_enabled_description')}
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
@@ -74,8 +73,6 @@ export function SalarySettingsContent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('title')} />
|
||||
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Payment */}
|
||||
<section className="space-y-4">
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function TransactionBookingDialog({
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
+9
-13
@@ -201,7 +201,7 @@
|
||||
"settings": {
|
||||
"section_name": "Name",
|
||||
"name_label": "Your name",
|
||||
"name_description": "The name we use to address you and that appears as the contact person on some documents. We greet you by your first name, so put the name you go by first.",
|
||||
"name_description": "Used when we address you and shown as the contact person on some documents.",
|
||||
"name_placeholder": "First name Last name",
|
||||
"name_saved": "Name updated",
|
||||
"name_save_failed": "Could not save name",
|
||||
@@ -837,7 +837,7 @@
|
||||
"bank_title": "Connect your bank account",
|
||||
"bank_description_psd2": "Connect via PSD2: transactions sync automatically every day.",
|
||||
"bank_description_file": "Import bank statements from your bank: CSV, OFX and most Swedish banks.",
|
||||
"step3_title": "Anslut Skatteverket",
|
||||
"step3_title": "Connect Skatteverket",
|
||||
"optional_suffix": ": optional",
|
||||
"skatteverket_connected_title": "Skatteverket connected",
|
||||
"skatteverket_connected_description": "You can now submit momsdeklaration and AGI directly, and see the balance on your skattekonto.",
|
||||
@@ -1233,7 +1233,7 @@
|
||||
"vacation_supplement_cba": "0.80% (common collective agreement level)",
|
||||
"vacation_supplement_help": "Applied with sammalöneregeln. Can be overridden per employee.",
|
||||
"info_heading": "Information",
|
||||
"info_payroll_scope": "The payroll module handles salaries for your employees: tax deductions, employer contributions, vacation pay liability and the employer declaration (AGI). If you run a sole proprietorship (enskild firma), you as the owner take money out via owner's drawings (account 2013), not salary. The module is for your employees.",
|
||||
"info_payroll_scope": "The payroll module handles salaries for your employees: tax deductions, employer contributions, vacation pay liability and the employer declaration (AGI). If you run a sole proprietorship (enskild firma), you as the owner take money out via owner's drawings (account 2013), not salary.",
|
||||
"info_current_year": "<strong>Current year:</strong> 2026 (Arbetsgivaravgifter 31.42%, prisbasbelopp 59,200 SEK)"
|
||||
},
|
||||
"salary_payments": {
|
||||
@@ -1253,7 +1253,7 @@
|
||||
"instructions_toggle": "How to import the file in your bank",
|
||||
"instructions_footer": "The amounts are pre-filled — all that remains is signing with BankID in your bank.",
|
||||
"your_bank": "your bank",
|
||||
"open_payments_note": "Open Payments / direct payment via PSD2 (without file import) is planned for the future via Enable Banking. Initiation requires a separate PIS agreement — we will follow up once enough customers use salary payment files regularly.",
|
||||
"open_payments_note": "Automatic direct payment via open banking (without file import) is planned.",
|
||||
"steps_bg_lb_swedbank": "Företagsbetalningar → Importera fil → choose LB format → upload and sign with BankID. Note: Swedbank stops accepting LB salary files after 1 August 2026.",
|
||||
"steps_bg_lb_seb": "Betalningar → Filöverföring → choose Bankgiro LB → upload and approve.",
|
||||
"steps_bg_lb_handelsbanken": "Betala → Filimport → choose LB → verify totals → sign. Note: Handelsbanken no longer accepts salary files via Bankgirot.",
|
||||
@@ -1283,7 +1283,7 @@
|
||||
},
|
||||
"settings_backup": {
|
||||
"heading": "Backup",
|
||||
"intro": "Download your own copy of all accounting data (SIE files, receipts, supporting documents and processing history) in a single ZIP file. The backup is your own copy for safety and portability. {appName} archives all accounting data for at least 7 years per BFL 7 kap. 2 §, so your backup does not replace our legal obligation; it complements it."
|
||||
"intro": "Download your own copy of all accounting data (SIE files, receipts, supporting documents and processing history) in a single ZIP file. {appName} archives all accounting data for at least 7 years per BFL 7 kap. 2 §, so your backup does not replace our legal obligation; it complements it."
|
||||
},
|
||||
"settings_api": {},
|
||||
"settings_banking": {
|
||||
@@ -2316,7 +2316,6 @@
|
||||
"bank_missing_warning": "Payment details missing: you need to add them before creating an invoice.",
|
||||
"bank_add_now": "Add now",
|
||||
"customer_card_title": "Customer",
|
||||
"customer_card_description": "Select which customer the invoice should be sent to",
|
||||
"select_customer_placeholder": "Select customer",
|
||||
"create_customer": "Create customer",
|
||||
"create_customer_dialog_title": "Add customer",
|
||||
@@ -2343,7 +2342,6 @@
|
||||
"row_actions_aria": "Row actions",
|
||||
"drag_handle_aria": "Drag to move the row",
|
||||
"notes_card_title": "Notes",
|
||||
"notes_card_description": "Optional message on the invoice",
|
||||
"notes_placeholder": "E.g. payment terms or thanks for the collaboration...",
|
||||
"details_card_title": "Invoice details",
|
||||
"document_type_label": "Document type",
|
||||
@@ -2572,7 +2570,6 @@
|
||||
"reminder_marked_paid": "Customer marked as paid",
|
||||
"reminder_objection": "Customer has objections",
|
||||
"credited_card_title": "Credited",
|
||||
"credited_description": "This invoice has been credited",
|
||||
"see_credit_note": "See credit note {number}",
|
||||
"credit_note_card_title": "Credit note",
|
||||
"credit_note_description": "This credit note credits",
|
||||
@@ -2642,7 +2639,6 @@
|
||||
"warning_title": "Irreversible action",
|
||||
"warning_description": "A credit note fully cancels the original invoice. All amounts become negative, a bookkeeping verifikat is created, and the original invoice is marked as credited. This action cannot be undone.",
|
||||
"original_card_title": "Original invoice",
|
||||
"original_card_description": "The credit note is based on this invoice",
|
||||
"invoice_number_label": "Invoice number:",
|
||||
"date_label": "Date:",
|
||||
"customer_label": "Customer:",
|
||||
@@ -2659,7 +2655,7 @@
|
||||
"total": "Total",
|
||||
"in_sek": "In SEK (rate {rate})",
|
||||
"reason_card_title": "Reason",
|
||||
"reason_card_description": "State the reason for crediting (shown on the credit note)",
|
||||
"reason_card_description": "Shown on the credit note",
|
||||
"reason_label": "Reason",
|
||||
"reason_placeholder": "E.g. Incorrect invoicing, returned goods...",
|
||||
"reason_default": "Credits invoice {number}",
|
||||
@@ -3595,7 +3591,7 @@
|
||||
"no_search_results_description": "No value matches “{term}”.",
|
||||
"viewer_disabled_tooltip": "You have read-only access and cannot create values",
|
||||
"created_title": "Value created",
|
||||
"created_description": "{code} has been added to the register.",
|
||||
"created_description": "{code} has been added to the register",
|
||||
"updated_title": "Value updated",
|
||||
"deleted_title": "Value deleted",
|
||||
"save_failed_title": "Could not save",
|
||||
@@ -3746,7 +3742,7 @@
|
||||
"ext_mcp_server_long_description": "Exposes accounted's bookkeeping engine as MCP tools (Model Context Protocol). Connect your MCP client with an API key and do bookkeeping through conversation: list uncategorized transactions, book them, create invoices.",
|
||||
"ext_cloud_backup_name": "Cloud sync",
|
||||
"ext_cloud_backup_description": "Sync backups to your own cloud storage",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive. Perfect for meeting your own redundancy requirements.",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive.",
|
||||
"ext_skatteverket_name": "Skatteverket integration",
|
||||
"ext_skatteverket_description": "Submit the VAT declaration directly to Skatteverket via BankID.",
|
||||
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from accounted. Save drafts, validate, lock and sign: without leaving the app.",
|
||||
@@ -4280,7 +4276,7 @@
|
||||
"viewer_disabled_tooltip": "You only have viewer access in this company",
|
||||
"load_customers_failed": "Could not load customers",
|
||||
"created_title": "Self-billing invoice registered",
|
||||
"created_description": "Self-billing invoice {number} has been booked as a sale.",
|
||||
"created_description": "Self-billing invoice {number} has been booked as a sale",
|
||||
"create_failed_title": "Could not register the self-billing invoice",
|
||||
"validation_customer_required": "Select a customer",
|
||||
"validation_external_number_required": "Invoice number is required",
|
||||
|
||||
+10
-14
@@ -7,7 +7,7 @@
|
||||
"cancel": "Avbryt",
|
||||
"create": "Skapa",
|
||||
"add": "Lägg till",
|
||||
"delete": "Radera",
|
||||
"delete": "Ta bort",
|
||||
"edit": "Redigera",
|
||||
"back": "Tillbaka",
|
||||
"next": "Nästa",
|
||||
@@ -201,7 +201,7 @@
|
||||
"settings": {
|
||||
"section_name": "Namn",
|
||||
"name_label": "Ditt namn",
|
||||
"name_description": "Namnet vi använder för att tilltala dig och som visas som kontaktperson på vissa underlag. Vi hälsar med ditt första namn, så skriv det du vill bli kallad först.",
|
||||
"name_description": "Används när vi tilltalar dig och visas som kontaktperson på vissa underlag.",
|
||||
"name_placeholder": "Förnamn Efternamn",
|
||||
"name_saved": "Namnet har uppdaterats",
|
||||
"name_save_failed": "Kunde inte spara namnet",
|
||||
@@ -1233,7 +1233,7 @@
|
||||
"vacation_supplement_cba": "0,80% (vanligt kollektivavtalsbelopp)",
|
||||
"vacation_supplement_help": "Tillämpas vid sammalöneregeln. Kan ändras per anställd.",
|
||||
"info_heading": "Information",
|
||||
"info_payroll_scope": "Lönemodulen hanterar löner till dina anställda: skatteavdrag, arbetsgivaravgifter, semesterlöneskuld och arbetsgivardeklaration (AGI). Driver du enskild firma tar du som ägare ut pengar via eget uttag (konto 2013), inte lön. Modulen är till för dina anställda.",
|
||||
"info_payroll_scope": "Lönemodulen hanterar löner till dina anställda: skatteavdrag, arbetsgivaravgifter, semesterlöneskuld och arbetsgivardeklaration (AGI). Driver du enskild firma tar du som ägare ut pengar via eget uttag (konto 2013), inte lön.",
|
||||
"info_current_year": "<strong>Aktuellt år:</strong> 2026 (Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK)"
|
||||
},
|
||||
"salary_payments": {
|
||||
@@ -1253,7 +1253,7 @@
|
||||
"instructions_toggle": "Så importerar du filen i din bank",
|
||||
"instructions_footer": "Beloppen är förifyllda — kvar är att signera med BankID i banken.",
|
||||
"your_bank": "din bank",
|
||||
"open_payments_note": "Open Payments / direktbetalning via PSD2 (utan filimport) är planerat för framtiden via Enable Banking. Initiering kräver separat PIS-avtal — vi följer upp när tillräckligt många kunder använder lönebetalfiler regelbundet.",
|
||||
"open_payments_note": "Automatisk direktbetalning via open banking (utan filimport) är planerad.",
|
||||
"steps_bg_lb_swedbank": "Företagsbetalningar → Importera fil → välj LB-format → ladda upp och signera med BankID. OBS: Swedbank tar inte emot LB-lönefiler efter 1 augusti 2026.",
|
||||
"steps_bg_lb_seb": "Betalningar → Filöverföring → välj Bankgiro LB → ladda upp och attestera.",
|
||||
"steps_bg_lb_handelsbanken": "Betala → Filimport → välj LB → kontrollera summor → signera. OBS: Handelsbanken tar inte längre emot lönefiler via Bankgirot.",
|
||||
@@ -1283,7 +1283,7 @@
|
||||
},
|
||||
"settings_backup": {
|
||||
"heading": "Säkerhetsbackup",
|
||||
"intro": "Ladda ner en egen kopia av all räkenskapsinformation (SIE-filer, kvitton, underlag och behandlingshistorik) i en enda ZIP-fil. Säkerhetsbackupen är din egen kopia för trygghet och portabilitet. {appName} arkiverar all räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter inte vårt lagkrav; den kompletterar det."
|
||||
"intro": "Ladda ner en egen kopia av all räkenskapsinformation (SIE-filer, kvitton, underlag och behandlingshistorik) i en enda ZIP-fil. {appName} arkiverar all räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter inte vårt lagkrav; den kompletterar det."
|
||||
},
|
||||
"settings_api": {},
|
||||
"settings_banking": {
|
||||
@@ -2316,7 +2316,6 @@
|
||||
"bank_missing_warning": "Betalningsuppgifter saknas: du behöver lägga till dem innan du skapar en faktura.",
|
||||
"bank_add_now": "Lägg till nu",
|
||||
"customer_card_title": "Kund",
|
||||
"customer_card_description": "Välj vilken kund fakturan ska skickas till",
|
||||
"select_customer_placeholder": "Välj kund",
|
||||
"create_customer": "Skapa kund",
|
||||
"create_customer_dialog_title": "Lägg till kund",
|
||||
@@ -2343,7 +2342,6 @@
|
||||
"row_actions_aria": "Radåtgärder",
|
||||
"drag_handle_aria": "Dra för att flytta raden",
|
||||
"notes_card_title": "Anteckningar",
|
||||
"notes_card_description": "Valfritt meddelande på fakturan",
|
||||
"notes_placeholder": "T.ex. betalningsvillkor eller tack för samarbetet...",
|
||||
"details_card_title": "Fakturadetaljer",
|
||||
"document_type_label": "Dokumenttyp",
|
||||
@@ -2572,7 +2570,6 @@
|
||||
"reminder_marked_paid": "Kunden markerat som betald",
|
||||
"reminder_objection": "Kunden har invändningar",
|
||||
"credited_card_title": "Krediterad",
|
||||
"credited_description": "Denna faktura har krediterats",
|
||||
"see_credit_note": "Se kreditfaktura {number}",
|
||||
"credit_note_card_title": "Kreditfaktura",
|
||||
"credit_note_description": "Denna kreditfaktura krediterar",
|
||||
@@ -2642,7 +2639,6 @@
|
||||
"warning_title": "Oåterkallelig åtgärd",
|
||||
"warning_description": "En kreditfaktura makulerar den ursprungliga fakturan helt. Alla belopp blir negativa, en bokföringsverifikation skapas, och den ursprungliga fakturan markeras som krediterad. Denna åtgärd kan inte ångras.",
|
||||
"original_card_title": "Ursprunglig faktura",
|
||||
"original_card_description": "Kreditfakturan baseras på denna faktura",
|
||||
"invoice_number_label": "Fakturanummer:",
|
||||
"date_label": "Datum:",
|
||||
"customer_label": "Kund:",
|
||||
@@ -2659,7 +2655,7 @@
|
||||
"total": "Totalt",
|
||||
"in_sek": "I SEK (kurs {rate})",
|
||||
"reason_card_title": "Anledning",
|
||||
"reason_card_description": "Ange anledning till kreditering (visas på kreditfakturan)",
|
||||
"reason_card_description": "Visas på kreditfakturan",
|
||||
"reason_label": "Anledning",
|
||||
"reason_placeholder": "T.ex. Felaktig fakturering, returnerade varor...",
|
||||
"reason_default": "Krediterar faktura {number}",
|
||||
@@ -2679,7 +2675,7 @@
|
||||
"created_toast_title": "Kreditfaktura skapad",
|
||||
"created_toast_description": "Kreditfaktura {number} har skapats",
|
||||
"create_failed_title": "Kunde inte skapa kreditfaktura",
|
||||
"create_failed_fallback": "Failed to create credit note",
|
||||
"create_failed_fallback": "Kunde inte skapa kreditfaktura",
|
||||
"try_again": "Försök igen."
|
||||
},
|
||||
"invoice_recurring": {
|
||||
@@ -3595,7 +3591,7 @@
|
||||
"no_search_results_description": "Inget värde matchar ”{term}”.",
|
||||
"viewer_disabled_tooltip": "Du har läsbehörighet och kan inte skapa värden",
|
||||
"created_title": "Värde skapat",
|
||||
"created_description": "{code} har lagts till i registret.",
|
||||
"created_description": "{code} har lagts till i registret",
|
||||
"updated_title": "Värde uppdaterat",
|
||||
"deleted_title": "Värde borttaget",
|
||||
"save_failed_title": "Kunde inte spara",
|
||||
@@ -3746,7 +3742,7 @@
|
||||
"ext_mcp_server_long_description": "Exponerar accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor.",
|
||||
"ext_cloud_backup_name": "Molnsynkronisering",
|
||||
"ext_cloud_backup_description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive.",
|
||||
"ext_skatteverket_name": "Skatteverket Integration",
|
||||
"ext_skatteverket_description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
||||
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från accounted. Spara utkast, validera, lås och signera: utan att lämna appen.",
|
||||
@@ -4280,7 +4276,7 @@
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"load_customers_failed": "Kunde inte ladda kunder",
|
||||
"created_title": "Självfaktura registrerad",
|
||||
"created_description": "Självfaktura {number} har bokförts som försäljning.",
|
||||
"created_description": "Självfaktura {number} har bokförts som försäljning",
|
||||
"create_failed_title": "Kunde inte registrera självfakturan",
|
||||
"validation_customer_required": "Välj en kund",
|
||||
"validation_external_number_required": "Fakturanummer krävs",
|
||||
|
||||
Reference in New Issue
Block a user