feat(ui): rest-of-nav 1 — Viktiga datum, Skattekonto, Periodiseringar, Import to the concept language (#1140)

* feat(ui): concept scenes 17/24/32/33 for Viktiga datum, Skattekonto, Periodiseringar and Import (rest-of-nav 1)

Viktiga datum: thread rows with type-icon circles behind a type seg
(Alla/Skatt/Fakturering/Egna), Narmast countdown pane, Ny deadline lifted
to the page header, both banners replaced by one AttnLine, mark-done via
ConfirmDialog. DeadlineCard/DeadlineFilters die; DeadlineRow is the row.

Skattekonto: card-less saldo hero with OCR + quiet copy, shortfall AttnLine
computed from the next drain date with a betalningsuppgifter dialog
(bankgiro 5050-1055 + OCR), one dry-table with Kommande/Forfallna/
Genomforda band rows, chips only on unbooked genomforda rows, quiet
hover actions. Tabs and per-row badge noise are gone; the concept's
Saldo column is dropped because SKV stores no per-row running balance.

Periodiseringar: banner becomes an AttnLine with inline Bokfor forfallna
(now confirm-first), house seg with Aktiva count, dry-table with muted
normal states and animated RowFoldout for installments, Los upp nu as a
quiet hover link through the shared ConfirmDialog.

Import: tabs collapse into one two-column row list (Importera | Exportera)
in the concept row language; SIE export moves into a small dialog and
Molnsynkronisering folds the CloudBackupCard open in place. The
/import?view=export#sie-export and /import#cloud-backup deep links keep
working. Sandbox notice is an AttnLine.

All four pages get stagger-enter, a help popover behind ? and sv+en keys
for every new string. 9189 tests green, lint clean, guards pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ui): align dashboard loading skeletons with the migrated page silhouettes

Folds in the parallel WIP from this checkout at the founder's request:
every loading.tsx under (dashboard) now mirrors its migrated page
row-for-row (24px title block, pill actions, borderless table heads,
single-line rows), and the shared (dashboard)/loading.tsx takes Hem's
greeting + Att gora silhouette. Also lands the pending DECISIONS.md
lines (onboarding swap plan note + rest-of-nav 1 deviations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(ui): authority logos, stat-tile skattekonto hero, quieter type on rest-of-nav 1

Viktiga datum: statutory deadlines wear the receiving authority's mark
(Skatteverket for tax dates, Bolagsverket for arsredovisning/arsstamma)
as a small badge on white; other deadlines keep the neutral type icons.
Row dates go muted, titles drop font-medium, the Narmast countdown
steps down to the house text-4xl display scale.

Skattekonto: the 32px serif hero becomes two compact metric tiles in
the KPIHeroCards idiom (saldo with the Skatteverket mark + OCR meta,
nasta dragning with date and event count), matching how numbers read
on the migrated pages.

Periodiseringar: chevron column dropped; rows expand on click exactly
like the verifikat list.

Import: provider logo chips return on Hamta fran annat system (live-
version parity) and Koppla bank carries the Enable Banking mark.

Adds skatteverket(_color), bolagsverket, enable-banking plus claude/
anthropic marks (for future use) under public/logos/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(ui): keep Importera and Exportera as separate tabs on the import page

Founder feedback: the merged two-column landing goes back to the
familiar split. The house seg switches between the Importera rows and
the Exportera rows (SIE 4 dialog + Molnsynkronisering fold), ?view=export
selects the export tab again and the hash deep links flip to it before
opening their surface. Row language and logo chips stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): readable Enable Banking mark and full-width skattekonto

The enable-banking.webp is the full stacked logo in white-on-transparent:
invisible on the light chip and mush at 16px. The chip now uses a cropped
368px icon square (enable-banking-icon.png) with the marketing site's
grayscale+brightness treatment in light mode and a white lift in dark.

