* 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>
261 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|