feat(ui): rest-of-nav 1 — Viktiga datum, Skattekonto, Periodiseringar, Import to the concept language (#1140)

* feat(ui): concept scenes 17/24/32/33 for Viktiga datum, Skattekonto, Periodiseringar and Import (rest-of-nav 1)

Viktiga datum: thread rows with type-icon circles behind a type seg
(Alla/Skatt/Fakturering/Egna), Narmast countdown pane, Ny deadline lifted
to the page header, both banners replaced by one AttnLine, mark-done via
ConfirmDialog. DeadlineCard/DeadlineFilters die; DeadlineRow is the row.

Skattekonto: card-less saldo hero with OCR + quiet copy, shortfall AttnLine
computed from the next drain date with a betalningsuppgifter dialog
(bankgiro 5050-1055 + OCR), one dry-table with Kommande/Forfallna/
Genomforda band rows, chips only on unbooked genomforda rows, quiet
hover actions. Tabs and per-row badge noise are gone; the concept's
Saldo column is dropped because SKV stores no per-row running balance.

Periodiseringar: banner becomes an AttnLine with inline Bokfor forfallna
(now confirm-first), house seg with Aktiva count, dry-table with muted
normal states and animated RowFoldout for installments, Los upp nu as a
quiet hover link through the shared ConfirmDialog.

Import: tabs collapse into one two-column row list (Importera | Exportera)
in the concept row language; SIE export moves into a small dialog and
Molnsynkronisering folds the CloudBackupCard open in place. The
/import?view=export#sie-export and /import#cloud-backup deep links keep
working. Sandbox notice is an AttnLine.

All four pages get stagger-enter, a help popover behind ? and sv+en keys
for every new string. 9189 tests green, lint clean, guards pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ui): align dashboard loading skeletons with the migrated page silhouettes

Folds in the parallel WIP from this checkout at the founder's request:
every loading.tsx under (dashboard) now mirrors its migrated page
row-for-row (24px title block, pill actions, borderless table heads,
single-line rows), and the shared (dashboard)/loading.tsx takes Hem's
greeting + Att gora silhouette. Also lands the pending DECISIONS.md
lines (onboarding swap plan note + rest-of-nav 1 deviations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(ui): authority logos, stat-tile skattekonto hero, quieter type on rest-of-nav 1

Viktiga datum: statutory deadlines wear the receiving authority's mark
(Skatteverket for tax dates, Bolagsverket for arsredovisning/arsstamma)
as a small badge on white; other deadlines keep the neutral type icons.
Row dates go muted, titles drop font-medium, the Narmast countdown
steps down to the house text-4xl display scale.

Skattekonto: the 32px serif hero becomes two compact metric tiles in
the KPIHeroCards idiom (saldo with the Skatteverket mark + OCR meta,
nasta dragning with date and event count), matching how numbers read
on the migrated pages.

Periodiseringar: chevron column dropped; rows expand on click exactly
like the verifikat list.

Import: provider logo chips return on Hamta fran annat system (live-
version parity) and Koppla bank carries the Enable Banking mark.

Adds skatteverket(_color), bolagsverket, enable-banking plus claude/
anthropic marks (for future use) under public/logos/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(ui): keep Importera and Exportera as separate tabs on the import page

Founder feedback: the merged two-column landing goes back to the
familiar split. The house seg switches between the Importera rows and
the Exportera rows (SIE 4 dialog + Molnsynkronisering fold), ?view=export
selects the export tab again and the hash deep links flip to it before
opening their surface. Row language and logo chips stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): readable Enable Banking mark and full-width skattekonto

The enable-banking.webp is the full stacked logo in white-on-transparent:
invisible on the light chip and mush at 16px. The chip now uses a cropped
368px icon square (enable-banking-icon.png) with the marketing site's
grayscale+brightness treatment in light mode and a white lift in dark.

Skattekonto loses its max-w-3xl cap so the table stretches the content
column exactly like Bokforing and Transaktioner; the saldo tiles take
KPI-card width (lg:grid-cols-4). Import's tab columns stretch too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): address review-bot findings on rest-of-nav 1

CodeRabbit triage, all three confirmed real: the Bokfor forfallna attn
action is hidden for read-only users instead of rendering a no-op link;
a failed deadline edit rethrows so the form stays open with the user's
input; authority marks are reserved for statutory (system-generated)
deadlines: a manual tax-category deadline keeps the neutral icon.

