refactor(ui): app-wide UI/UX consistency pass (#436)
* refactor(ui): app-wide UI/UX consistency pass
Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.
What changed:
- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
(Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
(p-4 for compact metric cards), space-y-8 between page sections.
- **Tables unified**: all 33 thead blocks now share the Resultatrapport
pattern via shadcn Table primitive (text-[11px] font-medium uppercase
tracking-wider text-muted-foreground). Hand-rolled <table> instances
converted where they were data tables; form/edit grids kept distinct.
- **Status badges unified**: every status indicator routes through
shadcn <Badge variant>. Eliminated raw Tailwind colors
(bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
in favor of the gnubok semantic palette (success=sage, warning=ochre,
destructive=terracotta).
- **Empty states unified**: list pages migrated from hand-rolled
"flex flex-col items-center py-12" divs to the EmptyState primitive.
- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
divs replaced with shadcn <Skeleton> across 15 files.
- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
9 icon-only navigation buttons.
- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
table-friendly) vs formatDateLong() for metadata (Swedish long form).
Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.
- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
now carries the action ("Kunde inte skapa lönekörning" etc.) with
description carrying the error detail.
- **Page-level cleanups**:
- Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
duplicates + Visa detaljer collapsible.
- Reports: 5-col mega-menu replaced with left-rail layout
(new ReportsNav component).
- Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
tabs (moved FiscalYearSelector inside journal tab).
- Bookkeeping: added voucher sort (A1 first / latest first) alongside
existing date sort. Required matching API param sort_by.
- KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
instead of inline info-button toggle; bigger numbers.
- Salary section: enum values translated to Swedish labels, mobile
table collapses to Anställd+Netto on <md, KPI typography aligned
with dashboard.
- Invoice forms: styled RequiredMark + aria-required, tabular-nums
on amount inputs.
- **CLAUDE.md**: new "Design System Tokens" subsection documents the
locked spacing scale, primitives table, typography rules, date helpers,
and forbidden patterns so future contributors don't drift.
Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback (Greptile + compliance bot)
- **formatDate / formatDateLong timezone fix**: switch from new Date() to
parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
than UTC midnight, eliminating the off-by-one display in west-of-UTC
timezones flagged by Greptile.
- **DashboardContentProps cleanup**: removed unused firstName and settings
fields from the interface, and the corresponding fetch (profiles table)
+ computation in app/(dashboard)/page.tsx. The greeting was dropped in
the dashboard cleanup; these props were dead weight.
- **Voucher sort behavior documented**: extended the comment in the journal
entries API route to explain why voucher sort intentionally uses strict
fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
series-scoped within a fiscal year). The row-count delta between date
sort and voucher sort is now a documented design choice.
- **delete_last_voucher migration + draft-delete test included**: the UI
already shipped the "Radera utkast" path in the previous commit; this
pulls in the backing RPC migration that allows draft deletes (with the
full safety logic — drafts skip series/period checks since they have
voucher_number=0, posted entries go through the existing unchanged
path). This was originally meant for a separate PR but the UI shipped
half the feature without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(migration): rename to match applied version
The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address compliance bot findings (payroll label + VAT visibility)
- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
6 §, karensavdrag is a single calculated amount (20% of one week's
sjuklön) deducted from the first sick day's pay — not bounded to the
first day. The qualifier could mislead users when the first sick day
and return-to-work span a weekend. Swedish-payroll bot recommendation.
- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
charge indicator is compliance-critical (ML 16 kap) — missing it leads
to incorrect input VAT deduction. Outline was too subtle; warning's
ochre fill matches its semantic weight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ed9bdc4c00
commit
d0fbc2b616
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -368,8 +368,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Transaktion</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
@@ -383,14 +383,14 @@ export function BankReconciliationView() {
|
||||
{dryRunResults.map((m) => (
|
||||
<tr key={m.transaction_id} className="border-b last:border-0">
|
||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
||||
<td className="py-2">{m.transaction_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
|
||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||
</td>
|
||||
<td className="py-2">{m.entry_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{METHOD_LABELS[m.method] || m.method}
|
||||
@@ -422,8 +422,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
@@ -454,7 +454,7 @@ export function BankReconciliationView() {
|
||||
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<option key={line.line_id} value={line.journal_entry_id}>
|
||||
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
|
||||
{line.voucher_series}{line.voucher_number} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -489,8 +489,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-16">Ver.nr</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{line.voucher_series}{line.voucher_number}
|
||||
</td>
|
||||
<td className="py-2">{line.entry_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
@@ -544,8 +544,8 @@ export function BankReconciliationView() {
|
||||
{showMatched && (
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface ReportItem {
|
||||
value: string
|
||||
label: string
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
interface ReportCategory {
|
||||
label: string
|
||||
items: ReportItem[]
|
||||
}
|
||||
|
||||
const CATEGORIES: ReportCategory[] = [
|
||||
{
|
||||
label: 'Löpande',
|
||||
items: [
|
||||
{ value: 'resultatrapport', label: 'Resultatrapport' },
|
||||
{ value: 'balansrapport', label: 'Balansrapport' },
|
||||
{ value: 'trial-balance', label: 'Saldobalans' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bokslut',
|
||||
items: [
|
||||
{ value: 'income-statement', label: 'Resultaträkning' },
|
||||
{ value: 'balance-sheet', label: 'Balansräkning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Skatt & moms',
|
||||
items: [
|
||||
{ value: 'vat-declaration', label: 'Momsdeklaration' },
|
||||
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
|
||||
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Huvudböcker',
|
||||
items: [
|
||||
{ value: 'huvudbok', label: 'Huvudbok' },
|
||||
{ value: 'grundbok', label: 'Grundbok' },
|
||||
{ value: 'kundreskontra', label: 'Kundreskontra' },
|
||||
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Avstämning',
|
||||
items: [
|
||||
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface ReportsNavProps {
|
||||
active: string
|
||||
onChange: (value: string) => void
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
const filtered = CATEGORIES
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
items: cat.items.filter(item => !item.entityType || item.entityType === entityType),
|
||||
}))
|
||||
.filter(cat => cat.items.length > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: grouped select */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={active} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filtered.map(cat => (
|
||||
<SelectGroup key={cat.label}>
|
||||
<SelectLabel>{cat.label}</SelectLabel>
|
||||
{cat.items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: vertical left rail */}
|
||||
<nav
|
||||
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
|
||||
aria-label="Rapportkategorier"
|
||||
>
|
||||
<ul className="space-y-6">
|
||||
{filtered.map(cat => (
|
||||
<li key={cat.label}>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
|
||||
{cat.label}
|
||||
</p>
|
||||
<ul className="space-y-px">
|
||||
{cat.items.map(item => {
|
||||
const isActive = active === item.value
|
||||
return (
|
||||
<li key={item.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-1.5 rounded-md text-[13px] transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user