Files
accounted/components/suppliers/SupplierInvoiceReviewContent.tsx
T
Jakob WennbergandClaude Opus 4.7 d0fbc2b616 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>
2026-05-11 16:34:07 +02:00

347 lines
12 KiB
TypeScript

'use client'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import type { Supplier } from '@/types'
interface ReviewLineItem {
description: string
amount: number
account_number: string
vat_rate: number
}
interface SupplierInvoiceReviewContentProps {
supplier: Supplier
invoiceNumber: string
invoiceDate: string
dueDate: string
deliveryDate?: string
currency: string
exchangeRate?: string
reverseCharge: boolean
paymentReference?: string
items: ReviewLineItem[]
subtotal: number
totalVat: number
total: number
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
interface JournalPreviewLine {
account_number: string
description: string
debit: number
credit: number
}
function getOutputVatAccount(rate: number): string {
if (rate === 0.12) return '2624'
if (rate === 0.06) return '2634'
return '2614'
}
function buildJournalPreview(
items: ReviewLineItem[],
subtotal: number,
totalVat: number,
total: number,
reverseCharge: boolean,
supplierType?: string,
): JournalPreviewLine[] {
const lines: JournalPreviewLine[] = []
// Aggregate expense amounts by account number
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + Math.round(item.amount * 100) / 100)
}
// Debit: Expense accounts
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
description: accountNumber,
debit: Math.round(amount * 100) / 100,
credit: 0,
})
}
if (reverseCharge) {
// Reverse charge: fiktiv moms per VAT rate (matches engine groupVatByRate logic)
const isDomesticRC = supplierType === 'swedish_business'
const inputAccount = isDomesticRC ? '2647' : '2645'
const vatByRate = new Map<number, number>()
for (const item of items) {
if (item.vat_rate > 0) {
const current = vatByRate.get(item.vat_rate) || 0
vatByRate.set(item.vat_rate, current + Math.round(item.amount * 100) / 100)
}
}
for (const [rate, netAmount] of vatByRate) {
const fiktivVat = Math.round(netAmount * rate * 100) / 100
const outputAccount = getOutputVatAccount(rate)
lines.push({
account_number: inputAccount,
description: inputAccount,
debit: fiktivVat,
credit: 0,
})
lines.push({
account_number: outputAccount,
description: outputAccount,
debit: 0,
credit: fiktivVat,
})
}
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(subtotal * 100) / 100,
})
} else {
if (totalVat > 0) {
lines.push({
account_number: '2641',
description: 'Ingående moms',
debit: Math.round(totalVat * 100) / 100,
credit: 0,
})
}
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(total * 100) / 100,
})
}
return lines
}
const ACCOUNT_LABELS: Record<string, string> = {
'2440': 'Leverantörsskulder',
'2641': 'Ingående moms',
'2645': 'Beräknad ing. moms förvärv utlandet',
'2647': 'Beräknad ing. moms omvänd i Sverige',
'2614': 'Utg. moms omvänd 25%',
'2624': 'Utg. moms omvänd 12%',
'2634': 'Utg. moms omvänd 6%',
}
export function SupplierInvoiceReviewContent({
supplier,
invoiceNumber,
invoiceDate,
dueDate,
deliveryDate,
currency,
exchangeRate,
reverseCharge,
paymentReference,
items,
subtotal,
totalVat,
total,
}: SupplierInvoiceReviewContentProps) {
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type)
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
return (
<div className="space-y-4">
{/* Supplier info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{supplier.name}</p>
<p className="text-sm text-muted-foreground">Fakturanr: {invoiceNumber}</p>
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
{reverseCharge && (
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
Omvänd skattskyldighet
</Badge>
)}
{currency !== 'SEK' && (
<Badge variant="outline" className="text-sm">
{currency}
{exchangeRate && ` (kurs ${exchangeRate})`}
</Badge>
)}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{dueDate}</p>
</div>
{deliveryDate && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<p className="font-medium">{deliveryDate}</p>
</div>
)}
</div>
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<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-20">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-16 text-right">Moms%</th>
<th className="py-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">{item.description}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<div className="flex items-center justify-between">
<p className="font-medium">{item.description}</p>
<AccountNumber number={item.account_number} size="sm" />
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{Math.round(item.vat_rate * 100)}% moms ({formatAmount(vatAmount)})</span>
</div>
</div>
)
})}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{formatCurrency(total, currency)}</span>
</div>
{currency !== 'SEK' && exchangeRate && (
<div className="flex justify-between text-muted-foreground">
<span>SEK-belopp (vid kurs {exchangeRate})</span>
<span>{formatCurrency(total * parseFloat(exchangeRate))}</span>
</div>
)}
</div>
{/* Verifikation preview */}
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
<div className="hidden sm:block">
<table className="w-full text-sm font-mono">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="text-left">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
<th className="pb-1 w-24 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{journalLines.map((line, index) => (
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
<td className="py-1">
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{ACCOUNT_LABELS[line.account_number] || line.description}
</td>
<td className="py-1 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>SUMMA</td>
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="sm:hidden space-y-1.5 text-sm">
{journalLines.map((line, index) => (
<div key={index} className="flex items-center justify-between py-1 border-b border-dashed border-muted-foreground/20 last:border-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<AccountNumber number={line.account_number} size="sm" />
<span className="text-xs text-muted-foreground truncate">
{ACCOUNT_LABELS[line.account_number] || line.description}
</span>
</div>
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{line.debit > 0 ? `D ${formatAmount(line.debit)}` : `K ${formatAmount(line.credit)}`}
</span>
</div>
))}
<div className="flex justify-between pt-1 border-t font-semibold text-xs font-mono">
<span>SUMMA</span>
<span>D {formatAmount(totalDebit)} / K {formatAmount(totalCredit)}</span>
</div>
</div>
</div>
{/* Payment reference */}
{paymentReference && (
<div className="border-t pt-3 text-sm text-muted-foreground">
<p>Betalningsreferens: {paymentReference}</p>
</div>
)}
</div>
)
}