Compliance swarm's two high findings verified clean, no change needed:
/api/bookkeeping/accruals/:id/dissolve and /api/deadlines/:id (+ /complete)
all run through withRouteContext with company_id scoping and 404 on miss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 12:36:08 +02:00
committed by GitHub
parent d840257c0c
commit 2bec5acedb
29 changed files with 1943 additions and 1620 deletions
+5 -1
View File
@@ -348,4 +348,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
[2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands.
[2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time.
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows.
[2026-07-23] Onboarding swap plan (dev_docs/onboarding_migration_plan.md): journey UI replaces wizard as pure UI swap (server action/lookup/BankID contract untouched); JourneyOrb = own canvas component, NOT thinking-orbs (needs comet+check morphs, reverses earlier npm intent); /companies/new folded into same component mode='add'; rollout behind NEXT_PUBLIC_ONBOARDING_JOURNEY flag.
[2026-07-24] BankID company picker in journey onboarding: searchable list (name + form/role + orgnr, "finns redan" section, filter with single-match Enter) is the standard at ANY engagement count; chips variant cut (founder call on concept c82c9358). Picking a company still runs zero Lens lookups: CompanyRoles prefill only, F-skatt/moms/rakenskapsar asked as questions per the plan's lookupRan rule.
[2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry.
[2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists).
+18 -11
View File
@@ -3,21 +3,28 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function BookkeepingLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + single "Nytt verifikat" split button (pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-44" />
<div className="flex gap-2">
<Skeleton className="h-10 w-40" />
<Skeleton className="h-10 w-44" />
</div>
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-36 rounded-full" />
</div>
<div className="space-y-3">
{/* Borderless table (JournalEntryList): header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-56" />
<Skeleton className="h-3 w-32" />
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-48" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
@@ -6,23 +6,15 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { AlertCircle, CalendarClock, ChevronDown, Loader2 } from 'lucide-react'
import { CalendarClock } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, RowFoldout } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
@@ -40,13 +32,12 @@ type ScheduleWithInstallments = AccrualSchedule & {
type StatusFilter = 'active' | 'completed' | 'all'
const SCHEDULE_BADGE: Record<
AccrualScheduleStatus,
{ label: string; variant: 'secondary' | 'success' | 'outline' }
> = {
active: { label: 'Aktiv', variant: 'secondary' },
completed: { label: 'Avslutad', variant: 'success' },
cancelled: { label: 'Makulerad', variant: 'outline' },
// Chips mark exceptions (design.md): Aktiv/Avslutad are normal states and
// render as muted text; only Makulerad deviates.
const SCHEDULE_STATUS_TEXT: Record<AccrualScheduleStatus, string> = {
active: 'Aktiv',
completed: 'Avslutad',
cancelled: 'Makulerad',
}
function monthLabel(periodMonth: string): string {
@@ -70,11 +61,12 @@ export default function AccrualSchedulesPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
const [schedules, setSchedules] = useState<ScheduleWithInstallments[]>([])
const [dueCount, setDueCount] = useState(0)
const [activeCount, setActiveCount] = useState<number | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [isPosting, setIsPosting] = useState(false)
const [postConfirmOpen, setPostConfirmOpen] = useState(false)
const [dissolveTarget, setDissolveTarget] = useState<ScheduleWithInstallments | null>(null)
const [isDissolving, setIsDissolving] = useState(false)
const fetchSchedules = useCallback(async (filter: StatusFilter) => {
setIsLoading(true)
@@ -84,6 +76,7 @@ export default function AccrualSchedulesPage() {
if (!res.ok) throw new Error(getErrorMessage(json, { context: 'journal_entry' }))
setSchedules(json.data ?? [])
setDueCount(json.due_count ?? 0)
if (filter === 'active') setActiveCount((json.data ?? []).length)
} catch (error) {
toast({
title: 'Kunde inte ladda periodiseringar',
@@ -140,7 +133,6 @@ export default function AccrualSchedulesPage() {
async function handleDissolve() {
if (!dissolveTarget) return
setIsDissolving(true)
try {
const res = await fetch(`/api/bookkeeping/accruals/${dissolveTarget.id}/dissolve`, {
method: 'POST',
@@ -159,8 +151,6 @@ export default function AccrualSchedulesPage() {
description: getErrorMessage(error, { context: 'journal_entry' }),
variant: 'destructive',
})
} finally {
setIsDissolving(false)
}
}
@@ -175,61 +165,88 @@ export default function AccrualSchedulesPage() {
[schedules],
)
const dissolveRemaining = dissolveTarget
? roundOre(dissolveTarget.total_amount - sumPosted(dissolveTarget.installments))
: 0
return (
<div className="space-y-8">
<PageHeader title="Periodiseringar" />
<PageHeader
title="Periodiseringar"
help={
<HelpPopover>
<p>
Periodisera en fakturarad när du registrerar en leverantörsfaktura eller
skapar en kundfaktura, fördelas beloppet över månaderna här. Månadens
andel bokförs automatiskt den sista dagen i varje månad.
</p>
</HelpPopover>
}
/>
{(dueCount > 0 || blockedInstallments > 0) && (
<div
role="status"
className="flex flex-col gap-3 rounded-lg border border-border bg-secondary/40 p-4 sm:flex-row sm:items-center sm:justify-between"
{dueCount > 0 ? (
<AttnLine
action={
canWrite
? {
label: isPosting ? 'Bokför…' : 'Bokför förfallna',
onClick: () => {
if (!isPosting) setPostConfirmOpen(true)
},
}
: undefined
}
>
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div className="text-sm">
<p className="font-medium">
{dueCount > 0
? `${dueCount} ${dueCount === 1 ? 'månad väntar' : 'månader väntar'} på att bokföras`
: 'Periodiseringar med fel'}
</p>
<p className="text-muted-foreground">
{blockedInstallments > 0
? `${blockedInstallments} ${blockedInstallments === 1 ? 'månad kunde' : 'månader kunde'} inte bokföras automatiskt: öppna raden för felmeddelandet.`
: 'Förfallna månader bokförs automatiskt varje natt, eller direkt här.'}
</p>
</div>
</div>
{canWrite && dueCount > 0 && (
<Button onClick={handlePostDue} disabled={isPosting} className="shrink-0">
{isPosting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Bokför
</>
) : (
'Bokför förfallna'
)}
</Button>
)}
</div>
)}
{dueCount === 1
? '1 månad väntar på att bokföras.'
: `${dueCount} månader väntar på att bokföras.`}{' '}
Förfallna månader bokförs annars automatiskt varje natt.
</AttnLine>
) : blockedInstallments > 0 ? (
<AttnLine>
{blockedInstallments === 1
? '1 månad kunde inte bokföras automatiskt: öppna raden för felmeddelandet.'
: `${blockedInstallments} månader kunde inte bokföras automatiskt: öppna raden för felmeddelandet.`}
</AttnLine>
) : null}
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="active">Aktiva</TabsTrigger>
<TabsTrigger value="completed">Avslutade</TabsTrigger>
<TabsTrigger value="all">Alla</TabsTrigger>
</TabsList>
</Tabs>
{/* Toolbar: status seg (concept scene 33) */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'active', label: 'Aktiva', count: activeCount },
{ key: 'completed', label: 'Avslutade', count: null },
{ key: 'all', label: 'Alla', count: null },
] as const
).map(({ key, label, count }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={statusFilter === key}
onClick={() => setStatusFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
statusFilter === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
{count !== null && count > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{count}
</span>
)}
</button>
))}
</div>
{isLoading ? (
<Card>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-2/3" />
</CardContent>
</Card>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : schedules.length === 0 ? (
<EmptyState
icon={CalendarClock}
@@ -237,22 +254,21 @@ export default function AccrualSchedulesPage() {
description="Periodisera en fakturarad när du registrerar en leverantörsfaktura eller skapar en kundfaktura, så fördelas beloppet automatiskt över månaderna här."
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Beskrivning</TableHead>
<TableHead>Konto</TableHead>
<TableHead>Period</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kvar</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
<>
<div className="overflow-x-auto" role="region" aria-label="Periodiseringar">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>Beskrivning</th>
<th className={TH_CLASS}>Konto</th>
<th className={TH_CLASS}>Period</th>
<th className={cn(TH_CLASS, 'text-right')}>Totalt</th>
<th className={cn(TH_CLASS, 'text-right')}>Kvar</th>
<th className={TH_CLASS}>Status</th>
<th className={cn(TH_CLASS, 'w-28')} />
</tr>
</thead>
<tbody className="stagger-enter">
{schedules.map((schedule) => {
const dissolved = sumPosted(schedule.installments)
const remaining =
@@ -260,7 +276,6 @@ export default function AccrualSchedulesPage() {
? 0
: roundOre(schedule.total_amount - dissolved)
const isOpen = expanded.has(schedule.id)
const badge = SCHEDULE_BADGE[schedule.status]
const sourceHref = schedule.supplier_invoice_id
? `/supplier-invoices/${schedule.supplier_invoice_id}`
: schedule.invoice_id
@@ -268,20 +283,11 @@ export default function AccrualSchedulesPage() {
: null
return (
<Fragment key={schedule.id}>
<TableRow
className="cursor-pointer"
<tr
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => toggleExpanded(schedule.id)}
>
<TableCell className="pr-0">
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-150',
isOpen && 'rotate-180',
)}
aria-hidden="true"
/>
</TableCell>
<TableCell className="max-w-[320px]">
<td className={cn(TD_CLASS, 'max-w-[320px]')}>
<span className="block truncate" title={schedule.description ?? ''}>
{schedule.description || '-'}
</span>
@@ -294,118 +300,148 @@ export default function AccrualSchedulesPage() {
{schedule.supplier_invoice_id ? 'Leverantörsfaktura' : 'Kundfaktura'}
</Link>
)}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{schedule.balance_account} {schedule.target_account}
</TableCell>
<TableCell className="tabular-nums">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(schedule.period_start)} till {formatDate(schedule.period_end)}
</TableCell>
<TableCell className="text-right tabular-nums">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(schedule.total_amount)}
</TableCell>
<TableCell className="text-right tabular-nums">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(remaining)}
</TableCell>
<TableCell>
<Badge variant={badge.variant}>{badge.label}</Badge>
</TableCell>
<TableCell className="text-right">
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{schedule.status === 'cancelled' ? (
<Badge variant="outline" className="font-normal">
{SCHEDULE_STATUS_TEXT.cancelled}
</Badge>
) : (
<span className="text-xs text-muted-foreground">
{SCHEDULE_STATUS_TEXT[schedule.status]}
</span>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{canWrite && schedule.status === 'active' && remaining > 0 && (
<Button
variant="outline"
size="sm"
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDissolveTarget(schedule)
}}
className={cn(
QUIET_LINK_CLASS,
'opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100',
)}
>
Lös upp nu
</Button>
</button>
)}
</TableCell>
</TableRow>
</td>
</tr>
{isOpen && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={8} className="bg-muted/30 p-0">
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<th className="pb-2">Månad</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2 pl-6">Status</th>
<th className="pb-2 pl-6">Verifikat</th>
</tr>
</thead>
<tbody>
{schedule.installments.map((installment) => (
<tr key={installment.id} className="border-t border-border/60">
<td className="py-1.5 tabular-nums">
{monthLabel(installment.period_month)}
</td>
<td className="py-1.5 text-right tabular-nums">
{formatCurrency(installment.amount)}
</td>
<td className="py-1.5 pl-6">
{installment.status === 'posted' ? (
<Badge variant="success">Bokförd</Badge>
) : installment.status === 'cancelled' ? (
<Badge variant="outline">Makulerad</Badge>
) : installment.last_error ? (
<span className="inline-flex items-center gap-1.5">
<Badge variant="destructive">Fel</Badge>
<span className="text-xs text-muted-foreground">
{installment.last_error}
</span>
</span>
) : (
<Badge variant="outline">Väntar</Badge>
)}
</td>
<td className="py-1.5 pl-6">
{installment.journal_entry_id ? (
<Link
href={`/bookkeeping/${installment.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
>
Öppna verifikat
</Link>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<tr className="hover:bg-transparent">
<td colSpan={7} className="border-b border-border bg-muted/30 p-0">
<RowFoldout>
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<th className="pb-2">Månad</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2 pl-6">Status</th>
<th className="pb-2 pl-6">Verifikat</th>
</tr>
))}
</tbody>
</table>
</div>
</TableCell>
</TableRow>
</thead>
<tbody>
{schedule.installments.map((installment) => (
<tr key={installment.id} className="border-t border-border/60">
<td className="py-1.5 tabular-nums">
{monthLabel(installment.period_month)}
</td>
<td className="py-1.5 text-right tabular-nums">
{formatCurrency(installment.amount)}
</td>
<td className="py-1.5 pl-6">
{installment.status === 'posted' ? (
<span className="text-xs text-muted-foreground">Bokförd</span>
) : installment.status === 'cancelled' ? (
<Badge variant="outline" className="font-normal">Makulerad</Badge>
) : installment.last_error ? (
<span className="inline-flex items-center gap-1.5">
<Badge variant="destructive" className="font-normal">Fel</Badge>
<span className="text-xs text-muted-foreground">
{installment.last_error}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground">Väntar</span>
)}
</td>
<td className="py-1.5 pl-6">
{installment.journal_entry_id ? (
<Link
href={`/bookkeeping/${installment.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
>
Öppna verifikat
</Link>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</RowFoldout>
</td>
</tr>
)}
</Fragment>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</tbody>
</table>
</div>
<p className="px-1 text-xs leading-5 text-muted-foreground">
Månadens andel bokförs automatiskt den sista dagen i varje månad. Öppna en rad
för att se varje månads verifikat.
</p>
</>
)}
{dissolveTarget && (
<ConfirmationDialog
open={!!dissolveTarget}
onOpenChange={(open) => !open && setDissolveTarget(null)}
onConfirm={handleDissolve}
isSubmitting={isDissolving}
title="Lös upp periodiseringen nu?"
warningText={`Återstående ${formatCurrency(
Math.round(
(dissolveTarget.total_amount - sumPosted(dissolveTarget.installments)) * 100,
) / 100,
)} bokförs i ett verifikat daterat idag, och periodiseringen avslutas.`}
confirmLabel="Lös upp nu"
>
<ConfirmDialog
open={postConfirmOpen}
onOpenChange={setPostConfirmOpen}
title="Bokför förfallna månader?"
description={
dueCount === 1
? 'Ett verifikat bokförs för den väntande månaden.'
: `${dueCount} verifikat bokförs, ett per väntande månad.`
}
confirmLabel="Bokför"
onConfirm={handlePostDue}
/>
<ConfirmDialog
open={dissolveTarget !== null}
onOpenChange={(open) => !open && setDissolveTarget(null)}
title="Lös upp periodiseringen nu?"
description={
dissolveTarget
? `Återstående ${formatCurrency(dissolveRemaining)} bokförs i ett verifikat daterat idag, och periodiseringen avslutas.`
: undefined
}
confirmLabel="Lös upp nu"
onConfirm={handleDissolve}
>
{dissolveTarget && (
<div className="space-y-1 text-sm">
<p className="font-medium">{dissolveTarget.description || 'Periodisering'}</p>
<p className="tabular-nums text-muted-foreground">
@@ -413,8 +449,8 @@ export default function AccrualSchedulesPage() {
{formatDate(dissolveTarget.period_start)} till {formatDate(dissolveTarget.period_end)}
</p>
</div>
</ConfirmationDialog>
)}
)}
</ConfirmDialog>
</div>
)
}
+14 -6
View File
@@ -3,6 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function CustomersLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + export menu + "Ny kund" action (pills) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-32" />
<div className="flex gap-2">
@@ -10,18 +11,25 @@ export default function CustomersLoading() {
<Skeleton className="h-9 w-28 rounded-full" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-lg border border-border">
{/* Search toolbar */}
<Skeleton className="h-9 w-full" />
{/* Borderless table: header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-40" />
<div className="flex items-center gap-6">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
+102 -64
View File
@@ -8,11 +8,15 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertTriangle, ArrowRight, CalendarClock, Loader2 } from 'lucide-react'
import { Lock, Plus } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -23,11 +27,14 @@ export default function DeadlinesPage() {
const { company } = useCompany()
const companyId = company?.id
const t = useTranslations('deadlines')
const { canWrite } = useCanWrite()
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
const [isLoading, setIsLoading] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const [showForm, setShowForm] = useState(false)
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
const { toast } = useToast()
const fetchData = useCallback(async () => {
@@ -240,6 +247,9 @@ export default function DeadlinesPage() {
description: error instanceof Error ? getUserErrorMessage(error) : t('retry'),
variant: 'destructive',
})
// Rethrow (like create) so a failed edit keeps the form open with the
// user's input instead of silently discarding it.
throw error
}
}
@@ -265,24 +275,60 @@ export default function DeadlinesPage() {
}
}
const handleFormSubmit = async (
data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>,
) => {
if (editingDeadline) {
await handleDeadlineEdit({ ...editingDeadline, ...data })
} else {
await handleDeadlineCreate(data)
}
setShowForm(false)
setEditingDeadline(null)
}
const openEdit = (deadline: Deadline) => {
setEditingDeadline(deadline)
setShowForm(true)
}
const pageHeader = (
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
<p className="mt-2">
<Link href="/settings/tax" className="underline underline-offset-2">
{t('generate_open_settings')}
</Link>
</p>
</HelpPopover>
}
action={
<Button
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('new_deadline')}
</Button>
}
/>
)
if (isLoading) {
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
{pageHeader}
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-lg border p-4">
<div className="flex items-center gap-4">
<div className="w-12 space-y-1">
<Skeleton className="h-5 w-8 mx-auto" />
<Skeleton className="h-3 w-6 mx-auto" />
</div>
<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>
<Skeleton className="h-4 w-16" />
<div key={i} className="flex items-center gap-3 py-3">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
@@ -294,65 +340,57 @@ export default function DeadlinesPage() {
// Statutory deadlines (moms, arbetsgivardeklaration, F-skatt) are generated
// from the company's tax settings — none present usually means those
// settings were never filled in, so point there instead of letting the page
// read as an empty manual todo list.
// read as an empty manual todo list. One attn line per page: this setup
// nudge outranks the overdue-invoices note because it unlocks the page.
const hasSystemDeadlines = deadlines.some((d) => d.source === 'system')
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
{pageHeader}
{!hasSystemDeadlines && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 rounded-lg border px-4 py-3">
<div className="flex items-center gap-3">
<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">
{t('no_system_deadlines_description')}
</span>
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button size="sm" onClick={handleGenerateSystemDeadlines} disabled={isGenerating}>
{isGenerating && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{t('generate_action')}
</Button>
<Link
href="/settings/tax"
className="group inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('generate_open_settings')}
<ArrowRight className="h-4 w-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
</div>
)}
{/* Overdue invoices alert */}
{overdueInvoices.count > 0 && (
<Link href="/invoices?status=unpaid" className="group block">
<div className="flex items-center justify-between rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 transition-colors hover:bg-destructive/10">
<div className="flex items-center gap-3">
<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-2 tabular-nums">
{formatCurrency(overdueInvoices.total)}
</span>
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
</div>
</Link>
)}
{!hasSystemDeadlines ? (
<AttnLine
action={{
label: isGenerating ? t('generating') : t('generate_action'),
onClick: () => {
if (!isGenerating) void handleGenerateSystemDeadlines()
},
}}
>
{t('no_system_deadlines_title')} {t('no_system_deadlines_description')}
</AttnLine>
) : overdueInvoices.count > 0 ? (
<AttnLine
action={{ label: t('overdue_invoices_action'), href: '/invoices?status=unpaid' }}
>
{t('overdue_invoices', { count: overdueInvoices.count })} ·{' '}
{formatCurrency(overdueInvoices.total)}.
</AttnLine>
) : null}
<DeadlineList
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
onDeadlineEdit={handleDeadlineEdit}
onDeadlineDelete={handleDeadlineDelete}
onDeadlineEdit={openEdit}
/>
<DeadlineForm
open={showForm}
onOpenChange={(open) => {
if (!open) {
setShowForm(false)
setEditingDeadline(null)
}
}}
onSubmit={handleFormSubmit}
onDelete={(deadline) => {
if (deadline.id) {
const full = deadlines.find((d) => d.id === deadline.id)
if (full) void handleDeadlineDelete(full)
}
}}
initialData={editingDeadline || undefined}
customers={customers}
/>
</div>
)
+256 -268
View File
@@ -2,16 +2,25 @@
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
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 { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
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'
import { ArrowLeft, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
@@ -1944,6 +1953,8 @@ export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [view, setView] = useState<'import' | 'export'>('import')
const [sieDialogOpen, setSieDialogOpen] = useState(false)
const [cloudOpen, setCloudOpen] = useState(false)
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
const [exportPeriodId, setExportPeriodId] = useState<string | null>(null)
@@ -1994,20 +2005,24 @@ export default function ImportPage() {
}
}, [isSandbox, searchParams])
// Hash-based deep links (#cloud-backup, #sie-export) → switch to export tab and scroll
// Hash-based deep links: both live on the export tab; #sie-export opens
// the SIE dialog, #cloud-backup expands the cloud panel and scrolls to it.
useEffect(() => {
if (typeof window === 'undefined') return
const hash = window.location.hash
if (hash === '#cloud-backup' || hash === '#sie-export') {
if (hash === '#sie-export') {
setView('export')
setSieDialogOpen(true)
} else if (hash === '#cloud-backup') {
setView('export')
setCloudOpen(true)
setTimeout(() => {
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 50)
}, 80)
}
}, [])
const handleViewChange = (next: string) => {
if (next !== 'import' && next !== 'export') return
const handleViewChange = (next: 'import' | 'export') => {
setView(next)
const params = new URLSearchParams(searchParams.toString())
if (next === 'export') params.set('view', 'export')
@@ -2022,276 +2037,171 @@ export default function ImportPage() {
return (
<div className="space-y-8">
<PageHeader
title={view === 'export' ? t('export_title') : t('title')}
description={view === 'export' ? t('export_subtitle') : undefined}
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
/>
{mode === null && (
<>
{isSandbox && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
{t('sandbox_disabled')}
</p>
{isSandbox && <AttnLine>{t('sandbox_disabled')}</AttnLine>}
{/* Importera / Exportera as separate tabs (house seg), like before */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'import', label: t('tab_import') },
{ key: 'export', label: t('tab_export') },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => handleViewChange(key)}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
view === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{view === 'import' ? (
<div>
<div className="stagger-enter">
{hasBankingExtension && (
<ImportRow
title={t('psd2_title')}
sub={t('psd2_description')}
chip={
hasBankSync ? (
<Badge variant="success" className="font-normal">{t('psd2_recommended')}</Badge>
) : (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
{t('psd2_requires_subscription')}
</span>
)
}
chips={<LogoChip src="/logos/enable-banking-icon.png" name="Enable Banking" mono />}
disabled={isSandbox}
onClick={() => setMode('psd2')}
/>
)}
{hasMigrationExtension && (
<ImportRow
title={t('migration_title')}
sub={t('migration_description')}
chips={
<>
<LogoChip src="/logos/fortnox.svg" name="Fortnox" />
<LogoChip src="/logos/visma.jpeg" name="Visma" />
<LogoChip src="/logos/bokio.png" name="Bokio" />
<LogoChip src="/logos/bjornlunden.png" name="Björn Lundén" />
<LogoChip src="/logos/Briox_logo.png" name="Briox" />
</>
}
disabled={isSandbox}
onClick={() => setMode('migration')}
/>
)}
<ImportRow
title={t('bankfile_title')}
sub={t('bankfile_description')}
onClick={() => setMode('bank')}
/>
<ImportRow
title={t('csv_data_title')}
sub={t('csv_data_description')}
onClick={() => setMode('csv_data')}
/>
<ImportRow
title={t('sie_title')}
sub={t('sie_description')}
onClick={() => setMode('sie')}
/>
</div>
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">{t('pgnote')}</p>
</div>
) : (
<div>
<div className="stagger-enter">
<ImportRow
id="sie-export"
title={t('export_sie_title')}
sub={t('export_sie_description')}
onClick={() => setSieDialogOpen(true)}
/>
{hasCloudBackup && (
<ImportRow
title={t('cloud_row_title')}
sub={t('cloud_row_description')}
expanded={cloudOpen}
onClick={() => setCloudOpen((v) => !v)}
/>
)}
</div>
{hasCloudBackup && cloudOpen && (
<div id="cloud-backup" className="mt-6 scroll-mt-24">
<CloudBackupCard />
</div>
)}
</div>
)}
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="import">{t('tab_import')}</TabsTrigger>
<TabsTrigger value="export">{t('tab_export')}</TabsTrigger>
</TabsList>
<TabsContent value="import" className="mt-6">
<div className="space-y-2">
{/* 1. Koppla bank */}
{hasBankingExtension && (
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'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'
)}
onClick={() => { if (!isSandbox) setMode('psd2') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('psd2') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Landmark className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
{hasBankSync ? (
<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 leading-relaxed max-w-lg">
{t('psd2_description')}
</p>
</div>
<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>
)}
{/* 2. Hämta från annat system */}
{hasMigrationExtension === true && (
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group rounded-lg border bg-card p-6 transition-colors',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15'
)}
onClick={() => { if (!isSandbox) setMode('migration') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('migration') } }}
>
<div className="flex items-start gap-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<ArrowRightLeft className="h-[18px] w-[18px] text-foreground/60" />
</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 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 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<div className="flex flex-wrap gap-2 mt-4 ml-[52px]">
{([
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
{ name: 'Visma', logo: '/logos/visma.jpeg' },
{ name: 'Bokio', logo: '/logos/bokio.png' },
{ 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-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>
))}
</div>
</div>
)}
{/* 3. Banktransaktioner: manual file imports (bank file, CSV/Excel,
SIE) run entirely on uploaded data with no external service, so
they stay available in the sandbox, unlike the API-backed options
above (bank connection, provider migration) which need live
credentials. */}
<div
role="button"
tabIndex={0}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
'cursor-pointer hover:border-foreground/15'
)}
onClick={() => setMode('bank')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<ArrowLeftRight className="h-[18px] w-[18px] text-foreground/60" />
</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 leading-relaxed max-w-lg">
{t('bankfile_description')}
</p>
<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}
</span>
))}
</div>
</div>
<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) */}
<div
role="button"
tabIndex={0}
className={cn(
'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')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('csv_data') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</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 leading-relaxed max-w-lg">
{t('csv_data_description')}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{[
{ key: 'XLSX', label: 'XLSX' },
{ key: 'CSV', label: 'CSV' },
{ key: 'opening_balances', label: t('csv_chip_opening_balances') },
{ key: 'customers', label: t('csv_chip_customers') },
{ key: 'suppliers', label: t('csv_chip_suppliers') },
{ key: 'articles', label: t('csv_chip_articles') },
].map(chip => (
<span key={chip.key} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{chip.label}
</span>
))}
</div>
</div>
<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) */}
<div
role="button"
tabIndex={0}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
'cursor-pointer hover:border-foreground/15'
)}
onClick={() => setMode('sie')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileText className="h-[18px] w-[18px] text-foreground/60" />
</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 leading-relaxed max-w-lg">
{t('sie_description')}
</p>
<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}
</span>
))}
</div>
</div>
<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>
<TabsContent value="export" className="mt-6">
{/* SIE export as a small centered dialog (concept overlay convention) */}
<Dialog open={sieDialogOpen} onOpenChange={setSieDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{t('export_sie_title')}
</DialogTitle>
<DialogDescription className="text-[13px] leading-relaxed">
{t('export_sie_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* SIE-export */}
<div id="sie-export" className="scroll-mt-24 rounded-lg border border-border bg-card p-6">
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Identity */}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('export_sie_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('export_sie_description')}
</p>
</div>
</div>
{/* Controls */}
<div className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<label className="flex items-start gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
checked={exportExcludeClosing}
onChange={(e) => setExportExcludeClosing(e.target.checked)}
/>
<span>{t('export_sie_exclude_closing_label')}</span>
</label>
<Button
onClick={() => {
if (exportPeriodId) {
const params = new URLSearchParams({ period_id: exportPeriodId })
if (exportExcludeClosing) params.set('exclude_closing', 'true')
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
}
}}
disabled={!exportPeriodId}
className="w-full sm:w-auto"
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
</div>
</div>
</div>
{/* Molnsynkronisering (Google Drive) */}
{hasCloudBackup && (
<div id="cloud-backup" className="scroll-mt-24">
<CloudBackupCard />
</div>
)}
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<label className="flex cursor-pointer items-start gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
checked={exportExcludeClosing}
onChange={(e) => setExportExcludeClosing(e.target.checked)}
/>
<span>{t('export_sie_exclude_closing_label')}</span>
</label>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
onClick={() => {
if (exportPeriodId) {
const params = new URLSearchParams({ period_id: exportPeriodId })
if (exportExcludeClosing) params.set('exclude_closing', 'true')
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
}
}}
disabled={!exportPeriodId}
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
@@ -2328,3 +2238,81 @@ export default function ImportPage() {
</div>
)
}
// Quiet action row (concept scene 32): borderless list row with title, muted
// sub-line and a chevron; chips only for the recommended/gated exceptions.
function ImportRow({
title,
sub,
chip,
chips,
disabled = false,
expanded,
onClick,
id,
}: {
title: string
sub: string
chip?: React.ReactNode
/** Logo chips under the sub line (provider marks, as on the live page). */
chips?: React.ReactNode
disabled?: boolean
/** For rows that fold a panel open below the grid (cloud backup). */
expanded?: boolean
onClick: () => void
id?: string
}) {
return (
<button
type="button"
id={id}
onClick={onClick}
disabled={disabled}
aria-expanded={expanded}
className={cn(
'group flex w-full items-center justify-between gap-4 border-b border-border/60 px-1 py-3 text-left',
'transition-colors duration-150 hover:bg-secondary/35',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
)}
>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{title}
{chip}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{sub}</span>
{chips && <span className="mt-2 flex flex-wrap gap-2">{chips}</span>}
</span>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform duration-150',
'group-hover:translate-x-0.5 group-hover:text-muted-foreground',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
</button>
)
}
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
// on a quiet bordered chip, so integrations read as first-class brands.
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
// site's grayscale+brightness treatment makes them read on a light ground,
// with the inverse lift in dark mode.
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
return (
<span className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<img
src={src}
alt=""
className={cn(
'h-4 w-4 shrink-0 rounded-sm object-contain',
mono &&
'opacity-90 [filter:grayscale(100%)_brightness(0.18)] dark:[filter:grayscale(100%)_brightness(1.5)]',
)}
/>
<span className="text-[11px] font-medium text-muted-foreground">{name}</span>
</span>
)
}
+26 -17
View File
@@ -3,25 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function InvoicesLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + primary action (single "Ny faktura" split button, pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-40" />
<div className="flex gap-2">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</div>
<Skeleton className="h-8 w-36" />
<Skeleton className="h-9 w-32 rounded-full" />
</div>
<Skeleton className="h-4 w-64" />
<Skeleton className="h-10 w-full" />
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-4 w-24" />
{/* Toolbar: status picker chip + search + fiscal-year picker */}
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 flex-1" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
{/* Borderless table: header row over hairline-separated single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-40" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
+33 -34
View File
@@ -1,47 +1,46 @@
import { Skeleton } from '@/components/ui/skeleton'
/**
* Shared loading fallback for the dashboard segment. It is the Suspense
* fallback for Hem (app/(dashboard)/page.tsx) and for every child route that
* has no loading.tsx of its own, so it deliberately mirrors Hem's silhouette:
* a greeting hero (H1 + date line) followed by a single "Att göra"-style pane
* of list rows (see DashboardContent / AttGoraSection).
*
* A greeting + hairline-separated rows reads honestly on Hem and stays neutral
* on the plain list pages that fall back here (a page title + rows), which is
* why it is not shaped like any one page's specific grid.
*/
export default function DashboardLoading() {
return (
<div className="space-y-8">
{/* Metrics row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-lg border border-border bg-card p-4 space-y-2">
<Skeleton className="h-3.5 w-20" />
<Skeleton className="h-7 w-28" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
{/* Greeting hero (title + date line) */}
<section>
<Skeleton className="h-7 w-52" />
<Skeleton className="mt-2 h-3.5 w-64" />
</section>
{/* Income / Expenses */}
<div className="grid md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div key={i} className="rounded-lg border border-border bg-card p-6 space-y-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-3 w-24" />
</div>
))}
</div>
{/* Alerts + deadlines */}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
{/* Single pane: header over a hairline, then list rows */}
<section>
<div className="flex items-baseline justify-between border-b border-border px-1 pb-2.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<Skeleton className="h-5 w-32" />
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-20" />
<Skeleton className="ml-1 mt-5 mb-1 h-2.5 w-20" />
<div>
{['w-44', 'w-52', 'w-40', 'w-48', 'w-44'].map((w, i) => (
<div
key={i}
className="flex items-start gap-3 border-b border-border px-1 py-3.5"
>
<Skeleton className="mt-px h-[15px] w-[15px] shrink-0 rounded" />
<Skeleton className={`h-3.5 ${w}`} />
<Skeleton className="ml-auto h-5 w-7 shrink-0 rounded-full" />
</div>
))}
</div>
</div>
</section>
</div>
)
}
+12 -3
View File
@@ -3,17 +3,26 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function PendingLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + conditional "Godkänn alla" action (pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-36 rounded-full" />
</div>
<div className="rounded-lg border border-border">
{/* Toolbar: segmented tabs + source picker */}
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-44 rounded-full" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
{/* Per-operation cards with approve/reject actions */}
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-border/60 px-4 py-4 last:border-b-0"
className="flex items-center gap-4 rounded-lg border border-border p-4"
>
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-3 w-40" />
+4 -2
View File
@@ -3,10 +3,12 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function ReportsLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + fiscal-year picker chip */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-10 w-44" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-32 rounded-full" />
</div>
{/* Section heading (e.g. "Senaste rapporter") */}
<Skeleton className="h-4 w-40" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
+21 -9
View File
@@ -3,17 +3,29 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function SalaryLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + single "Starta körning" action (pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-32" />
<div className="flex gap-2">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</div>
<Skeleton className="h-8 w-32" />
<Skeleton className="h-9 w-40 rounded-full" />
</div>
<Skeleton className="h-28 rounded-lg" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 rounded-lg" />
{/* Borderless table of salary runs: header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-32" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
</div>
+392 -335
View File
@@ -1,13 +1,16 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { EmptyState } from '@/components/ui/empty-state'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
@@ -18,6 +21,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
formatCurrency,
formatDate,
@@ -29,9 +33,7 @@ import {
AlertCircle,
Copy,
ExternalLink,
FileCheck,
Landmark,
Link2,
RefreshCw,
} from 'lucide-react'
import { useCapability } from '@/contexts/CompanyContext'
@@ -70,7 +72,9 @@ interface MatchCandidate {
export default function SkattekontoPage() {
const { toast } = useToast()
const t = useTranslations('skattekonto')
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
const [showPayment, setShowPayment] = useState(false)
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
const [loading, setLoading] = useState(true)
@@ -294,59 +298,85 @@ export default function SkattekontoPage() {
.catch(() => {})
}
// Next charge (concept attn line): the earliest upcoming due date and the
// sum of everything Skatteverket draws that day.
const nextCharge = useMemo(() => {
const upcoming = tx?.upcoming ?? []
const dueOf = (r: StoredSkattekontoTransaction) =>
r.forfallodatum ?? r.transaktionsdatum
const due = upcoming.map(dueOf).sort()[0]
if (!due) return null
const rows = upcoming.filter((r) => dueOf(r) === due)
const amount = rows.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0)
return { due, count: rows.length, amount: Math.round(Math.abs(amount) * 100) / 100 }
}, [tx])
const saldoNow = saldo?.data ? saldo.data.saldoSkatteverket : null
const shortfall =
nextCharge && saldoNow !== null && saldoNow < nextCharge.amount
? Math.round((nextCharge.amount - saldoNow) * 100) / 100
: null
const helpNode = (
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
)
if (notConnected) {
return (
<div className="space-y-6">
<PageHeading />
<Card>
<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 className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
<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>
</div>
)
}
if (loadError) {
return (
<div className="space-y-6">
<PageHeading />
<Card>
<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 className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
<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>
</div>
)
}
const data = saldo?.data ?? null
return (
<div className="space-y-6">
<PageHeading
right={
<div className="space-y-8">
<PageHeader
title="Skattekonto"
help={helpNode}
action={
// The span carries the tooltip: `title` is suppressed on disabled elements.
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
<Button onClick={syncNow} disabled={syncing || !hasSkvCapability}>
<Button
variant="ghost"
onClick={syncNow}
disabled={syncing || !hasSkvCapability}
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
</Button>
@@ -354,72 +384,131 @@ export default function SkattekontoPage() {
}
/>
{reconnectMessage && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
<div className="flex items-center gap-3">
<AlertCircle className="h-4 w-4 shrink-0 text-destructive" />
<p className="text-sm">{reconnectMessage}</p>
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards) */}
<section className="space-y-4">
{loading && !data ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-28 w-full rounded-lg" />
<Skeleton className="h-28 w-full rounded-lg" />
</div>
<Button asChild variant="outline" size="sm">
<Link href="/settings/tax">
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Anslut igen
</Link>
</Button>
</div>
)}
) : !data ? (
<p className="py-4 text-sm text-muted-foreground">
Inget saldo hämtat ännu: klicka Synkronisera nu.
</p>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<img
src="/logos/skatteverket_color.svg"
alt=""
className="h-4 w-4 shrink-0 object-contain"
/>
<p className="text-xs text-muted-foreground">Saldo hos Skatteverket</p>
</div>
<p
className={cn(
'mt-2 font-display text-2xl tabular-nums tracking-tight',
data.saldoSkatteverket < 0 && 'text-destructive',
)}
>
{formatCurrency(data.saldoSkatteverket)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
OCR <span className="tabular-nums">{data.ocrNummer}</span>{' '}
<button
type="button"
onClick={() => copyOcr(data.ocrNummer)}
className={QUIET_LINK_CLASS}
aria-label="Kopiera OCR"
>
{t('copy')}
</button>
{saldo?.lastSyncedAt && (
<>
{' '}· synkad{' '}
<span className="tabular-nums">{formatDateTime(saldo.lastSyncedAt)}</span>
</>
)}
</p>
{data.rantaSkatteverket !== 0 && (
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
</p>
)}
{data.saldoKronofogden !== 0 && (
<p className="mt-1 text-xs font-medium tabular-nums text-destructive">
Hos Kronofogden: {formatCurrency(data.saldoKronofogden)}
{data.rantaKronofogden !== 0 &&
` (ränta ${formatCurrency(data.rantaKronofogden)})`}
</p>
)}
</div>
<BalanceHero saldo={saldo} loading={loading} onCopyOcr={copyOcr} />
<div className="rounded-lg border border-border p-4">
<p className="text-xs text-muted-foreground">Nästa dragning</p>
{nextCharge ? (
<>
<p className="mt-2 font-display text-2xl tabular-nums tracking-tight">
{formatCurrency(nextCharge.amount)}
</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
{formatDateLong(nextCharge.due)}
{nextCharge.count > 1 && ` · ${nextCharge.count} händelser`}
</p>
</>
) : (
<p className="mt-2 text-sm text-muted-foreground">
Inga kommande dragningar.
</p>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Transaktioner</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="booked">
<TabsList>
<TabsTrigger value="booked">
Genomförda {tx?.booked ? `(${tx.booked.length})` : ''}
</TabsTrigger>
<TabsTrigger value="overdue">
Förfallna {tx?.overdue ? `(${tx.overdue.length})` : ''}
</TabsTrigger>
<TabsTrigger value="upcoming">
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="booked" className="mt-4">
<TransactionTable
rows={tx?.booked ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga genomförda transaktioner än."
/>
</TabsContent>
<TabsContent value="overdue" className="mt-4">
<TransactionTable
rows={tx?.overdue ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga förfallna transaktioner."
showForfallodatum
/>
</TabsContent>
<TabsContent value="upcoming" className="mt-4">
<TransactionTable
rows={tx?.upcoming ?? []}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
emptyText="Inga kommande transaktioner."
showForfallodatum
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{reconnectMessage ? (
<AttnLine action={{ label: t('attn_reconnect_action'), href: '/settings/tax' }}>
{reconnectMessage}
</AttnLine>
) : shortfall !== null && nextCharge ? (
<AttnLine
action={{ label: t('attn_show_payment'), onClick: () => setShowPayment(true) }}
>
{t('attn_shortfall', {
date: formatDateLong(nextCharge.due),
charge: formatCurrency(nextCharge.amount),
missing: formatCurrency(shortfall),
})}
</AttnLine>
) : null}
{data.informationstext.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Information från Skatteverket
</p>
{data.informationstext.map((info, i) => (
<p key={i} className="text-xs leading-5 text-muted-foreground">
{info}
</p>
))}
</div>
)}
</>
)}
</section>
{/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */}
<SkattekontoTable
tx={tx}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
/>
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
</p>
<MatchDialog
row={matchOpenFor}
@@ -432,256 +521,224 @@ export default function SkattekontoPage() {
}}
onConfirm={confirmMatch}
/>
<Dialog open={showPayment} onOpenChange={setShowPayment}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{t('payment_title')}
</DialogTitle>
{nextCharge && (
<DialogDescription className="text-[13px] leading-relaxed">
{t('payment_description', {
date: formatDateLong(nextCharge.due),
charge: formatCurrency(nextCharge.amount),
})}
</DialogDescription>
)}
</DialogHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('payment_bankgiro_label')}</dt>
<dd className="font-medium tabular-nums">5050-1055</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">OCR</dt>
<dd className="flex items-center gap-2 font-medium tabular-nums">
{data?.ocrNummer}
{data && (
<button
type="button"
onClick={() => copyOcr(data.ocrNummer)}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Kopiera OCR"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
</dd>
</div>
{shortfall !== null && (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('payment_shortfall_label')}</dt>
<dd className="font-medium tabular-nums">{formatCurrency(shortfall)}</dd>
</div>
)}
</dl>
</DialogContent>
</Dialog>
</div>
)
}
function PageHeading({ right }: { right?: React.ReactNode }) {
return <PageHeader title="Skattekonto" action={right} />
type TableSection = {
key: 'upcoming' | 'overdue' | 'booked'
label: string
rows: SkattekontoTransactionWithSuggestion[]
}
function BalanceHero({
saldo,
loading,
onCopyOcr,
}: {
saldo: SaldoEnvelope | null
loading: boolean
onCopyOcr: (ocr: string) => void
}) {
if (loading && !saldo?.data) {
return (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">
Hämtar saldo
</CardContent>
</Card>
)
}
if (!saldo?.data) {
return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Inget saldo hämtat ännu: klicka &quot;Synkronisera nu&quot;.
</CardContent>
</Card>
)
}
const { data } = saldo
const skvNegative = data.saldoSkatteverket < 0
const kfmNegative = data.saldoKronofogden < 0
return (
<Card>
<CardContent className="space-y-6 pt-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Skatteverket
</p>
<p
className={`font-display text-2xl tabular-nums ${
skvNegative ? 'text-destructive' : 'text-foreground'
}`}
>
{formatCurrency(data.saldoSkatteverket)}
</p>
{data.rantaSkatteverket !== 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
</p>
)}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Kronofogden
</p>
<p
className={`font-display text-2xl tabular-nums ${
kfmNegative ? 'text-destructive' : 'text-foreground'
}`}
>
{formatCurrency(data.saldoKronofogden)}
</p>
{data.rantaKronofogden !== 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
Preliminär ränta: {formatCurrency(data.rantaKronofogden)}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 border-t pt-4 text-sm sm:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
OCR
</p>
<p className="flex items-center gap-2 font-medium tabular-nums">
{data.ocrNummer}
<button
onClick={() => onCopyOcr(data.ocrNummer)}
className="text-muted-foreground hover:text-foreground"
aria-label="Kopiera OCR"
>
<Copy className="h-3.5 w-3.5" />
</button>
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Nästa avstämning
</p>
<p className="font-medium tabular-nums">{data.nastaAvstamningsdatum}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Saldo per
</p>
<p className="font-medium tabular-nums">
{formatDateLong(data.senastUppdaterad)}
</p>
</div>
</div>
{saldo.lastSyncedAt && (
<p className="text-xs text-muted-foreground">
Synkas automatiskt varje natt. Senast synkad{' '}
<span className="tabular-nums">
{formatDateTime(saldo.lastSyncedAt)}
</span>
. Skatteverket uppdaterar saldot periodvis: datumet ovan ändras
inte varje gång du synkroniserar.
</p>
)}
{data.informationstext.length > 0 && (
<div className="rounded-md border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase tracking-wide">
Information från Skatteverket
</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{data.informationstext.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)
}
function TransactionTable({
rows,
function SkattekontoTable({
tx,
onBokfor,
onMatch,
bookingId,
emptyText,
showForfallodatum = false,
}: {
rows: SkattekontoTransactionWithSuggestion[]
tx: TransaktionerEnvelope['data'] | null
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
emptyText: string
showForfallodatum?: boolean
}) {
if (rows.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyText}</p>
const t = useTranslations('skattekonto')
const allSections: TableSection[] = [
{ key: 'upcoming', label: t('band_upcoming'), rows: tx?.upcoming ?? [] },
{ key: 'overdue', label: t('band_overdue'), rows: tx?.overdue ?? [] },
{ key: 'booked', label: t('band_booked'), rows: tx?.booked ?? [] },
]
const sections = allSections.filter((s) => s.rows.length > 0)
if (sections.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('no_transactions')}
</p>
)
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
{showForfallodatum && <TableHead>Förfallodatum</TableHead>}
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(row => {
const negative = Number(row.belopp_skatteverket) < 0
const isBooked = !!row.journal_entry_id
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums">{formatDate(row.transaktionsdatum)}</TableCell>
{showForfallodatum && (
<TableCell className="tabular-nums">
{row.forfallodatum ? formatDate(row.forfallodatum) : '-'}
</TableCell>
)}
<TableCell>
{row.transaktionstext}
{!isBooked && row.match_suggestion && (
<p className="mt-1 text-xs text-warning">
Möjlig dublett av{' '}
{row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
<div className="overflow-x-auto" role="region" aria-label="Skattekontohändelser">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[110px]')}>Datum</th>
<th className={TH_CLASS}>Händelse</th>
<th className={cn(TH_CLASS, 'text-right')}>Belopp</th>
<th className={cn(TH_CLASS, 'w-[150px]')} />
</tr>
</thead>
<tbody className="stagger-enter">
{sections.map((section) => (
<Fragment key={section.key}>
<tr className="bg-muted/30">
<td
colSpan={4}
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
>
{section.label}
</td>
</tr>
{section.rows.map((row) => (
<SkattekontoRow
key={row.id}
row={row}
section={section.key}
onBokfor={onBokfor}
onMatch={onMatch}
bookingId={bookingId}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function SkattekontoRow({
row,
section,
onBokfor,
onMatch,
bookingId,
}: {
row: SkattekontoTransactionWithSuggestion
section: TableSection['key']
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
}) {
const t = useTranslations('skattekonto')
const amount = Number(row.belopp_skatteverket)
const isBooked = !!row.journal_entry_id
const displayDate =
section === 'upcoming' || section === 'overdue'
? (row.forfallodatum ?? row.transaktionsdatum)
: row.transaktionsdatum
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(displayDate)}
</td>
<td className={TD_CLASS}>
<span className="inline-flex flex-wrap items-center gap-2">
{row.transaktionstext}
{/* Chips mark exceptions: only a *genomförd* row that is still
unbooked deviates; upcoming rows are unbooked by nature. */}
{section === 'booked' && !isBooked && (
row.match_suggestion ? (
<Badge variant="warning" className="font-normal">
{t('chip_possible_duplicate', {
voucher:
row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
? formatVoucher({
voucher_series: row.match_suggestion.voucher_series,
voucher_number: row.match_suggestion.voucher_number,
})
: 'utkast'}{' '}
({formatDate(row.match_suggestion.entry_date)})
</p>
)}
</TableCell>
<TableCell
className={`text-right tabular-nums ${negative ? 'text-destructive' : ''}`}
>
{formatCurrency(Number(row.belopp_skatteverket))}
</TableCell>
<TableCell>
{isBooked ? (
<Badge variant="secondary" className="gap-1">
<FileCheck className="h-3 w-3" />
Bokförd
</Badge>
) : row.match_suggestion ? (
<Badge variant="warning">Möjlig dublett</Badge>
) : (
<Badge variant="outline">Ej bokförd</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isBooked ? (
<Button asChild variant="ghost" size="sm">
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
Visa verifikat
</Link>
</Button>
) : (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onMatch(row)}
title="Koppla till befintligt verifikat"
>
<Link2 className="mr-1 h-3.5 w-3.5" />
Matcha
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
>
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
</Button>
</div>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
: t('chip_draft'),
})}
</Badge>
) : (
<Badge variant="outline" className="font-normal">
{t('chip_not_booked')}
</Badge>
)
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums',
amount > 0 && 'text-success',
)}
>
{amount > 0 ? `+${formatCurrency(amount)}` : formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{isBooked ? (
<Link
href={`/bookkeeping/${row.journal_entry_id}`}
className={cn(
QUIET_LINK_CLASS,
'opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100',
)}
>
{t('action_show_voucher')}
</Link>
) : (
<span className="inline-flex items-center gap-3 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
type="button"
onClick={() => onMatch(row)}
className={QUIET_LINK_CLASS}
title="Koppla till befintligt verifikat"
>
{t('action_match')}
</button>
<button
type="button"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
className={QUIET_LINK_CLASS}
>
{bookingId === row.id ? t('action_booking') : t('action_book')}
</button>
</span>
)}
</td>
</tr>
)
}
+17 -10
View File
@@ -3,27 +3,34 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function SupplierInvoicesLoading() {
return (
<div className="space-y-8">
{/* Title (24px) + single "Registrera" action (pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-9 w-36 rounded-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-72" />
{/* Toolbar: status picker chip + search + fiscal-year picker */}
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 flex-1" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
<div className="rounded-lg border border-border">
{/* Borderless table: header row + single-line rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<div className="space-y-2">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-28" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-44" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
+24 -12
View File
@@ -3,23 +3,35 @@ import { Skeleton } from '@/components/ui/skeleton'
export default function TransactionsLoading() {
return (
<div className="space-y-8">
{/* Status bar: title (24px) + single "Importera" split button (pill) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-10 w-36" />
<Skeleton className="h-8 w-44" />
<Skeleton className="h-9 w-32 rounded-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-9 w-28" />
{/* Toolbar: inbox/history segmented tabs + search + source picker */}
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-9 w-44 rounded-full" />
<Skeleton className="h-9 flex-1" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
<Skeleton className="h-10 w-full" />
<div className="space-y-3">
{/* Borderless table: header row + single-line transaction rows */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
<div
key={i}
className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-48" />
<div className="flex items-center gap-6">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
-271
View File
@@ -1,271 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Deadline } from '@/types'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
isDeadlineOverdue,
DEADLINE_TYPE_LABELS,
parseDate,
startOfDay,
} from '@/lib/calendar/utils'
import { Check, Pencil } from 'lucide-react'
interface DeadlineCardProps {
deadline: Deadline
onToggle: (deadline: Deadline) => void
onEdit?: (deadline: Deadline) => void
onDelete?: (deadline: Deadline) => void
compact?: boolean
}
function getRelativeDate(dateStr: string): { text: string; urgent: boolean } | null {
const today = startOfDay(new Date())
const target = parseDate(dateStr)
const diffMs = target.getTime() - today.getTime()
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return { text: 'Idag', urgent: true }
if (diffDays === 1) return { text: 'Imorgon', urgent: false }
if (diffDays === -1) return { text: '1 dag sedan', urgent: true }
if (diffDays < -1) return { text: `${Math.abs(diffDays)} dagar sedan`, urgent: true }
if (diffDays <= 7) return { text: `Om ${diffDays} dagar`, urgent: false }
if (diffDays <= 14) return { text: `Om ${diffDays} dagar`, urgent: false }
return null
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
export function DeadlineCard({
deadline,
onToggle,
onEdit,
onDelete,
compact = false,
}: DeadlineCardProps) {
const [confirming, setConfirming] = useState(false)
const confirmRef = useRef<HTMLDivElement>(null)
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const relative = getRelativeDate(deadline.due_date)
const dueDate = parseDate(deadline.due_date)
const dayNum = dueDate.getDate()
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
// Dismiss confirmation when clicking outside
useEffect(() => {
if (!confirming) return
function handleClickOutside(e: MouseEvent) {
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
setConfirming(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [confirming])
function handleToggleClick(e: React.MouseEvent) {
e.stopPropagation()
if (completed) {
// Uncompleting doesn't need confirmation
onToggle(deadline)
return
}
setConfirming(true)
}
function handleConfirm(e: React.MouseEvent) {
e.stopPropagation()
setConfirming(false)
onToggle(deadline)
}
function handleCancel(e: React.MouseEvent) {
e.stopPropagation()
setConfirming(false)
}
function handleCardClick() {
if (confirming) return
if (onEdit) onEdit(deadline)
}
// -- Compact variant (used in dashboard widgets) --
if (compact) {
return (
<div
onClick={handleCardClick}
className={cn(
'group flex items-center gap-3 py-2.5 transition-colors',
onEdit && !confirming && 'cursor-pointer hover:bg-accent/30 -mx-2 px-2 rounded-md',
completed && 'opacity-50',
)}
>
<span className="text-xs tabular-nums w-14 flex-shrink-0 text-muted-foreground">
{dayNum} {monthStr}
</span>
<p className={cn(
'text-sm truncate flex-1 min-w-0',
completed ? 'line-through text-muted-foreground' : 'text-foreground',
)}>
{deadline.title}
</p>
{!completed && relative && (
<span className={cn(
'text-xs flex-shrink-0',
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
)}>
{relative.text}
</span>
)}
{completed && (
<Check className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
</div>
)
}
// -- Full card --
return (
<div ref={confirmRef}>
<div
onClick={handleCardClick}
className={cn(
'group rounded-lg border bg-card transition-all duration-150',
onEdit && !confirming && 'cursor-pointer',
confirming && 'ring-2 ring-ring ring-offset-1',
completed
? 'opacity-50 hover:opacity-70'
: !confirming && 'hover:bg-accent/40 active:bg-accent/60',
)}
>
{/* Main row */}
<div className="flex items-center gap-4 py-3 px-4">
{/* Date block */}
<div className="flex-shrink-0 w-12 text-center">
<span className={cn(
'block text-lg font-display leading-none tabular-nums',
completed && 'text-muted-foreground',
)}>
{dayNum}
</span>
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
{monthStr}
</span>
</div>
{/* Divider */}
<div className="w-px h-8 bg-border flex-shrink-0" />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className={cn(
'text-sm font-medium truncate',
completed && 'line-through text-muted-foreground',
)}>
{deadline.title}
</p>
{deadline.deadline_type !== 'other' && (
<span className="text-[11px] text-muted-foreground/60 flex-shrink-0 hidden sm:inline">
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{deadline.due_time && (
<span className="text-xs text-muted-foreground tabular-nums">
kl {deadline.due_time.slice(0, 5)}
</span>
)}
{deadline.due_time && deadline.customer && (
<span className="text-muted-foreground/30 text-xs">·</span>
)}
{deadline.customer && (
<span className="text-xs text-muted-foreground truncate">
{deadline.customer.name}
</span>
)}
</div>
</div>
{/* Right: relative date + action */}
<div className="flex items-center gap-3 flex-shrink-0">
{!completed && relative && (
<span className={cn(
'text-xs tabular-nums hidden sm:inline',
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
)}>
{relative.text}
</span>
)}
{completed ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
Klar
</span>
) : (
<button
onClick={handleToggleClick}
className={cn(
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent',
confirming && 'invisible',
)}
>
Markera klar
</button>
)}
{/* Edit hint */}
{onEdit && !confirming && (
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
)}
</div>
</div>
{/* Inline confirmation bar */}
<div
className={cn(
'grid transition-all duration-200 ease-out',
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Markera <span className="font-medium text-foreground">{deadline.title}</span> som klar?
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs"
onClick={handleCancel}
>
Avbryt
</Button>
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={handleConfirm}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
Bekräfta
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
-67
View File
@@ -1,67 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DeadlineType } from '@/types'
import { DEADLINE_TYPE_LABELS } from '@/lib/calendar/utils'
import { X } from 'lucide-react'
interface DeadlineFiltersProps {
status: 'all' | 'pending' | 'completed'
type: DeadlineType | 'all'
onStatusChange: (status: 'all' | 'pending' | 'completed') => void
onTypeChange: (type: DeadlineType | 'all') => void
onReset: () => void
}
export function DeadlineFilters({
status,
type,
onStatusChange,
onTypeChange,
onReset,
}: DeadlineFiltersProps) {
const hasFilters = status !== 'all' || type !== 'all'
return (
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={(v) => onStatusChange(v as typeof status)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="pending">Ej klara</SelectItem>
<SelectItem value="completed">Klara</SelectItem>
</SelectContent>
</Select>
<Select value={type} onValueChange={(v) => onTypeChange(v as typeof type)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla typer</SelectItem>
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={onReset}>
<X className="h-4 w-4 mr-1" />
Rensa
</Button>
)}
</div>
)
}
+64 -141
View File
@@ -1,17 +1,17 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue, parseDate } from '@/lib/calendar/utils'
import { Check, Pencil } from 'lucide-react'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { deadlineDateLabel } from './DeadlineRow'
import { Pencil } from 'lucide-react'
/**
* System tax deadlines that legally share the skattekonto date ("den 12:e"):
* for a small monthly-moms employer, moms + AGI (+ debiterad preliminärskatt)
* all fall due the same day. Rendering them as one grouped card instead of
* all fall due the same day. Rendering them as one grouped row instead of
* 2-3 identical-date rows keeps the list scannable.
*/
export const SKATTEKONTO_GROUP_TYPES = new Set([
@@ -32,158 +32,81 @@ export function isSkattekontoDeadline(d: Deadline): boolean {
)
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
interface DeadlineGroupCardProps {
interface DeadlineGroupRowProps {
/** Two or more skattekonto deadlines sharing the same due_date. */
deadlines: Deadline[]
onToggle: (deadline: Deadline) => void
onEdit?: (deadline: Deadline) => void
/** "Markera klar": the list confirms before toggling. */
onRequestToggle: (deadline: Deadline) => void
}
/**
* One card for all skattekonto obligations on a shared due date, with the
* date block rendered once and each obligation as a sub-row that keeps its
* own "Markera klar" confirmation. Visual language mirrors DeadlineCard.
* One thread row for all skattekonto obligations on a shared due date
* (concept scene 17 row language): the date renders once, each obligation is
* a sub-row with its own quiet "Markera klar".
*/
export function DeadlineGroupCard({ deadlines, onToggle, onEdit }: DeadlineGroupCardProps) {
export function DeadlineGroupCard({ deadlines, onEdit, onRequestToggle }: DeadlineGroupRowProps) {
const t = useTranslations('deadlines')
const [confirmingId, setConfirmingId] = useState<string | null>(null)
const confirmRef = useRef<HTMLDivElement>(null)
const first = deadlines[0]
const dueDate = parseDate(first.due_date)
const dayNum = dueDate.getDate()
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
const overdue = deadlines.some(isDeadlineOverdue)
useEffect(() => {
if (!confirmingId) return
function handleClickOutside(e: MouseEvent) {
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
setConfirmingId(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [confirmingId])
const confirming = deadlines.find((d) => d.id === confirmingId) ?? null
return (
<div ref={confirmRef}>
<div
className={cn(
'rounded-lg border bg-card transition-all duration-150',
confirmingId && 'ring-2 ring-ring ring-offset-1',
)}
>
<div className="flex items-start gap-4 py-3 px-4">
{/* Shared date block */}
<div className="flex-shrink-0 w-12 text-center pt-0.5">
<span className="block text-lg font-display leading-none tabular-nums">
{dayNum}
</span>
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
{monthStr}
</span>
</div>
<div className="flex items-start gap-3 py-3 -mx-2 px-2">
{/* Shared skattekonto date: always Skatteverket's mark (white ground,
badge not chrome). */}
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
<img src="/logos/skatteverket_color.svg" alt="Skatteverket" className="h-4 w-4 object-contain" />
</span>
<div className="w-px self-stretch bg-border flex-shrink-0" />
{/* Group content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className="text-sm font-medium">{t('group_skattekonto_title')}</p>
<span
className={cn(
'text-[11px] flex-shrink-0',
overdue ? 'text-destructive font-medium' : 'text-muted-foreground/60',
)}
>
{t('group_same_day', { count: deadlines.length })}
</span>
</div>
<div className="mt-2 space-y-1">
{deadlines.map((deadline) => (
<div
key={deadline.id}
onClick={() => {
if (!confirmingId && onEdit) onEdit(deadline)
}}
className={cn(
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors',
onEdit && !confirmingId && 'cursor-pointer hover:bg-accent/40',
)}
>
<p className="text-sm truncate flex-1 min-w-0">{deadline.title}</p>
<button
onClick={(e) => {
e.stopPropagation()
setConfirmingId(deadline.id)
}}
className={cn(
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent flex-shrink-0',
confirmingId && 'invisible',
)}
>
{t('group_mark_done')}
</button>
{onEdit && !confirmingId && (
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
)}
</div>
))}
</div>
</div>
</div>
{/* Inline confirmation bar (one at a time, mirrors DeadlineCard) */}
<div
<div className="min-w-0 flex-1">
<p className="text-sm leading-6">
<span className="tabular-nums text-muted-foreground">
{deadlineDateLabel(first.due_date)}
</span>
<span className="text-muted-foreground/50"> · </span>
<span>{t('group_skattekonto_title')}</span>
</p>
<p
className={cn(
'grid transition-all duration-200 ease-out',
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
'text-xs leading-5',
overdue ? 'text-destructive' : 'text-muted-foreground',
)}
>
<div className="overflow-hidden">
{confirming && (
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
{t.rich('group_confirm_question', {
title: confirming.title,
hl: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => setConfirmingId(null)}
>
{t('group_cancel')}
</Button>
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={() => {
setConfirmingId(null)
onToggle(confirming)
}}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
{t('group_confirm')}
</Button>
</div>
</div>
)}
</div>
{t('group_same_day', { count: deadlines.length })}
</p>
<div className="mt-1">
{deadlines.map((deadline) => (
<div
key={deadline.id}
onClick={() => onEdit?.(deadline)}
className={cn(
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors duration-150',
onEdit && 'cursor-pointer hover:bg-secondary/35',
)}
>
<p className="min-w-0 flex-1 truncate text-sm">{deadline.title}</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(
QUIET_LINK_CLASS,
'shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
)}
>
{t('group_mark_done')}
</button>
{onEdit && (
<Pencil
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
aria-hidden="true"
/>
)}
</div>
))}
</div>
</div>
</div>
+172 -146
View File
@@ -1,47 +1,46 @@
'use client'
import { useState, useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Deadline, DeadlineType } from '@/types'
import { Button } from '@/components/ui/button'
import { DeadlineCard } from './DeadlineCard'
import { DeadlineRow, deadlineDateLabel } from './DeadlineRow'
import { DeadlineGroupCard, isSkattekontoDeadline } from './DeadlineGroupCard'
import { DeadlineFilters } from './DeadlineFilters'
import { DeadlineForm } from './DeadlineForm'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { Plus, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
type TypeSegment = 'all' | 'tax' | 'invoicing' | 'own'
const SEGMENT_TYPES: Record<Exclude<TypeSegment, 'all'>, ReadonlyArray<DeadlineType>> = {
tax: ['tax'],
invoicing: ['invoicing'],
own: ['delivery', 'report', 'other'],
}
interface DeadlineListProps {
deadlines: Deadline[]
customers: { id: string; name: string }[]
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDeadlineToggle: (deadline: Deadline) => Promise<void>
onDeadlineEdit: (deadline: Deadline) => Promise<void>
onDeadlineDelete: (deadline: Deadline) => Promise<void>
onDeadlineEdit: (deadline: Deadline) => void
}
/**
* Viktiga datum as the concept thread (scene 17): a type seg, borderless
* rows under hairline section headers, and a "Närmast" countdown pane on the
* right. Marking done confirms up front via ConfirmDialog.
*/
export function DeadlineList({
deadlines,
customers,
onDeadlineCreate,
onDeadlineToggle,
onDeadlineEdit,
onDeadlineDelete,
}: DeadlineListProps) {
const { canWrite } = useCanWrite()
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('pending')
const [typeFilter, setTypeFilter] = useState<DeadlineType | 'all'>('all')
const [showForm, setShowForm] = useState(false)
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
const t = useTranslations('deadlines')
const [segment, setSegment] = useState<TypeSegment>('all')
const [confirmTarget, setConfirmTarget] = useState<Deadline | null>(null)
const filteredDeadlines = useMemo(() => {
return deadlines.filter((d) => {
if (statusFilter === 'pending' && d.is_completed) return false
if (statusFilter === 'completed' && !d.is_completed) return false
if (typeFilter !== 'all' && d.deadline_type !== typeFilter) return false
return true
})
}, [deadlines, statusFilter, typeFilter])
if (segment === 'all') return deadlines
const types = SEGMENT_TYPES[segment]
return deadlines.filter((d) => types.includes(d.deadline_type))
}, [deadlines, segment])
const groupedDeadlines = useMemo(() => {
const today = new Date().toISOString().split('T')[0]
@@ -65,41 +64,46 @@ export function DeadlineList({
return { overdue, today: todayDeadlines, upcoming, completed }
}, [filteredDeadlines])
const handleResetFilters = () => {
setStatusFilter('all')
setTypeFilter('all')
}
// "Närmast" pane: days to the nearest pending deadline (across the
// unfiltered list: the countdown is page context, not a filter readout).
const nearest = useMemo(() => {
const pending = deadlines
.filter((d) => !d.is_completed && !isDeadlineOverdue(d))
.sort((a, b) => a.due_date.localeCompare(b.due_date))
const first = pending[0]
if (!first) return null
const today = startOfDay(new Date())
const days = Math.round(
(parseDate(first.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
)
return { deadline: first, days: Math.max(0, days) }
}, [deadlines])
const handleFormSubmit = async (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => {
if (editingDeadline) {
await onDeadlineEdit({ ...editingDeadline, ...data })
} else {
await onDeadlineCreate(data)
const overdueCount = useMemo(
() => deadlines.filter((d) => !d.is_completed && isDeadlineOverdue(d)).length,
[deadlines],
)
const handleRequestToggle = (deadline: Deadline) => {
// Un-completing is a harmless revert: no confirmation (matches the undo
// toast the page already offers).
if (deadline.is_completed) {
void onDeadlineToggle(deadline)
return
}
setShowForm(false)
setEditingDeadline(null)
}
const handleEdit = (deadline: Deadline) => {
setEditingDeadline(deadline)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingDeadline(null)
setConfirmTarget(deadline)
}
const sections = [
{ key: 'overdue', label: 'Förfallna', items: groupedDeadlines.overdue },
{ key: 'today', label: 'Idag', items: groupedDeadlines.today },
{ key: 'upcoming', label: 'Kommande', items: groupedDeadlines.upcoming },
{ key: 'completed', label: 'Klara', items: groupedDeadlines.completed },
].filter(s => s.items.length > 0)
{ key: 'overdue', label: t('section_overdue'), items: groupedDeadlines.overdue },
{ key: 'today', label: t('section_today'), items: groupedDeadlines.today },
{ key: 'upcoming', label: t('section_upcoming'), items: groupedDeadlines.upcoming },
{ key: 'completed', label: t('section_completed'), items: groupedDeadlines.completed },
].filter((s) => s.items.length > 0)
// Skattekonto obligations legally share the same due date (moms + AGI +
// preliminärskatt all fall on "den 12:e"): collapse 2+ such rows into one
// grouped card per date instead of near-identical rows. Other deadlines
// grouped row per date instead of near-identical rows. Other deadlines
// render as before, in their original order.
type ListEntry =
| { kind: 'single'; deadline: Deadline }
@@ -131,103 +135,125 @@ export function DeadlineList({
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<DeadlineFilters
status={statusFilter}
type={typeFilter}
onStatusChange={setStatusFilter}
onTypeChange={setTypeFilter}
onReset={handleResetFilters}
/>
<Button
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Lock className="h-4 w-4 mr-2" />
)}
Ny deadline
</Button>
{/* Toolbar: the type seg (concept: Alla / Skatt / Fakturering / Egna) */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'all', label: t('seg_all') },
{ key: 'tax', label: t('seg_tax') },
{ key: 'invoicing', label: t('seg_invoicing') },
{ key: 'own', label: t('seg_own') },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={segment === key}
onClick={() => setSegment(key)}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
segment === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{/* Content */}
{filteredDeadlines.length === 0 ? (
<div className="py-20 text-center">
<p className="text-sm text-muted-foreground">
{statusFilter !== 'all' || typeFilter !== 'all'
? 'Inga deadlines matchar filtret.'
: 'Inga deadlines ännu.'}
<div className="gap-8 lg:grid lg:grid-cols-[minmax(0,1fr)_240px] lg:items-start">
{/* The thread */}
{filteredDeadlines.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
{segment !== 'all' ? t('empty_filtered') : t('empty_title')}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="h-3.5 w-3.5 mr-1.5" />
) : (
<Lock className="h-3.5 w-3.5 mr-1.5" />
)}
Skapa en deadline
</Button>
</div>
) : (
<div className="space-y-8">
{sections.map(({ key, label, items }) => (
<section key={key}>
<div className="flex items-center gap-3 mb-2 px-1">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</h3>
<span className="text-xs tabular-nums text-muted-foreground/50">
{items.length}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
<div className="space-y-1.5">
{toEntries(items).map((entry) =>
entry.kind === 'group' ? (
<DeadlineGroupCard
key={`skattekonto-${entry.date}`}
deadlines={entry.items}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
/>
) : (
<DeadlineCard
key={entry.deadline.id}
deadline={entry.deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
),
)}
</div>
</section>
))}
</div>
)}
) : (
<div className="stagger-enter space-y-8">
{sections.map(({ key, label, items }) => (
<section key={key}>
<div className="mb-1 flex items-center gap-3 px-1">
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</h2>
<span className="text-xs tabular-nums text-muted-foreground/50">
{items.length}
</span>
<div className="h-px flex-1 bg-border/60" />
</div>
<div>
{toEntries(items).map((entry) =>
entry.kind === 'group' ? (
<DeadlineGroupCard
key={`skattekonto-${entry.date}`}
deadlines={entry.items}
onEdit={onDeadlineEdit}
onRequestToggle={handleRequestToggle}
/>
) : (
<DeadlineRow
key={entry.deadline.id}
deadline={entry.deadline}
onEdit={onDeadlineEdit}
onRequestToggle={handleRequestToggle}
/>
),
)}
</div>
</section>
))}
</div>
)}
<DeadlineForm
open={showForm}
onOpenChange={handleFormClose}
onSubmit={handleFormSubmit}
onDelete={(deadline) => {
if (deadline.id) {
const full = deadlines.find(d => d.id === deadline.id)
if (full) onDeadlineDelete(full)
}
{/* "Närmast" pane (concept aside) */}
<aside className="mt-8 lg:mt-0">
<div className="rounded-lg border border-border">
<div className="border-b border-border px-4 py-2.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('nearest_title')}
</p>
</div>
<div className="px-4 py-6 text-center">
{nearest ? (
<>
<p className="font-display text-4xl leading-none tracking-tight tabular-nums">
{nearest.days === 0 ? t('rel_today') : nearest.days}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{nearest.days === 0
? nearest.deadline.title
: t('nearest_days_to', { title: nearest.deadline.title })}
</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground/60">
{deadlineDateLabel(nearest.deadline.due_date)}
</p>
</>
) : (
<p className="py-4 text-xs text-muted-foreground">{t('nearest_empty')}</p>
)}
{overdueCount > 0 && (
<p className="mt-3 text-xs font-medium text-destructive">
{t('nearest_overdue', { count: overdueCount })}
</p>
)}
</div>
</div>
</aside>
</div>
<ConfirmDialog
open={confirmTarget !== null}
onOpenChange={(open) => !open && setConfirmTarget(null)}
title={t('confirm_done_title')}
description={
confirmTarget
? `${deadlineDateLabel(confirmTarget.due_date)} · ${confirmTarget.title}`
: undefined
}
confirmLabel={t('group_confirm')}
onConfirm={async () => {
if (confirmTarget) await onDeadlineToggle(confirmTarget)
}}
initialData={editingDeadline || undefined}
customers={customers}
/>
</div>
)
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
CalendarClock,
Check,
FileText,
Landmark,
Pencil,
ReceiptText,
Truck,
} from 'lucide-react'
interface DeadlineRowProps {
deadline: Deadline
/** Row click: open the edit dialog. */
onEdit?: (deadline: Deadline) => void
/** "Markera klar" (or un-done): the list confirms before toggling. */
onRequestToggle: (deadline: Deadline) => void
}
function daysFromToday(dateStr: string): number {
const today = startOfDay(new Date())
const target = parseDate(dateStr)
return Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
const TYPE_ICONS: Record<Deadline['deadline_type'], typeof CalendarClock> = {
tax: Landmark,
invoicing: ReceiptText,
delivery: Truck,
report: FileText,
other: CalendarClock,
}
// Statutory deadlines carry the receiving authority's mark: Bolagsverket for
// the company-law dates, Skatteverket for everything tax-shaped. Non-system
// deadlines keep the neutral type icons.
const BOLAGSVERKET_TYPES = new Set(['arsredovisning', 'arsstamma'])
export function deadlineAuthorityLogo(deadline: Deadline): { src: string; alt: string } | null {
if (deadline.tax_deadline_type) {
return BOLAGSVERKET_TYPES.has(deadline.tax_deadline_type)
? { src: '/logos/bolagsverket.jpg', alt: 'Bolagsverket' }
: { src: '/logos/skatteverket_color.svg', alt: 'Skatteverket' }
}
// Manual deadlines (including user-created tax-category ones) keep the
// neutral type icons: the authority crest is reserved for statutory dates
// the system generated from the tax settings.
return null
}
/** The shared 28px round mark: authority logo on white, or a muted type icon. */
export function DeadlineMark({
deadline,
completed = false,
}: {
deadline: Deadline
completed?: boolean
}) {
const logo = deadlineAuthorityLogo(deadline)
if (completed) {
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground/60">
<Check className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
if (logo) {
// Deliberately white in both themes: the authority marks are badges, not
// chrome, and their brand colors need a white ground.
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
<img src={logo.src} alt={logo.alt} className="h-4 w-4 object-contain" />
</span>
)
}
const Icon = TYPE_ICONS[deadline.deadline_type] ?? CalendarClock
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
export function deadlineDateLabel(dateStr: string): string {
const date = parseDate(dateStr)
return `${date.getDate()} ${SWEDISH_MONTHS_SHORT[date.getMonth()]}`
}
/**
* One deadline as a concept thread row (scene 17): icon circle, "12 aug ·
* Title" line, muted status sentence below, quiet hover actions. Rows are
* borderless; the section headers carry the structure.
*/
export function DeadlineRow({ deadline, onEdit, onRequestToggle }: DeadlineRowProps) {
const t = useTranslations('deadlines')
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const diff = daysFromToday(deadline.due_date)
const relative = completed
? null
: diff === 0
? t('rel_today')
: diff === 1
? t('rel_tomorrow')
: diff < 0
? t('rel_days_ago', { count: Math.abs(diff) })
: diff <= 14
? t('rel_in_days', { count: diff })
: null
const subParts = [
relative,
deadline.due_time ? `kl ${deadline.due_time.slice(0, 5)}` : null,
deadline.customer?.name ?? null,
].filter(Boolean) as string[]
return (
<div
onClick={() => onEdit?.(deadline)}
className={cn(
'group flex items-start gap-3 rounded-md py-3 -mx-2 px-2 transition-colors duration-150',
onEdit && 'cursor-pointer hover:bg-secondary/35',
completed && 'opacity-60',
)}
>
<DeadlineMark deadline={deadline} completed={completed} />
<div className="min-w-0 flex-1">
<p className={cn('text-sm leading-6', completed && 'line-through text-muted-foreground')}>
<span className="tabular-nums text-muted-foreground">
{deadlineDateLabel(deadline.due_date)}
</span>
<span className="text-muted-foreground/50"> · </span>
<span>{deadline.title}</span>
</p>
{subParts.length > 0 && (
<p
className={cn(
'text-xs leading-5',
overdue && !completed ? 'text-destructive' : 'text-muted-foreground',
)}
>
{subParts.join(' · ')}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-3 pt-1">
{completed ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
>
{t('mark_not_done')}
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
>
{t('group_mark_done')}
</button>
)}
{onEdit && (
<Pencil
className="h-3.5 w-3.5 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
aria-hidden="true"
/>
)}
</div>
</div>
)
}
+1 -2
View File
@@ -1,5 +1,4 @@
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineRow } from './DeadlineRow'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
+66 -13
View File
@@ -537,7 +537,7 @@
"history_reason": "reason: \"{reason}\""
},
"deadlines": {
"title": "Deadlines",
"title": "Key dates",
"load_failed_title": "Could not load deadlines",
"load_failed_description": "Check your connection and try again.",
"created_title": "Deadline created",
@@ -570,7 +570,32 @@
"group_mark_done": "Mark as done",
"group_confirm_question": "Mark <hl>{title}</hl> as done?",
"group_cancel": "Cancel",
"group_confirm": "Confirm"
"group_confirm": "Confirm",
"new_deadline": "New deadline",
"read_only_tooltip": "You have read-only access in this company",
"generating": "Generating…",
"help_text": "Deadlines for VAT, employer declarations and F-tax are generated automatically from your company's tax settings. Add your own with New deadline; click a row to edit it.",
"overdue_invoices_action": "View the invoices",
"seg_all": "All",
"seg_tax": "Tax",
"seg_invoicing": "Invoicing",
"seg_own": "Your own",
"section_overdue": "Overdue",
"section_today": "Today",
"section_upcoming": "Upcoming",
"section_completed": "Done",
"nearest_title": "Next up",
"nearest_days_to": "days until {title}",
"nearest_empty": "Nothing coming up.",
"nearest_overdue": "{count, plural, one {1 overdue} other {# overdue}}",
"rel_today": "Today",
"rel_tomorrow": "Tomorrow",
"rel_days_ago": "{count, plural, one {1 day ago} other {# days ago}}",
"rel_in_days": "{count, plural, one {In 1 day} other {In # days}}",
"mark_not_done": "Undo done",
"empty_title": "No deadlines yet.",
"empty_filtered": "No deadlines match the filter.",
"confirm_done_title": "Mark as done?"
},
"bureau": {
"title": "Bureau",
@@ -5851,7 +5876,7 @@
"missing_rate": "Missing rate"
},
"import": {
"title": "Import",
"title": "Import / export",
"subtitle": "Import bank transactions or bookkeeping data into your company",
"export_title": "Export",
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
@@ -5862,28 +5887,33 @@
"psd2_title": "Connect bank",
"psd2_recommended": "Recommended",
"psd2_requires_subscription": "Requires subscription",
"psd2_description": "Connect your bank account directly and sync transactions automatically via PSD2.",
"psd2_description": "Automatic bank transaction sync via PSD2",
"migration_title": "Import from another system",
"migration_description": "Nothing changes in your existing system.",
"bankfile_title": "Bank transactions",
"bankfile_description": "Import statements from your bank. Supports most Swedish banks.",
"csv_data_title": "Import CSV / Excel data",
"csv_data_description": "Import opening balances, customers, suppliers, or articles.",
"bankfile_title": "Bank file",
"bankfile_description": "CSV, OFX or your bank's own formats: SEB, Swedbank, Nordea",
"csv_data_title": "CSV or Excel",
"csv_data_description": "Opening balances, customers, suppliers and articles",
"csv_chip_opening_balances": "Opening balances",
"csv_chip_customers": "Customers",
"csv_chip_suppliers": "Suppliers",
"csv_chip_articles": "Articles",
"sie_title": "Bookkeeping data (SIE)",
"sie_description": "Import journal entries and chart of accounts from another bookkeeping system.",
"sie_title": "SIE file",
"sie_description": "Bookkeeping from another system or from your accountant",
"loading_migration": "Loading migration tool...",
"export_heading": "Export",
"export_sie_title": "Export SIE file",
"export_sie_description": "Export your bookkeeping as a SIE4 file for your accountant or for migrating to another system.",
"export_sie_title": "SIE 4",
"export_sie_description": "Any fiscal year, with or without closing entries",
"export_sie_period_label": "Fiscal year",
"export_sie_button": "Download SIE",
"export_sie_no_period": "Select a fiscal year to export.",
"export_sie_exclude_closing_label": "Exclude year-end closing voucher (recommended for eDeklarera and systems that do their own year-end closing)",
"export_cloud_title": "Cloud backup to Google Drive"
"export_cloud_title": "Cloud backup to Google Drive",
"export_sie_dialog_description": "Download your bookkeeping as a SIE4 file for your accountant or for moving to another system.",
"cloud_row_title": "Cloud sync",
"cloud_row_description": "Continuous backup of the archive to Google Drive",
"help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.",
"pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first."
},
"annualReportStudio": {
"choose": "Välj",
@@ -5986,5 +6016,28 @@
"preset_reports_description": "Reports are generated automatically once you have enough data. Start by creating invoices or importing transactions.",
"preset_reports_action": "Create invoice",
"preset_reports_secondary": "Import transactions"
},
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
"copy": "copy",
"attn_reconnect_action": "Reconnect",
"attn_shortfall": "The charge on {date} is {charge}. The balance is {missing} short.",
"attn_show_payment": "Show payment details",
"band_upcoming": "Upcoming",
"band_overdue": "Overdue",
"band_booked": "Completed",
"no_transactions": "No transactions yet. Sync to fetch the tax account.",
"chip_not_booked": "Not booked",
"chip_possible_duplicate": "Possible duplicate of {voucher}",
"chip_draft": "draft",
"action_show_voucher": "Show voucher",
"action_match": "Match",
"action_book": "Book",
"action_booking": "Booking…",
"payment_title": "Payment details",
"payment_description": "The charge on {date} is {charge}.",
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
"payment_shortfall_label": "Missing on the account",
"pgnote": "Synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically, and deviations are flagged here. Kronofogden: {amount}."
}
}
+66 -13
View File
@@ -537,7 +537,7 @@
"history_reason": "orsak: \"{reason}\""
},
"deadlines": {
"title": "Deadlines",
"title": "Viktiga datum",
"load_failed_title": "Kunde inte ladda deadlines",
"load_failed_description": "Kontrollera din anslutning och försök igen.",
"created_title": "Deadline skapad",
@@ -570,7 +570,32 @@
"group_mark_done": "Markera klar",
"group_confirm_question": "Markera <hl>{title}</hl> som klar?",
"group_cancel": "Avbryt",
"group_confirm": "Bekräfta"
"group_confirm": "Bekräfta",
"new_deadline": "Ny deadline",
"read_only_tooltip": "Du har endast läsbehörighet i detta företag",
"generating": "Genererar…",
"help_text": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas automatiskt från företagets skatteinställningar. Egna deadlines lägger du till med Ny deadline; klicka på en rad för att ändra den.",
"overdue_invoices_action": "Visa fakturorna",
"seg_all": "Alla",
"seg_tax": "Skatt",
"seg_invoicing": "Fakturering",
"seg_own": "Egna",
"section_overdue": "Förfallna",
"section_today": "Idag",
"section_upcoming": "Kommande",
"section_completed": "Klara",
"nearest_title": "Närmast",
"nearest_days_to": "dagar till {title}",
"nearest_empty": "Inget på gång.",
"nearest_overdue": "{count, plural, one {1 förfallen} other {# förfallna}}",
"rel_today": "Idag",
"rel_tomorrow": "Imorgon",
"rel_days_ago": "{count, plural, one {1 dag sedan} other {# dagar sedan}}",
"rel_in_days": "{count, plural, one {Om 1 dag} other {Om # dagar}}",
"mark_not_done": "Ångra klar",
"empty_title": "Inga deadlines ännu.",
"empty_filtered": "Inga deadlines matchar filtret.",
"confirm_done_title": "Markera som klar?"
},
"bureau": {
"title": "Byrå",
@@ -5851,7 +5876,7 @@
"missing_rate": "Saknar timpris"
},
"import": {
"title": "Importera",
"title": "Importera / exportera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
"export_title": "Exportera",
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
@@ -5862,28 +5887,33 @@
"psd2_title": "Koppla bank",
"psd2_recommended": "Rekommenderat",
"psd2_requires_subscription": "Kräver abonnemang",
"psd2_description": "Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.",
"psd2_description": "Automatisk hämtning av banktransaktioner via PSD2",
"migration_title": "Hämta från annat system",
"migration_description": "Inget ändras i ditt befintliga system.",
"bankfile_title": "Banktransaktioner",
"bankfile_description": "Importera kontoutdrag från din bank. Stöder de flesta svenska banker.",
"csv_data_title": "Importera CSV/Excel-data",
"csv_data_description": "Importera ingående balanser, kunder, leverantörer eller artiklar.",
"bankfile_title": "Bankfil",
"bankfile_description": "CSV, OFX eller bankens egna format: SEB, Swedbank, Nordea",
"csv_data_title": "CSV eller Excel",
"csv_data_description": "Ingående balanser, kunder, leverantörer och artiklar",
"csv_chip_opening_balances": "Ingående balanser",
"csv_chip_customers": "Kunder",
"csv_chip_suppliers": "Leverantörer",
"csv_chip_articles": "Artiklar",
"sie_title": "Bokföringsdata (SIE)",
"sie_description": "Importera verifikationer och kontoplan från ett annat bokföringsprogram.",
"sie_title": "SIE-fil",
"sie_description": "Bokföring från ett annat system eller från revisorn",
"loading_migration": "Laddar migreringsverktyg...",
"export_heading": "Exportera",
"export_sie_title": "Exportera SIE-fil",
"export_sie_description": "Exportera bokföringen som SIE4-fil för revisor eller programmigrering.",
"export_sie_title": "SIE 4",
"export_sie_description": "Valfritt räkenskapsår, med eller utan bokslutsposter",
"export_sie_period_label": "Räkenskapsår",
"export_sie_button": "Ladda ner SIE",
"export_sie_no_period": "Välj ett räkenskapsår för att exportera.",
"export_sie_exclude_closing_label": "Exkludera bokslutsverifikat (rekommenderas för eDeklarera och system som gör eget bokslut)",
"export_cloud_title": "Säkerhetskopia till Google Drive"
"export_cloud_title": "Säkerhetskopia till Google Drive",
"export_sie_dialog_description": "Ladda ner bokföringen som SIE4-fil för revisorn eller för flytt till ett annat system.",
"cloud_row_title": "Molnsynkronisering",
"cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive",
"help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.",
"pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först."
},
"annualReportStudio": {
"choose": "Välj",
@@ -5986,5 +6016,28 @@
"preset_reports_description": "Rapporter genereras automatiskt när du har tillräckligt med data. Börja med att skapa fakturor eller importera transaktioner.",
"preset_reports_action": "Skapa faktura",
"preset_reports_secondary": "Importera transaktioner"
},
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
"copy": "kopiera",
"attn_reconnect_action": "Anslut igen",
"attn_shortfall": "Dragningen {date} är {charge}. Saldot saknar {missing}.",
"attn_show_payment": "Visa betalningsuppgifter",
"band_upcoming": "Kommande",
"band_overdue": "Förfallna",
"band_booked": "Genomförda",
"no_transactions": "Inga transaktioner ännu. Synkronisera för att hämta skattekontot.",
"chip_not_booked": "Ej bokförd",
"chip_possible_duplicate": "Möjlig dublett av {voucher}",
"chip_draft": "utkast",
"action_show_voucher": "Visa verifikat",
"action_match": "Matcha",
"action_book": "Bokför",
"action_booking": "Bokför…",
"payment_title": "Betalningsuppgifter",
"payment_description": "Dragningen {date} är {charge}.",
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
"payment_shortfall_label": "Saknas på kontot",
"pgnote": "Synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt, och avvikelser flaggas här. Kronofogden: {amount}."
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+84
View File
@@ -0,0 +1,84 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 69.4 63.1" style="enable-background:new 0 0 69.4 63.1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#231F20;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="63.2" width="69.6" x="-0.1" y="-0.1">
</sliceSourceBounds>
</sfw>
</metadata>
<g>
<path class="st0" d="M55.5,50.9c2-5.9,1.2-12.6-3.3-17.4c-2.9-3.3-7-5.3-11.2-6.5l0.1-0.7C50.2,28.4,59.3,34,60.9,44
c0.3,2.2,0.3,4.6-0.3,6.8L55.5,50.9z">
</path>
<path class="st0" d="M36.7,29.1c1.6,0.8,3,2,4.1,3.5c3.4,4.8,2.8,9.8,1.7,12.8c-1.1,3.5-3,5.4-3,5.4h-6.9c4.6-3,7.4-7.7,7.7-13.2
c0.1-2.9-1.3-6.4-3.9-8L36.7,29.1z">
</path>
<path class="st0" d="M39.5,4.4c-6.1,1.2-11.4,5.2-13.4,11.5c-1.4,4.1-1.1,8.7-0.1,13l-0.6,0.2c-2.7-9-2.4-19.7,5.6-26
c1.8-1.4,3.8-2.5,6-3.1L39.5,4.4z">
</path>
<path class="st0" d="M30.1,31.5c-0.1-1.8,0.2-3.6,1-5.3c2.4-5.3,7.1-7.3,10.3-7.9c3.6-0.8,6.2-0.1,6.2-0.1l3.4,6
c-4.9-2.5-10.3-2.6-15.2-0.1c-2.5,1.4-4.9,4.3-5,7.4H30.1z">
</path>
<g>
<path class="st0" d="M8.1,41.6c4.2,4.6,10.4,7.1,16.7,5.6c4.3-0.9,8.1-3.5,11.1-6.6l0.5,0.4c-6.3,6.9-15.6,12.2-25.1,8.6
c-2.1-0.8-4.1-2-5.8-3.6L8.1,41.6z">
</path>
<path class="st0" d="M36.1,35.7c-1.5,1-3.2,1.7-5.1,1.9c-5.8,0.6-9.9-2.3-12.1-4.7c-2.6-2.6-3.3-5.3-3.3-5.3l3.4-6
c0.4,5.5,3.1,10.2,7.8,13.1c2.5,1.4,6.2,2,8.9,0.5L36.1,35.7z">
</path>
</g>
<path class="st0" d="M4.9,55.9c-0.5-0.2-1.1-0.4-1.8-0.4c-0.6,0-1.4,0.3-1.4,1.2c0,1.5,3.8,0.9,3.8,3.8c0,1.9-1.5,2.6-3.2,2.6
c-0.9,0-1.4-0.1-2.2-0.3l0.2-1.6c0.6,0.3,1.3,0.5,1.9,0.5c0.7,0,1.5-0.3,1.5-1.1c0-1.6-3.8-1-3.8-3.8c0-1.9,1.5-2.6,3-2.6
c0.7,0,1.4,0.1,2.1,0.3L4.9,55.9z">
</path>
<path class="st0" d="M6.8,53.6h1.7V59l0,0l2-2.5h1.9L10,59.4l2.7,3.5h-2.1l-2.1-3.1l0,0v3.1H6.8V53.6z">
</path>
<path class="st0" d="M14.4,61.1c0-1,1.1-1.1,1.8-1.1h0.7c0,0.5-0.1,0.9-0.3,1.3c-0.3,0.3-0.7,0.6-1.2,0.6
C14.8,61.9,14.4,61.7,14.4,61.1z M13.5,58.4c0.6-0.4,1.3-0.7,2-0.7c1,0,1.4,0.3,1.4,1.4H16c-0.7,0-1.5,0.1-2.1,0.4
c-0.6,0.3-1.1,0.9-1.1,1.8c0,1.2,1.1,1.9,2.2,1.9c0.8,0,1.6-0.4,2-1.1l0,0c0,0.2,0,0.6,0.1,0.9h1.5c0-0.5-0.1-0.9-0.1-1.4
s0-0.9,0-1.6v-0.8c0-1.9-0.8-2.7-2.7-2.7c-0.7,0-1.6,0.2-2.2,0.5L13.5,58.4z">
</path>
<path class="st0" d="M20.2,57.8H19v-1.2h1.2v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
C23.2,63,22.8,63,22.3,63c-1.3,0-2-0.8-2-2.1L20.2,57.8z">
</path>
<path class="st0" d="M25,57.8h-1.2v-1.2H25v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
C28,63,27.6,63,27.1,63c-1.3,0-2-0.8-2-2.1L25,57.8z">
</path>
<path class="st0" d="M30.2,59.1c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H30.2z M34.1,61.2c-0.6,0.3-1.3,0.6-2.1,0.6
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5V61.2z">
</path>
<path class="st0" d="M34.8,56.6h1.8l1.6,4.6l0,0l1.5-4.6h1.7L39,62.9h-1.9L34.8,56.6z">
</path>
<path class="st0" d="M43.1,59.1c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H43.1z M47,61.2c-0.6,0.3-1.3,0.6-2.1,0.6
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5L47,61.2z">
</path>
<path class="st0" d="M48.7,56.6h1.5V58l0,0c0.1-0.6,0.8-1.6,1.7-1.6c0.2,0,0.3,0,0.5,0v1.7c-0.1,0-0.4-0.1-0.7-0.1
c-1.3,0-1.3,1.7-1.3,2.6v2.3h-1.7V56.6z">
</path>
<path class="st0" d="M53.1,53.7h1.7v5.4l0,0l2-2.5h1.9l-2.4,2.8L59,63h-2.1l-2.1-3.1l0,0V63h-1.7L53.1,53.7z">
</path>
<path class="st0" d="M60.4,59.2c0.1-0.9,0.5-1.5,1.5-1.5c0.9,0,1.3,0.7,1.3,1.5H60.4z M64.3,61.3c-0.6,0.3-1.3,0.6-2.1,0.6
c-1,0-1.7-0.6-1.8-1.6h4.4c0-2.2-0.7-3.8-3-3.8c-2,0-2.9,1.5-3,3.3c0,2.1,1.2,3.3,3.3,3.3c0.9,0,1.6-0.2,2.2-0.5V61.3z">
</path>
<path class="st0" d="M66.2,57.8H65v-1.2h1.2v-1.3l1.7-0.5v1.8h1.5v1.2h-1.5v3c0,0.5,0.1,1,0.8,1c0.3,0,0.6-0.1,0.8-0.2v1.3
C69.2,63,68.8,63,68.3,63c-1.3,0-2-0.8-2-2.1L66.2,57.8z">
</path>
</g>
<path class="st0" d="M38.8,27.8c6.2,1.9,11.5,6.8,12.8,13.5c0.6,3.1,0.1,6.6-1.1,9.7h-5.4c1.3-2.2,2.3-4.6,2.5-7.2
c0.4-4.5-1.2-9.9-5.1-12.9c-1.2-1-2.5-1.8-3.9-2.5L38.8,27.8z">
</path>
<path class="st0" d="M27.9,30.4c-1.5-6.3,0.2-13.3,5.3-17.8c2.4-2.1,5.6-3.4,8.9-3.9l2.7,4.7c-2.6,0-5.1,0.3-7.5,1.4
c-4.2,1.9-8,6-8.6,10.8c-0.2,1.5-0.3,3.1-0.2,4.6L27.9,30.4z">
</path>
<g>
<g>
<path class="st0" d="M36.3,38.2c-4.6,4.5-11.5,6.7-18,4.7c-3-1-5.8-3.1-7.9-5.6l2.6-4.7c1.3,2.2,2.9,4.2,5.1,5.7
c3.8,2.6,9.3,3.7,13.7,1.8c1.4-0.6,2.8-1.3,4.1-2.3L36.3,38.2z">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+142
View File
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="87.841667mm"
height="79.883453mm"
viewBox="0 0 332 301.92171"
version="1.1"
id="svg1103"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="Skatteverket logo.svg">
<defs
id="defs1097" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="212.42857"
inkscape:cy="130.96085"
inkscape:document-units="px"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="1267"
inkscape:window-height="728"
inkscape:window-x="83"
inkscape:window-y="0"
inkscape:window-maximized="0" />
<metadata
id="metadata1100">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-162.57143,-389.03915)">
<path
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 347.99303,521.79612 c 29.72199,8.92318 54.80843,32.42997 61.22108,64.38041 2.88664,15.01615 0.66761,31.37688 -5.02103,46.19555 l -25.90449,-0.0751 c 6.07415,-10.65338 10.78492,-22.02128 11.98845,-34.35764 2.07802,-21.75788 -5.88609,-47.38031 -24.30602,-61.48437 -5.78266,-4.60734 -11.89444,-8.71633 -18.89006,-11.72521 l 0.91207,-2.93364"
id="path37" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 427.82209,632.29686 c 9.77883,-28.13291 5.57583,-60.09276 -15.5897,-83.09182 -13.8408,-15.63671 -33.52069,-25.55659 -53.52026,-31.20761 l 0.61117,-3.21573 c 43.52519,10.0609 87.07857,37.05611 94.29047,84.88774 1.67367,10.71909 1.41979,22.03996 -1.45742,32.64625 l -24.33426,-0.0193"
id="path39" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 338.13898,528.33102 c 7.57858,4.04316 14.34854,9.60016 19.79272,16.81206 16.17267,22.78278 13.32366,46.79732 8.34021,61.44675 -5.04927,16.76505 -14.43317,25.70703 -14.43317,25.70703 l -33.09756,0.10344 c 21.92713,-14.21691 35.21316,-36.71761 36.70821,-62.99822 0.24445,-13.75618 -6.16819,-30.71868 -18.53277,-38.41011 l 1.22236,-2.66102"
id="path41" />
<path
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 295.84556,534.37695 c -7.08966,-30.21092 0.76161,-63.68461 25.2557,-85.17922 11.57475,-9.99509 26.86355,-16.23849 42.5379,-18.69259 l 12.86292,22.48189 c -12.26116,0.0469 -24.4753,1.63607 -35.75854,6.75114 -19.89616,9.05483 -38.11863,28.74412 -41.15571,51.74317 -1.10951,7.30592 -1.62666,14.64943 -0.74281,22.21862 l -2.99946,0.67699"
id="path43" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 351.56607,410.18586 c -29.25187,5.557 -54.63921,25.02063 -64.01372,54.84606 -6.64772,19.79272 -5.42536,41.79508 -0.3385,61.94511 l -3.09349,1.0813 c -12.99455,-42.74476 -11.33968,-93.96136 26.52507,-124.07826 8.45304,-6.78877 18.24128,-12.13892 28.87575,-14.94092 l 12.04489,21.14671"
id="path45" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 306.43302,539.65189 c -0.28208,-8.58469 1.15652,-17.22579 4.69196,-25.54719 11.67817,-25.36854 33.90618,-34.89349 49.09158,-37.87415 17.04713,-3.98675 29.61856,-0.39492 29.61856,-0.39492 l 16.46417,28.7065 c -23.26233,-11.91325 -49.39249,-12.19533 -72.90868,-0.38551 -12.04489,6.65713 -23.55382,20.67659 -24.04276,35.232 l -2.91483,0.26327"
id="path47" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 201.24481,588.01007 c 19.84915,22.19981 49.61815,33.91561 79.99832,26.56268 20.3945,-4.5039 38.61697,-16.90608 53.25699,-31.64955 l 2.51994,2.09681 c -29.91945,33.17279 -74.65758,58.16521 -119.98807,41.25913 -10.17375,-3.74229 -19.81153,-9.37451 -27.68162,-17.03772 l 11.89444,-21.23135"
id="path49" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 335.4498,559.91475 c -7.21189,4.67315 -15.35463,7.89828 -24.30603,9.15823 -27.76623,3.0747 -47.38029,-11.05759 -57.77029,-22.5289 C 241.16876,533.99144 237.76497,521.373 237.76497,521.373 l 16.10687,-28.91337 c 1.78651,26.07375 15.01611,48.61207 37.26295,62.66912 11.91325,6.8922 29.85364,9.51556 42.58491,2.43531 l 1.7301,2.35069"
id="path51" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 186.05003,656.17036 c -2.59515,-1.12832 -5.48179,-1.7771 -8.37783,-1.78649 -2.82082,0 -6.48788,1.23173 -6.48788,5.71686 -0.009,7.13665 18.03443,4.14657 18.01562,17.94977 -0.009,9.02661 -7.14606,12.4304 -15.46748,12.42098 -4.48509,0 -6.48788,-0.59236 -10.54985,-1.60786 l 0.77102,-7.42814 c 2.83022,1.54203 6.07417,2.54814 9.31809,2.54814 3.12171,0.009 7.31533,-1.57966 7.32472,-5.29374 0.009,-7.84185 -18.04381,-4.62612 -18.025,-18.25067 0.009,-9.19586 7.15546,-12.55262 14.40497,-12.54323 3.53541,0.009 6.84516,0.47954 9.84463,1.49504 l -0.77101,6.77934"
id="path53"
clip-path="none" />
<path
style="fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 195.02963,645.5641 7.89828,0.009 -0.0376,26.00792 h 0.12222 l 9.44972,-12.13891 9.3181,0.0193 -11.33968,13.54932 12.71248,16.82146 -10.02331,-0.009 -10.12672,-14.76227 h -0.11283 l -0.0188,14.74347 -7.90769,-0.009 0.0659,-44.23039"
id="path57"
clip-path="none" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 227.21512,668.24347 c 2.66097,-2.00277 6.02714,-3.29096 9.38392,-3.29096 4.66374,0.009 6.83578,1.66429 6.83578,6.56309 l -4.36287,-0.009 c -3.30975,-0.009 -7.20247,0.28207 -10.21135,1.74891 -3.00887,1.47621 -5.1903,4.12778 -5.1997,8.6693 -0.009,5.77327 5.23731,9.02661 10.60626,9.03601 3.59183,0.009 7.55038,-1.88055 9.38392,-5.23731 h 0.11283 c 0.0563,0.94967 0.0563,2.95244 0.34791,4.54152 l 6.958,0.009 c -0.16916,-2.36008 -0.28209,-4.48509 -0.3385,-6.77934 -0.0563,-2.23786 -0.11283,-4.54153 -0.11283,-7.60682 l 0.009,-3.89272 c 0.009,-8.9702 -3.8175,-12.80651 -13.07918,-12.81591 -3.35676,-0.009 -7.48455,0.87444 -10.55925,2.28487 z m 3.99615,13.22023 c 0.009,-4.66374 5.25613,-5.24672 8.79154,-5.23733 h 3.42261 c 0,2.36008 -0.35731,4.5415 -1.65489,6.2528 -1.24116,1.64548 -3.1311,2.70799 -5.557,2.70799 -2.83022,-0.009 -5.00226,-1.12833 -5.00226,-3.72346"
id="path59" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 259.03392,665.30042 -5.83908,-0.009 0.009,-5.78268 5.83908,0.009 0.009,-6.07415 7.89828,-2.52933 -0.009,8.61286 7.02384,0.009 -0.009,5.78266 -7.02383,-0.009 -0.0188,14.15108 c 0,2.59515 0.70521,5.01167 3.71407,5.02106 1.41041,0 2.77381,-0.29147 3.59185,-0.82744 l 0.23507,6.25281 c -1.65488,0.47014 -3.4884,0.70521 -5.84848,0.6958 -6.18699,0 -9.60017,-3.8363 -9.59077,-9.85406 l 0.0188,-15.44866"
id="path63" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 281.99535,665.32862 -5.8391,-0.009 0.009,-5.78266 5.8391,0.009 0.009,-6.07415 7.90769,-2.51994 -0.009,8.60348 7.01441,0.009 -0.009,5.78266 -7.01442,-0.009 -0.0188,14.15109 c -0.009,2.59515 0.69581,5.01165 3.70467,5.02105 1.41981,0 2.7738,-0.29147 3.60124,-0.82743 l 0.22566,6.2528 c -1.65486,0.47015 -3.479,0.70522 -5.83908,0.70522 -6.19639,-0.009 -9.60958,-3.84572 -9.60018,-9.86347 l 0.0188,-15.44866"
id="path67" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 325.89665,681.8398 c -2.95246,1.64548 -6.25281,3.05589 -10.08912,3.05589 -4.88942,-0.009 -8.31201,-2.79262 -8.65991,-7.68203 l 20.99627,0.0282 c 0.0188,-10.61569 -3.15931,-18.34471 -14.42376,-18.36352 -9.43093,-0.009 -14.10408,7.2401 -14.11349,16.02223 -0.0188,9.96689 5.81088,15.75896 15.84358,15.76835 4.24063,0.009 7.55038,-0.80862 10.43702,-2.39768 z m -18.73963,-10.05153 c 0.35731,-4.07136 2.60456,-7.13665 7.03323,-7.12723 4.41929,0.009 6.1776,3.30975 6.41267,7.14606 l -13.4459,-0.0193"
id="path71" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 329.01834,659.61177 8.49066,0.009 7.52216,22.13399 h 0.11283 l 7.35292,-22.1152 7.89828,0.0193 -11.24564,30.35199 -8.91377,-0.009 -11.21744,-30.38958"
id="path75" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 387.51264,681.93382 c -2.95244,1.64548 -6.2528,3.05589 -10.08911,3.04648 -4.89881,-0.009 -8.31199,-2.78321 -8.65989,-7.67262 l 20.99627,0.0282 c 0.0188,-10.61566 -3.15931,-18.34471 -14.42378,-18.3635 -9.43093,-0.009 -14.10408,7.23067 -14.11349,16.02223 -0.0188,9.96688 5.82029,15.74954 15.83419,15.76835 4.25002,0 7.55038,-0.81805 10.44643,-2.40709 z m -18.7396,-10.06091 c 0.35729,-4.06198 2.60454,-7.12727 7.03323,-7.11785 4.41928,0 6.18698,3.30976 6.41265,7.13666 l -13.44588,-0.0188"
id="path79" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 395.37332,659.7058 7.02384,0.009 -0.009,6.9016 h 0.11283 c 0.35731,-2.83022 3.61062,-7.60681 8.3308,-7.59739 0.76163,0 1.58906,0 2.41651,0.24446 l -0.009,7.9547 c -0.7052,-0.41372 -2.12501,-0.64878 -3.54482,-0.64878 -6.42206,-0.009 -6.44085,8.0111 -6.44085,12.37397 l -0.0188,11.14223 -7.89828,-0.009 0.0376,-30.37078"
id="path83" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 416.7551,645.87439 7.90768,0.009 -0.0376,26.01731 h 0.11284 l 9.45913,-12.13891 9.31809,0.009 -11.34908,13.5493 12.71248,16.83088 -10.02329,-0.0193 -10.11733,-14.76226 h -0.12222 l -0.0193,14.74345 -7.90771,-0.009 0.0657,-44.23039"
id="path87" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 470.08733,682.04665 c -2.95248,1.64548 -6.25282,3.05587 -10.08913,3.05587 -4.89882,-0.009 -8.31199,-2.78319 -8.65992,-7.682 l 20.9963,0.0282 c 0.009,-10.61567 -3.15932,-18.34471 -14.42379,-18.36352 -9.44031,-0.009 -14.10406,7.2401 -14.11348,16.02223 -0.0193,9.9669 5.81088,15.75898 15.83419,15.76837 4.25002,0.009 7.55979,-0.80864 10.44642,-2.39768 z m -18.73962,-10.0515 c 0.35731,-4.07137 2.60454,-7.13666 7.03324,-7.12727 4.41925,0.009 6.18699,3.30975 6.41265,7.14606 l -13.44589,-0.0193"
id="path91" />
<path
style="clip-rule:nonzero;fill:#003366;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 479.15154,665.61072 -5.82967,-0.009 0.009,-5.78266 5.82969,0.009 0.009,-6.07415 7.90769,-2.51994 -0.009,8.6035 7.01443,0.009 -0.009,5.78267 -7.01441,-0.009 -0.0193,14.15111 c 0,2.59515 0.69583,5.01164 3.70468,5.02105 1.41982,0 2.77381,-0.29151 3.60124,-0.82743 l 0.22554,6.25278 c -1.65486,0.47016 -3.479,0.70522 -5.83908,0.70522 -6.18698,-0.009 -9.60958,-3.8457 -9.60018,-9.86345 l 0.0193,-15.44867"
id="path95" />
<path
style="fill:#ffcc00;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.40709"
d="m 336.40887,571.19801 c -22.1904,21.69206 -54.90246,32.28894 -85.96904,22.44429 -14.5366,-4.72957 -27.78502,-14.58361 -37.99638,-26.73193 l 12.59025,-22.63235 c 6.37504,10.47463 14.05706,20.09362 24.26842,27.10805 18.0438,12.34577 44.31499,17.75233 65.57453,8.45304 6.82638,-2.83022 13.38007,-6.187 19.39781,-10.85074 l 2.13441,2.20964"
id="path99" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB