refactor(ui): app-wide UI/UX consistency pass (#436)
* refactor(ui): app-wide UI/UX consistency pass
Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.
What changed:
- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
(Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
(p-4 for compact metric cards), space-y-8 between page sections.
- **Tables unified**: all 33 thead blocks now share the Resultatrapport
pattern via shadcn Table primitive (text-[11px] font-medium uppercase
tracking-wider text-muted-foreground). Hand-rolled <table> instances
converted where they were data tables; form/edit grids kept distinct.
- **Status badges unified**: every status indicator routes through
shadcn <Badge variant>. Eliminated raw Tailwind colors
(bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
in favor of the gnubok semantic palette (success=sage, warning=ochre,
destructive=terracotta).
- **Empty states unified**: list pages migrated from hand-rolled
"flex flex-col items-center py-12" divs to the EmptyState primitive.
- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
divs replaced with shadcn <Skeleton> across 15 files.
- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
9 icon-only navigation buttons.
- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
table-friendly) vs formatDateLong() for metadata (Swedish long form).
Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.
- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
now carries the action ("Kunde inte skapa lönekörning" etc.) with
description carrying the error detail.
- **Page-level cleanups**:
- Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
duplicates + Visa detaljer collapsible.
- Reports: 5-col mega-menu replaced with left-rail layout
(new ReportsNav component).
- Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
tabs (moved FiscalYearSelector inside journal tab).
- Bookkeeping: added voucher sort (A1 first / latest first) alongside
existing date sort. Required matching API param sort_by.
- KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
instead of inline info-button toggle; bigger numbers.
- Salary section: enum values translated to Swedish labels, mobile
table collapses to Anställd+Netto on <md, KPI typography aligned
with dashboard.
- Invoice forms: styled RequiredMark + aria-required, tabular-nums
on amount inputs.
- **CLAUDE.md**: new "Design System Tokens" subsection documents the
locked spacing scale, primitives table, typography rules, date helpers,
and forbidden patterns so future contributors don't drift.
Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback (Greptile + compliance bot)
- **formatDate / formatDateLong timezone fix**: switch from new Date() to
parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
than UTC midnight, eliminating the off-by-one display in west-of-UTC
timezones flagged by Greptile.
- **DashboardContentProps cleanup**: removed unused firstName and settings
fields from the interface, and the corresponding fetch (profiles table)
+ computation in app/(dashboard)/page.tsx. The greeting was dropped in
the dashboard cleanup; these props were dead weight.
- **Voucher sort behavior documented**: extended the comment in the journal
entries API route to explain why voucher sort intentionally uses strict
fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
series-scoped within a fiscal year). The row-count delta between date
sort and voucher sort is now a documented design choice.
- **delete_last_voucher migration + draft-delete test included**: the UI
already shipped the "Radera utkast" path in the previous commit; this
pulls in the backing RPC migration that allows draft deletes (with the
full safety logic — drafts skip series/period checks since they have
voucher_number=0, posted entries go through the existing unchanged
path). This was originally meant for a separate PR but the UI shipped
half the feature without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(migration): rename to match applied version
The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address compliance bot findings (payroll label + VAT visibility)
- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
6 §, karensavdrag is a single calculated amount (20% of one week's
sjuklön) deducted from the first sick day's pay — not bounded to the
first day. The qualifier could mislead users when the first sick day
and return-to-work span a weekend. Swedish-payroll bot recommendation.
- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
charge indicator is compliance-critical (ML 16 kap) — missing it leads
to incorrect input VAT deduction. Outline was too subtle; warning's
ochre fill matches its semantic weight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -526,3 +526,69 @@ Swedish sole traders (enskild firma) and small business owners (aktiebolag) who
|
||||
- Keyboard-navigable with visible focus rings
|
||||
- Respect `prefers-reduced-motion`
|
||||
- Color never sole indicator of state — always pair with icons, text, or shape
|
||||
- Touch targets ≥ 40px (shadcn `Button size="icon"` default) — bump higher to 44px (WCAG AAA) for new mobile-critical surfaces
|
||||
- Icon-only buttons must have `aria-label`
|
||||
|
||||
### Design System Tokens
|
||||
|
||||
These conventions are locked. Don't reinvent them in new code; deviating from them on existing pages is a regression.
|
||||
|
||||
**Spacing scale.** Only use Tailwind values `1, 2, 3, 4, 6, 8, 10, 12`. **Forbidden:** `2.5`, `5`, hardcoded pixels in page logic.
|
||||
|
||||
| Token | Tailwind | Use for |
|
||||
|---|---|---|
|
||||
| 4 | `1` | icon padding |
|
||||
| 8 | `2` | tight inline gaps |
|
||||
| 12 | `3` | dense list rows, badge gaps |
|
||||
| 16 | `4` | default form / control / grid gap |
|
||||
| 24 | `6` | **card padding default** (`p-6`) |
|
||||
| 32 | `8` | **between page sections** (`space-y-8` on page root) |
|
||||
| 40 | `10` | hero spacing |
|
||||
| 48 | `12` | top of page after header |
|
||||
|
||||
Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail cards use `p-6`. Never mix `p-5`.
|
||||
|
||||
**Layout.**
|
||||
- Sidebar width: `md:w-64` (256px). Main content offset: `md:pl-64`.
|
||||
- Main container: `max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10` (via `components/dashboard/MainContainer.tsx`).
|
||||
- Page root: `<div className="space-y-8">`.
|
||||
|
||||
**Primitives — always use these, don't hand-roll.**
|
||||
|
||||
| Need | Component | Notes |
|
||||
|---|---|---|
|
||||
| Page title + action | `components/ui/page-header.tsx` `PageHeader` | Use this, not bespoke `<h1>` + `<p>` blocks. Drop the `description` prop when it just paraphrases the title. |
|
||||
| Data table | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `<CardContent className="p-0">` when the table is a card's primary content. Add `tabular-nums` to numeric cells. |
|
||||
| Status indicator | `components/ui/badge.tsx` `<Badge variant>` | Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. |
|
||||
| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `<div className="flex flex-col items-center py-12">…</div>`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). |
|
||||
| Loading placeholder | `components/ui/skeleton.tsx` `<Skeleton>` | Don't hand-roll `bg-muted rounded animate-pulse` divs. |
|
||||
| Inline help / formulas | `components/ui/info-tooltip.tsx` `InfoTooltip` | Hover-revealed; don't use always-visible info buttons. |
|
||||
| Fiscal year picker | `components/common/FiscalYearSelector.tsx` | Don't use raw `<select>` for fiscal periods. |
|
||||
|
||||
**Tabular display rules.**
|
||||
- All financial values get `tabular-nums`.
|
||||
- Dates in tables: `tabular-nums` for fixed width.
|
||||
- Right-align numeric columns (`text-right`).
|
||||
- For group bands inside tables (Resultatrapport-style): `<tr className="bg-muted/30"><td colSpan={n} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">{label}</td></tr>`.
|
||||
|
||||
**Date formatting.** Two helpers in `lib/utils.ts`:
|
||||
- `formatDate(x)` → `2026-05-11` (ISO `yyyy-MM-dd`). Use for accounting data — transaction dates, invoice dates, payment dates, voucher dates. Aligns in tables, matches SIE/BFL convention.
|
||||
- `formatDateLong(x)` → `11 maj 2026` (Swedish long form). Use for metadata — when something was created, linked, verified, expires. Settings panels and audit displays.
|
||||
|
||||
Never render raw `{x.invoice_date}` directly — always route through `formatDate()` for code consistency.
|
||||
|
||||
**Currency.** `formatCurrency(n, currency?)` from `lib/utils.ts`. Default SEK.
|
||||
|
||||
**Typography.**
|
||||
- Page title: `<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">` (or use `PageHeader`).
|
||||
- Card title: `<CardTitle className="text-base">` for sections, default for primary cards.
|
||||
- Section divider header inside a page: `<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">`.
|
||||
- Headline number: `font-display text-xl font-medium tabular-nums`.
|
||||
- Display font (`font-display`, Fraunces) reserved for h1/h2/h3 and primary financial numbers.
|
||||
|
||||
**Forbidden / dead patterns.**
|
||||
- Page descriptions that paraphrase the page title (e.g. `<PageHeader title="Fakturor" description="Hantera dina fakturor">`) → drop the description.
|
||||
- Two different status indicators on the same element (e.g. colored card border *and* Badge for status) → pick one (prefer Badge).
|
||||
- Mobile-specific `<select>` duplicating desktop tabs in code — use a single Tabs primitive or a single grouped `Select`.
|
||||
- Hand-rolled icon buttons smaller than `h-10 w-10`. Use shadcn `Button size="icon"`.
|
||||
- Color-coded status using full-rainbow Tailwind palette (`bg-amber-100`, `bg-emerald-500/10`, etc.). Use Badge variants tied to the brand palette.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
|
||||
import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
@@ -84,9 +85,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${id}`, { method: 'DELETE' })
|
||||
const result = await res.json()
|
||||
if (res.ok) {
|
||||
const wasDraft = result.data?.was_draft === true
|
||||
toast({
|
||||
title: 'Verifikat raderat',
|
||||
description: `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`,
|
||||
title: wasDraft ? 'Utkast raderat' : 'Verifikat raderat',
|
||||
description: wasDraft
|
||||
? 'Utkastet har tagits bort.'
|
||||
: `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`,
|
||||
})
|
||||
router.push('/bookkeeping')
|
||||
} else {
|
||||
@@ -156,7 +160,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const fullChain = [entry, ...chain]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/bookkeeping"
|
||||
@@ -178,9 +182,9 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<p className="text-muted-foreground">{entry.description}</p>
|
||||
</div>
|
||||
|
||||
{entry.status === 'posted' && (
|
||||
{(entry.status === 'posted' || entry.status === 'draft') && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
{isLastInSeries && (
|
||||
{(entry.status === 'draft' || isLastInSeries) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -190,7 +194,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
Radera verifikat
|
||||
{entry.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'}
|
||||
</Button>
|
||||
)}
|
||||
{canCorrect && (
|
||||
@@ -206,17 +210,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
Skapa ändringsverifikation
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
|
||||
Kopiera verifikat
|
||||
</Button>
|
||||
{entry.status === 'posted' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
|
||||
Kopiera verifikat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,7 +236,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<span>{entry.entry_date}</span>
|
||||
<span>{formatDate(entry.entry_date)}</span>
|
||||
</div>
|
||||
{entry.committed_at && (
|
||||
<div className="flex justify-between">
|
||||
@@ -260,11 +266,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
{!editingNotes && canWrite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
size="icon"
|
||||
onClick={() => { setNotesValue(entry.notes || ''); setEditingNotes(true) }}
|
||||
aria-label="Redigera anteckning"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,8 +387,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-48">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Debet</th>
|
||||
@@ -530,8 +536,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
onConfirm={handleDelete}
|
||||
isSubmitting={isDeleting}
|
||||
title="Radera verifikat"
|
||||
warningText={`Verifikat ${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''} raderas permanent. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.`}
|
||||
title={entry?.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'}
|
||||
warningText={
|
||||
entry?.status === 'draft'
|
||||
? 'Utkastet har aldrig bokförts och kan tas bort utan att påverka verifikationsserien. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.'
|
||||
: `Verifikat ${entry?.voucher_series ?? ''}${entry?.voucher_number ?? ''} raderas permanent. Eventuella kopplade underlag behålls men avlänkas. Denna åtgärd kan inte ångras.`
|
||||
}
|
||||
confirmLabel="Radera permanent"
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||
@@ -539,9 +549,9 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-1">Permanent radering</p>
|
||||
<p className="text-muted-foreground">
|
||||
Verifikatet och dess kontorader tas bort. Kopplade transaktioner och
|
||||
fakturor behåller sina uppgifter men markeras som ej bokförda.
|
||||
Underlag (kvitton, dokument) behålls men avlänkas.
|
||||
{entry?.status === 'draft'
|
||||
? 'Utkastet och dess kontorader tas bort. Kopplade fakturor och transaktioner påverkas inte — de stannar kvar som obokförda. Underlag (kvitton, dokument) behålls men avlänkas.'
|
||||
: 'Verifikatet och dess kontorader tas bort. Kopplade transaktioner och fakturor behåller sina uppgifter men markeras som ej bokförda. Underlag (kvitton, dokument) behålls men avlänkas.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsMana
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Lock, Loader2, Copy } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
interface CopyPrefill {
|
||||
@@ -129,25 +130,18 @@ export default function BookkeepingPage() {
|
||||
}, [refreshKey])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Bokföring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Skapa verifikationer, hantera kontoplanen och bifoga underlag
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild className="w-full sm:w-auto">
|
||||
<Link href="/bookkeeping/year-end">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Årsbokslut
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'journal' && (
|
||||
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
|
||||
)}
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Bokföring"
|
||||
action={
|
||||
<Button variant="outline" asChild className="w-full sm:w-auto">
|
||||
<Link href="/bookkeeping/year-end">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Årsbokslut
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
@@ -163,7 +157,8 @@ export default function BookkeepingPage() {
|
||||
<TabsTrigger value="accounts">Kontoplan</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="journal">
|
||||
<TabsContent value="journal" className="space-y-4">
|
||||
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
|
||||
<JournalEntryList key={`${refreshKey}-${periodId ?? 'all'}`} periodId={periodId ?? undefined} />
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export default function YearEndPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Årsbokslut</h1>
|
||||
<Button variant="outline" asChild>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function CustomerDetailPage({
|
||||
const Icon = customerTypeIcons[customer.customer_type]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -352,7 +352,7 @@ export default function CustomerDetailPage({
|
||||
>
|
||||
<div>
|
||||
<p className={cn('font-medium', !invoice.invoice_number && 'italic text-muted-foreground')}>{invoiceNumberDisplay(invoice.invoice_number)}</p>
|
||||
<p className="text-sm text-muted-foreground">{invoice.invoice_date}</p>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">{formatDate(invoice.invoice_date)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm tabular-nums">
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { Plus, Search, Users, Lock } from 'lucide-react'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { EmptyCustomers } from '@/components/ui/empty-state'
|
||||
import { EmptyCustomers, EmptyState } from '@/components/ui/empty-state'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import Link from 'next/link'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -105,45 +106,42 @@ export default function CustomersPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Kunder</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina kunder och deras faktureringsuppgifter
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Ny kund
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lägg till kund</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomerForm
|
||||
onSubmit={handleCreateCustomer}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Kunder"
|
||||
action={
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Ny kund
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lägg till kund</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomerForm
|
||||
onSubmit={handleCreateCustomer}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på namn, e-post eller org.nr..."
|
||||
placeholder="Sök kunder"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -167,15 +165,13 @@ export default function CustomersPage() {
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga kunder matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Inga träffar"
|
||||
description={`Inga kunder matchar "${searchTerm}".`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
@@ -197,9 +197,9 @@ export default function DeadlinesPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Deadlines" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-lg border p-4 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ExtensionsPage() {
|
||||
|
||||
{/* General extensions */}
|
||||
{generalSector && (
|
||||
<section className="mb-10">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
{generalSector.name}
|
||||
</h2>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HelpLink } from '@/components/ui/info-tooltip'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import {
|
||||
Search,
|
||||
BookOpen,
|
||||
@@ -319,13 +320,10 @@ export default function HelpPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-medium tracking-tight mb-2">Hjälp & Ordlista</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Förklaringar av skatte- och bokföringstermer på ren svenska.
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title="Hjälp & Ordlista"
|
||||
description="Förklaringar av skatte- och bokföringstermer på ren svenska."
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
@@ -369,7 +367,7 @@ export default function HelpPage() {
|
||||
</div>
|
||||
|
||||
{/* Terms list */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{filteredTerms.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
@@ -433,7 +431,6 @@ export default function HelpPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Externa resurser</CardTitle>
|
||||
<CardDescription>Mer hjälp från officiella källor</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
|
||||
@@ -369,11 +369,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
|
||||
@@ -56,6 +56,10 @@ type FormData = z.infer<typeof schema>
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
|
||||
}
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -438,9 +442,9 @@ export default function NewInvoicePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Tillbaka">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -475,7 +479,7 @@ export default function NewInvoicePage() {
|
||||
{/* Customer selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kund</CardTitle>
|
||||
<CardTitle>Kund<RequiredMark /></CardTitle>
|
||||
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -564,6 +568,7 @@ export default function NewInvoicePage() {
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="text-right tabular-nums"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
@@ -594,6 +599,7 @@ export default function NewInvoicePage() {
|
||||
type="number"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
className="text-right tabular-nums"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
@@ -729,13 +735,13 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Fakturadatum</Label>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
<Label>Fakturadatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('invoice_date')} aria-required="true" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum</Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
<Label>Förfallodatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('due_date')} aria-required="true" />
|
||||
</div>
|
||||
|
||||
{watchDocumentType === 'invoice' && (
|
||||
|
||||
@@ -16,19 +16,19 @@ import { cn } from '@/lib/utils'
|
||||
import { invoiceNumberDisplay } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { Plus, Search, Receipt, Lock } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import { EmptyInvoices, EmptyState } from '@/components/ui/empty-state'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { Invoice, InvoiceStatus } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; borderColor: string }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary', borderColor: 'border-muted-foreground/30' },
|
||||
sent: { label: 'Skickad', variant: 'default', borderColor: 'border-warning/50' },
|
||||
paid: { label: 'Betald', variant: 'success', borderColor: 'border-success/50' },
|
||||
partially_paid: { label: 'Delbetalad', variant: 'warning', borderColor: 'border-warning/50' },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive', borderColor: 'border-destructive/50' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary', borderColor: 'border-muted-foreground/30' },
|
||||
credited: { label: 'Krediterad', variant: 'secondary', borderColor: 'border-muted-foreground/30' },
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary' },
|
||||
sent: { label: 'Skickad', variant: 'default' },
|
||||
paid: { label: 'Betald', variant: 'success' },
|
||||
partially_paid: { label: 'Delbetalad', variant: 'warning' },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary' },
|
||||
credited: { label: 'Krediterad', variant: 'secondary' },
|
||||
}
|
||||
|
||||
function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
|
||||
@@ -134,10 +134,9 @@ export default function InvoicesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Fakturor"
|
||||
description="Skicka fakturor, följ betalningar och skapa kreditnotor"
|
||||
action={
|
||||
canWrite ? (
|
||||
<Link href="/invoices/new">
|
||||
@@ -158,58 +157,33 @@ export default function InvoicesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-7 bg-muted rounded w-16 animate-pulse" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Obetalda</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Att få in</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Inline summary */}
|
||||
{!isLoading && invoices.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{invoices.length} {invoices.length === 1 ? 'faktura' : 'fakturor'}
|
||||
{stats.unpaid > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-foreground">{stats.unpaid} obetalda</span>
|
||||
{' · '}
|
||||
{formatCurrency(stats.unpaidAmount)} att få in
|
||||
{stats.overdue > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="text-destructive">{stats.overdue} förfallna</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Search and tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på fakturanummer eller kund..."
|
||||
placeholder="Sök fakturor"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -265,30 +239,26 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Receipt className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga fakturor matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Receipt}
|
||||
title="Inga träffar"
|
||||
description={`Inga fakturor matchar "${searchTerm}".`}
|
||||
/>
|
||||
) : invoices.length === 0 ? (
|
||||
<EmptyInvoices />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Receipt className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga fakturor i denna kategori</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Prova att byta flik för att se fler fakturor
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Receipt}
|
||||
title="Inga fakturor i denna kategori"
|
||||
description="Prova att byta flik för att se fler fakturor."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{filteredInvoices.map((invoice) => {
|
||||
const status = statusConfig[invoice.status]
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
@@ -299,9 +269,7 @@ export default function InvoicesPage() {
|
||||
return (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
<Card className={cn(
|
||||
'cursor-pointer transition-all duration-150 hover:border-primary/50 hover:bg-accent/50 hover:shadow-sm active:scale-[0.99] active:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isCreditNote ? 'border-destructive/30' : isProforma ? 'border-primary/30' : isDeliveryNote ? 'border-success/30' : status.borderColor,
|
||||
invoice.status === 'overdue' && 'ring-1 ring-destructive/20'
|
||||
'cursor-pointer transition-all duration-150 hover:border-primary/50 hover:bg-accent/50 hover:shadow-sm active:scale-[0.99] active:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -1,48 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { KPIHeroCards } from '@/components/kpi/KPIHeroCards'
|
||||
import { KPITrendChart } from '@/components/kpi/KPITrendChart'
|
||||
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
|
||||
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { FiscalPeriod, KPIReport, KPIPreferences } from '@/types'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
export default function KpiPage() {
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>('')
|
||||
const [report, setReport] = useState<KPIReport | null>(null)
|
||||
const [preferences, setPreferences] = useState<KPIPreferences>(getDefaultPreferences())
|
||||
const [isLoadingInit, setIsLoadingInit] = useState(true)
|
||||
const [isLoadingReport, setIsLoadingReport] = useState(false)
|
||||
const [isSavingPrefs, setIsSavingPrefs] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const [periodsRes, prefsRes] = await Promise.all([
|
||||
fetch('/api/bookkeeping/fiscal-periods'),
|
||||
fetch('/api/kpi/preferences'),
|
||||
])
|
||||
const { data: periodsData } = await periodsRes.json()
|
||||
const { data: prefsData } = await prefsRes.json()
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const activePeriods = (periodsData || []).filter((p: FiscalPeriod) => p.period_start <= today)
|
||||
setPeriods(activePeriods)
|
||||
if (prefsData) setPreferences(prefsData)
|
||||
if (activePeriods.length > 0) {
|
||||
setSelectedPeriod(activePeriods[0].id)
|
||||
}
|
||||
const res = await fetch('/api/kpi/preferences')
|
||||
const { data } = await res.json()
|
||||
if (!cancelled && data) setPreferences(data)
|
||||
} catch {
|
||||
setError('Kunde inte hämta data')
|
||||
} finally {
|
||||
setIsLoadingInit(false)
|
||||
// Silently fall back to defaults
|
||||
}
|
||||
}
|
||||
init()
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const fetchReport = useCallback(async (periodId: string) => {
|
||||
@@ -63,7 +50,6 @@ export default function KpiPage() {
|
||||
useEffect(() => {
|
||||
if (!selectedPeriod) return
|
||||
let cancelled = false
|
||||
|
||||
fetchReport(selectedPeriod).then(() => {
|
||||
if (cancelled) setReport(null)
|
||||
})
|
||||
@@ -81,11 +67,7 @@ export default function KpiPage() {
|
||||
if (!res.ok) throw new Error()
|
||||
const { data } = await res.json()
|
||||
setPreferences(data)
|
||||
|
||||
// Re-fetch report if account overrides changed (calculations may differ)
|
||||
if (selectedPeriod) {
|
||||
await fetchReport(selectedPeriod)
|
||||
}
|
||||
if (selectedPeriod) await fetchReport(selectedPeriod)
|
||||
} catch {
|
||||
// Silently fail — user can retry
|
||||
} finally {
|
||||
@@ -93,25 +75,10 @@ export default function KpiPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingInit) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Nyckeltal</h1>
|
||||
<p className="text-muted-foreground">Översikt av företagets ekonomiska hälsa</p>
|
||||
</div>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Nyckeltal</h1>
|
||||
<p className="text-muted-foreground">Översikt av företagets ekonomiska hälsa</p>
|
||||
</div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Nyckeltal</h1>
|
||||
<KPISettingsDialog
|
||||
preferences={preferences}
|
||||
onSave={handleSavePreferences}
|
||||
@@ -119,23 +86,12 @@ export default function KpiPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
{periods.length > 0 && (
|
||||
<div>
|
||||
<Label>Räkenskapsår</Label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full mt-1 max-w-xs 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} ({p.period_start} — {p.period_end})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<FiscalYearSelector
|
||||
value={selectedPeriod || null}
|
||||
onChange={(id) => setSelectedPeriod(id || '')}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
@@ -153,36 +109,28 @@ export default function KpiPage() {
|
||||
{report.months.length > 0 && <KPITrendChart months={report.months} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoadingReport && !error && !report && periods.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<p>Inget räkenskapsår hittades. Skapa ett räkenskapsår för att se nyckeltal.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-5 space-y-2">
|
||||
<div className="h-3 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-7 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
|
||||
<CardContent className="p-6 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="h-4 bg-muted rounded w-40 animate-pulse" />
|
||||
<div className="h-56 bg-muted rounded animate-pulse" />
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-56" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default async function DashboardLayout({
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
className="safe-area-main-padding md:!pb-0 md:pl-[232px]"
|
||||
className="safe-area-main-padding md:!pb-0 md:pl-64"
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
@@ -147,7 +147,7 @@ export default async function DashboardLayout({
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
{children}
|
||||
</div>
|
||||
@@ -222,7 +222,7 @@ export default async function DashboardLayout({
|
||||
isSandbox={isSandbox}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-64" role="main">
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
{!isSandbox && (
|
||||
|
||||
+13
-18
@@ -2,15 +2,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="space-y-8">
|
||||
{/* Metrics row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-xl border border-border/60 bg-card p-4 space-y-2">
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
@@ -20,23 +14,24 @@ export default function DashboardLoading() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
{/* Income / Expenses */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="rounded-xl border border-border/60 bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alerts + Deadlines */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{/* Alerts */}
|
||||
{/* Alerts + deadlines */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
|
||||
@@ -55,7 +55,6 @@ export default async function DashboardPage() {
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [
|
||||
{ data: profile },
|
||||
{ data: settings },
|
||||
{ count: customerCount },
|
||||
{ count: invoiceCount },
|
||||
@@ -77,7 +76,6 @@ export default async function DashboardPage() {
|
||||
{ count: uncategorizedCount },
|
||||
{ count: skatteverketTokenCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
||||
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
@@ -108,8 +106,6 @@ export default async function DashboardPage() {
|
||||
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
||||
])
|
||||
|
||||
const firstName = profile?.full_name?.split(' ')[0] || null
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!settings?.onboarding_complete) {
|
||||
redirect('/onboarding')
|
||||
@@ -236,9 +232,7 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
firstName={firstName}
|
||||
companyId={companyId}
|
||||
settings={settings}
|
||||
summary={{
|
||||
ytd: ytdTotals,
|
||||
mtd: mtdTotals,
|
||||
|
||||
@@ -454,7 +454,7 @@ export default function PendingOperationsPage() {
|
||||
</Tabs>
|
||||
|
||||
{showBulkControls && bulkEligible.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
@@ -583,13 +583,13 @@ export default function PendingOperationsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
{op.status === 'committed' && (
|
||||
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Godkänd
|
||||
</Badge>
|
||||
)}
|
||||
{op.status === 'rejected' && (
|
||||
<Badge variant="destructive" className="bg-destructive/10">
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Avvisad
|
||||
</Badge>
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Download, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { ReportsNav } from '@/components/reports/ReportsNav'
|
||||
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
|
||||
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
|
||||
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
|
||||
@@ -89,14 +91,9 @@ export default function ReportsPage() {
|
||||
const isAktiebolag = company?.entity_type === 'aktiebolag'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Rapporter</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generera skattedeklarationer, resultaträkningar och exportera till Skatteverket
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Rapporter</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
@@ -125,19 +122,19 @@ export default function ReportsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="rounded-lg border p-1 space-y-1">
|
||||
<div className="h-8 bg-muted rounded animate-pulse" />
|
||||
<div className="h-8 bg-muted rounded animate-pulse" />
|
||||
<div className="h-8 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-8" />
|
||||
<Skeleton className="h-8" />
|
||||
<Skeleton className="h-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-64 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-64" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -166,173 +163,44 @@ export default function ReportsPage() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
{/* Mobile: compact select dropdown */}
|
||||
<div className="sm:hidden mb-2">
|
||||
<select
|
||||
value={activeTab}
|
||||
onChange={(e) => handleTabChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<optgroup label="Löpande rapporter">
|
||||
<option value="resultatrapport">Resultatrapport</option>
|
||||
<option value="balansrapport">Balansrapport</option>
|
||||
<option value="trial-balance">Saldobalans</option>
|
||||
</optgroup>
|
||||
<optgroup label="Bokslut">
|
||||
<option value="income-statement">Resultaträkning</option>
|
||||
<option value="balance-sheet">Balansräkning</option>
|
||||
</optgroup>
|
||||
<optgroup label="Skatt & moms">
|
||||
<option value="vat-declaration">Momsdeklaration</option>
|
||||
{isEnskildFirma && <option value="ne-declaration">NE-bilaga</option>}
|
||||
{isAktiebolag && <option value="ink2-declaration">INK2</option>}
|
||||
</optgroup>
|
||||
<optgroup label="Huvudböcker">
|
||||
<option value="huvudbok">Huvudbok</option>
|
||||
<option value="grundbok">Grundbok</option>
|
||||
<option value="kundreskontra">Kundreskontra</option>
|
||||
<option value="supplier-ledger">Leverantörsreskontra</option>
|
||||
</optgroup>
|
||||
<optgroup label="Avstämning">
|
||||
<option value="bank-reconciliation">Bankavstämning</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: inline grouped tab navigation */}
|
||||
<div className="hidden sm:grid sm:grid-cols-[auto_1px_auto_1px_auto_1px_auto_1px_auto] items-stretch mb-5 rounded-xl border border-border bg-card shadow-sm">
|
||||
{/* Löpande rapporter */}
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Löpande rapporter</span>
|
||||
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
|
||||
<TabsTrigger value="resultatrapport" className="w-full justify-start text-[13px]">
|
||||
Resultatrapport
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="balansrapport" className="w-full justify-start text-[13px]">
|
||||
Balansrapport
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="trial-balance" className="w-full justify-start text-[13px]">
|
||||
Saldobalans
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="bg-border" />
|
||||
|
||||
{/* Bokslut */}
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Bokslut</span>
|
||||
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
|
||||
<TabsTrigger value="income-statement" className="w-full justify-start text-[13px]">
|
||||
Resultaträkning
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="balance-sheet" className="w-full justify-start text-[13px]">
|
||||
Balansräkning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="bg-border" />
|
||||
|
||||
{/* Skatt & moms */}
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Skatt & moms</span>
|
||||
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
|
||||
<TabsTrigger value="vat-declaration" className="w-full justify-start text-[13px]">
|
||||
Momsdeklaration
|
||||
</TabsTrigger>
|
||||
{isEnskildFirma && (
|
||||
<TabsTrigger value="ne-declaration" className="w-full justify-start text-[13px]">
|
||||
NE-bilaga
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAktiebolag && (
|
||||
<TabsTrigger value="ink2-declaration" className="w-full justify-start text-[13px]">
|
||||
INK2
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="bg-border" />
|
||||
|
||||
{/* Huvudböcker */}
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Huvudböcker</span>
|
||||
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
|
||||
<TabsTrigger value="huvudbok" className="w-full justify-start text-[13px]">
|
||||
Huvudbok
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="grundbok" className="w-full justify-start text-[13px]">
|
||||
Grundbok
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="kundreskontra" className="w-full justify-start text-[13px]">
|
||||
Kundreskontra
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="supplier-ledger" className="w-full justify-start text-[13px]">
|
||||
Leverantörsreskontra
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="bg-border" />
|
||||
|
||||
{/* Avstämning */}
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.1em]">Avstämning</span>
|
||||
<TabsList className="flex flex-col h-auto bg-transparent p-0 gap-1 items-start">
|
||||
<TabsTrigger value="bank-reconciliation" className="w-full justify-start text-[13px]">
|
||||
Bankavstämning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="resultatrapport">
|
||||
<ResultatrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
</TabsContent>
|
||||
<TabsContent value="balansrapport">
|
||||
<BalansrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
</TabsContent>
|
||||
<TabsContent value="trial-balance">
|
||||
<TrialBalanceView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
</TabsContent>
|
||||
<TabsContent value="income-statement">
|
||||
<IncomeStatementView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
</TabsContent>
|
||||
<TabsContent value="balance-sheet">
|
||||
<BalanceSheetView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
</TabsContent>
|
||||
<TabsContent value="vat-declaration">
|
||||
<VatDeclarationView />
|
||||
</TabsContent>
|
||||
{isEnskildFirma && (
|
||||
<TabsContent value="ne-declaration">
|
||||
<div className="flex flex-col sm:flex-row gap-6 sm:gap-8">
|
||||
<ReportsNav
|
||||
active={activeTab}
|
||||
onChange={handleTabChange}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
{activeTab === 'resultatrapport' && (
|
||||
<ResultatrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'balansrapport' && (
|
||||
<BalansrapportView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'trial-balance' && (
|
||||
<TrialBalanceView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'income-statement' && (
|
||||
<IncomeStatementView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'balance-sheet' && (
|
||||
<BalanceSheetView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'vat-declaration' && <VatDeclarationView />}
|
||||
{isEnskildFirma && activeTab === 'ne-declaration' && (
|
||||
<NEDeclarationView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAktiebolag && (
|
||||
<TabsContent value="ink2-declaration">
|
||||
)}
|
||||
{isAktiebolag && activeTab === 'ink2-declaration' && (
|
||||
<INK2DeclarationView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="huvudbok">
|
||||
<GeneralLedgerView periodId={selectedPeriod} initialAccountFilter={glAccountFilter} />
|
||||
</TabsContent>
|
||||
<TabsContent value="grundbok">
|
||||
<JournalRegisterView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
<TabsContent value="kundreskontra">
|
||||
<ARLedgerView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
<TabsContent value="supplier-ledger">
|
||||
<SupplierLedgerView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
<TabsContent value="bank-reconciliation">
|
||||
<BankReconciliationView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
{activeTab === 'huvudbok' && (
|
||||
<GeneralLedgerView periodId={selectedPeriod} initialAccountFilter={glAccountFilter} />
|
||||
)}
|
||||
{activeTab === 'grundbok' && <JournalRegisterView periodId={selectedPeriod} />}
|
||||
{activeTab === 'kundreskontra' && <ARLedgerView periodId={selectedPeriod} />}
|
||||
{activeTab === 'supplier-ledger' && <SupplierLedgerView periodId={selectedPeriod} />}
|
||||
{activeTab === 'bank-reconciliation' && <BankReconciliationView />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
@@ -472,8 +340,8 @@ function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string;
|
||||
<div className="overflow-x-auto -mx-2 px-2">
|
||||
{viewMode === 'simplified' ? (
|
||||
<table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-32 text-right">Ingående saldo</th>
|
||||
@@ -512,8 +380,8 @@ function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string;
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-28 text-right">Period debet</th>
|
||||
@@ -1350,12 +1218,12 @@ function VatDeclarationView() {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
|
||||
<Badge
|
||||
className={
|
||||
variant={
|
||||
data.rutor.ruta49 > 0
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
? 'warning'
|
||||
: data.rutor.ruta49 < 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
? 'success'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{data.rutor.ruta49 > 0
|
||||
@@ -1704,8 +1572,8 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Leverantör</th>
|
||||
<th className="py-2 text-right">Ej förfallet</th>
|
||||
<th className="py-2 text-right">1-30 dagar</th>
|
||||
@@ -1936,8 +1804,8 @@ function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: strin
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-16">Ver.nr</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
@@ -2096,8 +1964,8 @@ function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-8"></th>
|
||||
<th className="py-2 w-16">Ver.nr</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
@@ -2330,8 +2198,8 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-8"></th>
|
||||
<th className="py-2">Kund</th>
|
||||
<th className="py-2 text-right">Ej förfallet</th>
|
||||
@@ -2371,8 +2239,8 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<td></td>
|
||||
<td className="py-1 text-xs" colSpan={2}>
|
||||
<span className="font-mono">{inv.invoice_number}</span>
|
||||
<span className="text-muted-foreground ml-2">{inv.invoice_date}</span>
|
||||
<span className="text-muted-foreground ml-2">förfaller {inv.due_date}</span>
|
||||
<span className="text-muted-foreground ml-2 tabular-nums">{formatDate(inv.invoice_date)}</span>
|
||||
<span className="text-muted-foreground ml-2 tabular-nums">förfaller {formatDate(inv.due_date)}</span>
|
||||
</td>
|
||||
<td className="py-1 text-right text-xs text-muted-foreground" colSpan={2}>
|
||||
{inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -111,7 +112,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte uppdatera anställd',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -133,8 +134,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-9 w-60 bg-muted rounded animate-pulse" />
|
||||
<div className="h-64 bg-muted rounded-lg animate-pulse" />
|
||||
<Skeleton className="h-9 w-60" />
|
||||
<Skeleton className="rounded-lg h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -144,11 +145,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href="/salary/employees"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary/employees" aria-label="Tillbaka till anställda"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function NewEmployeePage() {
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte skapa anställd',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -78,10 +78,10 @@ export default function NewEmployeePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href="/salary/employees"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary/employees" aria-label="Tillbaka till anställda"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny anställd</h1>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -33,11 +36,11 @@ export default function EmployeesPage() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href="/salary"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label="Tillbaka till löner"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Anställda</h1>
|
||||
@@ -57,65 +60,62 @@ export default function EmployeesPage() {
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-16 bg-muted rounded-lg animate-pulse" />
|
||||
<Skeleton key={i} className="h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : employees.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">Inga anställda registrerade</p>
|
||||
{canWrite && (
|
||||
<Button asChild size="sm">
|
||||
<Link href="/salary/employees/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till anställd
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={UserCircle}
|
||||
title="Inga anställda"
|
||||
description="Lägg till anställda för att kunna skapa lönekörningar och AGI-deklarationer."
|
||||
actionLabel={canWrite ? 'Lägg till anställd' : undefined}
|
||||
actionHref={canWrite ? '/salary/employees/new' : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">Namn</th>
|
||||
<th className="px-4 py-2 font-medium">Personnummer</th>
|
||||
<th className="px-4 py-2 font-medium">Typ</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Månadslön</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Sysselsättningsgrad</th>
|
||||
<th className="px-4 py-2 font-medium">Skattetabell</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namn</TableHead>
|
||||
<TableHead>Personnummer</TableHead>
|
||||
<TableHead>Typ</TableHead>
|
||||
<TableHead className="text-right">Månadslön</TableHead>
|
||||
<TableHead className="text-right">Sysselsättningsgrad</TableHead>
|
||||
<TableHead>Skattetabell</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map(emp => (
|
||||
<tr key={emp.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/salary/employees/${emp.id}`} className="text-sm font-medium hover:underline">
|
||||
<TableRow key={emp.id}>
|
||||
<TableCell>
|
||||
<Link href={`/salary/employees/${emp.id}`} className="font-medium hover:underline">
|
||||
{emp.first_name} {emp.last_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums">
|
||||
{emp.personnummer}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{emp.employment_degree}%
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums">
|
||||
{emp.tax_table_number ? `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { SalaryRun } from '@/types'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -17,12 +22,12 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
booked: 'Bokförd',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'bg-muted text-muted-foreground',
|
||||
review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
draft: 'secondary',
|
||||
review: 'warning',
|
||||
approved: 'default',
|
||||
paid: 'success',
|
||||
booked: 'success',
|
||||
}
|
||||
|
||||
export default function SalaryPage() {
|
||||
@@ -61,12 +66,12 @@ export default function SalaryPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-9 w-40 bg-muted rounded animate-pulse" />
|
||||
<div className="h-9 w-32 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-muted rounded-lg animate-pulse" />
|
||||
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,35 +79,33 @@ export default function SalaryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Löner</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Hantera anställda och lönekörningar</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/salary/employees">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Anställda
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button asChild>
|
||||
<Link href="/salary/runs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny lönekörning
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Löner"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/salary/employees">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Anställda
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button asChild>
|
||||
<Link href="/salary/runs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny lönekörning
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -113,7 +116,7 @@ export default function SalaryPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HandCoins className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -124,7 +127,7 @@ export default function SalaryPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -143,63 +146,58 @@ export default function SalaryPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{runs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<HandCoins className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">Inga lönekörningar ännu</p>
|
||||
{canWrite && (
|
||||
<Button asChild size="sm">
|
||||
<Link href="/salary/runs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Skapa första lönekörningen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={HandCoins}
|
||||
title="Inga lönekörningar ännu"
|
||||
description="Skapa en lönekörning för att räkna ut löner, skatt och arbetsgivaravgifter."
|
||||
actionLabel={canWrite ? 'Skapa lönekörning' : undefined}
|
||||
actionHref={canWrite ? '/salary/runs/new' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">Period</th>
|
||||
<th className="px-4 py-2 font-medium">Utbetalningsdag</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Brutto</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Netto</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Avgifter</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead>Utbetalningsdag</TableHead>
|
||||
<TableHead className="text-right">Brutto</TableHead>
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
<TableHead className="text-right">Avgifter</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.slice(0, 12).map(run => (
|
||||
<tr key={run.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-medium tabular-nums">
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="font-medium tabular-nums">
|
||||
{run.period_year}-{String(run.period_month).padStart(2, '0')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground tabular-nums">
|
||||
{run.payment_date}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums">
|
||||
{formatDate(run.payment_date)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(run.total_gross)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(run.total_net)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(run.total_avgifter)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[run.status]}`}>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
|
||||
{STATUS_LABELS[run.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link href={`/salary/runs/${run.id}`} className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,38 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AbsenceCalendar } from '@/components/salary/AbsenceCalendar'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, Employee } from '@/types'
|
||||
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
|
||||
|
||||
const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
|
||||
monthly_salary: 'Månadslön',
|
||||
hourly_salary: 'Timlön',
|
||||
overtime: 'Övertid',
|
||||
bonus: 'Bonus',
|
||||
commission: 'Provision',
|
||||
gross_deduction_pension: 'Bruttoavdrag — pension',
|
||||
gross_deduction_other: 'Bruttoavdrag — övrigt',
|
||||
benefit_car: 'Bilförmån',
|
||||
benefit_housing: 'Bostadsförmån',
|
||||
benefit_meals: 'Kostförmån',
|
||||
benefit_wellness: 'Friskvård',
|
||||
benefit_other: 'Övrig förmån',
|
||||
sick_karens: 'Karensavdrag',
|
||||
sick_day2_14: 'Sjuklön (dag 2–14, 80 %)',
|
||||
sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)',
|
||||
vab: 'VAB (vård av sjukt barn)',
|
||||
parental_leave: 'Föräldraledighet',
|
||||
vacation: 'Semester',
|
||||
traktamente_taxfree: 'Traktamente (skattefritt)',
|
||||
traktamente_taxable: 'Traktamente (skattepliktigt)',
|
||||
mileage_taxfree: 'Milersättning (skattefritt)',
|
||||
mileage_taxable: 'Milersättning (skattepliktigt)',
|
||||
net_deduction_advance: 'Nettoavdrag — förskott',
|
||||
net_deduction_union: 'Nettoavdrag — fackavgift',
|
||||
net_deduction_benefit_payment: 'Nettoavdrag — förmånsbetalning',
|
||||
net_deduction_other: 'Nettoavdrag — övrigt',
|
||||
correction: 'Korrigering',
|
||||
other: 'Övrigt',
|
||||
}
|
||||
|
||||
interface DetailResponse {
|
||||
run: SalaryRun
|
||||
@@ -95,7 +126,7 @@ export default function SalaryRunEmployeeDetailPage({
|
||||
const readOnly = run.status !== 'draft' && run.status !== 'review'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
@@ -165,18 +196,18 @@ export default function SalaryRunEmployeeDetailPage({
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">Typ</th>
|
||||
<th className="px-4 py-2 font-medium">Beskrivning</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Antal</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Belopp</th>
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="px-4 py-2">Typ</th>
|
||||
<th className="px-4 py-2">Beskrivning</th>
|
||||
<th className="px-4 py-2 text-right">Antal</th>
|
||||
<th className="px-4 py-2 text-right">Belopp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map(li => (
|
||||
<tr key={li.id} className="border-b last:border-0">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{li.item_type}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{LINE_ITEM_TYPE_LABELS[li.item_type] ?? li.item_type}</td>
|
||||
<td className="px-4 py-2 text-sm">{li.description}</td>
|
||||
<td className="px-4 py-2 text-sm text-right tabular-nums">{li.quantity ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-sm text-right tabular-nums">{formatCurrency(li.amount)}</td>
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen,
|
||||
ArrowLeftCircle, Loader2, Download,
|
||||
} from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types'
|
||||
import { AGIPanel } from '@/components/salary/AGIPanel'
|
||||
@@ -30,13 +33,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
corrected: 'Korrigerad',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'bg-muted text-muted-foreground',
|
||||
review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
corrected: 'bg-muted text-muted-foreground',
|
||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
draft: 'secondary',
|
||||
review: 'warning',
|
||||
approved: 'default',
|
||||
paid: 'success',
|
||||
booked: 'success',
|
||||
corrected: 'secondary',
|
||||
}
|
||||
|
||||
interface EntryPreview {
|
||||
@@ -113,7 +116,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte uppdatera status',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -134,7 +137,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte lägga till anställd',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -208,8 +211,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-9 w-60 bg-muted rounded animate-pulse" />
|
||||
<div className="h-48 bg-muted rounded-lg animate-pulse" />
|
||||
<Skeleton className="h-9 w-60" />
|
||||
<Skeleton className="rounded-lg h-48" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -224,40 +227,42 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href="/salary"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label="Tillbaka till löner"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
Lönekörning {periodLabel}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Utbetalning: {run.payment_date}
|
||||
Utbetalning: {formatDate(run.payment_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${STATUS_COLORS[run.status]}`}>
|
||||
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
|
||||
{STATUS_LABELS[run.status]}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: 'Brutto', value: run.total_gross },
|
||||
{ label: 'Skatt', value: run.total_tax },
|
||||
{ label: 'Netto', value: run.total_net },
|
||||
{ label: 'Netto', value: run.total_net, accent: true },
|
||||
{ label: 'Avgifter', value: run.total_avgifter },
|
||||
{ label: 'Total kostnad', value: run.total_employer_cost },
|
||||
].map(({ label, value }) => (
|
||||
].map(({ label, value, accent }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tabular-nums">{formatCurrency(value)}</p>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{label}</p>
|
||||
<p className={`font-display text-xl font-medium tabular-nums leading-tight ${accent ? 'text-success' : ''}`}>
|
||||
{formatCurrency(value)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -292,30 +297,30 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
Inga anställda tillagda ännu
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">Anställd</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Brutto</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Skatt</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Netto</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Avgifter</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Semester</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Anställd</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-right">Brutto</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-right">Skatt</TableHead>
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-right">Avgifter</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-right">Semester</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map(sre => {
|
||||
const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee
|
||||
const name = employee
|
||||
? `${employee.first_name} ${employee.last_name}`
|
||||
: `Anställd ${sre.employee_id.slice(0, 8)}...`
|
||||
return (
|
||||
<tr
|
||||
<TableRow
|
||||
key={sre.id}
|
||||
className="cursor-pointer border-b transition-colors last:border-0 hover:bg-accent/40"
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/salary/runs/${id}/employees/${sre.employee_id}`)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/salary/runs/${id}/employees/${sre.employee_id}`}
|
||||
className="hover:underline"
|
||||
@@ -323,17 +328,20 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.gross_salary)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.net_salary)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</td>
|
||||
</tr>
|
||||
<span className="md:hidden block text-xs text-muted-foreground font-normal mt-0.5 tabular-nums">
|
||||
Brutto {formatCurrency(sre.gross_salary)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-right tabular-nums">{formatCurrency(sre.gross_salary)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(sre.net_salary)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -384,8 +392,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
<div key={idx} className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{entry!.description}</h4>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1">Konto</th>
|
||||
<th className="text-left py-1">Beskrivning</th>
|
||||
<th className="text-right py-1">Debet</th>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function NewSalaryRunPage() {
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte skapa lönekörning',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -56,10 +56,10 @@ export default function NewSalaryRunPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href="/salary"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label="Tillbaka till löner"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny lönekörning</h1>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function BankingSettingsPage() {
|
||||
}, [searchParams, router, toast])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
|
||||
export default function SalarySettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-medium tracking-tight">Löneinställningar</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Konfiguration för lönemodulen
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader title="Löneinställningar" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function SkatteverketSettingsPage() {
|
||||
}, [searchParams, router, toast])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<SkatteverketConnectPanel />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTe
|
||||
|
||||
export default function TemplatesSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<BookingTemplatesPanel />
|
||||
<CounterpartyTemplatesPanel />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
@@ -365,69 +366,65 @@ function TransactionTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="py-2 pr-3">Datum</th>
|
||||
{showForfallodatum && <th className="py-2 pr-3">Förfallodatum</th>}
|
||||
<th className="py-2 pr-3">Beskrivning</th>
|
||||
<th className="py-2 pr-3 text-right">Belopp</th>
|
||||
<th className="py-2 pr-3">Status</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => {
|
||||
const negative = Number(row.belopp_skatteverket) < 0
|
||||
const isBooked = !!row.journal_entry_id
|
||||
return (
|
||||
<tr key={row.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-3 tabular-nums">{row.transaktionsdatum}</td>
|
||||
{showForfallodatum && (
|
||||
<td className="py-2 pr-3 tabular-nums">{row.forfallodatum ?? '–'}</td>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
{showForfallodatum && <TableHead>Förfallodatum</TableHead>}
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(row => {
|
||||
const negative = Number(row.belopp_skatteverket) < 0
|
||||
const isBooked = !!row.journal_entry_id
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">{row.transaktionsdatum}</TableCell>
|
||||
{showForfallodatum && (
|
||||
<TableCell className="tabular-nums">{row.forfallodatum ?? '–'}</TableCell>
|
||||
)}
|
||||
<TableCell>{row.transaktionstext}</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${negative ? 'text-destructive' : ''}`}
|
||||
>
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isBooked ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileCheck className="h-3 w-3" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
)}
|
||||
<td className="py-2 pr-3">{row.transaktionstext}</td>
|
||||
<td
|
||||
className={`py-2 pr-3 text-right tabular-nums ${
|
||||
negative ? 'text-destructive' : ''
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(Number(row.belopp_skatteverket))}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{isBooked ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileCheck className="h-3 w-3" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Ej bokförd</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{isBooked ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
|
||||
Visa verifikat
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
>
|
||||
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isBooked ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
|
||||
Visa verifikat
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onBokfor(row.id)}
|
||||
disabled={bookingId === row.id}
|
||||
>
|
||||
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -12,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
@@ -21,15 +23,15 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-success/10 text-success',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-destructive/10 text-destructive',
|
||||
disputed: 'bg-purple-100 text-purple-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
reversed: 'bg-gray-100 text-gray-500',
|
||||
const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
registered: 'secondary',
|
||||
approved: 'default',
|
||||
paid: 'success',
|
||||
partially_paid: 'warning',
|
||||
overdue: 'destructive',
|
||||
disputed: 'destructive',
|
||||
credited: 'secondary',
|
||||
reversed: 'secondary',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -177,7 +179,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
|
||||
</div>
|
||||
)
|
||||
@@ -202,7 +204,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')}>
|
||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')} aria-label="Tillbaka till leverantörsfakturor">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
@@ -210,7 +212,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<h1 className="font-display text-2xl sm:text-3xl font-medium tracking-tight">
|
||||
Ankomst #{invoice.arrival_number}
|
||||
</h1>
|
||||
<Badge className={statusColors[invoice.status] || ''}>
|
||||
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
|
||||
{statusLabels[invoice.status] || invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -321,11 +323,11 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<span>{invoice.invoice_date}</span>
|
||||
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<span>{invoice.due_date}</span>
|
||||
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
|
||||
</div>
|
||||
{invoice.delivery_date && (
|
||||
<div className="flex justify-between">
|
||||
@@ -341,7 +343,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
)}
|
||||
{invoice.reverse_charge && (
|
||||
<div className="mt-2">
|
||||
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
|
||||
<Badge variant="warning">Omvänd skattskyldighet</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -403,8 +405,8 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2">Beskrivning</th>
|
||||
<th className="pb-2 w-16 text-right">Antal</th>
|
||||
<th className="pb-2 w-16">Enhet</th>
|
||||
@@ -460,8 +462,8 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2">Datum</th>
|
||||
<th className="pb-2 text-right">Belopp</th>
|
||||
<th className="pb-2">Verifikation</th>
|
||||
@@ -471,7 +473,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.payment_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(p.payment_date)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
|
||||
<td className="py-2">
|
||||
{p.journal_entry_id ? (
|
||||
@@ -491,7 +493,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{payments.map((p) => (
|
||||
<div key={p.id} className="border rounded-lg p-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{p.payment_date}</span>
|
||||
<span className="tabular-nums">{formatDate(p.payment_date)}</span>
|
||||
<span className="font-mono font-medium">{formatAmount(p.amount)} {p.currency}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
|
||||
@@ -55,6 +55,10 @@ interface NewSupplierForm {
|
||||
default_expense_account: string
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -741,7 +745,7 @@ export default function NewSupplierInvoicePage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')} aria-label="Tillbaka till leverantörsfakturor">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -794,7 +798,7 @@ export default function NewSupplierInvoicePage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantör *</Label>
|
||||
<Label>Leverantör<RequiredMark /></Label>
|
||||
<Controller
|
||||
name="supplier_id"
|
||||
control={control}
|
||||
@@ -825,7 +829,7 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantörens fakturanummer *</Label>
|
||||
<Label>Leverantörens fakturanummer<RequiredMark /></Label>
|
||||
{(() => {
|
||||
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
|
||||
return (
|
||||
@@ -843,11 +847,11 @@ export default function NewSupplierInvoicePage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Fakturadatum *</Label>
|
||||
<Label>Fakturadatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum *</Label>
|
||||
<Label>Förfallodatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -879,8 +883,8 @@ export default function NewSupplierInvoicePage() {
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2 w-28">Konto</th>
|
||||
<th className="pb-2">Beskrivning</th>
|
||||
<th className="pb-2 w-32">Belopp (exkl.)</th>
|
||||
@@ -916,7 +920,9 @@ export default function NewSupplierInvoicePage() {
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
className="text-right tabular-nums"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
@@ -965,7 +971,7 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Rad {index + 1}</span>
|
||||
{fields.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => remove(index)}>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={`Ta bort rad ${index + 1}`}>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -994,7 +1000,9 @@ export default function NewSupplierInvoicePage() {
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
className="text-right tabular-nums"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
@@ -1109,7 +1117,7 @@ export default function NewSupplierInvoicePage() {
|
||||
{watchedCurrency !== 'SEK' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Växelkurs</Label>
|
||||
<Input type="number" step="0.0001" placeholder="1.0000" {...register('exchange_rate')} />
|
||||
<Input type="number" step="0.0001" inputMode="decimal" placeholder="1,0000" className="text-right tabular-nums" {...register('exchange_rate')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1234,7 +1242,7 @@ export default function NewSupplierInvoicePage() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Namn *</Label>
|
||||
<Label>Namn<RequiredMark /></Label>
|
||||
<Input
|
||||
placeholder="Leverantörens namn"
|
||||
value={newSupplier.name}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Plus, FileInput, Lock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
@@ -65,31 +70,28 @@ export default function SupplierInvoicesPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Leverantörsfakturor</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Registrera och hantera inkommande fakturor
|
||||
</p>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Leverantörsfakturor"
|
||||
action={
|
||||
canWrite ? (
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
title="Du har endast läsbehörighet i detta företag"
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
title="Du har endast läsbehörighet i detta företag"
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
@@ -106,84 +108,80 @@ export default function SupplierInvoicesPage() {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="p-3 border-b">
|
||||
<div className="h-4 bg-muted rounded w-full animate-pulse" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-3 border-b last:border-0">
|
||||
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse ml-auto" />
|
||||
<div className="h-5 bg-muted rounded w-16 animate-pulse" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-20 ml-auto" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileInput className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga fakturor</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
{activeTab === 'all'
|
||||
? 'Registrera din första leverantörsfaktura'
|
||||
: 'Inga fakturor i denna kategori'}
|
||||
</p>
|
||||
{activeTab === 'all' && canWrite && (
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button className="mt-4">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={FileInput}
|
||||
title="Inga fakturor"
|
||||
description={
|
||||
activeTab === 'all'
|
||||
? 'Registrera leverantörsfakturor för att hålla koll på inköp och betalningar.'
|
||||
: 'Inga fakturor i denna kategori.'
|
||||
}
|
||||
actionLabel={activeTab === 'all' && canWrite ? 'Registrera faktura' : undefined}
|
||||
actionHref={activeTab === 'all' && canWrite ? '/supplier-invoices/new' : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="p-3">Ankomst</th>
|
||||
<th className="p-3">Leverantör</th>
|
||||
<th className="p-3">Fakturanr</th>
|
||||
<th className="p-3">Fakturadatum</th>
|
||||
<th className="p-3">Förfaller</th>
|
||||
<th className="p-3 text-right">Belopp</th>
|
||||
<th className="p-3 text-right">Kvar att betala</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ankomst</TableHead>
|
||||
<TableHead>Leverantör</TableHead>
|
||||
<TableHead>Fakturanr</TableHead>
|
||||
<TableHead>Fakturadatum</TableHead>
|
||||
<TableHead>Förfaller</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="text-right">Kvar att betala</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInvoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="p-3 font-mono">{inv.arrival_number}</td>
|
||||
<td className="p-3">
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell className="font-mono tabular-nums">{inv.arrival_number}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers/${inv.supplier_id}`} className="hover:underline">
|
||||
{inv.supplier?.name || '-'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
|
||||
{inv.supplier_invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">{inv.invoice_date}</td>
|
||||
<td className="p-3">{inv.due_date}</td>
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.total)}</td>
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.remaining_amount)}</td>
|
||||
<td className="p-3">
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.total)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.remaining_amount)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariants[inv.status] || 'secondary'}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, Edit, Trash2, FileText, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import SupplierForm from '@/components/suppliers/SupplierForm'
|
||||
import Link from 'next/link'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
@@ -101,8 +104,8 @@ export default function SupplierDetailPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Card className="animate-pulse">
|
||||
<CardContent className="h-48" />
|
||||
</Card>
|
||||
@@ -121,13 +124,13 @@ export default function SupplierDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-success/10 text-success',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-destructive/10 text-destructive',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
|
||||
registered: 'secondary',
|
||||
approved: 'default',
|
||||
paid: 'success',
|
||||
partially_paid: 'warning',
|
||||
overdue: 'destructive',
|
||||
credited: 'secondary',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -140,10 +143,10 @@ export default function SupplierDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')} aria-label="Tillbaka till leverantörer">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -254,40 +257,40 @@ export default function SupplierDetailPage() {
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2">Ankomst</th>
|
||||
<th className="py-2">Fakturanr</th>
|
||||
<th className="py-2">Datum</th>
|
||||
<th className="py-2">Förfaller</th>
|
||||
<th className="py-2 text-right">Belopp</th>
|
||||
<th className="py-2 text-right">Kvar</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ankomst</TableHead>
|
||||
<TableHead>Fakturanr</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Förfaller</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="text-right">Kvar</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b last:border-0">
|
||||
<td className="py-2 font-mono">{inv.arrival_number}</td>
|
||||
<td className="py-2">
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell className="font-mono tabular-nums">{inv.arrival_number}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
|
||||
{inv.supplier_invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">{inv.invoice_date}</td>
|
||||
<td className="py-2">{inv.due_date}</td>
|
||||
<td className="py-2 text-right">{formatAmount(inv.total)} kr</td>
|
||||
<td className="py-2 text-right">{formatAmount(inv.remaining_amount)} kr</td>
|
||||
<td className="py-2">
|
||||
<Badge className={statusColors[inv.status] || ''}>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.invoice_date)}</TableCell>
|
||||
<TableCell className="tabular-nums">{formatDate(inv.due_date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.total)} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(inv.remaining_amount)} kr</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariants[inv.status] || 'secondary'}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
@@ -297,12 +300,12 @@ export default function SupplierDetailPage() {
|
||||
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline font-medium text-sm">
|
||||
{inv.supplier_invoice_number}
|
||||
</Link>
|
||||
<Badge className={statusColors[inv.status] || ''}>
|
||||
<Badge variant={statusVariants[inv.status] || 'secondary'}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{inv.invoice_date} → {inv.due_date}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{formatDate(inv.invoice_date)} → {formatDate(inv.due_date)}</span>
|
||||
<span className="font-mono">{formatAmount(inv.total)} kr</span>
|
||||
</div>
|
||||
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Search, Building2, Lock } from 'lucide-react'
|
||||
import SupplierForm from '@/components/suppliers/SupplierForm'
|
||||
@@ -107,7 +108,7 @@ export default function SuppliersPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Leverantörer</h1>
|
||||
@@ -157,7 +158,7 @@ export default function SuppliersPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<div className="h-5 bg-muted rounded w-1/2" />
|
||||
<div className="h-3 bg-muted rounded w-2/3" />
|
||||
<div className="h-px bg-muted" />
|
||||
@@ -169,36 +170,21 @@ export default function SuppliersPage() {
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga leverantörer matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="Inga träffar"
|
||||
description={`Inga leverantörer matchar "${searchTerm}".`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga leverantörer</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Lägg till din första leverantör för att börja registrera inköpsfakturor
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{canWrite ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Ny leverantör
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="Inga leverantörer"
|
||||
description="Lägg till din första leverantör för att börja registrera inköpsfakturor."
|
||||
actionLabel={canWrite ? 'Ny leverantör' : undefined}
|
||||
onAction={canWrite ? () => setIsDialogOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -210,7 +196,7 @@ export default function SuppliersPage() {
|
||||
return (
|
||||
<Link key={supplier.id} href={`/suppliers/${supplier.id}`} className="group">
|
||||
<Card className="h-full cursor-pointer transition-all duration-150 hover:border-foreground/20 hover:shadow-sm motion-safe:active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<CardContent className="p-5 flex flex-col h-full">
|
||||
<CardContent className="p-6 flex flex-col h-full">
|
||||
<div className="space-y-1 mb-4">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight leading-tight truncate group-hover:text-primary transition-colors">
|
||||
{supplier.name}
|
||||
|
||||
@@ -339,7 +339,6 @@ export default function TransactionsPage() {
|
||||
if (result.journal_entry_created) {
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
description: 'Transaktion bokförd och verifikation skapad',
|
||||
action: (
|
||||
<ToastAction altText="Ångra kategorisering" onClick={async () => {
|
||||
try {
|
||||
@@ -674,7 +673,7 @@ export default function TransactionsPage() {
|
||||
}, 350)
|
||||
setBookingDialogOpen(false)
|
||||
setBookingDialogTransaction(null)
|
||||
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
|
||||
toast({ title: 'Bokförd' })
|
||||
}
|
||||
|
||||
// Batch mode handlers
|
||||
@@ -889,7 +888,7 @@ export default function TransactionsPage() {
|
||||
|
||||
{/* Content based on mode */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="py-4">
|
||||
@@ -911,7 +910,7 @@ export default function TransactionsPage() {
|
||||
onCreateTransaction={() => setIsDialogOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{uncategorizedTransactions.map((transaction) => (
|
||||
<TransactionInboxCard
|
||||
@@ -970,7 +969,7 @@ export default function TransactionsPage() {
|
||||
Markera som privat
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
|
||||
Bokför {selectedIds.size} st
|
||||
Bokför
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,16 +28,30 @@ export async function GET(request: Request) {
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
|
||||
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||||
// sort_by overrides sort_date when present. sort_date is kept for backwards
|
||||
// compatibility with older clients.
|
||||
const sortBy = searchParams.get('sort_by')
|
||||
const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc'
|
||||
// Default on: when a fiscal period is selected, include follow-up entries
|
||||
// booked in later periods whose source aggregate (invoice, supplier invoice)
|
||||
// is dated inside the selected period. Pass include_related=false to
|
||||
// restore strict fiscal_period_id filtering.
|
||||
const includeRelated = searchParams.get('include_related') !== 'false'
|
||||
|
||||
const dateAscending = sortDate === 'asc'
|
||||
const sortDateParam = sortDate === 'asc' || sortDate === 'desc' ? sortDate : 'desc'
|
||||
const dateAscending = sortDate === 'asc' || sortBy === 'date_asc'
|
||||
const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
if (periodId && includeRelated) {
|
||||
// Voucher-sort path: include_related RPC doesn't support voucher ordering,
|
||||
// so fall through to the direct query below. This means voucher sort is
|
||||
// *strict by fiscal_period_id* — cross-period follow-up entries that the
|
||||
// RPC normally surfaces under date sort are excluded under voucher sort.
|
||||
// That's intentional: voucher numbers are series-scoped within a fiscal
|
||||
// year (BFL 5 kap 6–7 §§), so showing series A1, A2 … alongside entries
|
||||
// belonging to a different year's series would be misleading. The trade-off
|
||||
// is that the visible row count may differ between sort modes for the same
|
||||
// period; the strict count is the BFL-compliant view of that year.
|
||||
if (periodId && includeRelated && !isVoucherSort) {
|
||||
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: periodId,
|
||||
@@ -66,7 +80,12 @@ export async function GET(request: Request) {
|
||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (sortDate === 'asc' || sortDate === 'desc') {
|
||||
if (isVoucherSort) {
|
||||
const voucherAscending = sortBy === 'voucher_asc'
|
||||
query = query
|
||||
.order('voucher_series', { ascending: voucherAscending })
|
||||
.order('voucher_number', { ascending: voucherAscending })
|
||||
} else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') {
|
||||
query = query
|
||||
.order('entry_date', { ascending: dateAscending })
|
||||
.order('voucher_number', { ascending: dateAscending })
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Spacing scale — locked.
|
||||
* Card padding: p-6. Section gap: space-y-8. Form/control gap: gap-4.
|
||||
* Allowed Tailwind values: 1, 2, 3, 4, 6, 8, 10, 12. No 2.5 / 5.
|
||||
* Sidebar width is md:w-64 (256px); main offset matches md:pl-64.
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Grayscale Chrome Palette */
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function BookingTemplatePicker({ onApply, entityType }: Props) {
|
||||
setTemplates(data || [])
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ export default function ChartOfAccounts() {
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function ChartOfAccountsManager() {
|
||||
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
|
||||
await refreshAll()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte uppdatera kontot', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte uppdatera kontot', variant: 'destructive' })
|
||||
} finally {
|
||||
setTogglingAccount(null)
|
||||
}
|
||||
@@ -167,8 +167,7 @@ export default function ChartOfAccountsManager() {
|
||||
await refreshAll()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
|
||||
title: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -191,7 +190,7 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
await refreshAll()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte aktivera kontot', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte aktivera kontot', variant: 'destructive' })
|
||||
} finally {
|
||||
setActivatingAccounts((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -367,8 +366,8 @@ export default function ChartOfAccountsManager() {
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
@@ -497,8 +496,8 @@ export default function ChartOfAccountsManager() {
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Info } from 'lucide-react'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -71,7 +72,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
|
||||
<span className="font-mono text-sm">
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{entry.entry_date}</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatDate(entry.entry_date)}</span>
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={false} />
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
|
||||
@@ -17,6 +17,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
interface CorrectionLine {
|
||||
@@ -132,7 +133,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
} catch (err) {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte spara ändringsverifikation',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -165,15 +166,15 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{entry.voucher_series}{entry.voucher_number}</span>
|
||||
<span>{entry.entry_date}</span>
|
||||
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
|
||||
<Badge variant="outline" className="text-xs">Original</Badge>
|
||||
</div>
|
||||
<p className="text-sm">{entry.description}</p>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-1.5 w-48">Konto</th>
|
||||
<th className="py-1.5">Beskrivning</th>
|
||||
<th className="py-1.5 w-28 text-right">Debet</th>
|
||||
|
||||
@@ -633,8 +633,8 @@ export default function JournalEntryForm({
|
||||
{/* Entry lines — desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-28">Konto</th>
|
||||
<th className="py-2 px-1">Beskrivning</th>
|
||||
<th className="py-2 w-32 px-1 text-right">Debet</th>
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
@@ -40,7 +42,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
|
||||
const [dateSortDir, setDateSortDir] = useState<'desc' | 'asc'>('desc')
|
||||
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [dateFromInput, setDateFromInput] = useState('')
|
||||
@@ -106,7 +108,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String(page * pageSize),
|
||||
sort_date: dateSortDir,
|
||||
sort_by: sortBy,
|
||||
})
|
||||
if (periodId) params.set('period_id', periodId)
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
@@ -130,7 +132,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [periodId, page, dateSortDir, dateFrom, dateTo])
|
||||
}, [periodId, page, sortBy, dateFrom, dateTo])
|
||||
|
||||
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
||||
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
||||
@@ -196,32 +198,18 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 sm:hidden"
|
||||
onClick={() => { setDateSortDir(dateSortDir === 'desc' ? 'asc' : 'desc'); setPage(0) }}
|
||||
>
|
||||
{dateSortDir === 'desc' ? (
|
||||
<ArrowDownNarrowWide className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpNarrowWide className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 hidden sm:inline-flex"
|
||||
onClick={() => { setDateSortDir(dateSortDir === 'desc' ? 'asc' : 'desc'); setPage(0) }}
|
||||
>
|
||||
{dateSortDir === 'desc' ? (
|
||||
<ArrowDownNarrowWide className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpNarrowWide className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs">Datum {dateSortDir === 'desc' ? 'nyast först' : 'äldst först'}</span>
|
||||
</Button>
|
||||
<Select value={sortBy} onValueChange={(v) => { setSortBy(v as typeof sortBy); setPage(0) }}>
|
||||
<SelectTrigger className="h-8 w-auto gap-1.5 text-xs sm:w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">Datum, nyast först</SelectItem>
|
||||
<SelectItem value="date_asc">Datum, äldst först</SelectItem>
|
||||
<SelectItem value="voucher_asc">Verifikat, A1 först</SelectItem>
|
||||
<SelectItem value="voucher_desc">Verifikat, senaste först</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -308,8 +296,8 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
>
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground w-24">
|
||||
{entry.entry_date}
|
||||
<span className="text-sm text-muted-foreground tabular-nums w-24">
|
||||
{formatDate(entry.entry_date)}
|
||||
</span>
|
||||
{entry.out_of_period && (
|
||||
<Badge
|
||||
@@ -320,8 +308,8 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
Efterföljande
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed'} />
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{entry.description}</span>
|
||||
{attachmentCounts[entry.id] ? (
|
||||
@@ -352,8 +340,8 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
>
|
||||
{entry.voucher_series}{entry.voucher_number}
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{entry.entry_date}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatDate(entry.entry_date)}
|
||||
</span>
|
||||
{entry.out_of_period && (
|
||||
<Badge
|
||||
@@ -364,6 +352,9 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
Efterföljande
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
|
||||
|
||||
@@ -101,8 +101,8 @@ export function JournalEntryReviewContent({
|
||||
{/* Debit/Credit — table on desktop, cards on mobile */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Debet</th>
|
||||
|
||||
@@ -113,8 +113,7 @@ export default function CustomerForm({
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte verifiera VAT-nummer',
|
||||
title: 'Kunde inte verifiera VAT-nummer',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -12,23 +12,17 @@ import {
|
||||
ArrowLeftRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Landmark,
|
||||
CheckCircle2,
|
||||
FileWarning,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { getAllExtensions } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { QuickActionDefinition } from '@/lib/extensions/types'
|
||||
import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
|
||||
|
||||
interface DashboardContentProps {
|
||||
firstName?: string | null
|
||||
companyId: string
|
||||
settings: CompanySettings | null
|
||||
summary: {
|
||||
ytd: { income: number; expenses: number; net: number }
|
||||
mtd: { income: number; expenses: number; net: number }
|
||||
@@ -49,20 +43,12 @@ interface DashboardContentProps {
|
||||
onboardingProgress?: OnboardingProgress
|
||||
}
|
||||
|
||||
export default function DashboardContent({ firstName, companyId, settings, summary, onboardingProgress }: DashboardContentProps) {
|
||||
export default function DashboardContent({ companyId, summary, onboardingProgress }: DashboardContentProps) {
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [greeting, setGreeting] = useState('Hej')
|
||||
|
||||
// Setup gate — blocks dashboard until user imports data or chooses fresh start
|
||||
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
|
||||
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours()
|
||||
setGreeting(hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSetup) {
|
||||
setSetupGateActive(false)
|
||||
@@ -100,7 +86,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Build alert items for "Att hantera" section
|
||||
const alertItems: React.ReactNode[] = []
|
||||
|
||||
if (summary.overdueInvoicesCount > 0) {
|
||||
@@ -228,19 +213,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
|
||||
// Build extension quick actions from all compiled extensions
|
||||
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = getAllExtensions()
|
||||
.filter(def => def.quickAction)
|
||||
.map(def => ({ ...def.quickAction!, key: `${def.sector}/${def.slug}` }))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
|
||||
// Quick action items
|
||||
const quickActions = [
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
|
||||
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
|
||||
]
|
||||
|
||||
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
|
||||
@@ -248,18 +220,10 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
const todoCount = summary.uncategorizedCount + summary.overdueInvoicesCount + pendingReceiptsCount + passedDeadlinesCount
|
||||
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
{/* Header */}
|
||||
<header className="mb-12">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* 4 Key Summary Cards */}
|
||||
<section className="mb-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Card 1: Resultat */}
|
||||
<div className="stagger-enter space-y-8">
|
||||
{/* Key metrics — 4 compact cards */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
|
||||
@@ -276,7 +240,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
</CardContent>
|
||||
</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">
|
||||
@@ -295,7 +258,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Card 3: Banksaldo */}
|
||||
{summary.bankBalance !== null ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -315,27 +277,20 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
<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 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-xs text-muted-foreground mt-1">
|
||||
Behöver åtgärdas
|
||||
</p>
|
||||
</>
|
||||
<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>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
@@ -348,78 +303,46 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick actions */}
|
||||
<section id="quick-actions" className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Snabbåtgärder</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon
|
||||
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 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'
|
||||
)}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate flex items-center gap-1.5">
|
||||
<Icon className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0',
|
||||
action.accent ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
{action.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate md:block">{action.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* Extension quick actions */}
|
||||
{extensionQuickActions.map((action) => {
|
||||
const Icon = resolveIcon(action.icon)
|
||||
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 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 md:block">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
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 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 md:block">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Resultat — intäkter / kostnader (always visible) */}
|
||||
<section>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
<div className="mt-4 pt-3 border-t border-border/30 flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alerts section */}
|
||||
{/* Att hantera */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section" className="mb-10">
|
||||
<section id="alerts-section">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
|
||||
<div id="alerts-list" className="grid gap-3 md:grid-cols-2">
|
||||
<div id="alerts-list" className="grid gap-4 md:grid-cols-2">
|
||||
{visibleAlerts}
|
||||
</div>
|
||||
{hasMoreAlerts && (
|
||||
@@ -436,104 +359,19 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Upcoming deadlines — always visible */}
|
||||
{/* Upcoming deadlines */}
|
||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<section>
|
||||
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
|
||||
{/* Tax todo */}
|
||||
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
|
||||
<section className="mb-10">
|
||||
<section>
|
||||
<TaxTodoWidget deadlines={summary.deadlines} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Collapsible details section */}
|
||||
<button
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
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' : 'Visa detaljer'}
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showMore && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{showMore && (
|
||||
<div id="details-section">
|
||||
{/* Uncategorized transactions warning */}
|
||||
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
|
||||
<section className="mb-10">
|
||||
<Link href="/transactions?tab=uncategorized" className="group">
|
||||
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} obokförda transaktioner
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
|
||||
)}
|
||||
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
|
||||
{summary.uncategorizedExpenses > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
|
||||
)}
|
||||
{' '}saknas i resultatet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Income/Expenses */}
|
||||
<section className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-[232px] md:flex-col">
|
||||
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/90">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
|
||||
{/* Company switcher */}
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function WelcomeOnboarding({
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logError('create company action threw', { error: message })
|
||||
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
|
||||
toast({ title: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
@@ -93,8 +93,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -83,8 +83,7 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
title: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1652,7 +1652,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
|
||||
await fetchStatus()
|
||||
} catch (err) {
|
||||
toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
|
||||
toast({ title: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
|
||||
}
|
||||
}, [toast, fetchStatus])
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
setCustomers(customersData || [])
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hamta data',
|
||||
title: 'Kunde inte hamta data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -74,8 +73,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte skapa deadline',
|
||||
title: 'Kunde inte skapa deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
throw error
|
||||
@@ -101,8 +99,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
fetchData()
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera deadline',
|
||||
title: 'Kunde inte uppdatera deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,13 +121,13 @@ export default function CustomersEditStep({
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Namn</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-44">Kundtyp</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
|
||||
<th className="px-3 py-2 text-left font-medium">E-post</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left">Namn</th>
|
||||
<th className="px-3 py-2 text-left w-44">Kundtyp</th>
|
||||
<th className="px-3 py-2 text-left w-36">Orgnr</th>
|
||||
<th className="px-3 py-2 text-left">E-post</th>
|
||||
<th className="px-3 py-2 text-left w-32">Status</th>
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -197,10 +197,10 @@ export default function OpeningBalanceColumnMappingStep({
|
||||
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
||||
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
|
||||
{h || `Kolumn ${i + 1}`}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -199,12 +199,12 @@ export default function OpeningBalanceEditStep({
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium w-28">Konto</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Kontonamn</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-32">Debet</th>
|
||||
<th className="px-3 py-2 text-right font-medium w-32">Kredit</th>
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left w-28">Konto</th>
|
||||
<th className="px-3 py-2 text-left">Kontonamn</th>
|
||||
<th className="px-3 py-2 text-right w-32">Debet</th>
|
||||
<th className="px-3 py-2 text-right w-32">Kredit</th>
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -88,10 +88,10 @@ export default function RegisterColumnMappingStep<K extends string>({
|
||||
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
||||
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
|
||||
{h || `Kolumn ${i + 1}`}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -111,13 +111,13 @@ export default function SuppliersEditStep({
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Namn</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-44">Typ</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-36">Orgnr</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-32">Bankgiro/IBAN</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-32">Status</th>
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left">Namn</th>
|
||||
<th className="px-3 py-2 text-left w-44">Typ</th>
|
||||
<th className="px-3 py-2 text-left w-36">Orgnr</th>
|
||||
<th className="px-3 py-2 text-left w-32">Bankgiro/IBAN</th>
|
||||
<th className="px-3 py-2 text-left w-32">Status</th>
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -101,8 +101,8 @@ export function InvoiceReviewContent({
|
||||
{/* Line items — table on desktop, cards on mobile */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-16 text-right">Antal</th>
|
||||
<th className="py-2 w-16 text-center">Enhet</th>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Info, X } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { KPI_DEFINITIONS, getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
@@ -72,7 +71,6 @@ function getValueColor(
|
||||
? 'text-[hsl(var(--chart-1))]'
|
||||
: 'text-[hsl(var(--chart-2))]'
|
||||
}
|
||||
// negative-good (e.g. VAT: negative = refund = good, expense ratio: lower = better)
|
||||
if (colorLogic === 'negative-good') {
|
||||
return value <= 0
|
||||
? 'text-[hsl(var(--chart-1))]'
|
||||
@@ -83,9 +81,7 @@ function getValueColor(
|
||||
|
||||
export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
const prefs = preferences ?? getDefaultPreferences()
|
||||
const [infoOpen, setInfoOpen] = useState<string | null>(null)
|
||||
|
||||
// Build ordered, visible list
|
||||
const visibleDefs = prefs.kpiOrder
|
||||
.map((id) => KPI_DEFINITIONS.find((d) => d.id === id))
|
||||
.filter((d) => d && prefs.visibleKpis.includes(d.id)) as typeof KPI_DEFINITIONS
|
||||
@@ -100,7 +96,6 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Responsive grid: 2 cols on mobile, up to 4 on desktop
|
||||
const gridCols =
|
||||
visibleDefs.length <= 2
|
||||
? 'grid-cols-2'
|
||||
@@ -114,66 +109,51 @@ export function KPIHeroCards({ report, preferences }: KPIHeroCardsProps) {
|
||||
const { value, subtitle } = getKPIValue(report, def.id)
|
||||
const formatted = formatKPIValue(value, def.format, def.id)
|
||||
const color = getValueColor(value, def.colorLogic)
|
||||
const showInfo = infoOpen === def.id
|
||||
const hasOverride =
|
||||
prefs.accountOverrides[def.id] &&
|
||||
prefs.accountOverrides[def.id].length > 0
|
||||
|
||||
return (
|
||||
<Card key={def.id} className="relative">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{def.label}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setInfoOpen(showInfo ? null : def.id)
|
||||
}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors -mt-0.5 -mr-1 p-1"
|
||||
aria-label={`Visa formel för ${def.label}`}
|
||||
>
|
||||
{showInfo ? (
|
||||
<X className="h-3 w-3" />
|
||||
) : (
|
||||
<Info className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
const tooltipContent = (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<p className="text-foreground/90">{def.description}</p>
|
||||
<div>
|
||||
<span className="font-medium">Formel: </span>
|
||||
<span className="font-mono">{def.formula}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Konton: </span>
|
||||
{def.accountDescription}
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<div className="text-primary">
|
||||
<span className="font-medium">Anpassade: </span>
|
||||
<span className="font-mono">
|
||||
{prefs.accountOverrides[def.id].join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
{showInfo ? (
|
||||
<div className="space-y-1.5 text-[11px] text-muted-foreground mt-1">
|
||||
<p className="text-xs text-foreground/80">{def.description}</p>
|
||||
<div>
|
||||
<span className="font-medium">Formel: </span>
|
||||
<span className="font-mono">{def.formula}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Konton: </span>
|
||||
{def.accountDescription}
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<div className="text-primary">
|
||||
<span className="font-medium">Anpassade konton: </span>
|
||||
<span className="font-mono">
|
||||
{prefs.accountOverrides[def.id].join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className={`font-display text-xl tabular-nums tracking-tight ${color}`}
|
||||
>
|
||||
{formatted}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<Card key={def.id}>
|
||||
<CardContent className="p-6">
|
||||
<InfoTooltip
|
||||
content={tooltipContent}
|
||||
side="top"
|
||||
maxWidth="320px"
|
||||
iconClassName="h-3 w-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{def.label}</p>
|
||||
</InfoTooltip>
|
||||
<p
|
||||
className={`font-display text-2xl font-medium tabular-nums tracking-tight mt-2 ${color}`}
|
||||
>
|
||||
{formatted}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function BankIdCompanyPicker({
|
||||
setSetup({ kind: 'opening', companyId })
|
||||
const result = await switchCompany(companyId)
|
||||
if (result.error) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
toast({ title: result.error, variant: 'destructive' })
|
||||
setSetup({ kind: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -368,8 +368,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Transaktion</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
@@ -383,14 +383,14 @@ export function BankReconciliationView() {
|
||||
{dryRunResults.map((m) => (
|
||||
<tr key={m.transaction_id} className="border-b last:border-0">
|
||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
||||
<td className="py-2">{m.transaction_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
|
||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||
</td>
|
||||
<td className="py-2">{m.entry_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{METHOD_LABELS[m.method] || m.method}
|
||||
@@ -422,8 +422,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
@@ -454,7 +454,7 @@ export function BankReconciliationView() {
|
||||
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<option key={line.line_id} value={line.journal_entry_id}>
|
||||
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
|
||||
{line.voucher_series}{line.voucher_number} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -489,8 +489,8 @@ export function BankReconciliationView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-16">Ver.nr</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{line.voucher_series}{line.voucher_number}
|
||||
</td>
|
||||
<td className="py-2">{line.entry_date}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
@@ -544,8 +544,8 @@ export function BankReconciliationView() {
|
||||
{showMatched && (
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface ReportItem {
|
||||
value: string
|
||||
label: string
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
interface ReportCategory {
|
||||
label: string
|
||||
items: ReportItem[]
|
||||
}
|
||||
|
||||
const CATEGORIES: ReportCategory[] = [
|
||||
{
|
||||
label: 'Löpande',
|
||||
items: [
|
||||
{ value: 'resultatrapport', label: 'Resultatrapport' },
|
||||
{ value: 'balansrapport', label: 'Balansrapport' },
|
||||
{ value: 'trial-balance', label: 'Saldobalans' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bokslut',
|
||||
items: [
|
||||
{ value: 'income-statement', label: 'Resultaträkning' },
|
||||
{ value: 'balance-sheet', label: 'Balansräkning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Skatt & moms',
|
||||
items: [
|
||||
{ value: 'vat-declaration', label: 'Momsdeklaration' },
|
||||
{ value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' },
|
||||
{ value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Huvudböcker',
|
||||
items: [
|
||||
{ value: 'huvudbok', label: 'Huvudbok' },
|
||||
{ value: 'grundbok', label: 'Grundbok' },
|
||||
{ value: 'kundreskontra', label: 'Kundreskontra' },
|
||||
{ value: 'supplier-ledger', label: 'Leverantörsreskontra' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Avstämning',
|
||||
items: [
|
||||
{ value: 'bank-reconciliation', label: 'Bankavstämning' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface ReportsNavProps {
|
||||
active: string
|
||||
onChange: (value: string) => void
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
export function ReportsNav({ active, onChange, entityType }: ReportsNavProps) {
|
||||
const filtered = CATEGORIES
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
items: cat.items.filter(item => !item.entityType || item.entityType === entityType),
|
||||
}))
|
||||
.filter(cat => cat.items.length > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: grouped select */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={active} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filtered.map(cat => (
|
||||
<SelectGroup key={cat.label}>
|
||||
<SelectLabel>{cat.label}</SelectLabel>
|
||||
{cat.items.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: vertical left rail */}
|
||||
<nav
|
||||
className="hidden sm:block w-56 flex-shrink-0 sticky top-8 self-start"
|
||||
aria-label="Rapportkategorier"
|
||||
>
|
||||
<ul className="space-y-6">
|
||||
{filtered.map(cat => (
|
||||
<li key={cat.label}>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-[0.08em] mb-2 px-3">
|
||||
{cat.label}
|
||||
</p>
|
||||
<ul className="space-y-px">
|
||||
{cat.items.map(item => {
|
||||
const isActive = active === item.value
|
||||
return (
|
||||
<li key={item.value}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-1.5 rounded-md text-[13px] transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function TaxPaymentPanel({
|
||||
if (!res.ok) {
|
||||
const result = await res.json().catch(() => ({ error: 'Kunde inte markera som betald' }))
|
||||
toast({
|
||||
title: 'Fel',
|
||||
title: 'Kunde inte markera som betald',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
|
||||
@@ -168,7 +168,7 @@ export function ApiKeysPanel() {
|
||||
setKeys(json.data.filter((k: ApiKey) => !k.revoked_at))
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte hämta API-nycklar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export function ApiKeysPanel() {
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: json.error, variant: 'destructive' })
|
||||
toast({ title: json.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ export function ApiKeysPanel() {
|
||||
setNewKeyScopes(new Set(ALL_SCOPES))
|
||||
fetchKeys()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte skapa nyckel', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte skapa nyckel', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export function ApiKeysPanel() {
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id))
|
||||
toast({ title: 'Nyckel återkallad' })
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte återkalla nyckel', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte återkalla nyckel', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
|
||||
interface BankIdIdentity {
|
||||
given_name: string | null
|
||||
@@ -122,7 +123,7 @@ export function BankIdSettings() {
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')}
|
||||
Kopplat {formatDateLong(identity.linked_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -44,7 +44,7 @@ export function BookingTemplatesPanel() {
|
||||
const json = await res.json()
|
||||
if (json.data) setTemplates(json.data)
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function BookingTemplatesPanel() {
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
@@ -82,7 +82,7 @@ export function BookingTemplatesPanel() {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte exportera mallar', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte exportera mallar', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,8 +304,8 @@ function TemplateSection({
|
||||
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 w-14">Konto</th>
|
||||
<th className="text-left py-1">Beskrivning</th>
|
||||
<th className="text-center py-1 w-16">Typ</th>
|
||||
@@ -384,7 +384,7 @@ function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
toast({ title: 'Fel', description: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
|
||||
toast({ title: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: 'Mall skapad' })
|
||||
|
||||
@@ -62,8 +62,7 @@ export function CalendarFeedSettings() {
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte skapa kalenderfeed.',
|
||||
title: 'Kunde inte skapa kalenderfeed.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -91,8 +90,7 @@ export function CalendarFeedSettings() {
|
||||
setFeed(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera inställning.',
|
||||
title: 'Kunde inte uppdatera inställning.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -129,8 +127,7 @@ export function CalendarFeedSettings() {
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte skapa ny länk.',
|
||||
title: 'Kunde inte skapa ny länk.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -149,8 +146,7 @@ export function CalendarFeedSettings() {
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte kopiera länken.',
|
||||
title: 'Kunde inte kopiera länken.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { Loader2, Plus, Trash2, Mail, Clock, Users } from 'lucide-react'
|
||||
|
||||
interface CompanyMemberItem {
|
||||
@@ -85,7 +86,7 @@ export function CompanyMembersSection() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
|
||||
toast({ title: data.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ export function CompanyMembersSection() {
|
||||
setInviteRole('viewer')
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte skicka inbjudan.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
@@ -115,14 +116,14 @@ export function CompanyMembersSection() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
|
||||
toast({ title: data.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Medlem borttagen' })
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort medlem.', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte ta bort medlem.', variant: 'destructive' })
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
@@ -135,14 +136,14 @@ export function CompanyMembersSection() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: data.error, variant: 'destructive' })
|
||||
toast({ title: data.error, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Inbjudan återkallad' })
|
||||
fetchMembers()
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte återkalla inbjudan.', variant: 'destructive' })
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
@@ -286,7 +287,7 @@ export function CompanyMembersSection() {
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
Går ut {new Date(inv.expires_at).toLocaleDateString('sv-SE')}
|
||||
Går ut {formatDateLong(inv.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function CounterpartyTemplatesPanel() {
|
||||
setTemplates(json.data)
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -76,14 +76,14 @@ export function CounterpartyTemplatesPanel() {
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (expandedId === id) setExpandedId(null)
|
||||
toast({ title: 'Mall borttagen' })
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
toast({ title: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function SettingsLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface VoucherSeries {
|
||||
@@ -52,8 +53,8 @@ export function VoucherSeriesManager({ defaultSeries }: VoucherSeriesManagerProp
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
) : seriesEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -204,8 +204,8 @@ export function SupplierInvoiceReviewContent({
|
||||
{/* Line items — table on desktop, cards on mobile */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
@@ -277,8 +277,8 @@ export function SupplierInvoiceReviewContent({
|
||||
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground text-xs">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="pb-1 w-16">Konto</th>
|
||||
<th className="pb-1">Beskrivning</th>
|
||||
<th className="pb-1 w-24 text-right">Debet</th>
|
||||
|
||||
@@ -244,9 +244,9 @@ export default function TransactionInboxCard({
|
||||
{/* Delete button — available for all unbooked transactions */}
|
||||
{isDeletable && onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 w-9 p-0 ml-auto text-muted-foreground hover:text-destructive"
|
||||
size="icon"
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(transaction.id)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
aria-label="Ta bort transaktion"
|
||||
|
||||
@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -86,7 +86,7 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -237,4 +237,57 @@ describe('delete_last_voucher.pg — RPC + immutability trigger interaction', ()
|
||||
),
|
||||
).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/)
|
||||
})
|
||||
|
||||
// Drafts (voucher_number=0, never committed) can arise as orphans when a
|
||||
// mark-paid or similar engine flow fails between draft creation and commit.
|
||||
// They are not part of the verifikationsserie under BFL and must be
|
||||
// deletable so users can clean up their books.
|
||||
it('deletes a draft entry without touching the voucher series', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const draftId = await insertDraftJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
})
|
||||
await insertBalancedLines(draftId)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const result = await client.query<{ delete_last_voucher: { deleted: boolean; was_draft: boolean } }>(
|
||||
`SELECT public.delete_last_voucher($1::uuid, $2::uuid)`,
|
||||
[companyId, draftId],
|
||||
)
|
||||
expect(result.rows[0]!.delete_last_voucher.deleted).toBe(true)
|
||||
expect(result.rows[0]!.delete_last_voucher.was_draft).toBe(true)
|
||||
|
||||
const after = await client.query(
|
||||
`SELECT 1 FROM public.journal_entries WHERE id = $1`,
|
||||
[draftId],
|
||||
)
|
||||
expect(after.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a draft even when the fiscal period is locked', async () => {
|
||||
// Drafts are not bokförda — period locks (which protect committed entries)
|
||||
// do not need to block draft cleanup.
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const draftId = await insertDraftJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
})
|
||||
await insertBalancedLines(draftId)
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query(
|
||||
`SELECT public.delete_last_voucher($1::uuid, $2::uuid)`,
|
||||
[companyId, draftId],
|
||||
)
|
||||
const after = await client.query(
|
||||
`SELECT 1 FROM public.journal_entries WHERE id = $1`,
|
||||
[draftId],
|
||||
)
|
||||
expect(after.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+19
-2
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { format as formatDateFns } from "date-fns"
|
||||
import { format as formatDateFns, parseISO } from "date-fns"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -16,10 +16,27 @@ export function formatCurrency(amount: number, currency: string = 'SEK'): string
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
// parseISO interprets bare 'yyyy-MM-dd' as local midnight, not UTC midnight.
|
||||
// Using new Date() would shift the displayed day by one in timezones west of
|
||||
// UTC for bare date strings — that's an off-by-one we don't want for
|
||||
// accounting data.
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
return formatDateFns(d, 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-form Swedish date for metadata/audit contexts (e.g. "9 maj 2026").
|
||||
* Use formatDate for transaction/voucher/invoice dates that need to align in tables.
|
||||
*/
|
||||
export function formatDateLong(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
return d.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatOrgNumber(orgNumber: string): string {
|
||||
// Format Swedish org number: XXXXXX-XXXX
|
||||
const cleaned = orgNumber.replace(/\D/g, '')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Allow delete_last_voucher RPC to delete draft entries.
|
||||
--
|
||||
-- Background: createJournalEntry does createDraft + commit atomically. If
|
||||
-- commit fails the orphan draft is now cancelled (commit 28df5d85), but
|
||||
-- legacy drafts created before that fix remain stuck — the previous RPC
|
||||
-- only accepted status='posted'. There is no UI path to remove them.
|
||||
--
|
||||
-- Drafts are not part of the verifikationsserie (voucher_number = 0,
|
||||
-- never assigned a sequence number) and are not bokförda under BFL.
|
||||
-- Deletion of a draft does not affect the audit trail and does not
|
||||
-- require the same protections as posted entries.
|
||||
--
|
||||
-- For drafts we skip:
|
||||
-- * last-in-series check (drafts have no number)
|
||||
-- * voucher_sequences row update (no number was issued)
|
||||
-- * reverses_id un-reversal (drafts cannot be reversed)
|
||||
-- * fiscal-period closed/locked check (drafts are not posted)
|
||||
--
|
||||
-- We keep:
|
||||
-- * owner/admin role gate
|
||||
-- * audit_log entry
|
||||
-- * gnubok.allow_delete flag so the line-immutability trigger lets
|
||||
-- the cascade delete its rows
|
||||
--
|
||||
-- For posted entries the existing logic is unchanged.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_entry record;
|
||||
v_period record;
|
||||
v_max_voucher integer;
|
||||
v_ref_count integer;
|
||||
v_caller_role text;
|
||||
v_snapshot jsonb;
|
||||
v_lines_snapshot jsonb;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_entry
|
||||
FROM journal_entries
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_entry IS NULL THEN
|
||||
RAISE EXCEPTION 'Journal entry not found';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status NOT IN ('posted', 'draft') THEN
|
||||
RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status;
|
||||
END IF;
|
||||
|
||||
-- Snapshot for audit log (same shape for draft and posted)
|
||||
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
|
||||
FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_entry_id;
|
||||
|
||||
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
|
||||
|
||||
-- Draft path: simplified deletion (no series, no period checks needed)
|
||||
IF v_entry.status = 'draft' THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number,
|
||||
'was_draft', true
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Posted path: existing logic unchanged
|
||||
SELECT * INTO v_period
|
||||
FROM fiscal_periods
|
||||
WHERE id = v_entry.fiscal_period_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_period.is_closed THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
|
||||
END IF;
|
||||
|
||||
IF v_period.locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
|
||||
END IF;
|
||||
|
||||
PERFORM 1 FROM voucher_sequences
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT MAX(voucher_number) INTO v_max_voucher
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
AND status NOT IN ('cancelled', 'draft');
|
||||
|
||||
IF v_entry.voucher_number != v_max_voucher THEN
|
||||
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
|
||||
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_ref_count
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND status != 'cancelled'
|
||||
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
|
||||
|
||||
IF v_ref_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
|
||||
v_ref_count;
|
||||
END IF;
|
||||
|
||||
IF v_entry.reverses_id IS NOT NULL THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
UPDATE journal_entries
|
||||
SET status = 'posted', reversed_by_id = NULL
|
||||
WHERE id = v_entry.reverses_id
|
||||
AND company_id = p_company_id;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
UPDATE voucher_sequences
|
||||
SET last_number = GREATEST(last_number - 1, 0)
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series;
|
||||
|
||||
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
|
||||
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user