feat: UI slop cleanup, invoice icon/header polish + year-end in Rapporter, journal-list DataList refactor (#847)

UI cleanup: removed AI-slop (redundant suppliers subtitle, decorative Sparkles glyph), decluttered the article-detail header (single status badge + muted type · #number), standardized the invoice icon Receipt→ReceiptText (no $ in a SEK app), and matched ReportExportMenu trigger size to the primary CTA on list pages.

Bookkeeping: surfaced year-end closing in Rapporter (catalog descriptor) and dropped the redundant header button; refactored JournalEntryList to DataList primitives + chunked /api/documents/counts in 50-ID batches (large pages previously 400'd); added optional fraction-digit overrides to formatCurrency. The fiscal-year lock indicator is preserved as a labeled Låst/Stängt badge in FiscalYearSelector.

All PR-bot findings triaged as false positives (unused import, formatCurrency öre, lock indicator) or intentional design (year-end placement, empty-state messaging). CI green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-30 20:42:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent db843a7a5b
commit d63d2aecf0
19 changed files with 569 additions and 559 deletions
+4 -6
View File
@@ -191,15 +191,13 @@ export default function ArticleDetailPage({
<div>
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{article.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary">{t(ARTICLE_TYPE_KEY[article.type])}</Badge>
{article.article_number && (
<span className="text-sm text-muted-foreground tabular-nums">
{article.article_number}
</span>
)}
<Badge variant={article.active ? 'success' : 'secondary'}>
{article.active ? t('status_active') : t('status_inactive')}
</Badge>
<span className="text-sm text-muted-foreground tabular-nums">
{t(ARTICLE_TYPE_KEY[article.type])}
{article.article_number ? ` · #${article.article_number}` : ''}
</span>
</div>
</div>
</div>
+1
View File
@@ -240,6 +240,7 @@ function ArticlesPageInner() {
action={
<div className="flex items-center gap-2">
<ReportExportMenu
size="default"
items={[
{ format: 'xlsx', href: '/api/export/articles' },
{ format: 'csv', href: '/api/export/articles?format=csv' },
+16 -25
View File
@@ -1,7 +1,6 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
@@ -11,7 +10,7 @@ import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
import { useToast } from '@/components/ui/use-toast'
import { Lock, Plus } from 'lucide-react'
import { Plus } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
@@ -129,29 +128,21 @@ export default function BookkeepingPage() {
<PageHeader
title={t('title')}
action={
<div className="flex gap-2 w-full sm:w-auto">
<Button
className="w-full sm:w-auto"
onClick={() => {
setCopyPrefill(null)
setShowNewEntry(true)
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-primary-foreground/70 tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</Button>
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
{t('year_end')}
</Link>
</Button>
</div>
<Button
className="w-full sm:w-auto"
onClick={() => {
setCopyPrefill(null)
setShowNewEntry(true)
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-primary-foreground/70 tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</Button>
}
/>
+3 -3
View File
@@ -23,7 +23,7 @@ import {
Edit2,
Trash2,
Loader2,
Receipt,
ReceiptText,
Lock,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -313,7 +313,7 @@ export default function CustomerDetailPage({
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Receipt className="h-4 w-4 text-muted-foreground" />
<ReceiptText className="h-4 w-4 text-muted-foreground" />
<span>{t('invoice_count', { count: customer.invoices?.length || 0 })}</span>
</div>
</CardContent>
@@ -336,7 +336,7 @@ export default function CustomerDetailPage({
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Receipt className="h-4 w-4" />
<ReceiptText className="h-4 w-4" />
{t('section_invoices')}
{customer.invoices?.length > 0 && (
<Badge variant="secondary">{customer.invoices.length}</Badge>
+1
View File
@@ -244,6 +244,7 @@ function CustomersPageInner() {
action={
<div className="flex items-center gap-2">
<ReportExportMenu
size="default"
items={[
{ format: 'xlsx', href: '/api/export/customers' },
{ format: 'csv', href: '/api/export/customers?format=csv' },
+3 -3
View File
@@ -24,7 +24,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, Receipt, Lock, Repeat } from 'lucide-react'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -256,7 +256,7 @@ export default function InvoicesPage() {
) : filteredInvoices.length === 0 ? (
searchTerm ? (
<DataListEmpty
icon={<Receipt className="h-6 w-6" />}
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/>
@@ -264,7 +264,7 @@ export default function InvoicesPage() {
<EmptyInvoices />
) : (
<DataListEmpty
icon={<Receipt className="h-6 w-6" />}
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_category_title')}
description={t('no_category_description')}
/>
+5 -5
View File
@@ -43,7 +43,7 @@ import {
ClipboardCheck,
ArrowLeftRight,
Users,
Receipt,
ReceiptText,
Bot,
BookOpen,
ChevronDown,
@@ -63,14 +63,14 @@ import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTr
const OPERATION_LABEL_KEYS: Record<string, { labelKey: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
categorize_transaction: { labelKey: 'type_categorize_transaction', icon: ArrowLeftRight, variant: 'default' },
create_customer: { labelKey: 'type_create_customer', icon: Users, variant: 'secondary' },
create_invoice: { labelKey: 'type_create_invoice', icon: Receipt, variant: 'outline' },
create_invoice: { labelKey: 'type_create_invoice', icon: ReceiptText, variant: 'outline' },
create_transaction: { labelKey: 'type_create_transaction', icon: ArrowLeftRight, variant: 'secondary' },
create_voucher: { labelKey: 'type_create_voucher', icon: BookOpen, variant: 'outline' },
correct_entry: { labelKey: 'type_correct_entry', icon: BookOpen, variant: 'outline' },
reverse_entry: { labelKey: 'type_reverse_entry', icon: BookOpen, variant: 'outline' },
mark_invoice_paid: { labelKey: 'type_mark_invoice_paid', icon: Receipt, variant: 'default' },
send_invoice: { labelKey: 'type_send_invoice', icon: Receipt, variant: 'outline' },
mark_invoice_sent: { labelKey: 'type_mark_invoice_sent', icon: Receipt, variant: 'outline' },
mark_invoice_paid: { labelKey: 'type_mark_invoice_paid', icon: ReceiptText, variant: 'default' },
send_invoice: { labelKey: 'type_send_invoice', icon: ReceiptText, variant: 'outline' },
mark_invoice_sent: { labelKey: 'type_mark_invoice_sent', icon: ReceiptText, variant: 'outline' },
match_transaction_invoice: { labelKey: 'type_match_transaction_invoice', icon: ArrowLeftRight, variant: 'secondary' },
}
+1 -3
View File
@@ -116,12 +116,10 @@ export default function SuppliersPage() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-2xl md:text-3xl tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
{t('subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<ReportExportMenu
size="default"
items={[
{ format: 'xlsx', href: '/api/export/suppliers' },
{ format: 'csv', href: '/api/export/suppliers?format=csv' },
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import {
Receipt,
ReceiptText,
ArrowLeftRight,
Users,
Wallet,
@@ -35,7 +35,7 @@ type Entry = {
}
const ACTION_ENTRIES: Entry[] = [
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: Receipt, href: '/invoices/new', keywords: 'fakturera ny invoice send create' },
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: ReceiptText, href: '/invoices/new', keywords: 'fakturera ny invoice send create' },
{ id: 'book-transaction', label: 'Boka transaktion', hint: 'Gå till transaktionsinkorgen', icon: ArrowLeftRight, href: '/transactions', keywords: 'transaktion bokför kategorisera categorize' },
{ id: 'new-customer', label: 'Lägg till kund', icon: Users, href: '/customers', keywords: 'kund customer ny lägg till' },
{ id: 'new-supplier-invoice', label: 'Skapa leverantörsfaktura', icon: Wallet, href: '/supplier-invoices/new', keywords: 'leverantörsfaktura supplier invoice ny' },
+2 -2
View File
@@ -21,7 +21,7 @@ import {
Inbox,
Landmark,
Loader2,
Receipt,
ReceiptText,
ShieldCheck,
Stamp,
} from 'lucide-react'
@@ -341,7 +341,7 @@ export default function AttGoraSection({
{counts.overdue_invoice > 0 && (
<WorklistRow
href="/invoices?status=unpaid"
icon={Receipt}
icon={ReceiptText}
label={t('row_overdue_invoices')}
count={counts.overdue_invoice}
/>
+2 -2
View File
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import {
LayoutDashboard,
Home,
Receipt,
ReceiptText,
Users,
ArrowLeftRight,
BookOpen,
@@ -135,7 +135,7 @@ const navItems: NavItem[] = [
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' },
// Försäljning dropdown
{ href: '/invoices', labelKey: 'invoices', icon: Receipt, group: 'försäljning' },
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'försäljning' },
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'försäljning' },
// Inköp dropdown
+3 -1
View File
@@ -26,9 +26,11 @@ export interface ReportExportItem {
export function ReportExportMenu({
items,
children,
size = 'sm',
}: {
items?: ReportExportItem[]
children?: React.ReactNode
size?: 'default' | 'sm'
}) {
const t = useTranslations('reports')
const hasItems = !!items && items.length > 0
@@ -39,7 +41,7 @@ export function ReportExportMenu({
{hasItems && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size={size}>
<Download className="h-4 w-4 mr-2" />
{t('export')}
</Button>
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Sparkles, X } from 'lucide-react'
import { X } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -73,7 +73,6 @@ export default function BankSyncSinceLastVisit() {
return (
<div className="inline-flex items-center gap-2 rounded-md border border-success/30 bg-success/5 px-2.5 py-1 text-xs text-success">
<Sparkles className="h-3.5 w-3.5" />
<span>
{count === 1
? t('bank_sync_new_since_last_visit_one')
+2 -2
View File
@@ -6,7 +6,7 @@ import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
Receipt,
ReceiptText,
Users,
ArrowLeftRight,
Camera,
@@ -103,7 +103,7 @@ export function EmptyInvoices() {
const t = useTranslations('empty')
return (
<EmptyState
icon={Receipt}
icon={ReceiptText}
title={t('preset_invoices_title')}
description={t('preset_invoices_description')}
actionLabel={t('preset_invoices_action')}
+11
View File
@@ -129,6 +129,17 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
},
// --- Bokslut (year-end) ---
{
// The year-end closing wizard (dispositions, accruals, execute). Owns its
// route under /bookkeeping; surfaced here so the closing flow is reachable
// from Rapporter rather than only via the Bokföring header.
slug: 'year-end-closing',
labelKey: 'name_year_end_closing',
descKey: 'desc_year_end_closing',
category: 'year_end',
params: 'fiscal',
route: '/bookkeeping/year-end',
},
{
slug: 'income-statement',
labelKey: 'name_income_statement',
+7 -3
View File
@@ -15,12 +15,16 @@ export function cn(...inputs: ClassValue[]) {
*/
const INVALID_DATE_PLACEHOLDER = '—'
export function formatCurrency(amount: number, currency: string = 'SEK'): string {
export function formatCurrency(
amount: number,
currency: string = 'SEK',
options?: { minimumFractionDigits?: number; maximumFractionDigits?: number },
): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
minimumFractionDigits: options?.minimumFractionDigits ?? 0,
maximumFractionDigits: options?.maximumFractionDigits ?? 2,
}).format(amount)
}
+2 -1
View File
@@ -423,7 +423,6 @@
},
"suppliers": {
"title": "Suppliers",
"subtitle": "Manage your suppliers and their payment details",
"new_supplier": "New supplier",
"add_supplier": "Add supplier",
"viewer_disabled_tooltip": "You only have viewer access in this company",
@@ -4141,6 +4140,7 @@
"desc_balance_sheet": "Financial position at the end of the period",
"desc_kassaflodesanalys": "Change in liquidity during the year",
"desc_arsredovisning": "Directors' report, notes and signatures",
"desc_year_end_closing": "Close the fiscal year — dispositions, accruals and closing entries",
"desc_vat_declaration": "Basis for the VAT return (boxes)",
"desc_periodisk_sammanstallning": "EU sales of goods and services",
"desc_ne_declaration": "NE appendix for sole traders",
@@ -4164,6 +4164,7 @@
"name_balance_sheet": "Balance sheet",
"name_kassaflodesanalys": "Cash flow statement",
"name_arsredovisning": "Annual report",
"name_year_end_closing": "Year-end closing",
"name_vat_declaration": "VAT declaration",
"name_periodisk_sammanstallning": "EU sales report",
"name_ne_declaration": "NE-bilaga",
+2 -1
View File
@@ -423,7 +423,6 @@
},
"suppliers": {
"title": "Leverantörer",
"subtitle": "Hantera dina leverantörer och deras betalningsuppgifter",
"new_supplier": "Ny leverantör",
"add_supplier": "Lägg till leverantör",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
@@ -4141,6 +4140,7 @@
"desc_balance_sheet": "Ekonomisk ställning vid periodens slut",
"desc_kassaflodesanalys": "Likviditetens förändring under året",
"desc_arsredovisning": "Förvaltningsberättelse, noter och underskrifter",
"desc_year_end_closing": "Stäng räkenskapsåret – dispositioner, periodiseringar och bokslutstransaktioner",
"desc_vat_declaration": "Underlag till momsdeklarationen (rutor)",
"desc_periodisk_sammanstallning": "EU-försäljning av varor och tjänster",
"desc_ne_declaration": "NE-bilaga för enskild firma",
@@ -4164,6 +4164,7 @@
"name_balance_sheet": "Balansräkning",
"name_kassaflodesanalys": "Kassaflödesanalys",
"name_arsredovisning": "Årsredovisning",
"name_year_end_closing": "Årsbokslut",
"name_vat_declaration": "Momsdeklaration",
"name_periodisk_sammanstallning": "Periodisk sammanställning",
"name_ne_declaration": "NE-bilaga",