feat: mobile UX improvements across dashboard (#9)

* feat: mobile UX improvements across dashboard pages

- Responsive layouts with stacked mobile forms and grid desktop views
- Safe area handling (viewportFit cover, bottom nav insets, main padding)
- Touch-friendly dialogs, tabs, and page headers
- Mobile nav drawer open/close animation
- Dashboard layout parallel Supabase queries
- SIE parser: handle quoted VER/TRANS fields
- Skip account override for template-based transaction booking

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

* fix: address PR review feedback from Greptile

- Fix nav drawer close timer race condition with useRef + clearTimeout
- Show locked VAT rate as read-only label on mobile instead of hiding it
- Remove dead bottom-16 class overridden by inline style

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-11 15:36:52 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 109f860e22
commit a1ca96453c
47 changed files with 1628 additions and 738 deletions
+52
View File
@@ -0,0 +1,52 @@
---
name: Mobile UX Core
description: Universal mobile UX principles for touch-first interfaces. Enforces touch targets, safe areas, and mobile-specific interaction patterns.
metadata:
labels: [mobile, ux, design, accessibility, cross-platform]
triggers:
files:
[
'**/*_page.dart',
'**/*_screen.dart',
'**/*_view.dart',
'**/*.swift',
'**/*Activity.kt',
'**/*Screen.tsx',
]
keywords: [mobile, responsive, SafeArea, touch, gesture, viewport]
---
# Mobile UX Core
## **Priority: P0 (CRITICAL)**
Universal UX principles for mobile applications.
## Guidelines
- **Touch Targets**: Min 44x44pt (iOS) / 48x48dp (Android). Add padding if needed.
- **Safe Areas**: Wrap content in `SafeArea`/`WindowInsets`. Avoid notches.
- **Interactions**: Use active states (no hover). Haptic feedback (short).
- **Typography**: Min 16sp body. Line height 1.5x.
- **Keyboards**: Auto-scroll inputs. Set `InputType` (email/number) & `Action`.
## Code Examples
```dart
// ✅ Correct Target
IconButton(icon: Icon(Icons.close), padding: EdgeInsets.all(12))
// ❌ Too Small
Icon(Icons.close, size: 16)
```
## Anti-Patterns
- **No Hover Effects**: Mobile has no cursor. Use pressed states.
- **No Tiny Targets**: All clickable elements ≥44pt.
- **No Fixed Bottom**: Account for Home Indicator & Keyboard.
- **No OS Mix**: Use Material (Android) & Cupertino (iOS) conventions.
## Related Topics
mobile-accessibility | mobile-performance | flutter-design-system | react-native-dls
+3 -3
View File
@@ -14,14 +14,14 @@ export default function BookkeepingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Bokföring</h1>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Bokföring</h1>
<p className="text-muted-foreground">
Verifikationer, kontoplan och manuella bokföringsorder
</p>
</div>
<Button variant="outline" asChild>
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
Årsbokslut
+1 -1
View File
@@ -364,7 +364,7 @@ export default function CustomerDetailPage({
{/* Edit dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Redigera kund</DialogTitle>
</DialogHeader>
+3 -3
View File
@@ -107,9 +107,9 @@ export default function CustomersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kunder</h1>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Kunder</h1>
<p className="text-muted-foreground">
Hantera dina kunder och deras faktureringsuppgifter
</p>
@@ -121,7 +121,7 @@ export default function CustomersPage() {
Ny kund
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till kund</DialogTitle>
</DialogHeader>
+29 -3
View File
@@ -190,9 +190,35 @@ export default function DeadlinesPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
{/* Overdue alert skeleton */}
<div className="rounded-lg border p-4 animate-pulse">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-5 w-5 bg-muted rounded" />
<div className="space-y-1">
<div className="h-4 bg-muted rounded w-32" />
<div className="h-3 bg-muted rounded w-24" />
</div>
</div>
<div className="h-5 bg-muted rounded w-8" />
</div>
</div>
{/* Deadline list skeleton */}
<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 justify-between">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-48" />
<div className="h-3 bg-muted rounded w-32" />
</div>
<div className="flex items-center gap-2">
<div className="h-5 bg-muted rounded w-16" />
<div className="h-8 bg-muted rounded w-8" />
</div>
</div>
</div>
))}
</div>
</div>
)
+35 -18
View File
@@ -356,14 +356,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<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()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Proforma</Badge>
)}
@@ -383,7 +383,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{isProforma && invoice.status !== 'cancelled' && (
<Button onClick={convertToInvoice} disabled={isConverting}>
{isConverting ? (
@@ -492,8 +492,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header */}
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
{/* Header — desktop */}
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
@@ -501,20 +501,37 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div className="col-span-2 text-right">Summa</div>
</div>
{/* Items */}
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
{/* Items — desktop */}
<div className="hidden sm:block space-y-4">
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</div>
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
))}
</div>
{/* Items — mobile cards */}
<div className="sm:hidden space-y-2">
{invoice.items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
</div>
<p className="text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</p>
</div>
</div>
))}
))}
</div>
<Separator />
+216 -89
View File
@@ -422,99 +422,213 @@ export default function NewInvoicePage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="grid gap-4 md:grid-cols-12 items-start">
<div className="md:col-span-3 space-y-2">
<Label>Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive">
{errors.items[index].description?.message}
</p>
)}
</div>
<div className="md:col-span-2 space-y-2">
<Label>Antal</Label>
<Input
type="number"
step="0.01"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Enhet</Label>
<Controller
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>à-pris</Label>
<Input
type="number"
step="0.01"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Moms</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
{fields.map((field, index) => {
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
return (
<div key={field.id}>
{/* Desktop: 12-col grid */}
<div className="hidden md:grid gap-4 md:grid-cols-12 items-start">
<div className="md:col-span-3 space-y-2">
<Label>Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive">
{errors.items[index].description?.message}
</p>
)}
</div>
<div className="md:col-span-2 space-y-2">
<Label>Antal</Label>
<Input
type="number"
step="0.01"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Enhet</Label>
<Controller
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>à-pris</Label>
<Input
type="number"
step="any"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Moms</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="md:col-span-1 flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Mobile: compact card layout */}
<div className="md:hidden rounded-lg border bg-card p-4 space-y-3 relative">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Label className="text-xs text-muted-foreground">Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive">
{errors.items[index].description?.message}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Antal</Label>
<Input
type="number"
step="0.01"
inputMode="decimal"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Enhet</Label>
<Controller
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">à-pris</Label>
<Input
type="number"
step="any"
inputMode="decimal"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
</div>
{isRateLocked ? (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Moms</Label>
<p className="text-sm py-2">
{availableRates.find(r => r.rate === (watchItems[index]?.vat_rate ?? 25))?.label ?? `${watchItems[index]?.vat_rate ?? 25}%`}
</p>
</div>
) : (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Moms</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
/>
<div className="flex justify-between text-sm pt-1 border-t border-border/40">
<span className="text-muted-foreground">Rad {index + 1}</span>
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
</div>
</div>
</div>
<div className="md:col-span-1 flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
)
})}
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() =>
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
}
@@ -651,8 +765,21 @@ export default function NewInvoicePage() {
</CardContent>
</Card>
{/* Actions */}
<Button type="submit" className="w-full" size="lg" disabled={isSubmitting}>
{/* Actions — desktop only */}
<Button type="submit" className="w-full hidden lg:block" size="lg" disabled={isSubmitting}>
Granska & skapa
</Button>
</div>
</div>
{/* Mobile sticky total bar */}
<div className="lg:hidden fixed left-0 right-0 z-40 bg-card/98 backdrop-blur-sm border-t border-border/40 px-5 py-3" style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}>
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
<div>
<p className="text-xs text-muted-foreground">Totalt</p>
<p className="text-lg font-bold tabular-nums">{formatCurrency(total, watchCurrency)}</p>
</div>
<Button type="submit" disabled={isSubmitting}>
Granska & skapa
</Button>
</div>
@@ -708,7 +835,7 @@ export default function NewInvoicePage() {
{/* Create customer dialog */}
<Dialog open={isCreateCustomerOpen} onOpenChange={setIsCreateCustomerOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till kund</DialogTitle>
</DialogHeader>
+103 -87
View File
@@ -119,52 +119,72 @@ export default function InvoicesPage() {
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Obetalda</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold tabular-nums">{stats.unpaid}</p>
{stats.overdue > 0 && (
<Badge variant="destructive" className="text-xs">
{stats.overdue} förfallna
</Badge>
)}
{isLoading ? (
<>
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted animate-pulse" />
<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>
</div>
</CardContent>
</Card>
))}
</>
) : (
<>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Send className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Att få in</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Obetalda</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold tabular-nums">{stats.unpaid}</p>
{stats.overdue > 0 && (
<Badge variant="destructive" className="text-xs">
{stats.overdue} förfallna
</Badge>
)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Send className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Att få in</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
</div>
</div>
</CardContent>
</Card>
</>
)}
</div>
{/* Search and tabs */}
@@ -248,51 +268,47 @@ export default function InvoicesPage() {
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${borderClass} ${invoice.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${isCreditNote ? 'bg-destructive/10' : 'bg-muted'}`}>
<StatusIcon className={`h-5 w-5 ${isCreditNote ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{invoice.invoice_number}</p>
{isCreditNote && (
<Badge variant="destructive" className="text-xs">
Kredit
</Badge>
)}
{isProforma && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
Proforma
</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
Följesedel
</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{(invoice.customer as { name: string })?.name} · {formatDate(invoice.invoice_date)}
</p>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
{relativeTime.text}
</span>
)}
</div>
</div>
<div className="flex items-start gap-3 sm:gap-4 sm:items-center">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${isCreditNote ? 'bg-destructive/10' : 'bg-muted'}`}>
<StatusIcon className={`h-5 w-5 ${isCreditNote ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div className="text-right">
<p className={`font-medium tabular-nums ${isCreditNote ? 'text-destructive' : ''}`}>
{formatCurrency(Number(invoice.total), invoice.currency)}
<div className="flex-1 min-w-0">
<div className="flex items-start sm:items-center justify-between gap-2">
<p className="font-medium truncate">{invoice.invoice_number}</p>
<p className={`font-medium tabular-nums shrink-0 ${isCreditNote ? 'text-destructive' : ''}`}>
{formatCurrency(Number(invoice.total), invoice.currency)}
</p>
</div>
<p className="text-sm text-muted-foreground truncate">
{(invoice.customer as { name: string })?.name} · {formatDate(invoice.invoice_date)}
</p>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{isCreditNote && (
<Badge variant="destructive" className="text-xs">
Kredit
</Badge>
)}
{isProforma && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
Proforma
</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
Följesedel
</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
{relativeTime.text}
</span>
)}
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<p className={`text-sm tabular-nums ${isCreditNote ? 'text-destructive/70' : 'text-muted-foreground'}`}>
<p className={`text-xs tabular-nums mt-0.5 ${isCreditNote ? 'text-destructive/70' : 'text-muted-foreground'}`}>
{formatCurrency(Number(invoice.total_sek))}
</p>
)}
+13 -12
View File
@@ -18,11 +18,18 @@ export default async function DashboardLayout({
redirect('/login')
}
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('user_id', user.id)
.single()
const [{ data: settings }, { count: uncategorizedCount }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('user_id', user.id)
.single(),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null),
])
if (!settings?.onboarding_complete) {
redirect('/onboarding')
@@ -30,12 +37,6 @@ export default async function DashboardLayout({
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const { count: uncategorizedCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null)
const isSandbox = settings.is_sandbox === true
return (
@@ -54,7 +55,7 @@ export default async function DashboardLayout({
uncategorizedTransactionCount={uncategorizedCount ?? 0}
isSandbox={isSandbox}
/>
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
+85 -22
View File
@@ -33,6 +33,7 @@ export default function ReportsPage() {
const [selectedPeriod, setSelectedPeriod] = useState('')
const [activeTab, setActiveTab] = useState('trial-balance')
const [entityType, setEntityType] = useState<string | null>(null)
const [isLoadingInit, setIsLoadingInit] = useState(true)
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
@@ -56,8 +57,9 @@ export default function ReportsPage() {
}
useEffect(() => {
fetchPeriods()
fetchEntityType()
Promise.all([fetchPeriods(), fetchEntityType()]).finally(() => {
setIsLoadingInit(false)
})
}, [])
const isEnskildFirma = entityType === 'enskild_firma'
@@ -65,16 +67,45 @@ export default function ReportsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rapporter</h1>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Rapporter</h1>
<p className="text-muted-foreground">
Huvudbok, grundbok, kundreskontra, saldobalans, resultaträkning, balansräkning, momsdeklaration och mer
</p>
</div>
</div>
<div className="flex items-end gap-4">
{isLoadingInit ? (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<div>
<div className="h-4 bg-muted rounded w-24 animate-pulse mb-1" />
<div className="h-10 bg-muted rounded w-64 animate-pulse" />
</div>
</div>
<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" />
<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" />
</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" />
</CardContent>
</Card>
</div>
) : (
<>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<div>
<Label>Räkenskapsår</Label>
<select
@@ -104,7 +135,37 @@ export default function ReportsPage() {
{selectedPeriod ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-2">
{/* Mobile: compact select dropdown */}
<div className="sm:hidden mb-2">
<select
value={activeTab}
onChange={(e) => setActiveTab(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<optgroup label="Bokslut">
<option value="trial-balance">Saldobalans</option>
<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: grid tab navigation */}
<div className="hidden sm:grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-2">
{/* Bokslut (Financial Statements) */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
@@ -224,6 +285,8 @@ export default function ReportsPage() {
</CardContent>
</Card>
)}
</>
)}
</div>
)
}
@@ -303,7 +366,7 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
</div>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">Konto</th>
@@ -351,7 +414,7 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
</td>
</tr>
</tfoot>
</table>
</table></div>
</CardContent>
</Card>
</div>
@@ -635,7 +698,7 @@ function ReportSectionTable({
{sections.map((section) => (
<div key={section.title}>
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
<table className="w-full text-sm">
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
<tbody>
{section.rows.map((row) => (
<tr key={row.account_number} className="border-b last:border-0">
@@ -647,7 +710,7 @@ function ReportSectionTable({
</tr>
))}
</tbody>
</table>
</table></div>
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
<span>{section.title}</span>
<span>
@@ -838,7 +901,7 @@ function VatDeclarationView() {
{/* Utgående moms */}
<div>
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
<table className="w-full text-sm">
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
<tbody>
{data.rutor.ruta05 > 0 && (
<tr className="border-b">
@@ -890,13 +953,13 @@ function VatDeclarationView() {
</td>
</tr>
</tfoot>
</table>
</table></div>
</div>
{/* Ingående moms */}
<div>
<h4 className="font-semibold mb-3">Ingående moms (avdragsgill)</h4>
<table className="w-full text-sm">
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
<tbody>
<tr className="border-b">
<td className="py-2">
@@ -928,7 +991,7 @@ function VatDeclarationView() {
<td className="py-2 text-right">{formatAmount(data.rutor.ruta48)} kr</td>
</tr>
</tfoot>
</table>
</table></div>
</div>
</div>
@@ -1126,7 +1189,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<CardTitle>Ålderfördelning per leverantör</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<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">
<th className="py-2">Leverantör</th>
@@ -1162,7 +1225,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table>
</table></div>
</CardContent>
</Card>
)}
@@ -1343,7 +1406,7 @@ function GeneralLedgerView({ periodId }: { periodId: string }) {
</div>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<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">
<th className="py-2 w-16">Ver.nr</th>
@@ -1380,7 +1443,7 @@ function GeneralLedgerView({ periodId }: { periodId: string }) {
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
</tr>
</tfoot>
</table>
</table></div>
</CardContent>
</Card>
))}
@@ -1498,7 +1561,7 @@ function JournalRegisterView({ periodId }: { periodId: string }) {
<CardTitle>Grundbok (registreringsordning)</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<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">
<th className="py-2 w-8"></th>
@@ -1568,7 +1631,7 @@ function JournalRegisterView({ periodId }: { periodId: string }) {
<td className="py-2 text-right">{formatAmount(data.total_credit)}</td>
</tr>
</tfoot>
</table>
</table></div>
</CardContent>
</Card>
</div>
@@ -1724,7 +1787,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<CardTitle>Ålderfördelning per kund</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<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">
<th className="py-2 w-8"></th>
@@ -1797,7 +1860,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table>
</table></div>
</CardContent>
</Card>
)}
+38 -2
View File
@@ -241,8 +241,44 @@ export default function SettingsPage() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="space-y-6">
<div>
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="h-4 bg-muted rounded w-72 mt-2 animate-pulse" />
</div>
<div className="h-10 bg-muted rounded w-96 animate-pulse" />
<Card>
<CardHeader>
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
<div className="h-4 bg-muted rounded w-56 mt-1 animate-pulse" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-16 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
</CardContent>
</Card>
</div>
)
}
+101 -60
View File
@@ -165,28 +165,28 @@ export default function SupplierInvoiceDetailPage() {
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
<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')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
Ankomst #{invoice.arrival_number}
</h1>
<Badge className={statusColors[invoice.status] || ''}>
{statusLabels[invoice.status] || invoice.status}
</Badge>
</div>
<p className="text-muted-foreground">
<p className="text-muted-foreground text-sm sm:text-base truncate">
{invoice.supplier?.name} | Faktura {invoice.supplier_invoice_number}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{invoice.status === 'registered' && (
<>
<Button onClick={handleApprove} disabled={isProcessing}>
@@ -311,34 +311,53 @@ export default function SupplierInvoiceDetailPage() {
<CardTitle className="text-lg">Rader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-16 text-right">Antal</th>
<th className="pb-2 w-16">Enhet</th>
<th className="pb-2 w-28 text-right">À-pris</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
{/* 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="pb-2">Beskrivning</th>
<th className="pb-2 w-16 text-right">Antal</th>
<th className="pb-2 w-16">Enhet</th>
<th className="pb-2 w-28 text-right">À-pris</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 space-y-1.5">
<div className="font-medium text-sm">{item.description}</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatAmount(item.unit_price)}</span>
<span className="font-mono">{formatAmount(item.line_total)} kr</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span><AccountNumber number={item.account_number} /> · {Math.round(item.vat_rate * 100)}% moms</span>
<span className="font-mono">moms {formatAmount(item.vat_amount)}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
@@ -349,32 +368,54 @@ export default function SupplierInvoiceDetailPage() {
<CardTitle className="text-lg">Betalningshistorik</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
</thead>
<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 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
{/* 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="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
))}
</tbody>
</table>
</thead>
<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 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{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="font-mono font-medium">{formatAmount(p.amount)} {p.currency}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : <span>-</span>}
<span>{p.notes || ''}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
+123 -46
View File
@@ -287,7 +287,7 @@ export default function NewSupplierInvoicePage() {
<CardTitle className="text-lg">Datum</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fakturadatum *</Label>
<Input type="date" {...register('invoice_date')} />
@@ -383,21 +383,97 @@ export default function NewSupplierInvoicePage() {
</Button>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
{/* 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="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</td>
<td className="py-2 pr-2">
<Input
type="number"
step="0.01"
placeholder="0,00"
{...register(`items.${index}.amount`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="border rounded-lg p-3 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<Controller
name={`items.${index}.account_number`}
control={control}
@@ -409,22 +485,35 @@ export default function NewSupplierInvoicePage() {
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</td>
<td className="py-2 pr-2">
</div>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Belopp (exkl.)</label>
<Input
type="number"
step="0.01"
placeholder="0,00"
{...register(`items.${index}.amount`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Momssats</label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
@@ -445,26 +534,14 @@ export default function NewSupplierInvoicePage() {
</Select>
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="text-right text-sm text-muted-foreground">
Moms: <span className="font-mono">{formatAmount(itemTotals[index]?.vatAmount || 0)} kr</span>
</div>
</div>
))}
</div>
{/* Totals */}
<div className="mt-4 pt-4 border-t space-y-2 text-right">
+72 -41
View File
@@ -71,9 +71,9 @@ export default function SupplierInvoicesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Leverantörsfakturor</h1>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Leverantörsfakturor</h1>
<p className="text-muted-foreground">
Registrera och hantera inkommande fakturor
</p>
@@ -88,43 +88,61 @@ export default function SupplierInvoicesPage() {
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<FileInput className="h-4 w-4" />
Totalt obetalt
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(totalUnpaid)} kr</p>
<p className="text-xs text-muted-foreground">
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
Förfallet
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{formatAmount(overdueAmount)} kr</p>
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Antal fakturor
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{invoices.length}</p>
</CardContent>
</Card>
{isLoading ? (
<>
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
</CardHeader>
<CardContent className="space-y-2">
<div className="h-7 bg-muted rounded w-32 animate-pulse" />
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
</CardContent>
</Card>
))}
</>
) : (
<>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<FileInput className="h-4 w-4" />
Totalt obetalt
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(totalUnpaid)} kr</p>
<p className="text-xs text-muted-foreground">
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
Förfallet
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-destructive">{formatAmount(overdueAmount)} kr</p>
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Antal fakturor
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{invoices.length}</p>
</CardContent>
</Card>
</>
)}
</div>
{/* Tabs */}
@@ -140,8 +158,21 @@ export default function SupplierInvoicesPage() {
<TabsContent value={activeTab}>
{isLoading ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar fakturor...
<CardContent className="p-0">
<div className="p-3 border-b">
<div className="h-4 bg-muted rounded w-full animate-pulse" />
</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" />
</div>
))}
</CardContent>
</Card>
) : filteredInvoices.length === 0 ? (
+63 -34
View File
@@ -238,40 +238,69 @@ export default function SupplierDetailPage() {
Inga fakturor registrerade för denna leverantör
</p>
) : (
<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>
{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">
<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] || ''}>
{statusLabels[inv.status] || inv.status}
</Badge>
</td>
<>
{/* 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>
))}
</tbody>
</table>
</thead>
<tbody>
{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">
<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] || ''}>
{statusLabels[inv.status] || inv.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{invoices.map((inv) => (
<div key={inv.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<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] || ''}>
{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="font-mono">{formatAmount(inv.total)} kr</span>
</div>
{Number(inv.remaining_amount) > 0 && Number(inv.remaining_amount) !== Number(inv.total) && (
<div className="text-xs text-muted-foreground text-right">
Kvar: {formatAmount(inv.remaining_amount)} kr
</div>
)}
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
@@ -280,7 +309,7 @@ export default function SupplierDetailPage() {
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Redigera leverantör</DialogTitle>
</DialogHeader>
+3 -3
View File
@@ -95,9 +95,9 @@ export default function SuppliersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Leverantörer</h1>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Leverantörer</h1>
<p className="text-muted-foreground">
Hantera dina leverantörer och deras betalningsuppgifter
</p>
@@ -109,7 +109,7 @@ export default function SuppliersPage() {
Ny leverantör
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till leverantör</DialogTitle>
</DialogHeader>
@@ -203,8 +203,8 @@ export async function POST(
vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`),
})
// Apply account override if provided (only for business transactions)
if (is_business && body.account_override) {
// Apply account override if provided (only for category-based booking, not templates)
if (is_business && body.account_override && !body.template_id) {
// Validate the account exists in the user's chart of accounts
const { data: accountExists } = await supabase
.from('chart_of_accounts')
+5
View File
@@ -194,6 +194,11 @@ h1, h2, h3 {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
}
/* Main content padding: clears bottom nav + safe area on mobile */
.safe-area-main-padding {
padding-bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
}
/* Animation Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
+1
View File
@@ -37,6 +37,7 @@ export const viewport: Viewport = {
themeColor: "#304D83",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({
+1 -1
View File
@@ -126,7 +126,7 @@ export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDi
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer</Label>
<Input
@@ -419,7 +419,7 @@ export default function ChartOfAccountsManager() {
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-7 w-7 min-h-[44px] min-w-[44px]"
onClick={() => setEditAccount(account)}
>
<Pencil className="h-3.5 w-3.5" />
@@ -428,7 +428,7 @@ export default function ChartOfAccountsManager() {
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
className="h-7 w-7 min-h-[44px] min-w-[44px] text-destructive hover:text-destructive"
onClick={() => deleteAccount(account)}
disabled={deletingAccount === account.account_number}
>
@@ -125,7 +125,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
</DialogHeader>
@@ -139,34 +139,50 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
</div>
<p className="text-sm">{entry.description}</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-1.5">
{originalLines.map((line) => (
<div key={line.id} className="flex items-center justify-between py-1.5 border-b last:border-0 text-sm">
<div className="min-w-0">
<AccountNumber number={line.account_number} showName />
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{Number(line.debit_amount) > 0
? `D ${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`
: `K ${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`}
</span>
</div>
))}
</div>
</div>
{/* Divider */}
@@ -178,45 +194,49 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<div className="space-y-2">
{lines.map((line, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_120px_120px_auto] gap-2 items-start">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(index, 'account_number', v)}
/>
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(index, 'account_number', v)}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] sm:order-last"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Beskrivning"
className="h-8"
/>
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="Debet"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="Kredit"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="grid grid-cols-2 gap-2 sm:contents">
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="Debet"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="Kredit"
className="h-8 text-right"
min={0}
step="0.01"
/>
</div>
</div>
))}
</div>
+1 -1
View File
@@ -88,7 +88,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard momskod</Label>
<Input
@@ -185,7 +185,7 @@ export default function JournalEntryAttachments({
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
className="h-6 w-6 p-0 shrink-0 min-h-[44px] min-w-[44px]"
onClick={() => handleDownload(doc.id)}
title="Ladda ner"
>
+89 -5
View File
@@ -230,7 +230,7 @@ export default function JournalEntryForm({
const formContent = (
<div className="space-y-4">
<div className={`grid gap-4 ${embedded && initialDate ? 'grid-cols-2' : 'grid-cols-3'}`}>
<div className={`grid gap-4 grid-cols-1 ${embedded && initialDate ? 'sm:grid-cols-2' : 'sm:grid-cols-3'}`}>
<div>
<Label>Räkenskapsår</Label>
<select
@@ -265,8 +265,90 @@ export default function JournalEntryForm({
</div>
</div>
{/* Entry lines */}
<div>
{/* Entry lines — mobile cards */}
<div className="sm:hidden space-y-3">
{lines.map((line, index) => (
<div key={index} className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px] shrink-0 -mr-1 -mt-1"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Debet</Label>
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="0,00"
className="text-right"
inputMode="decimal"
min="0"
step="0.01"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Kredit</Label>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="0,00"
className="text-right"
inputMode="decimal"
min="0"
step="0.01"
/>
</div>
</div>
</div>
))}
{/* Mobile totals */}
<div className="flex justify-between items-center px-1 pt-2 font-semibold text-sm">
<span>Summa</span>
<div className="flex gap-4">
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
<span className={isBalanced ? 'text-success' : 'text-destructive'}>
K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={addLine}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
</div>
{/* Entry lines — desktop table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
@@ -302,6 +384,7 @@ export default function JournalEntryForm({
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
inputMode="decimal"
min="0"
step="0.01"
/>
@@ -313,6 +396,7 @@ export default function JournalEntryForm({
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
inputMode="decimal"
min="0"
step="0.01"
/>
@@ -323,9 +407,9 @@ export default function JournalEntryForm({
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0"
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
+119 -52
View File
@@ -138,9 +138,10 @@ export default function JournalEntryList({ periodId }: Props) {
<Card key={entry.id}>
<button
onClick={() => toggleExpand(entry.id)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
className="w-full p-4 text-left hover:bg-muted/50 transition-colors min-h-[44px]"
>
<div className="flex items-center gap-3 flex-1">
{/* Desktop: single row */}
<div className="hidden sm:flex items-center gap-3 flex-1">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
@@ -153,7 +154,6 @@ export default function JournalEntryList({ periodId }: Props) {
{entry.entry_date}
</span>
<span className="flex-1 truncate">{entry.description}</span>
{/* Attachment indicator */}
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground mr-1" title={`${attachmentCounts[entry.id]} underlag`}>
<Paperclip className="h-3.5 w-3.5" />
@@ -167,6 +167,37 @@ export default function JournalEntryList({ periodId }: Props) {
)
)}
</div>
{/* Mobile: two rows */}
<div className="sm:hidden">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-mono text-sm text-muted-foreground">
{entry.voucher_series}{entry.voucher_number}
</span>
<span className="text-sm text-muted-foreground">
{entry.entry_date}
</span>
<span className="ml-auto flex items-center gap-1">
{attachmentCounts[entry.id] ? (
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
<span title="Underlag saknas">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
</span>
)
)}
</span>
</div>
<p className="mt-1 ml-6 text-sm truncate">{entry.description}</p>
</div>
</button>
{isExpanded && (
@@ -174,59 +205,95 @@ export default function JournalEntryList({ periodId }: Props) {
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-48">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
<>
{/* Mobile: stacked cards */}
<div className="sm:hidden space-y-2">
{lines
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
<td className="py-2 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
</tr>
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm"><AccountNumber number={line.account_number} showName /></div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
<p>{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D</p>
)}
{Number(line.credit_amount) > 0 && (
<p>{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K</p>
)}
</div>
</div>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
Summa
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
<div className="flex justify-between font-semibold text-sm pt-1">
<span>Summa</span>
<div className="flex gap-3 tabular-nums">
<span>D: {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
<span>K: {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
{/* 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 w-48">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{lines
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
<td className="py-2 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
Summa
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
<JournalEntryAttachments
@@ -82,46 +82,71 @@ export function JournalEntryReviewContent({
</div>
)}
{/* Debit/Credit table */}
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{activeLines.map((line, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={line.account_number} />
</td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.debit_amount) > 0
? formatAmount(parseFloat(line.debit_amount))
: ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.credit_amount) > 0
? formatAmount(parseFloat(line.credit_amount))
: ''}
</td>
{/* Debit/Credit — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={2} className="py-2">Summa</td>
<td className="py-2 text-right text-success">{formatAmount(totalDebit)}</td>
<td className="py-2 text-right text-success">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</thead>
<tbody>
{activeLines.map((line, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={line.account_number} />
</td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.debit_amount) > 0
? formatAmount(parseFloat(line.debit_amount))
: ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.credit_amount) > 0
? formatAmount(parseFloat(line.credit_amount))
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={2} className="py-2">Summa</td>
<td className="py-2 text-right text-success">{formatAmount(totalDebit)}</td>
<td className="py-2 text-right text-success">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="sm:hidden space-y-1.5">
{activeLines.map((line, index) => (
<div key={index} className="flex items-center justify-between py-2 border-b last:border-0 text-sm">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<AccountNumber number={line.account_number} />
{line.line_description && (
<span className="text-muted-foreground text-xs truncate">{line.line_description}</span>
)}
</div>
</div>
<span className="font-mono text-sm shrink-0 ml-2">
{parseFloat(line.debit_amount) > 0
? `D ${formatAmount(parseFloat(line.debit_amount))}`
: `K ${formatAmount(parseFloat(line.credit_amount))}`}
</span>
</div>
))}
<div className="flex justify-between pt-2 border-t-2 font-semibold text-sm">
<span>Summa</span>
<span className="text-success">D {formatAmount(totalDebit)} / K {formatAmount(totalCredit)}</span>
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -168,7 +168,7 @@ export default function CustomerForm({
</div>
{/* Contact */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
@@ -202,7 +202,7 @@ export default function CustomerForm({
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
+27 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -72,11 +72,18 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
const router = useRouter()
const supabase = createClient()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
const isOnOvrigtPage = ['/help', '/settings'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
const openMobileMenu = () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
setIsClosing(false)
setIsMobileMenuOpen(true)
}
@@ -92,7 +99,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
return pathname.startsWith(href)
}
const closeMobileMenu = () => setIsMobileMenuOpen(false)
const closeMobileMenu = () => {
setIsClosing(true)
closeTimerRef.current = setTimeout(() => {
setIsMobileMenuOpen(false)
setIsClosing(false)
closeTimerRef.current = null
}, 200)
}
// Filter nav items by entity type and hidden flag
const filteredItems = navItems.filter(item =>
@@ -251,7 +265,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
</aside>
{/* Mobile bottom navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" aria-label="Mobilnavigation">
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label="Mobilnavigation">
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const Icon = item.icon
@@ -306,13 +320,21 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
<>
{/* Backdrop */}
<div
className="md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
className={cn(
"md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50",
isClosing ? "animate-out fade-out duration-200" : "animate-in fade-in duration-300"
)}
onClick={closeMobileMenu}
aria-hidden="true"
/>
{/* Drawer */}
<div
className="md:hidden fixed right-0 top-0 bottom-0 w-72 bg-card border-l border-border/40 z-50 overflow-y-auto animate-in slide-in-from-right duration-300"
className={cn(
"md:hidden fixed right-0 top-0 bottom-0 w-72 bg-card border-l border-border/40 z-50 overflow-y-auto",
isClosing
? "animate-out slide-out-to-right duration-200"
: "animate-in slide-in-from-right duration-300"
)}
role="dialog"
aria-label="Navigeringsmeny"
>
+5 -5
View File
@@ -148,7 +148,7 @@ export function DeadlineForm({
</div>
{/* Date and Time */}
<div className="grid gap-4 grid-cols-2">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="due_date">Datum *</Label>
<Input
@@ -171,7 +171,7 @@ export function DeadlineForm({
</div>
{/* Type and Priority */}
<div className="grid gap-4 grid-cols-2">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div className="space-y-2">
<Label>Typ *</Label>
<Select
@@ -215,14 +215,14 @@ export function DeadlineForm({
<div className="space-y-2">
<Label>Kund (valfritt)</Label>
<Select
value={formData.customer_id}
onValueChange={(v) => updateField('customer_id', v)}
value={formData.customer_id || 'none'}
onValueChange={(v) => updateField('customer_id', v === 'none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Välj kund..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ingen kund</SelectItem>
<SelectItem value="none">Ingen kund</SelectItem>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
@@ -150,7 +150,7 @@ export default function InboxDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>Granska faktura</DialogTitle>
@@ -229,7 +229,7 @@ export default function MockDataImportDialog<T>({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{step === 'upload' && 'Importera testdata'}
@@ -79,7 +79,7 @@ export default function BankFileColumnMappingStep({
</CardHeader>
<CardContent className="space-y-6">
{/* Delimiter and decimal settings */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Avgränsare</Label>
<Select value={delimiter} onValueChange={setDelimiter}>
@@ -110,7 +110,7 @@ export default function BankFileColumnMappingStep({
{/* Required column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Obligatoriska kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Datum *</Label>
<Select
@@ -173,7 +173,7 @@ export default function BankFileColumnMappingStep({
{/* Optional column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Valfria kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Referens/OCR</Label>
<Select
+51 -35
View File
@@ -61,18 +61,18 @@ export function InvoiceReviewContent({
return (
<div className="space-y-4">
{/* Customer info */}
<div className="bg-muted rounded-lg p-4 flex items-center justify-between">
<div>
<p className="font-medium text-base">{customer.name}</p>
<p className="text-sm text-muted-foreground">{customer.email}</p>
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{customer.name}</p>
<p className="text-sm text-muted-foreground truncate">{customer.email}</p>
</div>
<Badge variant="outline">
<Badge variant="outline" className="self-start sm:self-auto shrink-0">
{customerTypeLabel[customer.customer_type] || customer.customer_type}
</Badge>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoiceDate}</p>
@@ -83,35 +83,51 @@ export function InvoiceReviewContent({
</div>
</div>
{/* Line items table */}
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
<th className="py-2 w-28 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
<th className="py-2 w-28 text-right">Belopp</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">({item.vat_rate ?? 0}% moms)</span>}
</div>
<p className="text-right font-medium">
{formatCurrency(item.quantity * item.unit_price, currency)}
</p>
</div>
))}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
@@ -135,7 +151,7 @@ export function InvoiceReviewContent({
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-2xl">
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{formatCurrency(total, currency)}</span>
</div>
@@ -114,7 +114,7 @@ export default function Step2CompanyDetails({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
@@ -328,7 +328,7 @@ export default function Step3TaxRegistration({
name="is_first_fiscal_year"
control={control}
render={({ field }) => (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => field.onChange(true)}
+1 -1
View File
@@ -78,7 +78,7 @@ export default function Step6ConnectBank({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<InfoTooltip
content={
+6 -6
View File
@@ -119,7 +119,7 @@ export default function SupplierForm({
</div>
{/* Contact */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
@@ -145,7 +145,7 @@ export default function SupplierForm({
{/* Business info */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Företagsuppgifter</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
@@ -176,7 +176,7 @@ export default function SupplierForm({
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input id="postal_code" placeholder="123 45" {...register('postal_code')} />
@@ -195,7 +195,7 @@ export default function SupplierForm({
{/* Payment details */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Betalningsuppgifter</h3>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Input id="bankgiro" placeholder="XXX-XXXX" {...register('bankgiro')} />
@@ -205,7 +205,7 @@ export default function SupplierForm({
<Input id="plusgiro" placeholder="XXXXXXX-X" {...register('plusgiro')} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="iban">IBAN</Label>
<Input id="iban" placeholder="SE00 0000 0000 0000 0000 0000" {...register('iban')} />
@@ -220,7 +220,7 @@ export default function SupplierForm({
{/* Defaults */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Standardvärden</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="default_expense_account">Kostnadskonto</Label>
<Input
@@ -139,12 +139,12 @@ export function SupplierInvoiceReviewContent({
return (
<div className="space-y-4">
{/* Supplier info */}
<div className="bg-muted rounded-lg p-4 flex items-center justify-between">
<div>
<p className="font-medium text-base">{supplier.name}</p>
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{supplier.name}</p>
<p className="text-sm text-muted-foreground">Fakturanr: {invoiceNumber}</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
{reverseCharge && (
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
Omvänd skattskyldighet
@@ -160,7 +160,7 @@ export function SupplierInvoiceReviewContent({
</div>
{/* Dates */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoiceDate}</p>
@@ -177,34 +177,53 @@ export function SupplierInvoiceReviewContent({
)}
</div>
{/* Line items table */}
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-16 text-right">Moms%</th>
<th className="py-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">{item.description}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
</tr>
)
})}
</tbody>
</table>
{/* Line items — table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-16 text-right">Moms%</th>
<th className="py-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">{item.description}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) => {
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<div className="flex items-center justify-between">
<p className="font-medium">{item.description}</p>
<AccountNumber number={item.account_number} size="sm" />
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{Math.round(item.vat_rate * 100)}% moms ({formatAmount(vatAmount)})</span>
</div>
</div>
)
})}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
@@ -217,7 +236,7 @@ export function SupplierInvoiceReviewContent({
<span>{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-2xl">
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{formatCurrency(total, currency)}</span>
</div>
@@ -230,43 +249,66 @@ export function SupplierInvoiceReviewContent({
</div>
{/* Verifikation preview */}
<div className="bg-muted/50 border rounded-lg p-4 space-y-2">
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
<table className="w-full text-sm font-mono">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
<th className="pb-1 w-24 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{journalLines.map((line, index) => (
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
<td className="py-1">
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{ACCOUNT_LABELS[line.account_number] || line.description}
</td>
<td className="py-1 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
<div className="hidden sm:block">
<table className="w-full text-sm font-mono">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
<th className="pb-1 w-24 text-right">Kredit</th>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>SUMMA</td>
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</thead>
<tbody>
{journalLines.map((line, index) => (
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
<td className="py-1">
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{ACCOUNT_LABELS[line.account_number] || line.description}
</td>
<td className="py-1 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>SUMMA</td>
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="sm:hidden space-y-1.5 text-sm">
{journalLines.map((line, index) => (
<div key={index} className="flex items-center justify-between py-1 border-b border-dashed border-muted-foreground/20 last:border-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<AccountNumber number={line.account_number} size="sm" />
<span className="text-xs text-muted-foreground truncate">
{ACCOUNT_LABELS[line.account_number] || line.description}
</span>
</div>
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{line.debit > 0 ? `D ${formatAmount(line.debit)}` : `K ${formatAmount(line.credit)}`}
</span>
</div>
))}
<div className="flex justify-between pt-1 border-t font-semibold text-xs font-mono">
<span>SUMMA</span>
<span>D {formatAmount(totalDebit)} / K {formatAmount(totalCredit)}</span>
</div>
</div>
</div>
{/* Payment reference */}
@@ -91,7 +91,7 @@ export default function TransactionBookingDialog({
}
onOpenChange(o)
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogDescription>
+1 -1
View File
@@ -93,7 +93,7 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Datum *</Label>
<Input id="date" type="date" {...register('date')} />
@@ -162,7 +162,7 @@ export default function TransactionHistoryList({
<Button
size="sm"
variant="default"
className="h-7 text-xs"
className="h-7 min-h-[44px] text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
Bokför
+6 -6
View File
@@ -37,24 +37,24 @@ export function ConfirmationDialog({
}: ConfirmationDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl border-t-2 border-primary p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogContent className="sm:max-w-2xl border-t-2 border-primary p-0 gap-0 max-h-[95dvh] sm:max-h-[90dvh] flex flex-col">
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 shrink-0">
<ClipboardCheck className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle className="text-xl">{title}</DialogTitle>
<DialogTitle className="text-lg sm:text-xl">{title}</DialogTitle>
<DialogDescription>Granska uppgifterna innan du bekräftar</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="overflow-y-auto max-h-[70vh] px-6 pb-4">
<div className="overflow-y-auto flex-1 min-h-0 px-4 sm:px-6 pb-4">
{children}
</div>
<div className="border-t px-6 py-4 space-y-4">
<div className="border-t px-4 sm:px-6 py-3 sm:py-4 space-y-3 sm:space-y-4 shrink-0">
{warningText && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
+2 -2
View File
@@ -8,14 +8,14 @@ interface PageHeaderProps {
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="font-display text-2xl font-medium tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
)}
</div>
{action && <div>{action}</div>}
{action && <div className="w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto">{action}</div>}
</div>
)
}
+1 -1
View File
@@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground max-w-full overflow-x-auto scrollbar-hide",
className
)}
{...props}
+90
View File
@@ -106,6 +106,52 @@ const SIE_WITH_OBJECT_LIST = [
'}',
].join('\n')
// SIE file where all VER/TRANS fields are quoted (common from some accounting programs)
const SIE_QUOTED_FIELDS = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Quoted AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 3001 "Försäljning"',
'#KONTO 2611 "Utgående moms 25%"',
'#VER "A" "1" "20240115" "Faktura 1001"',
'{',
'#TRANS "1510" {} "12500.00"',
'#TRANS "3001" {} "-10000.00"',
'#TRANS "2611" {} "-2500.00"',
'}',
].join('\n')
// SIE file with empty series (some programs use "" for series)
const SIE_EMPTY_SERIES = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Empty Series AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#VER "" 1 20240115 "No series"',
'{',
'#TRANS 1930 {} 10000.00',
'#TRANS 3001 {} -10000.00',
'}',
].join('\n')
// SIE file with { on same line as #VER
const SIE_BRACE_ON_VER_LINE = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Brace AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#VER A 1 20240115 "Inline brace" {',
'#TRANS 1930 {} 10000.00',
'#TRANS 3001 {} -10000.00',
'}',
].join('\n')
// --- parseSIEFile tests ---
describe('parseSIEFile', () => {
@@ -232,6 +278,50 @@ describe('parseSIEFile', () => {
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
})
it('parses quoted VER fields (series, number, date)', () => {
const result = parseSIEFile(SIE_QUOTED_FIELDS)
expect(result.vouchers).toHaveLength(1)
const v = result.vouchers[0]
expect(v.series).toBe('A')
expect(v.number).toBe(1)
expect(v.date).toEqual(new Date(2024, 0, 15))
expect(v.description).toBe('Faktura 1001')
expect(v.lines).toHaveLength(3)
expect(v.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
expect(v.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
expect(v.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
const errors = result.issues.filter((i) => i.severity === 'error')
expect(errors).toHaveLength(0)
})
it('allows empty series in VER', () => {
const result = parseSIEFile(SIE_EMPTY_SERIES)
expect(result.vouchers).toHaveLength(1)
const v = result.vouchers[0]
expect(v.series).toBe('')
expect(v.number).toBe(1)
expect(v.lines).toHaveLength(2)
const errors = result.issues.filter((i) => i.severity === 'error')
expect(errors).toHaveLength(0)
})
it('handles { on same line as #VER', () => {
const result = parseSIEFile(SIE_BRACE_ON_VER_LINE)
expect(result.vouchers).toHaveLength(1)
const v = result.vouchers[0]
expect(v.series).toBe('A')
expect(v.number).toBe(1)
expect(v.lines).toHaveLength(2)
const errors = result.issues.filter((i) => i.severity === 'error')
expect(errors).toHaveLength(0)
})
it('detects unbalanced vouchers as errors', () => {
const result = parseSIEFile(SIE_UNBALANCED_VOUCHER)
expect(result.vouchers).toHaveLength(1)
+10 -8
View File
@@ -146,8 +146,9 @@ function parseStringField(field: string): string {
*/
function parseNumberField(field: string): number {
if (!field) return 0
// SIE uses dot as decimal separator
return parseFloat(field.replace(',', '.')) || 0
// Strip quotes and use dot as decimal separator
const cleaned = parseStringField(field)
return parseFloat(cleaned.replace(',', '.')) || 0
}
/**
@@ -434,12 +435,13 @@ export function parseSIEFile(content: string): ParsedSIEFile {
case 'VER': {
// #VER series number date "description" [regdate] [signature]
// Some programs quote all fields, so strip quotes from number/date too
const series = parseStringField(fields[1])
const number = parseInt(fields[2], 10)
const date = parseSIEDate(fields[3])
const number = parseInt(parseStringField(fields[2]), 10)
const date = parseSIEDate(parseStringField(fields[3]))
const description = parseStringField(fields[4])
if (series && !isNaN(number) && date) {
if (!isNaN(number) && date) {
currentVoucher = {
series,
number,
@@ -450,7 +452,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// Optional registration date and signature
if (fields[5]) {
currentVoucher.registrationDate = parseSIEDate(fields[5]) || undefined
currentVoucher.registrationDate = parseSIEDate(parseStringField(fields[5])) || undefined
}
if (fields[6]) {
currentVoucher.signature = parseStringField(fields[6])
@@ -470,7 +472,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// Parse account and skip object list (in braces)
let fieldIndex = 1
const account = fields[fieldIndex++]
const account = parseStringField(fields[fieldIndex++])
// Skip object list if present (now a single field thanks to brace-aware splitting)
if (fields[fieldIndex]?.startsWith('{')) {
@@ -486,7 +488,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
// Optional fields
if (fields[fieldIndex]) {
transLine.date = parseSIEDate(fields[fieldIndex++]) || undefined
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
}
if (fields[fieldIndex]) {
transLine.description = parseStringField(fields[fieldIndex++])