Files
accounted/components/deadlines/DeadlineList.tsx
T
Jakob Wennberg 9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00

247 lines
9.0 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 { SegmentedControl } from '@/components/ui/segmented-control'
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) */}
<SegmentedControl
value={segment}
onChange={setSegment}
options={[
{ value: 'all', label: t('seg_all') },
{ value: 'tax', label: t('seg_tax') },
{ value: 'invoicing', label: t('seg_invoicing') },
{ value: 'own', label: t('seg_own') },
]}
/>
<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 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>
)
}