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:
co-authored by
Claude Fable 5
parent
d840257c0c
commit
2bec5acedb
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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å "Synkronisera nu".
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user