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
co-authored by Claude Fable 5
parent d840257c0c
commit 2bec5acedb
29 changed files with 1943 additions and 1620 deletions
+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, så 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 på ”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 på &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>