Skattekonto loses its max-w-3xl cap so the table stretches the content
column exactly like Bokforing and Transaktioner; the saldo tiles take
KPI-card width (lg:grid-cols-4). Import's tab columns stretch too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): address review-bot findings on rest-of-nav 1

CodeRabbit triage, all three confirmed real: the Bokfor forfallna attn
action is hidden for read-only users instead of rendering a no-op link;
a failed deadline edit rethrows so the form stays open with the user's
input; authority marks are reserved for statutory (system-generated)
deadlines: a manual tax-category deadline keeps the neutral icon.

Compliance swarm's two high findings verified clean, no change needed:
/api/bookkeeping/accruals/:id/dissolve and /api/deadlines/:id (+ /complete)
all run through withRouteContext with company_id scoping and 404 on miss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 12:36:08 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d840257c0c
commit 2bec5acedb
29 changed files with 1943 additions and 1620 deletions
-271
View File
@@ -1,271 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Deadline } from '@/types'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
isDeadlineOverdue,
DEADLINE_TYPE_LABELS,
parseDate,
startOfDay,
} from '@/lib/calendar/utils'
import { Check, Pencil } from 'lucide-react'
interface DeadlineCardProps {
deadline: Deadline
onToggle: (deadline: Deadline) => void
onEdit?: (deadline: Deadline) => void
onDelete?: (deadline: Deadline) => void
compact?: boolean
}
function getRelativeDate(dateStr: string): { text: string; urgent: boolean } | null {
const today = startOfDay(new Date())
const target = parseDate(dateStr)
const diffMs = target.getTime() - today.getTime()
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return { text: 'Idag', urgent: true }
if (diffDays === 1) return { text: 'Imorgon', urgent: false }
if (diffDays === -1) return { text: '1 dag sedan', urgent: true }
if (diffDays < -1) return { text: `${Math.abs(diffDays)} dagar sedan`, urgent: true }
if (diffDays <= 7) return { text: `Om ${diffDays} dagar`, urgent: false }
if (diffDays <= 14) return { text: `Om ${diffDays} dagar`, urgent: false }
return null
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
export function DeadlineCard({
deadline,
onToggle,
onEdit,
onDelete,
compact = false,
}: DeadlineCardProps) {
const [confirming, setConfirming] = useState(false)
const confirmRef = useRef<HTMLDivElement>(null)
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const relative = getRelativeDate(deadline.due_date)
const dueDate = parseDate(deadline.due_date)
const dayNum = dueDate.getDate()
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
// Dismiss confirmation when clicking outside
useEffect(() => {
if (!confirming) return
function handleClickOutside(e: MouseEvent) {
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
setConfirming(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [confirming])
function handleToggleClick(e: React.MouseEvent) {
e.stopPropagation()
if (completed) {
// Uncompleting doesn't need confirmation
onToggle(deadline)
return
}
setConfirming(true)
}
function handleConfirm(e: React.MouseEvent) {
e.stopPropagation()
setConfirming(false)
onToggle(deadline)
}
function handleCancel(e: React.MouseEvent) {
e.stopPropagation()
setConfirming(false)
}
function handleCardClick() {
if (confirming) return
if (onEdit) onEdit(deadline)
}
// -- Compact variant (used in dashboard widgets) --
if (compact) {
return (
<div
onClick={handleCardClick}
className={cn(
'group flex items-center gap-3 py-2.5 transition-colors',
onEdit && !confirming && 'cursor-pointer hover:bg-accent/30 -mx-2 px-2 rounded-md',
completed && 'opacity-50',
)}
>
<span className="text-xs tabular-nums w-14 flex-shrink-0 text-muted-foreground">
{dayNum} {monthStr}
</span>
<p className={cn(
'text-sm truncate flex-1 min-w-0',
completed ? 'line-through text-muted-foreground' : 'text-foreground',
)}>
{deadline.title}
</p>
{!completed && relative && (
<span className={cn(
'text-xs flex-shrink-0',
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
)}>
{relative.text}
</span>
)}
{completed && (
<Check className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
</div>
)
}
// -- Full card --
return (
<div ref={confirmRef}>
<div
onClick={handleCardClick}
className={cn(
'group rounded-lg border bg-card transition-all duration-150',
onEdit && !confirming && 'cursor-pointer',
confirming && 'ring-2 ring-ring ring-offset-1',
completed
? 'opacity-50 hover:opacity-70'
: !confirming && 'hover:bg-accent/40 active:bg-accent/60',
)}
>
{/* Main row */}
<div className="flex items-center gap-4 py-3 px-4">
{/* Date block */}
<div className="flex-shrink-0 w-12 text-center">
<span className={cn(
'block text-lg font-display leading-none tabular-nums',
completed && 'text-muted-foreground',
)}>
{dayNum}
</span>
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
{monthStr}
</span>
</div>
{/* Divider */}
<div className="w-px h-8 bg-border flex-shrink-0" />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className={cn(
'text-sm font-medium truncate',
completed && 'line-through text-muted-foreground',
)}>
{deadline.title}
</p>
{deadline.deadline_type !== 'other' && (
<span className="text-[11px] text-muted-foreground/60 flex-shrink-0 hidden sm:inline">
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{deadline.due_time && (
<span className="text-xs text-muted-foreground tabular-nums">
kl {deadline.due_time.slice(0, 5)}
</span>
)}
{deadline.due_time && deadline.customer && (
<span className="text-muted-foreground/30 text-xs">·</span>
)}
{deadline.customer && (
<span className="text-xs text-muted-foreground truncate">
{deadline.customer.name}
</span>
)}
</div>
</div>
{/* Right: relative date + action */}
<div className="flex items-center gap-3 flex-shrink-0">
{!completed && relative && (
<span className={cn(
'text-xs tabular-nums hidden sm:inline',
relative.urgent ? 'text-destructive font-medium' : 'text-muted-foreground',
)}>
{relative.text}
</span>
)}
{completed ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
Klar
</span>
) : (
<button
onClick={handleToggleClick}
className={cn(
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent',
confirming && 'invisible',
)}
>
Markera klar
</button>
)}
{/* Edit hint */}
{onEdit && !confirming && (
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
)}
</div>
</div>
{/* Inline confirmation bar */}
<div
className={cn(
'grid transition-all duration-200 ease-out',
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Markera <span className="font-medium text-foreground">{deadline.title}</span> som klar?
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs"
onClick={handleCancel}
>
Avbryt
</Button>
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={handleConfirm}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
Bekräfta
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
-67
View File
@@ -1,67 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DeadlineType } from '@/types'
import { DEADLINE_TYPE_LABELS } from '@/lib/calendar/utils'
import { X } from 'lucide-react'
interface DeadlineFiltersProps {
status: 'all' | 'pending' | 'completed'
type: DeadlineType | 'all'
onStatusChange: (status: 'all' | 'pending' | 'completed') => void
onTypeChange: (type: DeadlineType | 'all') => void
onReset: () => void
}
export function DeadlineFilters({
status,
type,
onStatusChange,
onTypeChange,
onReset,
}: DeadlineFiltersProps) {
const hasFilters = status !== 'all' || type !== 'all'
return (
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={(v) => onStatusChange(v as typeof status)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="pending">Ej klara</SelectItem>
<SelectItem value="completed">Klara</SelectItem>
</SelectContent>
</Select>
<Select value={type} onValueChange={(v) => onTypeChange(v as typeof type)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla typer</SelectItem>
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={onReset}>
<X className="h-4 w-4 mr-1" />
Rensa
</Button>
)}
</div>
)
}
+64 -141
View File
@@ -1,17 +1,17 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue, parseDate } from '@/lib/calendar/utils'
import { Check, Pencil } from 'lucide-react'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { deadlineDateLabel } from './DeadlineRow'
import { Pencil } from 'lucide-react'
/**
* System tax deadlines that legally share the skattekonto date ("den 12:e"):
* for a small monthly-moms employer, moms + AGI (+ debiterad preliminärskatt)
* all fall due the same day. Rendering them as one grouped card instead of
* all fall due the same day. Rendering them as one grouped row instead of
* 2-3 identical-date rows keeps the list scannable.
*/
export const SKATTEKONTO_GROUP_TYPES = new Set([
@@ -32,158 +32,81 @@ export function isSkattekontoDeadline(d: Deadline): boolean {
)
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
interface DeadlineGroupCardProps {
interface DeadlineGroupRowProps {
/** Two or more skattekonto deadlines sharing the same due_date. */
deadlines: Deadline[]
onToggle: (deadline: Deadline) => void
onEdit?: (deadline: Deadline) => void
/** "Markera klar": the list confirms before toggling. */
onRequestToggle: (deadline: Deadline) => void
}
/**
* One card for all skattekonto obligations on a shared due date, with the
* date block rendered once and each obligation as a sub-row that keeps its
* own "Markera klar" confirmation. Visual language mirrors DeadlineCard.
* One thread row for all skattekonto obligations on a shared due date
* (concept scene 17 row language): the date renders once, each obligation is
* a sub-row with its own quiet "Markera klar".
*/
export function DeadlineGroupCard({ deadlines, onToggle, onEdit }: DeadlineGroupCardProps) {
export function DeadlineGroupCard({ deadlines, onEdit, onRequestToggle }: DeadlineGroupRowProps) {
const t = useTranslations('deadlines')
const [confirmingId, setConfirmingId] = useState<string | null>(null)
const confirmRef = useRef<HTMLDivElement>(null)
const first = deadlines[0]
const dueDate = parseDate(first.due_date)
const dayNum = dueDate.getDate()
const monthStr = SWEDISH_MONTHS_SHORT[dueDate.getMonth()]
const overdue = deadlines.some(isDeadlineOverdue)
useEffect(() => {
if (!confirmingId) return
function handleClickOutside(e: MouseEvent) {
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
setConfirmingId(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [confirmingId])
const confirming = deadlines.find((d) => d.id === confirmingId) ?? null
return (
<div ref={confirmRef}>
<div
className={cn(
'rounded-lg border bg-card transition-all duration-150',
confirmingId && 'ring-2 ring-ring ring-offset-1',
)}
>
<div className="flex items-start gap-4 py-3 px-4">
{/* Shared date block */}
<div className="flex-shrink-0 w-12 text-center pt-0.5">
<span className="block text-lg font-display leading-none tabular-nums">
{dayNum}
</span>
<span className="block text-[11px] uppercase tracking-wider text-muted-foreground mt-0.5">
{monthStr}
</span>
</div>
<div className="flex items-start gap-3 py-3 -mx-2 px-2">
{/* Shared skattekonto date: always Skatteverket's mark (white ground,
badge not chrome). */}
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
<img src="/logos/skatteverket_color.svg" alt="Skatteverket" className="h-4 w-4 object-contain" />
</span>
<div className="w-px self-stretch bg-border flex-shrink-0" />
{/* Group content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<p className="text-sm font-medium">{t('group_skattekonto_title')}</p>
<span
className={cn(
'text-[11px] flex-shrink-0',
overdue ? 'text-destructive font-medium' : 'text-muted-foreground/60',
)}
>
{t('group_same_day', { count: deadlines.length })}
</span>
</div>
<div className="mt-2 space-y-1">
{deadlines.map((deadline) => (
<div
key={deadline.id}
onClick={() => {
if (!confirmingId && onEdit) onEdit(deadline)
}}
className={cn(
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors',
onEdit && !confirmingId && 'cursor-pointer hover:bg-accent/40',
)}
>
<p className="text-sm truncate flex-1 min-w-0">{deadline.title}</p>
<button
onClick={(e) => {
e.stopPropagation()
setConfirmingId(deadline.id)
}}
className={cn(
'text-xs text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1 rounded-md border border-border hover:border-foreground/30 hover:bg-accent flex-shrink-0',
confirmingId && 'invisible',
)}
>
{t('group_mark_done')}
</button>
{onEdit && !confirmingId && (
<Pencil className="h-3.5 w-3.5 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-colors flex-shrink-0" />
)}
</div>
))}
</div>
</div>
</div>
{/* Inline confirmation bar (one at a time, mirrors DeadlineCard) */}
<div
<div className="min-w-0 flex-1">
<p className="text-sm leading-6">
<span className="tabular-nums text-muted-foreground">
{deadlineDateLabel(first.due_date)}
</span>
<span className="text-muted-foreground/50"> · </span>
<span>{t('group_skattekonto_title')}</span>
</p>
<p
className={cn(
'grid transition-all duration-200 ease-out',
confirming ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
'text-xs leading-5',
overdue ? 'text-destructive' : 'text-muted-foreground',
)}
>
<div className="overflow-hidden">
{confirming && (
<div className="border-t px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
{t.rich('group_confirm_question', {
title: confirming.title,
hl: (chunks) => (
<span className="font-medium text-foreground">{chunks}</span>
),
})}
</p>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => setConfirmingId(null)}
>
{t('group_cancel')}
</Button>
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={() => {
setConfirmingId(null)
onToggle(confirming)
}}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
{t('group_confirm')}
</Button>
</div>
</div>
)}
</div>
{t('group_same_day', { count: deadlines.length })}
</p>
<div className="mt-1">
{deadlines.map((deadline) => (
<div
key={deadline.id}
onClick={() => onEdit?.(deadline)}
className={cn(
'group flex items-center gap-3 rounded-md py-1.5 -mx-2 px-2 transition-colors duration-150',
onEdit && 'cursor-pointer hover:bg-secondary/35',
)}
>
<p className="min-w-0 flex-1 truncate text-sm">{deadline.title}</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(
QUIET_LINK_CLASS,
'shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
)}
>
{t('group_mark_done')}
</button>
{onEdit && (
<Pencil
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
aria-hidden="true"
/>
)}
</div>
))}
</div>
</div>
</div>
+172 -146
View File
@@ -1,47 +1,46 @@
'use client'
import { useState, useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Deadline, DeadlineType } from '@/types'
import { Button } from '@/components/ui/button'
import { DeadlineCard } from './DeadlineCard'
import { DeadlineRow, deadlineDateLabel } from './DeadlineRow'
import { DeadlineGroupCard, isSkattekontoDeadline } from './DeadlineGroupCard'
import { DeadlineFilters } from './DeadlineFilters'
import { DeadlineForm } from './DeadlineForm'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { Plus, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
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[]
customers: { id: string; name: string }[]
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDeadlineToggle: (deadline: Deadline) => Promise<void>
onDeadlineEdit: (deadline: Deadline) => Promise<void>
onDeadlineDelete: (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,
customers,
onDeadlineCreate,
onDeadlineToggle,
onDeadlineEdit,
onDeadlineDelete,
}: DeadlineListProps) {
const { canWrite } = useCanWrite()
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('pending')
const [typeFilter, setTypeFilter] = useState<DeadlineType | 'all'>('all')
const [showForm, setShowForm] = useState(false)
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
const t = useTranslations('deadlines')
const [segment, setSegment] = useState<TypeSegment>('all')
const [confirmTarget, setConfirmTarget] = useState<Deadline | null>(null)
const filteredDeadlines = useMemo(() => {
return deadlines.filter((d) => {
if (statusFilter === 'pending' && d.is_completed) return false
if (statusFilter === 'completed' && !d.is_completed) return false
if (typeFilter !== 'all' && d.deadline_type !== typeFilter) return false
return true
})
}, [deadlines, statusFilter, typeFilter])
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]
@@ -65,41 +64,46 @@ export function DeadlineList({
return { overdue, today: todayDeadlines, upcoming, completed }
}, [filteredDeadlines])
const handleResetFilters = () => {
setStatusFilter('all')
setTypeFilter('all')
}
// "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 handleFormSubmit = async (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => {
if (editingDeadline) {
await onDeadlineEdit({ ...editingDeadline, ...data })
} else {
await onDeadlineCreate(data)
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
}
setShowForm(false)
setEditingDeadline(null)
}
const handleEdit = (deadline: Deadline) => {
setEditingDeadline(deadline)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingDeadline(null)
setConfirmTarget(deadline)
}
const sections = [
{ key: 'overdue', label: 'Förfallna', items: groupedDeadlines.overdue },
{ key: 'today', label: 'Idag', items: groupedDeadlines.today },
{ key: 'upcoming', label: 'Kommande', items: groupedDeadlines.upcoming },
{ key: 'completed', label: 'Klara', items: groupedDeadlines.completed },
].filter(s => s.items.length > 0)
{ 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 card per date instead of near-identical rows. Other deadlines
// grouped row per date instead of near-identical rows. Other deadlines
// render as before, in their original order.
type ListEntry =
| { kind: 'single'; deadline: Deadline }
@@ -131,103 +135,125 @@ export function DeadlineList({
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<DeadlineFilters
status={statusFilter}
type={typeFilter}
onStatusChange={setStatusFilter}
onTypeChange={setTypeFilter}
onReset={handleResetFilters}
/>
<Button
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Lock className="h-4 w-4 mr-2" />
)}
Ny deadline
</Button>
{/* 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>
{/* Content */}
{filteredDeadlines.length === 0 ? (
<div className="py-20 text-center">
<p className="text-sm text-muted-foreground">
{statusFilter !== 'all' || typeFilter !== 'all'
? 'Inga deadlines matchar filtret.'
: 'Inga deadlines ännu.'}
<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>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="h-3.5 w-3.5 mr-1.5" />
) : (
<Lock className="h-3.5 w-3.5 mr-1.5" />
)}
Skapa en deadline
</Button>
</div>
) : (
<div className="space-y-8">
{sections.map(({ key, label, items }) => (
<section key={key}>
<div className="flex items-center gap-3 mb-2 px-1">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</h3>
<span className="text-xs tabular-nums text-muted-foreground/50">
{items.length}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
<div className="space-y-1.5">
{toEntries(items).map((entry) =>
entry.kind === 'group' ? (
<DeadlineGroupCard
key={`skattekonto-${entry.date}`}
deadlines={entry.items}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
/>
) : (
<DeadlineCard
key={entry.deadline.id}
deadline={entry.deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
),
)}
</div>
</section>
))}
</div>
)}
) : (
<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>
)}
<DeadlineForm
open={showForm}
onOpenChange={handleFormClose}
onSubmit={handleFormSubmit}
onDelete={(deadline) => {
if (deadline.id) {
const full = deadlines.find(d => d.id === deadline.id)
if (full) onDeadlineDelete(full)
}
{/* "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)
}}
initialData={editingDeadline || undefined}
customers={customers}
/>
</div>
)
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
CalendarClock,
Check,
FileText,
Landmark,
Pencil,
ReceiptText,
Truck,
} from 'lucide-react'
interface DeadlineRowProps {
deadline: Deadline
/** Row click: open the edit dialog. */
onEdit?: (deadline: Deadline) => void
/** "Markera klar" (or un-done): the list confirms before toggling. */
onRequestToggle: (deadline: Deadline) => void
}
function daysFromToday(dateStr: string): number {
const today = startOfDay(new Date())
const target = parseDate(dateStr)
return Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
const TYPE_ICONS: Record<Deadline['deadline_type'], typeof CalendarClock> = {
tax: Landmark,
invoicing: ReceiptText,
delivery: Truck,
report: FileText,
other: CalendarClock,
}
// Statutory deadlines carry the receiving authority's mark: Bolagsverket for
// the company-law dates, Skatteverket for everything tax-shaped. Non-system
// deadlines keep the neutral type icons.
const BOLAGSVERKET_TYPES = new Set(['arsredovisning', 'arsstamma'])
export function deadlineAuthorityLogo(deadline: Deadline): { src: string; alt: string } | null {
if (deadline.tax_deadline_type) {
return BOLAGSVERKET_TYPES.has(deadline.tax_deadline_type)
? { src: '/logos/bolagsverket.jpg', alt: 'Bolagsverket' }
: { src: '/logos/skatteverket_color.svg', alt: 'Skatteverket' }
}
// Manual deadlines (including user-created tax-category ones) keep the
// neutral type icons: the authority crest is reserved for statutory dates
// the system generated from the tax settings.
return null
}
/** The shared 28px round mark: authority logo on white, or a muted type icon. */
export function DeadlineMark({
deadline,
completed = false,
}: {
deadline: Deadline
completed?: boolean
}) {
const logo = deadlineAuthorityLogo(deadline)
if (completed) {
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground/60">
<Check className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
if (logo) {
// Deliberately white in both themes: the authority marks are badges, not
// chrome, and their brand colors need a white ground.
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-white">
<img src={logo.src} alt={logo.alt} className="h-4 w-4 object-contain" />
</span>
)
}
const Icon = TYPE_ICONS[deadline.deadline_type] ?? CalendarClock
return (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
const SWEDISH_MONTHS_SHORT = [
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
]
export function deadlineDateLabel(dateStr: string): string {
const date = parseDate(dateStr)
return `${date.getDate()} ${SWEDISH_MONTHS_SHORT[date.getMonth()]}`
}
/**
* One deadline as a concept thread row (scene 17): icon circle, "12 aug ·
* Title" line, muted status sentence below, quiet hover actions. Rows are
* borderless; the section headers carry the structure.
*/
export function DeadlineRow({ deadline, onEdit, onRequestToggle }: DeadlineRowProps) {
const t = useTranslations('deadlines')
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const diff = daysFromToday(deadline.due_date)
const relative = completed
? null
: diff === 0
? t('rel_today')
: diff === 1
? t('rel_tomorrow')
: diff < 0
? t('rel_days_ago', { count: Math.abs(diff) })
: diff <= 14
? t('rel_in_days', { count: diff })
: null
const subParts = [
relative,
deadline.due_time ? `kl ${deadline.due_time.slice(0, 5)}` : null,
deadline.customer?.name ?? null,
].filter(Boolean) as string[]
return (
<div
onClick={() => onEdit?.(deadline)}
className={cn(
'group flex items-start gap-3 rounded-md py-3 -mx-2 px-2 transition-colors duration-150',
onEdit && 'cursor-pointer hover:bg-secondary/35',
completed && 'opacity-60',
)}
>
<DeadlineMark deadline={deadline} completed={completed} />
<div className="min-w-0 flex-1">
<p className={cn('text-sm leading-6', completed && 'line-through text-muted-foreground')}>
<span className="tabular-nums text-muted-foreground">
{deadlineDateLabel(deadline.due_date)}
</span>
<span className="text-muted-foreground/50"> · </span>
<span>{deadline.title}</span>
</p>
{subParts.length > 0 && (
<p
className={cn(
'text-xs leading-5',
overdue && !completed ? 'text-destructive' : 'text-muted-foreground',
)}
>
{subParts.join(' · ')}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-3 pt-1">
{completed ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
>
{t('mark_not_done')}
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
>
{t('group_mark_done')}
</button>
)}
{onEdit && (
<Pencil
className="h-3.5 w-3.5 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground/50"
aria-hidden="true"
/>
)}
</div>
</div>
)
}
+1 -2
View File
@@ -1,5 +1,4 @@
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineRow } from './DeadlineRow'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'