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:
Jakob Wennberg
2026-05-11 16:34:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ed9bdc4c00
commit d0fbc2b616
80 changed files with 1491 additions and 1391 deletions
+38 -28
View File
@@ -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>
+15 -20
View File
@@ -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>
+3 -3
View File
@@ -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">
+39 -43
View File
@@ -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 &quot;{searchTerm}&quot;
</p>
</div>
<EmptyState
icon={Users}
title="Inga träffar"
description={`Inga kunder matchar "${searchTerm}".`}
/>
) : (
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
)}
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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>
+6 -9
View File
@@ -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>
+2 -2
View File
@@ -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>
+13 -7
View File
@@ -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' && (
+44 -76
View File
@@ -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 &quot;{searchTerm}&quot;
</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">
+29 -81
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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) => (
-6
View File
@@ -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,
+3 -3
View File
@@ -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>
+65 -197
View File
@@ -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>
+44 -44
View File
@@ -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>
)}
+79 -81
View File
@@ -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>
+58 -50
View File
@@ -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>
+4 -4
View File
@@ -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>
+1 -1
View File
@@ -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" />
+3 -7
View File
@@ -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>
)
+1 -1
View File
@@ -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>
+60 -63
View File
@@ -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>
)
}
+23 -21
View File
@@ -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">
+18 -10
View File
@@ -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}
+74 -76
View File
@@ -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>
)}
+43 -40
View File
@@ -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) && (
+17 -31
View File
@@ -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 &quot;{searchTerm}&quot;
</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}
+4 -5
View File
@@ -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>
</>
)}
+23 -4
View File
@@ -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 })
+7
View File
@@ -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 */