Files
accounted/components/deadlines/DeadlineList.tsx
T
Jakob WennbergandClaude Fable 5 2bec5acedb 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>
2026-07-24 12:36:08 +02:00

261 lines
9.5 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Deadline, DeadlineType } from '@/types'
import { DeadlineRow, deadlineDateLabel } from './DeadlineRow'
import { DeadlineGroupCard, isSkattekontoDeadline } from './DeadlineGroupCard'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
type TypeSegment = 'all' | 'tax' | 'invoicing' | 'own'
const SEGMENT_TYPES: Record<Exclude<TypeSegment, 'all'>, ReadonlyArray<DeadlineType>> = {
tax: ['tax'],
invoicing: ['invoicing'],
own: ['delivery', 'report', 'other'],
}
interface DeadlineListProps {
deadlines: Deadline[]
onDeadlineToggle: (deadline: Deadline) => Promise<void>
onDeadlineEdit: (deadline: Deadline) => void
}
/**
* Viktiga datum as the concept thread (scene 17): a type seg, borderless
* rows under hairline section headers, and a "Närmast" countdown pane on the
* right. Marking done confirms up front via ConfirmDialog.
*/
export function DeadlineList({
deadlines,
onDeadlineToggle,
onDeadlineEdit,
}: DeadlineListProps) {
const t = useTranslations('deadlines')
const [segment, setSegment] = useState<TypeSegment>('all')
const [confirmTarget, setConfirmTarget] = useState<Deadline | null>(null)
const filteredDeadlines = useMemo(() => {
if (segment === 'all') return deadlines
const types = SEGMENT_TYPES[segment]
return deadlines.filter((d) => types.includes(d.deadline_type))
}, [deadlines, segment])
const groupedDeadlines = useMemo(() => {
const today = new Date().toISOString().split('T')[0]
const overdue: Deadline[] = []
const todayDeadlines: Deadline[] = []
const upcoming: Deadline[] = []
const completed: Deadline[] = []
for (const d of filteredDeadlines) {
if (d.is_completed) {
completed.push(d)
} else if (isDeadlineOverdue(d)) {
overdue.push(d)
} else if (d.due_date === today) {
todayDeadlines.push(d)
} else {
upcoming.push(d)
}
}
return { overdue, today: todayDeadlines, upcoming, completed }
}, [filteredDeadlines])
// "Närmast" pane: days to the nearest pending deadline (across the
// unfiltered list: the countdown is page context, not a filter readout).
const nearest = useMemo(() => {
const pending = deadlines
.filter((d) => !d.is_completed && !isDeadlineOverdue(d))
.sort((a, b) => a.due_date.localeCompare(b.due_date))
const first = pending[0]
if (!first) return null
const today = startOfDay(new Date())
const days = Math.round(
(parseDate(first.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
)
return { deadline: first, days: Math.max(0, days) }
}, [deadlines])
const overdueCount = useMemo(
() => deadlines.filter((d) => !d.is_completed && isDeadlineOverdue(d)).length,
[deadlines],
)
const handleRequestToggle = (deadline: Deadline) => {
// Un-completing is a harmless revert: no confirmation (matches the undo
// toast the page already offers).
if (deadline.is_completed) {
void onDeadlineToggle(deadline)
return
}
setConfirmTarget(deadline)
}
const sections = [
{ key: 'overdue', label: t('section_overdue'), items: groupedDeadlines.overdue },
{ key: 'today', label: t('section_today'), items: groupedDeadlines.today },
{ key: 'upcoming', label: t('section_upcoming'), items: groupedDeadlines.upcoming },
{ key: 'completed', label: t('section_completed'), items: groupedDeadlines.completed },
].filter((s) => s.items.length > 0)
// Skattekonto obligations legally share the same due date (moms + AGI +
// preliminärskatt all fall on "den 12:e"): collapse 2+ such rows into one
// grouped row per date instead of near-identical rows. Other deadlines
// render as before, in their original order.
type ListEntry =
| { kind: 'single'; deadline: Deadline }
| { kind: 'group'; date: string; items: Deadline[] }
const toEntries = (items: Deadline[]): ListEntry[] => {
const byDate = new Map<string, Deadline[]>()
for (const d of items) {
if (!isSkattekontoDeadline(d)) continue
const group = byDate.get(d.due_date) ?? []
group.push(d)
byDate.set(d.due_date, group)
}
const emittedDates = new Set<string>()
const entries: ListEntry[] = []
for (const d of items) {
const group = byDate.get(d.due_date)
if (isSkattekontoDeadline(d) && group && group.length >= 2) {
if (!emittedDates.has(d.due_date)) {
emittedDates.add(d.due_date)
entries.push({ kind: 'group', date: d.due_date, items: group })
}
continue
}
entries.push({ kind: 'single', deadline: d })
}
return entries
}
return (
<div className="space-y-6">
{/* Toolbar: the type seg (concept: Alla / Skatt / Fakturering / Egna) */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'all', label: t('seg_all') },
{ key: 'tax', label: t('seg_tax') },
{ key: 'invoicing', label: t('seg_invoicing') },
{ key: 'own', label: t('seg_own') },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={segment === key}
onClick={() => setSegment(key)}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
segment === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
<div className="gap-8 lg:grid lg:grid-cols-[minmax(0,1fr)_240px] lg:items-start">
{/* The thread */}
{filteredDeadlines.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
{segment !== 'all' ? t('empty_filtered') : t('empty_title')}
</p>
) : (
<div className="stagger-enter space-y-8">
{sections.map(({ key, label, items }) => (
<section key={key}>
<div className="mb-1 flex items-center gap-3 px-1">
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</h2>
<span className="text-xs tabular-nums text-muted-foreground/50">
{items.length}
</span>
<div className="h-px flex-1 bg-border/60" />
</div>
<div>
{toEntries(items).map((entry) =>
entry.kind === 'group' ? (
<DeadlineGroupCard
key={`skattekonto-${entry.date}`}
deadlines={entry.items}
onEdit={onDeadlineEdit}
onRequestToggle={handleRequestToggle}
/>
) : (
<DeadlineRow
key={entry.deadline.id}
deadline={entry.deadline}
onEdit={onDeadlineEdit}
onRequestToggle={handleRequestToggle}
/>
),
)}
</div>
</section>
))}
</div>
)}
{/* "Närmast" pane (concept aside) */}
<aside className="mt-8 lg:mt-0">
<div className="rounded-lg border border-border">
<div className="border-b border-border px-4 py-2.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('nearest_title')}
</p>
</div>
<div className="px-4 py-6 text-center">
{nearest ? (
<>
<p className="font-display text-4xl leading-none tracking-tight tabular-nums">
{nearest.days === 0 ? t('rel_today') : nearest.days}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{nearest.days === 0
? nearest.deadline.title
: t('nearest_days_to', { title: nearest.deadline.title })}
</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground/60">
{deadlineDateLabel(nearest.deadline.due_date)}
</p>
</>
) : (
<p className="py-4 text-xs text-muted-foreground">{t('nearest_empty')}</p>
)}
{overdueCount > 0 && (
<p className="mt-3 text-xs font-medium text-destructive">
{t('nearest_overdue', { count: overdueCount })}
</p>
)}
</div>
</div>
</aside>
</div>
<ConfirmDialog
open={confirmTarget !== null}
onOpenChange={(open) => !open && setConfirmTarget(null)}
title={t('confirm_done_title')}
description={
confirmTarget
? `${deadlineDateLabel(confirmTarget.due_date)} · ${confirmTarget.title}`
: undefined
}
confirmLabel={t('group_confirm')}
onConfirm={async () => {
if (confirmTarget) await onDeadlineToggle(confirmTarget)
}}
/>
</div>
)
}