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:
co-authored by
Claude Opus 4.6
parent
109f860e22
commit
a1ca96453c
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -37,6 +37,7 @@ export const viewport: Viewport = {
|
||||
themeColor: "#304D83",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++])
|
||||
|
||||
Reference in New Issue
Block a user