feat: comprehensive UI design audit and normalization (#28)

* feat: import system improvements, INK2 fix, and Swedish text corrections

- SIE parser: Windows-1252 and CP437 encoding detection and decoding
- Bank file parser: add Nordea Business (Företag) CSV format
- Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV
- INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years
- Dashboard: parallel Supabase queries, simplified dashboard page
- Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures
- Import wizard UI improvements across all steps
- Migration: add 'bas_range' match type to sie_account_mappings constraint
- Extensive new tests for SIE parser encoding and bank file parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding

Arcim migration wizard improvements:
- Progress bar now excludes non-interactive steps (migrating/result)
- Fix OAuth text to match target="_blank" behavior (new tab, not redirect)
- Display month names instead of "Månad X" in preview
- Fix Swedish typo "förifylla" in no-company-info message
- Replace native checkboxes with shadcn Switch in options step
- Add ConfirmationDialog before starting migration
- Show progress percentage during migration
- Add "Nästa steg" guidance and navigation links in result step
- Add "Försök igen" button in error state (returns to options)
- Add Bokio company ID help text (GUID from URL)
- Add Fortnox integration add-on hint on connection failure

Also includes: SIE import system improvements, INK2 fixes, Swedish text
corrections, Sentry error tracking setup, and arcim-migration extension
scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix OAuth error recovery blank page (restore provider from URL params)
- Pass real userId to MigrationWizard instead of empty string
- Remove ~50 debug console.log statements from sie-import.ts
- Fix comment referencing account 3740 → 3741

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: comprehensive UI design audit and normalization

Dashboard audit:
- Fix muted-foreground contrast (4.31:1 → 5.08:1) for WCAG AA
- Add prefers-reduced-motion media query for all animations
- Replace border-l-2 accent anti-pattern with subtle full-border colors
- Add aria-expanded to toggle buttons, role="status" to live counters
- Fix touch targets on deadline buttons (28px → 36px)
- Vary section spacing for rhythm (mb-12/mb-10/mb-8)
- Remove unused imports and dead code

Transactions audit + hardening:
- Add pagination (200 per page) with "Ladda fler" button
- Replace height animation with transform-only exit animation
- Show batch progress in floating action bar during processing
- Fix batch bar mobile overlap (bottom-20 on mobile)
- Replace clickable badges with proper button elements
- Add safe area padding to fullscreen swipe view
- Add response.ok check to suggestion fetch
- Add truncation to invoice number buttons

Invoicing audit:
- Remove border-l-4 accent pattern from invoice cards
- Replace string concatenation with cn() utility

Systemic sweep (34 files):
- All page headings: font-bold → font-display font-medium (Fraunces)
- All stat numbers: font-bold → font-display font-medium tabular-nums
- All hard-coded blue/amber/emerald colors → design tokens
- Remove all dark mode overrides (tokens handle automatically)
- Tint pure white card background to 99%

Design context added to CLAUDE.md with brand personality,
aesthetic direction, and 5 design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bookkeeping flow audit — design system, accessibility, UX

- Replace raw <select> with shadcn Select component (JournalEntryForm)
- Add confirmation dialog for account deletion (ChartOfAccountsManager)
- Remove console.error from production code (JournalEntryList, JournalEntryForm)
- Fix contradictory h-7/min-h-[44px] button sizing → h-10 (ChartOfAccountsManager)
- Increase BAS catalog "Lägg till" touch target h-7 → h-9
- Improve loading state with spinner (JournalEntryList)
- Improve empty state with icon, description, and guidance (JournalEntryList)
- Add response.ok check on journal entry fetch
- Add aria-expanded to entry expand buttons
- Add tabular-nums to desktop debit/credit columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: onboarding and empty state improvements

Onboarding:
- Replace font-serif with font-display (Fraunces) for brand consistency
- Remove console.error calls from production code

Empty states:
- Fix broken /transactions/new link in EmptyTransactions (route doesn't exist)
- Add actionHref fallback to EmptyCustomers when no onAction prop provided
- Improve EmptyTransactions description copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clarify Swedish UX copy — terminology, errors, descriptions

Terminology consistency:
- "Försenad" → "Förfallen" for overdue invoices (customers/[id])
- "bokföringsorder" → actionable description in bookkeeping page
- "verifikation har bifogats" → "underlag har bifogats" in doc warning
- "Fortsätt ändå" → "Bokför utan underlag" (specific action)

Error messages — replace generic "Fel" + "Något gick fel" with specific:
- "Något gick fel vid bokföring" → "Transaktionen kunde inte bokföras"
- "Något gick fel vid matchning" → "Transaktionen kunde inte matchas"
- "Kunde inte hämta X" → "Kunde inte ladda X" + recovery hint
- Add "Försök igen" guidance to all error toasts

Page descriptions — replace redundant with actionable:
- Invoices: "Skapa och hantera" → "Skicka, följ betalningar, skapa kreditnotor"
- Bookkeeping: list of features → actionable description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: design critique — dashboard affordance, reports description

Dashboard:
- Add ChevronRight indicator to clickable summary cards
  (Att få betalt, Koppla bank) to distinguish from static cards
- Add cursor-pointer to linked cards

Reports:
- Replace feature list description with actionable guidance
  "Huvudbok, grundbok..." → "Generera skattedeklarationer..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace generic "Fel" error toasts with specific messages

Deadlines: 5 generic "Fel" → specific per-action titles
  (create, toggle, edit, delete, load)
Expenses detail: 5 generic "Fel" → specific per-action titles
  (load, approve, pay, credit, delete)
Expenses new: 3 generic "Fel" → instructional validation messages
  (supplier name, supplier selection, invoice number)
Customers: 1 generic "Fel" → specific load error with recovery hint

All error toasts now follow pattern:
  title = what failed, description = how to recover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace all remaining generic "Fel" error toasts (37 instances)

Systematic sweep across 12 dashboard pages replacing generic
title: 'Fel' with context-specific error titles:

- Load errors: "Kunde inte ladda [resurs]"
- Action errors: "[Åtgärd] misslyckades"
- Validation: "[Fält] saknas"

Every error toast now tells the user what failed without needing
to read the description. Recovery hints added where missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: import flow — normalize stat typography, remove console.warn

- Replace font-bold with font-display font-medium on 13 stat numbers
  across SIEPreviewStep, BankFilePreviewStep, BankFileConfirmStep,
  ImportResultStep (missed by systemic sweep since these are in
  components/import/, not app/(dashboard)/)
- Add tabular-nums to stat numbers displaying counts/currency
- Remove console.warn in ArcimMigrationWorkspace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: final cleanup — console statements, remaining font-bold stats

Remove production console statements:
- Step1EntityType: remove debug console.warn (dead code after onNext)
- TransactionBookingDialog: remove console.error on doc link failure
- JournalEntryAttachments: remove 3 console.error calls

Normalize remaining font-bold stat displays:
- SwipeCategorizationView: 3 instances (completion, amount displays)
- NEDeclarationView: yearly result heading + value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review feedback

loadMoreTransactions: add inbox item enrichment matching fetchTransactions
- Paginated transactions now fetch invoice_inbox_items in parallel
- Fixes missing document indicator, template suggestions, and inbox
  match card for transactions loaded via "Ladda fler"

fetchAllPages: add maxPages guard (default 500) to prevent infinite loop
- If Arcim gateway returns hasMore:true indefinitely, the loop now
  exits after 500 pages instead of running forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-14 14:48:40 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a438c50e9c
commit a7be1aab17
62 changed files with 688 additions and 515 deletions
+2 -2
View File
@@ -16,9 +16,9 @@ export default function BookkeepingPage() {
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Bokföring</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Bokföring</h1>
<p className="text-muted-foreground">
Verifikationer, kontoplan och manuella bokföringsorder
Skapa verifikationer, hantera kontoplanen och bifoga underlag
</p>
</div>
<Button variant="outline" asChild className="w-full sm:w-auto">
@@ -7,7 +7,7 @@ export default function YearEndPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Årsbokslut</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Årsbokslut</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
+8 -8
View File
@@ -84,8 +84,8 @@ export default function CustomerDetailPage({
setCustomer(data)
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hitta kunden',
title: 'Kunde inte ladda kund',
description: 'Kunden hittades inte.',
variant: 'destructive',
})
router.push('/customers')
@@ -115,8 +115,8 @@ export default function CustomerDetailPage({
fetchCustomer()
} catch {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera kunden',
title: 'Kunde inte uppdatera kund',
description: 'Försök igen.',
variant: 'destructive',
})
} finally {
@@ -150,8 +150,8 @@ export default function CustomerDetailPage({
router.push('/customers')
} catch {
toast({
title: 'Fel',
description: 'Kunde inte ta bort kunden',
title: 'Kunde inte ta bort kund',
description: 'Försök igen.',
variant: 'destructive',
})
}
@@ -196,7 +196,7 @@ export default function CustomerDetailPage({
<Icon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{customer.name}</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{customer.name}</h1>
<Badge variant="secondary">{customerTypeLabels[customer.customer_type]}</Badge>
</div>
</div>
@@ -346,7 +346,7 @@ export default function CustomerDetailPage({
{formatCurrency(invoice.total, invoice.currency)}
</span>
<Badge variant={invoice.payment_status === 'paid' ? 'success' : 'secondary'}>
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Försenad' : 'Obetald'}
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Förfallen' : 'Obetald'}
</Badge>
</div>
</Link>
+5 -5
View File
@@ -56,8 +56,8 @@ export default function CustomersPage() {
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta kunder',
title: 'Kunde inte ladda kunder',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} else {
@@ -83,8 +83,8 @@ export default function CustomersPage() {
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte skapa kund',
title: 'Kunde inte skapa kund',
description: result.error || 'Försök igen.',
variant: 'destructive',
})
} else {
@@ -109,7 +109,7 @@ export default function CustomersPage() {
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Kunder</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Kunder</h1>
<p className="text-muted-foreground">
Hantera dina kunder och deras faktureringsuppgifter
</p>
+12 -12
View File
@@ -47,8 +47,8 @@ export default function DeadlinesPage() {
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hamta data',
title: 'Kunde inte ladda deadlines',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} finally {
@@ -83,8 +83,8 @@ export default function DeadlinesPage() {
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
title: 'Kunde inte skapa deadline',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
throw error
@@ -111,8 +111,8 @@ export default function DeadlinesPage() {
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
title: 'Kunde inte uppdatera status',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -139,8 +139,8 @@ export default function DeadlinesPage() {
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
title: 'Kunde inte spara ändringar',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -164,8 +164,8 @@ export default function DeadlinesPage() {
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
title: 'Kunde inte ta bort deadline',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -175,7 +175,7 @@ export default function DeadlinesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Deadlines</h1>
</div>
{/* Overdue alert skeleton */}
<div className="rounded-lg border p-4 animate-pulse">
@@ -214,7 +214,7 @@ export default function DeadlinesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Deadlines</h1>
</div>
{overdueInvoices.count > 0 && (
+6 -6
View File
@@ -46,7 +46,7 @@ export default function ExpenseDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
toast({ title: 'Kunde inte ladda utgift', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
@@ -76,7 +76,7 @@ export default function ExpenseDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kunde inte godkänna', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Utgiften har godkänts' })
fetchInvoice()
@@ -93,7 +93,7 @@ export default function ExpenseDetailPage() {
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Betalning misslyckades', description: result.error, variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
@@ -117,7 +117,7 @@ export default function ExpenseDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kunde inte kreditera', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
@@ -136,7 +136,7 @@ export default function ExpenseDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kunde inte ta bort', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/expenses')
@@ -177,7 +177,7 @@ export default function ExpenseDetailPage() {
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
Utgift #{invoice.arrival_number}
</h1>
<Badge className={status.color}>
+4 -4
View File
@@ -194,7 +194,7 @@ export default function NewExpensePage() {
async function handleCreateSupplier() {
if (!newSupplier.name.trim()) {
toast({ title: 'Fel', description: 'Ange leverantörsnamn', variant: 'destructive' })
toast({ title: 'Namn saknas', description: 'Ange ett namn för leverantören.', variant: 'destructive' })
return
}
setIsCreatingSupplier(true)
@@ -231,11 +231,11 @@ export default function NewExpensePage() {
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
toast({ title: 'Leverantör saknas', description: 'Välj eller skapa en leverantör.', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
toast({ title: 'Fakturanummer saknas', description: 'Ange leverantörens fakturanummer.', variant: 'destructive' })
return
}
@@ -337,7 +337,7 @@ export default function NewExpensePage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Ny utgift</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny utgift</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura
</p>
+4 -4
View File
@@ -75,7 +75,7 @@ export default function ExpensesPage() {
.order('due_date', { ascending: true })
if (error) {
toast({ title: 'Fel', description: 'Kunde inte hämta utgifter', variant: 'destructive' })
toast({ title: 'Kunde inte ladda utgifter', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
} else {
setInvoices(data || [])
}
@@ -134,7 +134,7 @@ export default function ExpensesPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Att betala</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.unpaidCount} utgifter</p>
</div>
</div>
@@ -150,13 +150,13 @@ export default function ExpensesPage() {
{stats.overdueCount > 0 ? (
<>
<p className="text-sm text-muted-foreground">Förfallet</p>
<p className="text-2xl font-bold tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.overdueCount} utgifter</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
</>
)}
</div>
+9 -9
View File
@@ -438,7 +438,7 @@ function SIEImportWizard() {
const data = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: data.error || 'Kunde inte skapa konton', variant: 'destructive' })
toast({ title: 'Kunde inte skapa konton', description: data.error || 'Försök igen.', variant: 'destructive' })
return
}
@@ -463,7 +463,7 @@ function SIEImportWizard() {
}
}
} catch (err) {
toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Kunde inte skapa konton', variant: 'destructive' })
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive' })
} finally {
setIsCreatingAccounts(false)
}
@@ -601,8 +601,8 @@ function PSD2ConnectWizard() {
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
title: 'Kunde inte ansluta bank',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
setIsConnecting(false)
@@ -634,8 +634,8 @@ function PSD2ConnectWizard() {
fetchConnections()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
title: 'Synkronisering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -671,8 +671,8 @@ function PSD2ConnectWizard() {
fetchConnections()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
title: 'Kunde inte koppla bort bank',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -780,7 +780,7 @@ export default function ImportPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Importera</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Importera</h1>
<p className="text-muted-foreground">
Importera banktransaktioner eller bokföringsdata till ditt företag
</p>
@@ -51,8 +51,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
if (error || !data) {
toast({
title: 'Fel',
description: 'Kunde inte hämta faktura',
title: 'Kunde inte ladda faktura',
description: 'Fakturan hittades inte.',
variant: 'destructive',
})
router.push('/invoices')
@@ -120,8 +120,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
router.push(`/invoices/${creditNote.id}`)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skapa kreditfaktura',
title: 'Kunde inte skapa kreditfaktura',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -151,7 +151,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Skapa kreditfaktura</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Skapa kreditfaktura</h1>
<p className="text-muted-foreground">
Krediterar faktura {invoice.invoice_number}
</p>
+13 -13
View File
@@ -90,8 +90,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (error || !data) {
toast({
title: 'Fel',
description: 'Kunde inte hämta faktura',
title: 'Kunde inte ladda faktura',
description: 'Fakturan hittades inte.',
variant: 'destructive',
})
router.push('/invoices')
@@ -211,8 +211,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
fetchInvoice()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
title: 'Statusuppdatering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -255,8 +255,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
fetchInvoice()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skicka fakturan',
title: 'Kunde inte skicka faktura',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -287,8 +287,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
router.push(`/invoices/${data.data.id}`)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte konvertera',
title: 'Konvertering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -324,8 +324,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ladda ner PDF',
title: 'Kunde inte ladda ner PDF',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -363,12 +363,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">{invoice.invoice_number}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Proforma</Badge>
<Badge variant="secondary" className="bg-primary/10 text-primary">Proforma</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">Följesedel</Badge>
<Badge variant="secondary" className="bg-success/10 text-success">Följesedel</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
<StatusIcon className="mr-1 h-3 w-3" />
+5 -5
View File
@@ -168,8 +168,8 @@ export default function NewInvoicePage() {
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta kunder',
title: 'Kunde inte ladda kunder',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} else {
@@ -191,8 +191,8 @@ export default function NewInvoicePage() {
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte skapa kund',
title: 'Kunde inte skapa kund',
description: result.error || 'Försök igen.',
variant: 'destructive',
})
} else {
@@ -370,7 +370,7 @@ export default function NewInvoicePage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'}
</h1>
<p className="text-muted-foreground">
+20 -17
View File
@@ -11,17 +11,18 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, FileQuestion, Truck } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import type { Invoice, InvoiceStatus } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType; borderColor: string }> = {
draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-l-muted-foreground/30' },
sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-l-warning' },
paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-l-success' },
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-l-destructive' },
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-l-muted-foreground/30' },
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-l-muted-foreground/30' },
draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-muted-foreground/30' },
sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-warning/50' },
paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-success/50' },
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-destructive/50' },
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
}
function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
@@ -62,8 +63,8 @@ export default function InvoicesPage() {
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta fakturor',
title: 'Kunde inte ladda fakturor',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} else {
@@ -106,7 +107,7 @@ export default function InvoicesPage() {
<div className="space-y-6">
<PageHeader
title="Fakturor"
description="Skapa och hantera dina fakturor"
description="Skicka fakturor, följ betalningar och skapa kreditnotor"
action={
<Link href="/invoices/new">
<Button>
@@ -145,7 +146,7 @@ export default function InvoicesPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
</div>
</div>
</CardContent>
@@ -159,7 +160,7 @@ export default function InvoicesPage() {
<div>
<p className="text-sm text-muted-foreground">Obetalda</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold tabular-nums">{stats.unpaid}</p>
<p className="font-display text-2xl font-medium tabular-nums">{stats.unpaid}</p>
{stats.overdue > 0 && (
<Badge variant="destructive" className="text-xs">
{stats.overdue} förfallna
@@ -178,7 +179,7 @@ export default function InvoicesPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Att få in</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
</div>
</div>
</CardContent>
@@ -262,11 +263,13 @@ export default function InvoicesPage() {
const isDeliveryNote = docType === 'delivery_note'
const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon
const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : isProforma ? 'border-l-4 border-l-blue-400' : isDeliveryNote ? 'border-l-4 border-l-emerald-400' : `border-l-4 ${status.borderColor}`
return (
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${borderClass} ${invoice.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
<Card className={cn(
'hover:border-primary/50 transition-colors cursor-pointer',
isCreditNote ? 'border-destructive/30' : isProforma ? 'border-primary/30' : isDeliveryNote ? 'border-success/30' : status.borderColor,
invoice.status === 'overdue' && 'ring-1 ring-destructive/20'
)}>
<CardContent className="py-4">
<div className="flex items-start gap-3 sm:gap-4 sm:items-center">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${isCreditNote ? 'bg-destructive/10' : 'bg-muted'}`}>
@@ -289,12 +292,12 @@ export default function InvoicesPage() {
</Badge>
)}
{isProforma && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
<Badge variant="secondary" className="text-xs bg-primary/10 text-primary">
Proforma
</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
<Badge variant="secondary" className="text-xs bg-success/10 text-success">
Följesedel
</Badge>
)}
+1 -1
View File
@@ -30,7 +30,7 @@ export default function ReceiptsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kvitton</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Kvitton</h1>
<p className="text-muted-foreground">
Hantera och granska dina kvitton
</p>
+8 -8
View File
@@ -69,9 +69,9 @@ export default function ReportsPage() {
<div className="space-y-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Rapporter</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Rapporter</h1>
<p className="text-muted-foreground">
Huvudbok, grundbok, kundreskontra, saldobalans, resultaträkning, balansräkning, momsdeklaration och mer
Generera skattedeklarationer, resultaträkningar och exportera till Skatteverket
</p>
</div>
</div>
@@ -1160,7 +1160,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
</CardContent>
</Card>
@@ -1169,7 +1169,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-success">{formatAmount(ledger.total_current)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
@@ -1177,7 +1177,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
@@ -1758,7 +1758,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
</CardContent>
</Card>
@@ -1767,7 +1767,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-success">{formatAmount(ledger.total_current)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
@@ -1775,7 +1775,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
+5 -5
View File
@@ -196,8 +196,8 @@ export default function SettingsPage() {
setSettings({ ...settings, ...updates } as typeof settings)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara inställningar',
title: 'Kunde inte spara inställningar',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
@@ -229,8 +229,8 @@ export default function SettingsPage() {
router.push('/login')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte radera kontot',
title: 'Kunde inte radera kontot',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
setIsDeleting(false)
@@ -284,7 +284,7 @@ export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Inställningar</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
<p className="text-muted-foreground">
Hantera dina företags- och kontoinställningar
</p>
@@ -55,7 +55,7 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
toast({ title: 'Kunde inte ladda leverantörsfaktura', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
@@ -72,7 +72,7 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Godkännande misslyckades', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Fakturan har godkänts' })
fetchInvoice()
@@ -89,7 +89,7 @@ export default function SupplierInvoiceDetailPage() {
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Betalning misslyckades', description: result.error, variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
@@ -113,7 +113,7 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kreditering misslyckades', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
@@ -132,7 +132,7 @@ export default function SupplierInvoiceDetailPage() {
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kunde inte ta bort faktura', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/supplier-invoices')
@@ -172,7 +172,7 @@ export default function SupplierInvoiceDetailPage() {
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">
Ankomst #{invoice.arrival_number}
</h1>
<Badge className={statusColors[invoice.status] || ''}>
@@ -165,11 +165,11 @@ export default function NewSupplierInvoicePage() {
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
toast({ title: 'Leverantör saknas', description: 'Välj en leverantör innan du fortsätter.', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
toast({ title: 'Fakturanummer saknas', description: 'Ange leverantörens fakturanummer.', variant: 'destructive' })
return
}
@@ -229,7 +229,7 @@ export default function NewSupplierInvoicePage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Registrera leverantörsfaktura</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Registrera leverantörsfaktura</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura (uppfyller BFL verifikationskrav)
</p>
+4 -4
View File
@@ -73,7 +73,7 @@ export default function SupplierInvoicesPage() {
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Leverantörsfakturor</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Leverantörsfakturor</h1>
<p className="text-muted-foreground">
Registrera och hantera inkommande fakturor
</p>
@@ -112,7 +112,7 @@ export default function SupplierInvoicesPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(totalUnpaid)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(totalUnpaid)} kr</p>
<p className="text-xs text-muted-foreground">
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
</p>
@@ -126,7 +126,7 @@ export default function SupplierInvoicesPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{formatAmount(overdueAmount)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatAmount(overdueAmount)} kr</p>
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
</CardContent>
</Card>
@@ -138,7 +138,7 @@ export default function SupplierInvoicesPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{invoices.length}</p>
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
</CardContent>
</Card>
</>
+7 -7
View File
@@ -39,7 +39,7 @@ export default function SupplierDetailPage() {
const res = await fetch(`/api/suppliers/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
toast({ title: 'Kunde inte ladda leverantör', description: error, variant: 'destructive' })
} else {
setSupplier(data)
}
@@ -69,7 +69,7 @@ export default function SupplierDetailPage() {
const result = await res.json()
if (!res.ok) {
const fieldErrors = result.errors?.map((e: { field: string; message: string }) => `${e.field}: ${e.message}`).join(', ')
toast({ title: 'Fel', description: fieldErrors || result.error || 'Kunde inte uppdatera leverantör', variant: 'destructive' })
toast({ title: 'Kunde inte uppdatera leverantör', description: fieldErrors || result.error || 'Försök igen.', variant: 'destructive' })
} else {
toast({ title: 'Sparat', description: 'Leverantören har uppdaterats' })
setSupplier({ ...result.data, stats: supplier?.stats })
@@ -90,7 +90,7 @@ export default function SupplierDetailPage() {
const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
toast({ title: 'Kunde inte ta bort leverantör', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen', description: 'Leverantören har tagits bort' })
router.push('/suppliers')
@@ -145,7 +145,7 @@ export default function SupplierDetailPage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">{supplier.name}</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{supplier.name}</h1>
<p className="text-muted-foreground">
{supplierTypeLabels[supplier.supplier_type]}
{supplier.org_number && ` | Org.nr: ${supplier.org_number}`}
@@ -170,7 +170,7 @@ export default function SupplierDetailPage() {
<CardTitle className="text-sm text-muted-foreground">Utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
</CardContent>
</Card>
<Card>
@@ -178,7 +178,7 @@ export default function SupplierDetailPage() {
<CardTitle className="text-sm text-muted-foreground">Totalt betalt</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
</CardContent>
</Card>
<Card>
@@ -186,7 +186,7 @@ export default function SupplierDetailPage() {
<CardTitle className="text-sm text-muted-foreground">Antal fakturor</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{supplier.stats?.invoice_count || 0}</p>
<p className="font-display text-2xl font-medium tabular-nums">{supplier.stats?.invoice_count || 0}</p>
</CardContent>
</Card>
</div>
+5 -5
View File
@@ -43,8 +43,8 @@ export default function SuppliersPage() {
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta leverantörer',
title: 'Kunde inte ladda leverantörer',
description: 'Kontrollera din anslutning och försök igen.',
variant: 'destructive',
})
} else {
@@ -71,8 +71,8 @@ export default function SuppliersPage() {
if (!response.ok) {
const fieldErrors = result.errors?.map((e: { field: string; message: string }) => `${e.field}: ${e.message}`).join(', ')
toast({
title: 'Fel',
description: fieldErrors || result.error || 'Kunde inte skapa leverantör',
title: 'Kunde inte skapa leverantör',
description: fieldErrors || result.error || 'Försök igen.',
variant: 'destructive',
})
} else {
@@ -97,7 +97,7 @@ export default function SuppliersPage() {
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Leverantörer</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Leverantörer</h1>
<p className="text-muted-foreground">
Hantera dina leverantörer och deras betalningsuppgifter
</p>
+109 -23
View File
@@ -76,6 +76,10 @@ export default function TransactionsPage() {
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Pagination
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
@@ -93,15 +97,18 @@ export default function TransactionsPage() {
})
const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id)
const PAGE_SIZE = 200
async function fetchTransactions() {
setIsLoading(true)
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.order('date', { ascending: false })
.limit(PAGE_SIZE)
if (txError) {
toast({ title: 'Fel', description: 'Kunde inte hämta transaktioner', variant: 'destructive' })
toast({ title: 'Kunde inte ladda transaktioner', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
setIsLoading(false)
return
}
@@ -149,9 +156,71 @@ export default function TransactionsPage() {
}))
setTransactions(transactionsWithInvoices)
setHasMore((txData || []).length >= PAGE_SIZE)
setIsLoading(false)
}
async function loadMoreTransactions() {
setIsLoadingMore(true)
const offset = transactions.length
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.order('date', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1)
if (txError || !txData) {
setIsLoadingMore(false)
return
}
setHasMore(txData.length >= PAGE_SIZE)
const potentialInvoiceIds = txData
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const unbookedTxIds = txData
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
const [invoiceResult, inboxResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
unbookedTxIds.length > 0
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (inboxResult.data) {
inboxItemMap = inboxResult.data.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
matched_inbox_item: inboxItemMap[t.id] || undefined,
}))
setTransactions((prev) => [...prev, ...newTransactions])
setIsLoadingMore(false)
}
async function fetchCategorySuggestions(txIds: string[]) {
if (txIds.length === 0) return
setIsLoadingSuggestions(true)
@@ -161,6 +230,7 @@ export default function TransactionsPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_ids: txIds }),
})
if (!response.ok) throw new Error('Failed to fetch suggestions')
const data = await response.json()
if (data.suggestions) {
setCategorySuggestions(data.suggestions)
@@ -227,7 +297,7 @@ export default function TransactionsPage() {
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', variant: 'destructive' })
toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
setProcessingId(null)
return null
}
@@ -254,10 +324,10 @@ export default function TransactionsPage() {
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
} else {
const errData = await undoRes.json()
toast({ title: 'Kunde inte ångra', description: errData.error || 'Något gick fel', variant: 'destructive' })
toast({ title: 'Kunde inte ångra', description: errData.error || 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte ångra', description: 'Något gick fel', variant: 'destructive' })
toast({ title: 'Kunde inte ångra', description: 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
}
}}>
Ångra
@@ -290,7 +360,7 @@ export default function TransactionsPage() {
return result.journal_entry_id || null
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' })
toast({ title: 'Bokföring misslyckades', description: 'Transaktionen kunde inte bokföras. Försök igen.', variant: 'destructive' })
setProcessingId(null)
return null
}
@@ -312,7 +382,7 @@ export default function TransactionsPage() {
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' })
toast({ title: 'Fakturamatchning misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
setIsConfirmingMatch(false)
return
}
@@ -350,7 +420,7 @@ export default function TransactionsPage() {
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
setIsConfirmingMatch(false)
}
}
@@ -364,7 +434,7 @@ export default function TransactionsPage() {
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' })
toast({ title: 'Fakturamatchning misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
return false
}
@@ -390,7 +460,7 @@ export default function TransactionsPage() {
toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` })
return true
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
return false
}
}
@@ -399,7 +469,7 @@ export default function TransactionsPage() {
setIsCreating(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast({ title: 'Fel', description: 'Du måste vara inloggad', variant: 'destructive' })
toast({ title: 'Inloggning krävs', description: 'Du måste vara inloggad för att lägga till transaktioner.', variant: 'destructive' })
setIsCreating(false)
return
}
@@ -420,7 +490,7 @@ export default function TransactionsPage() {
.single()
if (error) {
toast({ title: 'Fel', description: error.message, variant: 'destructive' })
toast({ title: 'Kunde inte skapa transaktion', description: error.message, variant: 'destructive' })
} else {
toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` })
setTransactions([transaction, ...transactions])
@@ -719,23 +789,39 @@ export default function TransactionsPage() {
transactions={transactions}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
/>
)}
{/* Batch mode floating action bar */}
{isBatchMode && selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button variant="outline" size="sm" onClick={handleBatchMarkPrivate}>
Markera som privat
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför {selectedIds.size} st
</Button>
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
{batchProgress ? (
<>
<Badge variant="secondary">
{batchProgress.done}/{batchProgress.total}
</Badge>
<p className="text-sm text-muted-foreground">
Bokför {batchProgress.done} av {batchProgress.total}...
</p>
</>
) : (
<>
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button variant="outline" size="sm" onClick={handleBatchMarkPrivate}>
Markera som privat
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför {selectedIds.size} st
</Button>
</>
)}
</div>
)}
+6 -7
View File
@@ -114,8 +114,7 @@ function OnboardingPageContent() {
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' })
if (error) {
console.error('Error saving settings:', JSON.stringify(error), 'code:', error.code, 'message:', error.message, 'details:', error.details)
console.error('Payload was:', JSON.stringify({ ...settingsToSave, user_id: user.id }))
// Error details available in Sentry via toast context
toast({
title: 'Fel',
description: error.message || 'Kunde inte spara. Försök igen.',
@@ -170,8 +169,8 @@ function OnboardingPageContent() {
p_entity_type: stepData.entity_type,
})
}
} catch (err) {
console.error('Failed to seed chart of accounts:', err)
} catch {
// Non-critical — chart of accounts can be seeded later
}
}
@@ -423,7 +422,7 @@ function OnboardingPageContent() {
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-serif font-bold text-white/[0.02] leading-none select-none">
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-display font-bold text-white/[0.02] leading-none select-none">
{String(currentStep).padStart(2, '0')}
</span>
</div>
@@ -439,7 +438,7 @@ function OnboardingPageContent() {
height={30}
className="invert opacity-90"
/>
<span className="font-serif text-base tracking-tight">gnubok</span>
<span className="font-display text-base tracking-tight">gnubok</span>
</div>
{/* Step indicator — inline with logo row */}
<div className="flex items-center gap-1.5">
@@ -465,7 +464,7 @@ function OnboardingPageContent() {
{/* Step title — compact */}
<div key={`title-${currentStep}`} className="animate-fade-in">
<h1 className="font-serif text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
{stepInfo.title}
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
+21 -3
View File
@@ -9,10 +9,10 @@
--background: 0 0% 97%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card: 0 0% 99%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover: 0 0% 99%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 15%;
@@ -22,7 +22,7 @@
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 92%;
--muted-foreground: 0 0% 45%;
--muted-foreground: 0 0% 40%;
--accent: 0 0% 92%;
--accent-foreground: 0 0% 9%;
@@ -337,3 +337,21 @@ input[type="number"] {
background: hsl(var(--primary) / 0.3);
color: hsl(var(--foreground));
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.stagger-enter > * {
animation: none;
opacity: 1;
}
.hover-lift:hover {
transform: none;
}
}