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:
co-authored by
Claude Opus 4.6
parent
a438c50e9c
commit
a7be1aab17
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "BAS-katalog"-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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "Ny verifikation".
|
||||
</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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user