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
+45
View File
@@ -406,3 +406,48 @@ Hosted on **Vercel**. Cron jobs in `vercel.json` (banking sync daily 05:00, dead
## Other
Never create a NUL/nul file: \gnubok\NUL
---
## Design Context
### Users
Swedish sole traders (enskild firma) and small business owners (aktiebolag) who need to manage their own bookkeeping. They are not accountants — they are professionals (consultants, freelancers, shop owners) who want to stay compliant without hiring one. They use gnubok in short, focused sessions: sending an invoice, categorizing bank transactions, filing a VAT declaration. Speed and clarity matter — every second spent in the app is a second away from their real work.
### Brand Personality
**Minimal. Sharp. Efficient.**
gnubok is the tool that makes accounting feel handled. It doesn't try to be fun — it earns trust through precision, clarity, and speed. The interface should feel like a well-made instrument: considered, quiet, and confident. Think Mercury's calm authority applied to Swedish bookkeeping.
### Aesthetic Direction
- **Palette**: Grayscale foundation with restrained semantic colors — sage green (success/balance), terracotta (errors/overdue), ochre (warnings/attention). No loud brand color. The absence of color *is* the brand.
- **Typography**: Fraunces (serif) for display headings — adds warmth and distinction to an otherwise minimal interface. Geist (sans) for body — clean, modern, excellent for tabular data. Tabular numbers everywhere financial data appears.
- **Surfaces**: White/near-white cards on light gray backgrounds. Subtle borders (60% opacity). Soft shadows that suggest depth without drama. Dark mode follows the same restraint.
- **Spacing**: Generous whitespace. Let content breathe. Dense data (tables, ledgers) should use tighter spacing but never feel cramped.
- **Motion**: Subtle and purposeful. Stagger animations for list entry, spring easing for interactive feedback. Never decorative — always communicates state change.
- **Icons**: Lucide — consistent 15px in navigation, slightly larger in empty states and actions.
- **Reference**: Mercury (banking) — calm, trustworthy, clean data presentation, financial precision.
- **Anti-reference**: Enterprise software (SAP/Oracle density, overwhelming dashboards with 50 widgets, toolbar soup).
### Design Principles
1. **Clarity over cleverness.** Every element should be immediately understandable. Accounting is complex enough — the UI must never add confusion. Use familiar patterns, clear labels (in Swedish), and obvious hierarchy.
2. **Earned minimalism.** Remove everything that doesn't serve the user's task. But don't strip away context that prevents errors — in accounting, a missing detail can mean a compliance violation. Minimal means *considered*, not *sparse*.
3. **Numbers are first-class.** Financial data deserves typographic care: tabular-nums, proper alignment, adequate contrast, clear positive/negative distinction. A journal entry grid should feel as precise as a printed ledger.
4. **Trust through consistency.** Same patterns, same spacing, same behavior everywhere. Buttons work the same way. Cards look the same way. When the user learns one screen, they've learned them all.
5. **Speed is a feature.** Fast load, fast interaction, fast comprehension. Users are here to get a job done and leave. Optimize for the 90-second session: open app, categorize three transactions, close app.
### Accessibility
- Target: **WCAG AA** compliance
- Minimum contrast ratio: 4.5:1 for text, 3:1 for UI components
- All interactive elements keyboard-navigable with visible focus rings
- Respect `prefers-reduced-motion` for users who disable animations
- Color is never the sole indicator of state — always pair with icons, text, or shape
+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;
}
}
+3 -3
View File
@@ -118,9 +118,9 @@ export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDi
<div className="space-y-4 py-2">
{isBASMatch && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-2 rounded-lg bg-warning/10 border border-warning/30 p-3">
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-sm text-warning-foreground">
Kontonummer {accountNumber} finns i BAS-standarden. Använd &quot;BAS-katalog&quot;-fliken för att aktivera standardkonton istället.
</p>
</div>
@@ -152,6 +152,8 @@ export default function ChartOfAccountsManager() {
}
async function deleteAccount(account: BASAccount) {
const confirmed = window.confirm(`Vill du ta bort konto ${account.account_number} ${account.account_name}?`)
if (!confirmed) return
setDeletingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
@@ -419,7 +421,7 @@ export default function ChartOfAccountsManager() {
<Button
variant="ghost"
size="icon"
className="h-7 w-7 min-h-[44px] min-w-[44px]"
className="h-10 w-10"
onClick={() => setEditAccount(account)}
>
<Pencil className="h-3.5 w-3.5" />
@@ -428,7 +430,7 @@ export default function ChartOfAccountsManager() {
<Button
variant="ghost"
size="icon"
className="h-7 w-7 min-h-[44px] min-w-[44px] text-destructive hover:text-destructive"
className="h-10 w-10 text-destructive hover:text-destructive"
onClick={() => deleteAccount(account)}
disabled={deletingAccount === account.account_number}
>
@@ -537,7 +539,7 @@ export default function ChartOfAccountsManager() {
</td>
<td className="py-2 text-right">
{account.is_activated ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
Aktiverat
</span>
@@ -545,7 +547,7 @@ export default function ChartOfAccountsManager() {
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
className="h-9 text-xs"
onClick={() => activateBASAccount(account.account_number)}
disabled={activatingAccounts.has(account.account_number)}
>
@@ -53,7 +53,7 @@ export default function JournalEntryAttachments({
setDocuments(data || [])
onCountChangeRef.current?.(data?.length || 0)
} catch {
console.error('Failed to fetch documents')
// Non-critical — silently ignore
} finally {
setLoading(false)
}
@@ -82,7 +82,7 @@ export default function JournalEntryAttachments({
window.open(data.download_url, '_blank')
}
} catch {
console.error('Failed to get download URL')
// Non-critical — silently ignore
}
}
@@ -103,7 +103,7 @@ export default function JournalEntryAttachments({
)
}
} catch {
console.error('Failed to get preview URL')
// Non-critical — silently ignore
return
}
}
+19 -18
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, AlertTriangle } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
@@ -196,8 +197,7 @@ export default function JournalEntryForm({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
} catch (linkErr) {
console.error('[JournalEntryForm] Failed to link document:', linkErr)
} catch {
linkFailCount++
}
}
@@ -233,17 +233,18 @@ export default function JournalEntryForm({
<div className={`grid gap-4 grid-cols-1 ${embedded && initialDate ? 'sm:grid-cols-2' : 'sm:grid-cols-3'}`}>
<div>
<Label>Räkenskapsår</Label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!(embedded && initialDate) && (
<div>
@@ -514,12 +515,12 @@ export default function JournalEntryForm({
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Ingen verifikation har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost."
confirmLabel="Fortsätt ändå"
warningText="Inget underlag har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost."
confirmLabel="Bokför utan underlag"
>
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="text-sm text-warning-foreground">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som
+22 -10
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, X } from 'lucide-react'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
@@ -53,7 +53,7 @@ export default function JournalEntryList({ periodId }: Props) {
const { data } = await res.json()
setAttachmentCounts(data || {})
} catch {
console.error('Failed to fetch attachment counts')
// Non-critical — silently ignore
}
}, [])
@@ -69,6 +69,10 @@ export default function JournalEntryList({ periodId }: Props) {
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) {
setLoading(false)
return
}
const { data, count: total } = await res.json()
const loadedEntries = data || []
setEntries(loadedEntries)
@@ -95,8 +99,9 @@ export default function JournalEntryList({ periodId }: Props) {
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar verifikationer...
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Laddar verifikationer...</p>
</CardContent>
</Card>
)
@@ -105,8 +110,14 @@ export default function JournalEntryList({ periodId }: Props) {
if (entries.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga verifikationer hittades.
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-4 rounded-full bg-muted mb-4">
<BookOpen className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">Inga verifikationer</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken &quot;Ny verifikation&quot;.
</p>
</CardContent>
</Card>
)
@@ -208,6 +219,7 @@ export default function JournalEntryList({ periodId }: Props) {
<Card key={entry.id}>
<button
onClick={() => toggleExpand(entry.id)}
aria-expanded={isExpanded}
className="w-full p-4 text-left hover:bg-muted/50 transition-colors min-h-[44px]"
>
{/* Desktop: single row */}
@@ -232,7 +244,7 @@ export default function JournalEntryList({ periodId }: Props) {
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span className="mr-1" title="Underlag saknas">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)}
@@ -260,7 +272,7 @@ export default function JournalEntryList({ periodId }: Props) {
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title="Underlag saknas">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)}
@@ -327,14 +339,14 @@ export default function JournalEntryList({ periodId }: Props) {
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
<td className="py-2 text-right">
<td className="py-2 text-right tabular-nums">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
+123 -136
View File
@@ -2,8 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
@@ -12,7 +11,7 @@ import {
Receipt,
ArrowLeftRight,
ChevronDown,
ChevronUp,
ChevronRight,
Camera,
Users,
Landmark,
@@ -22,7 +21,7 @@ import {
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { QuickActionDefinition } from '@/lib/extensions/types'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
@@ -70,8 +69,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return () => window.removeEventListener('extension-toggle-changed', handler)
}, [])
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const formatLargeNumber = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'decimal',
@@ -80,15 +77,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
}).format(amount)
}
const isNewUser = onboardingProgress && !onboardingProgress.hasInvoices && !onboardingProgress.hasCustomers
// Build alert items for "Att hantera" section
const alertItems: React.ReactNode[] = []
if (summary.overdueInvoicesCount > 0) {
alertItems.push(
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
<Card className="h-full border-destructive/30 hover:bg-destructive/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
@@ -108,7 +103,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
alertItems.push(
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
@@ -128,7 +123,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.uncategorizedCount > 0) {
alertItems.push(
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
@@ -148,7 +143,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
alertItems.push(
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
<Card className="h-full border-primary/30 hover:bg-primary/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
@@ -170,7 +165,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.missingUnderlagCount > 0) {
alertItems.push(
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
@@ -191,7 +186,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const conn = summary.expiringBankConnections[0]
alertItems.push(
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
@@ -229,19 +224,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
]
const hour = new Date().getHours()
const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll'
const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
const pendingReceiptsCount = summary.receiptQueue
? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count
: 0
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
return (
<div className="stagger-enter">
{/* Header */}
<header className="mb-10">
<header className="mb-12">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{(() => {
const hour = new Date().getHours()
if (hour < 5) return 'God natt'
if (hour < 10) return 'Godmorgon'
if (hour < 14) return 'Hej'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? `, ${firstName}` : ''}
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
</header>
@@ -257,100 +254,96 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)}
{/* 4 Key Summary Cards */}
{(() => {
const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
const pendingReceiptsCount = summary.receiptQueue
? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count
: 0
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
<section className="mb-10">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Card 1: Resultat */}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
)}>
{formatLargeNumber(summary.mtd.net)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} i år
</p>
</CardContent>
</Card>
return (
<section className="mb-10">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Card 1: Resultat */}
<Card>
{/* Card 2: Att få betalt */}
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</CardContent>
</Card>
</Link>
{/* Card 3: Banksaldo */}
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
</CardContent>
</Card>
) : (
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
)}>
{formatLargeNumber(summary.mtd.net)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
{formatCurrency(summary.ytd.net)} i år
</p>
<div className="flex items-start justify-between">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-xs text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
</Card>
</Link>
)}
{/* Card 2: Att få betalt */}
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
<div role="status" aria-live="polite">
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
{formatCurrency(summary.unpaidInvoicesTotal)}
<p className="text-xs text-muted-foreground mt-1">
Behöver åtgärdas
</p>
</CardContent>
</Card>
</Link>
{/* Card 3: Banksaldo */}
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
</p>
</CardContent>
</Card>
) : (
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-[11px] text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
</Card>
</Link>
)}
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
{todoCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
</p>
<p className="text-[11px] text-muted-foreground mt-1">
Åtgärder att hantera
</p>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">Allt klart!</p>
</div>
</>
)}
</CardContent>
</Card>
</div>
</section>
)
})()}
</>
) : (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 text-success" />
<p className="text-sm font-medium text-success">Allt klart!</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* Quick actions */}
<section id="quick-actions" className="mb-10">
@@ -361,7 +354,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
<Link key={action.href} href={action.href} className="group">
<div className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150',
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150 active:scale-[0.98]',
action.accent
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
@@ -374,7 +367,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)} />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.desc}</p>
</div>
</div>
</Link>
@@ -386,13 +379,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (action.href) {
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 active:scale-[0.98] transition-colors duration-150">
<div className="min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.description}</p>
</div>
</div>
</Link>
@@ -404,13 +397,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
onClick={() => window.dispatchEvent(new Event(action.event!))}
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 active:scale-[0.98] transition-colors duration-150">
<div className="min-w-0">
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
<p className="text-xs text-muted-foreground truncate md:block">{action.description}</p>
</div>
</div>
</button>
@@ -423,16 +416,18 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<div className="grid gap-3 md:grid-cols-2">
<div id="alerts-list" className="grid gap-3 md:grid-cols-2">
{visibleAlerts}
</div>
{hasMoreAlerts && !showAllAlerts && (
{hasMoreAlerts && (
<button
onClick={() => setShowAllAlerts(true)}
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
onClick={() => setShowAllAlerts(!showAllAlerts)}
aria-expanded={showAllAlerts}
aria-controls="alerts-list"
className="mt-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla ({alertItems.length})
<ChevronDown className="h-3 w-3" />
{showAllAlerts ? 'Visa färre' : `Visa alla (${alertItems.length})`}
<ChevronDown className={cn('h-3 w-3 transition-transform', showAllAlerts && 'rotate-180')} />
</button>
)}
</section>
@@ -440,7 +435,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<section className="mb-8">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
@@ -452,27 +447,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Collapsible details section */}
<button
onClick={() => setShowMore(!showMore)}
className="mb-6 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
aria-expanded={showMore}
aria-controls="details-section"
className="mb-6 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
>
{showMore ? (
<>
Dölj detaljer
<ChevronUp className="h-3.5 w-3.5" />
</>
) : (
<>
Visa detaljer
<ChevronDown className="h-3.5 w-3.5" />
</>
)}
{showMore ? 'Dölj detaljer' : 'Visa detaljer'}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showMore && 'rotate-180')} />
</button>
{showMore && (
<div>
<div id="details-section">
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-10">
+6 -6
View File
@@ -140,7 +140,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
<nav className="flex-1 px-3" aria-label="Huvudnavigation">
{/* Huvudmeny group */}
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels.main}
</p>
<div className="space-y-px">
@@ -160,7 +160,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</Link>
@@ -171,7 +171,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{/* Finans group */}
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
{groupLabels.finans}
</p>
<div className="space-y-px">
@@ -194,7 +194,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{badge !== null && (
@@ -212,7 +212,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
<div className="mb-4">
<button
onClick={() => setManualOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
<ChevronDown className={cn(
@@ -238,7 +238,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</Link>
+3 -3
View File
@@ -18,19 +18,19 @@ export function SandboxBanner() {
}
return (
<div className="relative z-50 flex items-center justify-center gap-3 bg-amber-500/90 px-4 py-2 text-sm text-amber-950">
<div className="relative z-50 flex items-center justify-center gap-3 bg-warning px-4 py-2 text-sm text-warning-foreground">
<span className="font-medium">
Sandlådemiljö — dina data raderas automatiskt efter 24 timmar
</span>
<button
onClick={handleCreateAccount}
className="rounded-md bg-amber-950/15 px-3 py-0.5 text-xs font-semibold hover:bg-amber-950/25 transition-colors"
className="rounded-md bg-warning-foreground/15 px-3 py-0.5 text-xs font-semibold hover:bg-warning-foreground/25 transition-colors"
>
Skapa konto
</button>
<button
onClick={() => setDismissed(true)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-0.5 hover:bg-amber-950/15 transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-0.5 hover:bg-warning-foreground/15 transition-colors"
aria-label="Stäng"
>
<X className="h-3.5 w-3.5" />
+11 -12
View File
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus, TAX_DEADLINE_TYPE_LABELS, DEADLINE_STATUS_LABELS } from '@/types'
import { getReportUrl } from '@/lib/tax/deadline-config'
import { STATUS_COLORS } from '@/lib/calendar/utils'
// Use the labels from types
const STATUS_LABELS = DEADLINE_STATUS_LABELS
@@ -139,12 +138,12 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
return (
<div
key={deadline.id}
className={`p-3 rounded-lg border ${
className={`p-3 rounded-lg ${
isOverdue
? 'border-destructive/50 bg-destructive/5'
? 'bg-destructive/5 border border-destructive/30'
: isActionNeeded
? 'border-warning/40 bg-warning/10'
: 'border-border bg-background'
? 'bg-warning/5 border border-warning/20'
: ''
}`}
>
<div className="flex items-start justify-between gap-2">
@@ -177,8 +176,8 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{/* Link to report if available */}
{reportLink && (
<Link href={reportLink}>
<Button variant="ghost" size="sm" className="h-7 px-2">
<ExternalLink className="h-3 w-3" />
<Button variant="ghost" size="sm" className="h-9 px-2.5">
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</Link>
)}
@@ -188,12 +187,12 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
className="h-9 px-3 text-xs"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'in_progress')}
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
'Påbörja'
)}
@@ -205,15 +204,15 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs gap-1"
className="h-9 px-3 text-xs gap-1"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Send className="h-3 w-3" />
<Send className="h-3.5 w-3.5" />
Inskickad
</>
)}
@@ -9,9 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus } from '@/types'
import {
getUpcomingDeadlines,
PRIORITY_LABELS,
STATUS_LABELS,
STATUS_COLORS,
} from '@/lib/calendar/utils'
import { Calendar, ChevronRight, AlertTriangle, Clock, Check, Send, Loader2 } from 'lucide-react'
@@ -125,18 +123,23 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<CardContent className="space-y-2">
{displayDeadlines.map((deadline) => {
const isUpdating = updatingId === deadline.id
const statusColors = STATUS_COLORS[deadline.status] || STATUS_COLORS.upcoming
return (
<div
key={deadline.id}
className={`flex items-center justify-between p-2 rounded-lg border ${statusColors.border} ${statusColors.bg}`}
className={`flex items-center justify-between p-2 rounded-lg ${
deadline.status === 'overdue'
? 'bg-destructive/5 border border-destructive/30'
: deadline.status === 'action_needed'
? 'bg-warning/5 border border-warning/20'
: ''
}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{deadline.status === 'overdue' ? (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
) : deadline.status === 'action_needed' ? (
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0" />
<Clock className="h-4 w-4 text-warning-foreground flex-shrink-0" />
) : (
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
@@ -167,15 +170,15 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
title="Markera som inskickad"
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3 w-3" />
<Send className="h-3.5 w-3.5" />
)}
</Button>
)}
@@ -184,15 +187,15 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
className="h-9 px-2.5"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
title="Markera som bekräftad"
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3 w-3" />
<Check className="h-3.5 w-3.5" />
)}
</Button>
)}
+4 -4
View File
@@ -5,10 +5,10 @@ import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
accounting: { label: 'Bokföring & Skatt', className: 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/30 dark:text-rose-400 dark:border-rose-800' },
reports: { label: 'Branschrapporter', className: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800' },
import: { label: 'Smart Import', className: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800' },
operations: { label: 'Verktyg', className: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700' },
accounting: { label: 'Bokföring & Skatt', className: 'bg-destructive/10 text-destructive border-destructive/30' },
reports: { label: 'Branschrapporter', className: 'bg-primary/10 text-primary border-primary/30' },
import: { label: 'Smart Import', className: 'bg-success/10 text-success border-success/30' },
operations: { label: 'Verktyg', className: 'bg-muted text-muted-foreground border-border' },
}
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
@@ -424,10 +424,10 @@ function PreviewStep({
)}
{preview && !preview.sieAvailable && !isLoading && (
<div className="flex gap-3 rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div className="flex gap-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-warning-foreground" />
<div>
<p className="text-sm font-medium text-amber-700">SIE-hämtning inte tillgänglig</p>
<p className="text-sm font-medium text-warning-foreground">SIE-hämtning inte tillgänglig</p>
<p className="text-xs text-muted-foreground">
SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via SIE-importen.
</p>
@@ -922,7 +922,7 @@ function ResultRow({
<p className="text-sm text-muted-foreground">{value}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
{warnings && warnings.length > 0 && (
<p className="mt-1 text-xs text-amber-600">{warnings.join('. ')}</p>
<p className="mt-1 text-xs text-warning-foreground">{warnings.join('. ')}</p>
)}
{errors && errors.length > 0 && (
<details className="mt-1">
@@ -1215,7 +1215,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!result.success && result.errors.length > 0) {
// Log but don't fail — continue with API import
console.warn('SIE import warnings:', result.errors)
// Non-critical — continue with API import
}
}
}
@@ -19,15 +19,15 @@ export default function MockDataBanner({ importedAt, onClear, onReplace }: MockD
: null
return (
<Card className="border-l-4 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20 dark:border-l-amber-400">
<Card className="border-l-4 border-l-warning bg-warning/[0.03]">
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-3">
<FlaskConical className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
<FlaskConical className="h-5 w-5 text-warning-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
<p className="text-sm font-medium text-warning-foreground">
Testdata aktivt
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
<p className="text-xs text-warning-foreground mt-0.5">
Rapporten visar importerad testdata istället för bokföringsdata.
{formatted && <> Importerat {formatted}.</>}
</p>
+4 -4
View File
@@ -67,7 +67,7 @@ export default function BankFileConfirmStep({
<FileText className="h-4 w-4" />
<span className="text-xs">Transaktioner</span>
</div>
<p className="text-xl font-bold">{stats.parsed_rows}</p>
<p className="text-xl font-display font-medium tabular-nums">{stats.parsed_rows}</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
@@ -84,7 +84,7 @@ export default function BankFileConfirmStep({
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-bold">
<p className="text-xl font-display font-medium tabular-nums">
{formatCurrency(stats.total_income)}
</p>
</div>
@@ -93,7 +93,7 @@ export default function BankFileConfirmStep({
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
</div>
<p className="text-xl font-bold">
<p className="text-xl font-display font-medium tabular-nums">
{formatCurrency(stats.total_expenses)}
</p>
</div>
@@ -102,7 +102,7 @@ export default function BankFileConfirmStep({
{/* Additional info */}
{refsCount > 0 && (
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-blue-600 border-blue-300">
<Badge variant="outline" className="text-primary border-primary/30">
<Link2 className="mr-1 h-3 w-3" />
{refsCount} med OCR/referens
</Badge>
+3 -3
View File
@@ -48,7 +48,7 @@ export default function BankFilePreviewStep({
<FileText className="h-4 w-4" />
<span className="text-sm">Transaktioner</span>
</div>
<p className="text-2xl font-bold">{stats.parsed_rows}</p>
<p className="text-2xl font-display font-medium tabular-nums">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{stats.skipped_rows} rader hoppades över
@@ -75,7 +75,7 @@ export default function BankFilePreviewStep({
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Inkomster</span>
</div>
<p className="text-lg font-bold">
<p className="text-lg font-display font-medium tabular-nums">
{formatCurrency(stats.total_income)}
</p>
</CardContent>
@@ -87,7 +87,7 @@ export default function BankFilePreviewStep({
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
</div>
<p className="text-lg font-bold">
<p className="text-lg font-display font-medium tabular-nums">
{formatCurrency(stats.total_expenses)}
</p>
</CardContent>
+3 -3
View File
@@ -58,7 +58,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
<FileText className="h-4 w-4" />
<span className="text-sm">Verifikationer skapade</span>
</div>
<p className="text-2xl font-bold">{result.journalEntriesCreated}</p>
<p className="text-2xl font-display font-medium tabular-nums">{result.journalEntriesCreated}</p>
</CardContent>
</Card>
@@ -67,7 +67,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Räkenskapsår</span>
</div>
<div className="text-2xl font-bold">
<div className="text-2xl font-display font-medium">
{result.fiscalPeriodId ? (
<Badge variant="default" className="bg-success">Skapat</Badge>
) : (
@@ -82,7 +82,7 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Ingående balanser</span>
</div>
<div className="text-2xl font-bold">
<div className="text-2xl font-display font-medium">
{result.openingBalanceEntryId ? (
<Badge variant="default" className="bg-success">Importerade</Badge>
) : (
+4 -4
View File
@@ -107,7 +107,7 @@ export default function SIEPreviewStep({
<FileText className="h-4 w-4" />
<span className="text-sm">Konton</span>
</div>
<p className="text-2xl font-bold">{preview.accountCount}</p>
<p className="text-2xl font-display font-medium tabular-nums">{preview.accountCount}</p>
</CardContent>
</Card>
@@ -117,7 +117,7 @@ export default function SIEPreviewStep({
<BarChart3 className="h-4 w-4" />
<span className="text-sm">Verifikationer</span>
</div>
<p className="text-2xl font-bold">{preview.voucherCount}</p>
<p className="text-2xl font-display font-medium tabular-nums">{preview.voucherCount}</p>
</CardContent>
</Card>
@@ -126,7 +126,7 @@ export default function SIEPreviewStep({
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Transaktionsrader</span>
</div>
<p className="text-2xl font-bold">{preview.transactionLineCount}</p>
<p className="text-2xl font-display font-medium tabular-nums">{preview.transactionLineCount}</p>
</CardContent>
</Card>
@@ -135,7 +135,7 @@ export default function SIEPreviewStep({
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">IB Summa</span>
</div>
<p className="text-2xl font-bold">{formatCurrency(preview.openingBalanceTotal)}</p>
<p className="text-2xl font-display font-medium tabular-nums">{formatCurrency(preview.openingBalanceTotal)}</p>
</CardContent>
</Card>
</div>
+1 -1
View File
@@ -123,7 +123,7 @@ export default function NewUserChecklist({
return (
<Card
className={cn(
'transition-all duration-300',
'transition-[opacity,transform] duration-300',
isAnimatingOut && 'opacity-0 scale-95',
className
)}
@@ -42,8 +42,6 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
if (selected) {
onNext({ entity_type: selected })
}
console.warn("Something went wrong")
console.warn("Selected: ", selected)
}
return (
@@ -706,7 +706,7 @@ export default function Step3TaxRegistration({
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-amber-700 bg-amber-50 rounded px-2 py-1">
<p className="text-xs text-warning-foreground bg-warning/10 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
+4 -4
View File
@@ -50,9 +50,9 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardTitle className="text-lg">INK2 (Aktiebolag)</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-blue-50 rounded-md">
<Info className="h-4 w-4 text-blue-600 mt-0.5 shrink-0" />
<p className="text-sm text-blue-800">
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-primary">
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
hanteras av din revisor/redovisningskonsult.
@@ -106,7 +106,7 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
<Badge className="bg-primary/10 text-primary">
{data.fiscalYear.name}
</Badge>
</div>
+3 -3
View File
@@ -115,7 +115,7 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
<Badge className="bg-primary/10 text-primary">
{data.fiscalYear.name}
</Badge>
</div>
@@ -207,10 +207,10 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-bold text-xl">Årets resultat</span>
<span className="font-display font-medium text-xl">Årets resultat</span>
</div>
<span
className={`text-2xl font-bold ${
className={`font-display text-2xl font-medium tabular-nums ${
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
}`}
>
@@ -61,9 +61,9 @@ export default function BatchCategorySelector({
) : (
<div className="space-y-4 py-2">
{/* Underlag reminder */}
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-2 rounded-lg bg-warning/10 border border-warning/30 p-3">
<Paperclip className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-xs text-warning-foreground">
Underlag behöver bifogas separat för varje transaktion efter bokföring.
</p>
</div>
@@ -417,7 +417,7 @@ export default function DescribeTransactionDialog({
{step === 'pick' && describeResult && (
<div className="space-y-4 min-h-0 flex flex-col">
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten är osäkra. Försök beskriv mer detaljerat för bättre träffar.</p>
</div>
@@ -465,7 +465,7 @@ export default function DescribeTransactionDialog({
{aiSuggestion.warnings.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{aiSuggestion.warnings.map((warning, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
<Badge key={i} variant="outline" className="text-[10px] px-1.5 py-0 text-warning-foreground border-warning/30">
{warning}
</Badge>
))}
@@ -538,12 +538,12 @@ export default function DescribeTransactionDialog({
</Badge>
)}
{template.deductibility === 'non_deductible' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-warning-foreground border-warning/30">
Ej avdragsgill
</Badge>
)}
{template.deductibility === 'conditional' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-warning-foreground border-warning/30">
Villkorligt avdrag
</Badge>
)}
@@ -551,7 +551,7 @@ export default function DescribeTransactionDialog({
{(template.deductibility_note_sv || template.special_rules_sv) && selectedTemplateId === template.template_id && (
<div className="mt-2 space-y-1">
{template.deductibility_note_sv && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
<p className="text-[11px] text-warning-foreground">
{template.deductibility_note_sv}
</p>
)}
+1 -1
View File
@@ -47,7 +47,7 @@ export default function InboxZeroState({ hasTransactions, onCreateTransaction }:
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h3 className="text-lg font-bold">Alla transaktioner bokförda!</h3>
<h3 className="text-lg font-medium">Alla transaktioner bokförda!</h3>
<p className="text-muted-foreground text-center mt-1 max-w-sm">
Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik.
</p>
@@ -85,7 +85,7 @@ export default function InvoiceMatchDialog({
const diff = Math.abs(txAmount - invAmount)
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400">
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">Beloppen skiljer sig</p>
+8 -12
View File
@@ -157,11 +157,7 @@ export default function QuickReviewDialog({
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${isIncome ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
@@ -202,8 +198,8 @@ export default function QuickReviewDialog({
{/* Template special rules */}
{template?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{template.special_rules_sv}
</p>
</div>
@@ -211,8 +207,8 @@ export default function QuickReviewDialog({
{/* Deductibility note */}
{template?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2">
<p className="text-xs text-foreground leading-snug">
{template.deductibility_note_sv}
</p>
</div>
@@ -220,10 +216,10 @@ export default function QuickReviewDialog({
{/* Reverse charge warning */}
{template?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
@@ -300,7 +300,7 @@ export default function SwipeCategorizationView({
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h2 className="text-2xl font-bold">Klart!</h2>
<h2 className="font-display text-2xl font-medium">Klart!</h2>
<p className="text-muted-foreground mt-2">
Alla transaktioner är nu bokförda
</p>
@@ -329,7 +329,7 @@ export default function SwipeCategorizationView({
<Card className="mx-4 mt-3">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
<p className="font-display text-2xl font-medium tabular-nums mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
@@ -397,7 +397,7 @@ export default function SwipeCategorizationView({
<CardContent className="pt-4 space-y-1">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
<p className="text-2xl font-bold mt-2">
<p className="font-display text-2xl font-medium tabular-nums mt-2">
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
</p>
@@ -428,8 +428,8 @@ export default function SwipeCategorizationView({
{/* Template special rules warning */}
{selectedTemplate?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
<p className="text-xs text-warning-foreground leading-snug">
{selectedTemplate.special_rules_sv}
</p>
</div>
@@ -437,8 +437,8 @@ export default function SwipeCategorizationView({
{/* Deductibility note */}
{selectedTemplate?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2.5">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2.5">
<p className="text-xs text-foreground leading-snug">
{selectedTemplate.deductibility_note_sv}
</p>
</div>
@@ -446,10 +446,10 @@ export default function SwipeCategorizationView({
{/* Reverse charge VAT registration warning */}
{selectedTemplate?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
<AlertTriangle className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
@@ -509,11 +509,11 @@ export default function SwipeCategorizationView({
{/* Document upload / pre-attached document */}
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
<div className="rounded-lg border bg-blue-500/5 border-blue-500/30 px-3 py-2.5">
<div className="rounded-lg border bg-success/5 border-success/30 px-3 py-2.5">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-blue-600" />
<Paperclip className="h-4 w-4 text-success" />
<span className="text-sm font-medium">Underlag bifogat</span>
<Check className="h-4 w-4 text-blue-600" />
<Check className="h-4 w-4 text-success" />
</div>
<p className="text-xs text-muted-foreground mt-1">
Dokumentet från inkorgen länkas automatiskt till verifikationen.
@@ -585,7 +585,7 @@ export default function SwipeCategorizationView({
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="fixed inset-0 bg-background z-50 flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top, 0px)', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
@@ -701,9 +701,9 @@ export default function SwipeCategorizationView({
{/* Document Match from Inbox */}
{currentTransaction.matched_inbox_item && (
<div className="p-4 rounded-lg border-2 border-blue-500/40 bg-blue-500/5 space-y-2">
<div className="p-4 rounded-lg border-2 border-primary/40 bg-primary/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<div className="flex items-center gap-2 text-primary">
<Paperclip className="h-5 w-5" />
<span className="font-semibold text-sm">
{currentTransaction.matched_inbox_item.document_type === 'receipt'
@@ -714,7 +714,7 @@ export default function SwipeCategorizationView({
</span>
</div>
{currentTransaction.matched_inbox_item.match_confidence != null && (
<Badge variant="outline" className="text-blue-600 border-blue-500">
<Badge variant="outline" className="text-primary border-primary">
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
</Badge>
)}
@@ -740,7 +740,7 @@ export default function SwipeCategorizationView({
)
})()}
{currentTransaction.matched_inbox_item.suggested_template_id && (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
<p className="text-xs text-primary mt-1">
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
</p>
)}
@@ -789,13 +789,15 @@ export default function SwipeCategorizationView({
{/* Document template match — primary action when inbox item has a suggested template */}
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id!
const inboxItem = currentTransaction.matched_inbox_item
if (!inboxItem?.suggested_template_id) return null
const tmplId = inboxItem.suggested_template_id
const template = getTemplateById(tmplId)
if (!template) return null
return (
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleTemplateSelect(tmplId, currentTransaction.matched_inbox_item!.id)}
className="w-full"
onClick={() => handleTemplateSelect(tmplId, inboxItem.id)}
disabled={isProcessing}
>
<Paperclip className="mr-2 h-4 w-4" />
+2 -2
View File
@@ -99,7 +99,7 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
template.vat_treatment === 'reverse_charge'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
? 'bg-warning/10 text-warning-foreground'
: ''
}`}
>
@@ -107,7 +107,7 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp
</Badge>
)}
{template.requires_vat_registration_data && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-400 text-amber-600 dark:text-amber-400 gap-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-warning/30 text-warning-foreground gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Kräver momsreg.nr
</Badge>
@@ -64,8 +64,7 @@ export default function TransactionBookingDialog({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
} catch (linkErr) {
console.error('[TransactionBookingDialog] Failed to link document:', linkErr)
} catch {
linkFailCount++
}
}
@@ -7,20 +7,25 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { HistoryFilter } from './transaction-types'
import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText, Loader2 } from 'lucide-react'
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
hasMore?: boolean
isLoadingMore?: boolean
onLoadMore?: () => void
}
export default function TransactionHistoryList({
transactions,
onOpenMatchDialog,
onOpenCategoryDialog,
hasMore,
isLoadingMore,
onLoadMore,
}: TransactionHistoryListProps) {
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<HistoryFilter>('all')
@@ -83,7 +88,11 @@ export default function TransactionHistoryList({
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-full flex items-center justify-center bg-muted text-muted-foreground"
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
transaction.amount > 0
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
@@ -115,7 +124,7 @@ export default function TransactionHistoryList({
{transaction.invoice_id && (
<>
<span>·</span>
<Badge variant="outline" className="text-blue-600 border-blue-600">
<Badge variant="outline" className="text-primary border-primary">
<Link2 className="h-3 w-3 mr-1" />
Kopplad till faktura
</Badge>
@@ -132,26 +141,26 @@ export default function TransactionHistoryList({
) : transaction.is_business === null ? (
<>
<span>·</span>
<Badge
variant="outline"
className="text-warning border-warning cursor-pointer hover:bg-warning/10"
<button
type="button"
className="inline-flex items-center rounded-md border border-warning px-2.5 py-0.5 text-xs font-semibold text-warning-foreground hover:bg-warning/10 transition-colors"
onClick={() => onOpenCategoryDialog(transaction)}
>
Ej bokförd
</Badge>
</button>
</>
) : null}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
<span>·</span>
<Badge
variant="outline"
className="text-blue-600 border-blue-600 cursor-pointer hover:bg-blue-50"
<button
type="button"
className="inline-flex items-center rounded-md border border-primary px-2.5 py-0.5 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors"
onClick={() => onOpenMatchDialog(transaction)}
>
<FileText className="h-3 w-3 mr-1" />
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
</Badge>
</button>
</>
)}
</div>
@@ -162,14 +171,14 @@ export default function TransactionHistoryList({
<Button
size="sm"
variant="default"
className="h-7 min-h-[44px] text-xs"
className="h-10 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
Bokför
</Button>
)}
<div className="text-right">
<p className="font-medium">
<p className="font-medium tabular-nums">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -184,6 +193,24 @@ export default function TransactionHistoryList({
</CardContent>
</Card>
))}
{hasMore && onLoadMore && !searchTerm && (
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Laddar...
</>
) : (
'Ladda fler'
)}
</Button>
</div>
)}
</div>
)}
</div>
@@ -5,7 +5,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
@@ -71,9 +71,9 @@ export default function TransactionInboxCard({
return (
<motion.div
layout
initial={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
initial={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, x: -16 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
onAnimationComplete={(definition) => {
// Only call on exit animation
if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) {
@@ -82,11 +82,12 @@ export default function TransactionInboxCard({
}}
>
<Card
className={`transition-colors ${
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
isDisabled ? 'opacity-50' : ''
}`}
className={cn(
'transition-colors',
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-primary/50' : 'border-warning/50',
isSelected && 'border-primary bg-primary/[0.02]',
isDisabled && 'opacity-50'
)}
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
>
<CardContent className="py-4">
@@ -102,11 +103,8 @@ export default function TransactionInboxCard({
/>
)}
<div
className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${isIncome ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}
aria-hidden="true"
>
{isIncome ? (
<ArrowUpRight className="h-5 w-5" />
@@ -131,7 +129,7 @@ export default function TransactionInboxCard({
{/* Right: amount */}
<div className="text-right flex-shrink-0">
<p className={`font-medium ${isIncome ? 'text-success' : ''}`}>
<p className={cn('font-medium tabular-nums', isIncome && 'text-success')}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -151,14 +149,14 @@ export default function TransactionInboxCard({
<Button
size="sm"
variant="default"
className="h-8 text-xs"
className="h-9 text-xs max-w-full truncate"
onClick={() => onOpenMatchDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
<Loader2 className="mr-1.5 h-3 w-3 animate-spin flex-shrink-0" />
) : (
<FileText className="mr-1.5 h-3 w-3" />
<FileText className="mr-1.5 h-3 w-3 flex-shrink-0" />
)}
Matcha Faktura {transaction.potential_invoice!.invoice_number}
</Button>
@@ -166,14 +164,14 @@ export default function TransactionInboxCard({
<Button
size="sm"
variant="default"
className="h-8 text-xs"
className="h-9 text-xs max-w-full truncate"
onClick={() => onOpenMatchDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
<Loader2 className="mr-1.5 h-3 w-3 animate-spin flex-shrink-0" />
) : (
<FileText className="mr-1.5 h-3 w-3" />
<FileText className="mr-1.5 h-3 w-3 flex-shrink-0" />
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
</Button>
@@ -186,7 +184,7 @@ export default function TransactionInboxCard({
key={ts.template_id}
size="sm"
variant={idx === 0 ? 'default' : 'outline'}
className="h-8 text-xs"
className="h-9 text-xs"
onClick={() => {
if (onOpenTemplateReview && tmpl) {
onOpenTemplateReview(transaction, ts.template_id)
@@ -211,7 +209,7 @@ export default function TransactionInboxCard({
<Button
size="sm"
variant="default"
className="h-8 text-xs"
className="h-9 text-xs"
onClick={() => handleSuggestionClick(topSuggestion)}
disabled={isProcessing || isDisabled}
>
@@ -237,7 +235,7 @@ export default function TransactionInboxCard({
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
className="h-9 text-xs"
onClick={() => onOpenDescribe(transaction)}
disabled={isProcessing || isDisabled}
>
@@ -250,13 +248,11 @@ export default function TransactionInboxCard({
<Button
size="sm"
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'default' : 'outline'}
className="h-8 text-xs"
className="h-9 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0)
? 'Välj mall...'
: 'Välj mall...'}
Välj mall...
</Button>
</div>
)}
@@ -34,14 +34,14 @@ export default function TransactionStatusBar({
{/* Header with title + actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Transaktioner</h1>
{uncategorizedCount > 0 && mode === 'inbox' && (
<p className="text-muted-foreground mt-1">
<span className="text-foreground font-semibold">{uncategorizedCount}</span> att bokföra
{invoiceMatchCount > 0 && (
<span className="ml-2">
· <FileText className="inline h-3.5 w-3.5 text-blue-500" />{' '}
<span className="text-blue-600">{invoiceMatchCount} fakturamatchningar</span>
· <FileText className="inline h-3.5 w-3.5 text-primary" />{' '}
<span className="text-foreground font-semibold">{invoiceMatchCount} fakturamatchningar</span>
</span>
)}
</p>
@@ -67,7 +67,7 @@ export default function TransactionStatusBar({
disabled={isLoadingSuggestions}
>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : 'Bokför alla'}
{isLoadingSuggestions ? 'Laddar...' : 'Gå igenom alla'}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
+3 -3
View File
@@ -56,9 +56,9 @@ export function ConfirmationDialog({
<div className="border-t px-4 sm:px-6 py-3 sm:py-4 space-y-3 sm:space-y-4 shrink-0">
{warningText && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">{warningText}</p>
<div className="flex items-start gap-2 rounded-lg bg-warning/10 border border-warning/30 p-3">
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-sm text-warning-foreground">{warningText}</p>
</div>
)}
+2 -3
View File
@@ -107,6 +107,7 @@ export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
title="Inga kunder ännu"
description="Lägg till dina kunder för att enkelt skapa fakturor och hålla koll på betalningar."
actionLabel="Lägg till kund"
actionHref={onAction ? undefined : '/customers/new'}
onAction={onAction}
/>
)
@@ -117,11 +118,9 @@ export function EmptyTransactions() {
<EmptyState
icon={ArrowLeftRight}
title="Inga transaktioner"
description="Importera kontoutdrag från din bank eller lägg till transaktioner manuellt."
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få koll på ekonomin."
actionLabel="Importera transaktioner"
actionHref="/import"
secondaryActionLabel="Lägg till manuellt"
secondaryActionHref="/transactions/new"
/>
)
}
@@ -135,12 +135,13 @@ async function fetchAllPages<T>(
consentId: string,
resource: string,
params?: Record<string, string>,
pageSize: number = 100
pageSize: number = 100,
maxPages: number = 500
): Promise<T[]> {
const all: T[] = []
let page = 1
while (true) {
while (page <= maxPages) {
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
+6 -6
View File
@@ -265,12 +265,12 @@ export const STATUS_LABELS: Record<DeadlineStatus, string> = {
// Status colors for styling
export const STATUS_COLORS: Record<DeadlineStatus, { bg: string; text: string; border: string; dot: string }> = {
upcoming: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', dot: 'bg-blue-500' },
action_needed: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', dot: 'bg-orange-500' },
in_progress: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', dot: 'bg-yellow-500' },
submitted: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200', dot: 'bg-purple-500' },
confirmed: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200', dot: 'bg-green-500' },
overdue: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', dot: 'bg-red-500' }
upcoming: { bg: 'bg-primary/5', text: 'text-foreground', border: 'border-primary/20', dot: 'bg-primary' },
action_needed: { bg: 'bg-warning/10', text: 'text-warning-foreground', border: 'border-warning/30', dot: 'bg-warning' },
in_progress: { bg: 'bg-warning/5', text: 'text-warning-foreground', border: 'border-warning/20', dot: 'bg-warning' },
submitted: { bg: 'bg-primary/5', text: 'text-foreground', border: 'border-primary/20', dot: 'bg-primary' },
confirmed: { bg: 'bg-success/10', text: 'text-success', border: 'border-success/30', dot: 'bg-success' },
overdue: { bg: 'bg-destructive/5', text: 'text-destructive', border: 'border-destructive/30', dot: 'bg-destructive' }
}
// Get deadlines needing attention (action_needed or overdue)