d0fbc2b616
* 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>
211 lines
8.0 KiB
TypeScript
211 lines
8.0 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import type { BASAccount } from '@/types'
|
|
|
|
const CLASS_LABELS: Record<number, string> = {
|
|
1: 'Tillgångar',
|
|
2: 'Eget kapital och skulder',
|
|
3: 'Intäkter',
|
|
4: 'Varor och material',
|
|
5: 'Övriga externa kostnader',
|
|
6: 'Övriga externa kostnader',
|
|
7: 'Personal och avskrivningar',
|
|
8: 'Finansiella poster',
|
|
}
|
|
|
|
export default function ChartOfAccounts() {
|
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [editingSRU, setEditingSRU] = useState<string | null>(null)
|
|
const [sruValue, setSruValue] = useState('')
|
|
|
|
async function fetchAccounts() {
|
|
const res = await fetch('/api/bookkeeping/accounts')
|
|
const { data } = await res.json()
|
|
setAccounts(data || [])
|
|
setLoading(false)
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchAccounts()
|
|
}, [])
|
|
|
|
async function updateSRUCode(accountId: string, newSruCode: string) {
|
|
const supabase = createClient()
|
|
const trimmed = newSruCode.trim() || null
|
|
await supabase
|
|
.from('chart_of_accounts')
|
|
.update({ sru_code: trimmed })
|
|
.eq('id', accountId)
|
|
|
|
setAccounts((prev) =>
|
|
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
|
|
)
|
|
setEditingSRU(null)
|
|
}
|
|
|
|
const toggleClass = (cls: number) => {
|
|
const next = new Set(expandedClasses)
|
|
if (next.has(cls)) {
|
|
next.delete(cls)
|
|
} else {
|
|
next.add(cls)
|
|
}
|
|
setExpandedClasses(next)
|
|
}
|
|
|
|
const filteredAccounts = accounts.filter((a) => {
|
|
if (!searchQuery) return true
|
|
const q = searchQuery.toLowerCase()
|
|
return (
|
|
a.account_number.includes(q) ||
|
|
a.account_name.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
const groupedByClass = filteredAccounts.reduce(
|
|
(acc, account) => {
|
|
const cls = account.account_class
|
|
if (!acc[cls]) acc[cls] = []
|
|
acc[cls].push(account)
|
|
return acc
|
|
},
|
|
{} as Record<number, BASAccount[]>
|
|
)
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Laddar kontoplan...
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Sök konto (nummer eller namn)..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{Object.entries(groupedByClass)
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.map(([cls, classAccounts]) => {
|
|
const classNum = Number(cls)
|
|
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
|
|
|
return (
|
|
<Card key={cls}>
|
|
<button
|
|
onClick={() => toggleClass(classNum)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
<span className="font-semibold">
|
|
Klass {cls}: {CLASS_LABELS[classNum] || ''}
|
|
</span>
|
|
<Badge variant="secondary">{classAccounts.length} konton</Badge>
|
|
</div>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<CardContent className="pt-0 pb-4">
|
|
<table className="w-full text-sm">
|
|
<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">Konto</th>
|
|
<th className="py-2">Namn</th>
|
|
<th className="py-2 w-20 text-center">SRU</th>
|
|
<th className="py-2 w-24 text-center">Typ</th>
|
|
<th className="py-2 w-24 text-center">Normal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{classAccounts.map((account) => (
|
|
<tr
|
|
key={account.id}
|
|
className={`border-b last:border-0 ${
|
|
!account.is_active ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<td className="py-2"><AccountNumber number={account.account_number} name={account.account_name} /></td>
|
|
<td className="py-2">{account.account_name}</td>
|
|
<td className="py-2 text-center">
|
|
{editingSRU === account.id ? (
|
|
<Input
|
|
value={sruValue}
|
|
onChange={(e) => setSruValue(e.target.value)}
|
|
onBlur={() => updateSRUCode(account.id, sruValue)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') updateSRUCode(account.id, sruValue)
|
|
if (e.key === 'Escape') setEditingSRU(null)
|
|
}}
|
|
className="h-7 w-16 text-xs text-center px-1"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<button
|
|
className="text-xs font-mono text-muted-foreground hover:text-foreground cursor-pointer min-w-[2rem]"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditingSRU(account.id)
|
|
setSruValue(account.sru_code || '')
|
|
}}
|
|
>
|
|
{account.sru_code || '—'}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td className="py-2 text-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{account.account_type === 'asset'
|
|
? 'Tillgång'
|
|
: account.account_type === 'liability'
|
|
? 'Skuld'
|
|
: account.account_type === 'equity'
|
|
? 'EK'
|
|
: account.account_type === 'revenue'
|
|
? 'Intäkt'
|
|
: 'Kostnad'}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-2 text-center text-xs text-muted-foreground">
|
|
{account.normal_balance === 'debit' ? 'Debet' : 'Kredit'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|