diff --git a/CLAUDE.md b/CLAUDE.md index 473b48c7..896b05e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,7 +251,7 @@ Path params extracted as `_paramName` search params (e.g., `/:id` → `searchPar ## Database & Migrations **Location**: `supabase/migrations/` — 45 files, numbered `20240101000001`–`20240101000045`. -**Next migration**: `20240101000046_*.sql` +**Next migration**: `20240101000049_*.sql` ### Placeholder Migrations diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 1eb9f060..3255469f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -5,9 +5,8 @@ import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { useToast } from '@/components/ui/use-toast' -import { Loader2, Mail, Sparkles } from 'lucide-react' +import { Loader2, Mail, ArrowLeft } from 'lucide-react' import { getErrorMessage } from '@/lib/errors/get-error-message' export default function LoginPage() { @@ -61,47 +60,56 @@ export default function LoginPage() { if (isEmailSent) { return ( -
- - -
- +
+
+
+
+
- Kolla din e-post - - Vi har skickat en inloggningslänk till {email} - - - -

- Klicka på länken i e-posten för att logga in. Länken är giltig i 1 timme. +

+ +
+

Kolla din e-post

+

+ Vi har skickat en inloggningslänk till{' '} + {email}

- - - +
+ +
+

+ Klicka på länken i e-posten för att logga in. + Länken är giltig i 1 timme. +

+
+ + +
) } return ( -
- - -
- -
- ERP Base - +
+
+
+

+ Gnubok +

+

Logga in med din e-post för att hantera din ekonomi - - - -

+

+
+ +
+
setEmail(e.target.value)} required disabled={isLoading} + className="h-11" />
-

- Genom att logga in godkänner du våra{' '} - - villkor - {' '} - och{' '} - - integritetspolicy - - . -

- - +
+ +

+ Genom att logga in godkänner du våra{' '} + + villkor + {' '} + och{' '} + + integritetspolicy + + . +

+
) } diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index c7266acb..3e8d8e74 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -1,174 +1,13 @@ -'use client' - -import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { SuccessAnimation } from '@/components/ui/success-animation' -import { useToast } from '@/components/ui/use-toast' -import { - CheckCircle2, - AlertCircle, - AlertTriangle, - ArrowLeft, - ArrowRight, - Loader2, - Lock, - BookOpen, - ChevronDown, - ChevronUp, -} from 'lucide-react' -import { AccountNumber } from '@/components/ui/account-number' -import type { - FiscalPeriod, - YearEndValidation, - YearEndPreview, - YearEndResult, -} from '@/types' - -function formatAmount(amount: number): string { - return amount.toLocaleString('sv-SE', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) -} - -const STEP_LABELS = ['Välj period', 'Validering', 'Förhandsgranskning', 'Genomför'] +import { ArrowLeft, Lock } from 'lucide-react' export default function YearEndPage() { - const { toast } = useToast() - - const [step, setStep] = useState(0) - const [periods, setPeriods] = useState([]) - const [selectedPeriodId, setSelectedPeriodId] = useState('') - const [validation, setValidation] = useState(null) - const [preview, setPreview] = useState(null) - const [result, setResult] = useState(null) - const [loading, setLoading] = useState(false) - const [loadingPeriods, setLoadingPeriods] = useState(true) - const [executing, setExecuting] = useState(false) - const [error, setError] = useState(null) - const [showConfirmDialog, setShowConfirmDialog] = useState(false) - const [showLinesDetail, setShowLinesDetail] = useState(false) - const [showSuccess, setShowSuccess] = useState(false) - - const selectedPeriod = periods.find((p) => p.id === selectedPeriodId) - - useEffect(() => { - fetchPeriods() - }, []) - - async function fetchPeriods() { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - const allPeriods: FiscalPeriod[] = data || [] - setPeriods(allPeriods) - // Pre-select first open period - const openPeriod = allPeriods.find((p) => !p.is_closed) - if (openPeriod) { - setSelectedPeriodId(openPeriod.id) - } - } catch { - toast({ title: 'Fel', description: 'Kunde inte hämta räkenskapsår', variant: 'destructive' }) - } finally { - setLoadingPeriods(false) - } - } - - const fetchValidationAndPreview = useCallback(async () => { - if (!selectedPeriodId) return - setLoading(true) - setError(null) - setValidation(null) - setPreview(null) - - try { - const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`) - const json = await res.json() - - if (!res.ok) { - setError(json.error || 'Kunde inte validera perioden') - return - } - - setValidation(json.data.validation) - setPreview(json.data.preview) - } catch { - setError('Nätverksfel vid validering') - } finally { - setLoading(false) - } - }, [selectedPeriodId]) - - async function executeYearEnd() { - setShowConfirmDialog(false) - setExecuting(true) - setError(null) - - try { - const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, { - method: 'POST', - }) - const json = await res.json() - - if (!res.ok) { - setError(json.error || 'Årsbokslut misslyckades') - toast({ title: 'Fel', description: json.error || 'Årsbokslut misslyckades', variant: 'destructive' }) - return - } - - setResult(json.data) - setShowSuccess(true) - } catch { - setError('Nätverksfel vid genomförande') - toast({ title: 'Fel', description: 'Nätverksfel vid genomförande', variant: 'destructive' }) - } finally { - setExecuting(false) - } - } - - function goToStep(nextStep: number) { - if (nextStep === 1 && !validation) { - fetchValidationAndPreview() - } - setStep(nextStep) - } - - function getPeriodStatus(period: FiscalPeriod) { - if (period.is_closed) return { label: 'Stängd', variant: 'secondary' as const } - if (period.locked_at) return { label: 'Låst', variant: 'outline' as const } - return { label: 'Öppen', variant: 'default' as const } - } - return (
- {/* Header */}
-
-

Årsbokslut

-

- Stäng räkenskapsåret och generera ingående balanser -

-
+

Årsbokslut

- {/* Step indicator */} -
- {STEP_LABELS.map((label, i) => ( -
-
-
- {i < step ? : i + 1} -
- -
- {i < STEP_LABELS.length - 1 && ( -
- )} + + +
+
- ))} -
- - {/* Error banner */} - {error && ( - - - -

{error}

-
-
- )} - - {/* Step 0: Period Selection */} - {step === 0 && ( - - - Välj räkenskapsår att stänga - - - {loadingPeriods ? ( -
- - -
- ) : periods.length === 0 ? ( -

- Inga räkenskapsår hittades. Skapa ett räkenskapsår först. -

- ) : ( - <> -
- {periods.map((period) => { - const status = getPeriodStatus(period) - const isSelected = period.id === selectedPeriodId - return ( - - ) - })} -
- -
- -
- - )} -
-
- )} - - {/* Step 1: Validation */} - {step === 1 && ( - - - Validering — {selectedPeriod?.name} - - - {loading ? ( -
- - - -
- ) : validation ? ( - <> - {/* Ready indicator */} -
- {validation.ready ? ( - - ) : ( - - )} -
-

- {validation.ready - ? 'Perioden är redo för årsbokslut' - : 'Perioden kan inte stängas ännu'} -

- {!validation.ready && ( -

- Åtgärda felen nedan innan du kan fortsätta -

- )} -
-
- - {/* Errors */} - {validation.errors.length > 0 && ( -
-

Fel som måste åtgärdas

- {validation.errors.map((err, i) => ( -
- - {err} -
- ))} -
- )} - - {/* Warnings */} - {validation.warnings.length > 0 && ( -
-

Varningar

- {validation.warnings.map((warn, i) => ( -
- - {warn} -
- ))} -
- )} - - {/* Details */} -
-
-

Utkast kvar

-

{validation.draftCount}

-
-
-

Saldobalans

-

- {validation.trialBalanceBalanced ? 'Balanserad' : 'Obalanserad'} -

-
-
- - {/* Voucher gaps */} - {validation.voucherGaps.length > 0 && ( -
-

Verifikationsnummerluckor

-
- {validation.voucherGaps.map((gap, i) => ( - - {gap.gap_start}–{gap.gap_end} - - ))} -
-
- )} - - ) : null} - -
- -
- - -
-
-
-
- )} - - {/* Step 2: Preview */} - {step === 2 && preview && ( -
- {/* Net result highlight */} - - -
-

Årets resultat

-

= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' - }`} - > - {formatAmount(preview.netResult)} kr -

-

- Bokförs på {preview.closingAccount} — {preview.closingAccountName} -

-
-
-
- - {/* Result account summary */} - - - Resultatkonton som nollställs - - - - - - Konto - Namn - Belopp - - - - {preview.resultAccountSummary.map((account) => ( - - - {account.account_name} - - {formatAmount(account.amount)} kr - - - ))} - -
-
-
- - {/* Closing journal lines (expandable) */} - - - - - {showLinesDetail && ( - - - - - Konto - Beskrivning - Debet - Kredit - - - - {preview.closingLines.map((line, i) => ( - - - {line.line_description} - - {line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''} - - - {line.credit_amount > 0 ? formatAmount(line.credit_amount) : ''} - - - ))} - {/* Totals row */} - - Summa - - {formatAmount( - preview.closingLines.reduce((sum, l) => sum + l.debit_amount, 0) - )} - - - {formatAmount( - preview.closingLines.reduce((sum, l) => sum + l.credit_amount, 0) - )} - - - -
-
- )} -
- - {/* Navigation */} -
- - -
-
- )} - - {/* Step 3: Execute */} - {step === 3 && !result && ( - - - Genomför årsbokslut - - -
-

- Följande åtgärder kommer att genomföras: -

-
    -
  • - - Bokslutsverifikation skapas med {preview?.closingLines.length} rader -
  • -
  • - - Perioden {selectedPeriod?.name} låses och stängs permanent -
  • -
  • - - Nytt räkenskapsår skapas med ingående balanser -
  • -
- {preview && ( -
-

- Årets resultat:{' '} - - {formatAmount(preview.netResult)} kr - {' '} - → {preview.closingAccount} ({preview.closingAccountName}) -

-
- )} -
- -
-
- -
-

- Denna åtgärd kan inte ångras -

-

- Perioden stängs permanent enligt Bokföringslagen. Säkerställ att alla bokföringar - är korrekta innan du fortsätter. -

-
-
-
- -
- - -
-
-
- )} - - {/* Step 3: Success state */} - {step === 3 && result && ( - - -
-
-
- -
-
-
-

Årsbokslutet är genomfört

-

- {selectedPeriod?.name} har stängts och ett nytt räkenskapsår har skapats. -

-
- -
-
- Bokslutsverifikation - - Visa - -
-
- Period stängd - - - Stängd - -
-
- Nytt räkenskapsår - {result.nextPeriod.name} -
-
- Ingående balanser - - - Skapade - -
-
- -
- -
-
-
-
- )} - - {/* Confirmation dialog */} - - - - Bekräfta årsbokslut - - Är du säker på att du vill stänga {selectedPeriod?.name}? - Denna åtgärd kan inte ångras. Perioden kommer att stängas permanent. - - - {preview && ( -
-

- Årets resultat:{' '} - {formatAmount(preview.netResult)} kr -

-

- Bokförs på {preview.closingAccount} — {preview.closingAccountName} -

-
- )} - - - - -
-
- - {/* Success animation overlay */} - +

Kommer snart

+

+ Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version. +

+ +
) } diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index 74afecfc..2d2f9bcb 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -28,9 +28,9 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types' const customerTypeLabels: Record = { individual: 'Privatperson', - swedish_business: 'Svenskt foretag', - eu_business: 'EU-foretag', - non_eu_business: 'Utanfor EU', + swedish_business: 'Svenskt företag', + eu_business: 'EU-företag', + non_eu_business: 'Utanför EU', } const customerTypeIcons: Record = { @@ -263,7 +263,7 @@ export default function CustomerDetailPage({ {/* Business details */} - Foretagsuppgifter + Företagsuppgifter {customer.org_number && ( @@ -286,7 +286,7 @@ export default function CustomerDetailPage({ {customer.default_payment_terms || 30} dagar
{!customer.org_number && !customer.vat_number && ( -

Inga foretagsuppgifter

+

Inga företagsuppgifter

)} @@ -294,7 +294,7 @@ export default function CustomerDetailPage({ {/* Summary */} - Oversikt + Översikt
@@ -346,7 +346,7 @@ export default function CustomerDetailPage({ {formatCurrency(invoice.total, invoice.currency)} - {invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Forsenad' : 'Obestallt'} + {invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Försenad' : 'Obetald'}
diff --git a/app/(dashboard)/expenses/[id]/page.tsx b/app/(dashboard)/expenses/[id]/page.tsx new file mode 100644 index 00000000..82f5b4aa --- /dev/null +++ b/app/(dashboard)/expenses/[id]/page.tsx @@ -0,0 +1,450 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useToast } from '@/components/ui/use-toast' +import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { AccountNumber } from '@/components/ui/account-number' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' +import { formatCurrency } from '@/lib/utils' +import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types' + +const statusConfig: Record = { + registered: { label: 'Obetald', color: 'bg-blue-100 text-blue-800' }, + approved: { label: 'Obetald', color: 'bg-yellow-100 text-yellow-800' }, + paid: { label: 'Betald', color: 'bg-green-100 text-green-800' }, + partially_paid: { label: 'Delbetald', color: 'bg-orange-100 text-orange-800' }, + overdue: { label: 'Förfallen', color: 'bg-red-100 text-red-800' }, + disputed: { label: 'Tvist', color: 'bg-purple-100 text-purple-800' }, + credited: { label: 'Krediterad', color: 'bg-gray-100 text-gray-800' }, +} + +function formatAmount(amount: number): string { + return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function ExpenseDetailPage() { + const params = useParams() + const router = useRouter() + const { toast } = useToast() + const [invoice, setInvoice] = useState(null) + const [entityType, setEntityType] = useState('enskild_firma') + const [isLoading, setIsLoading] = useState(true) + const [isPayDialogOpen, setIsPayDialogOpen] = useState(false) + const [payAmount, setPayAmount] = useState('') + const [isProcessing, setIsProcessing] = useState(false) + const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() + + useEffect(() => { + fetchInvoice() + fetchEntityType() + }, [params.id]) + + async function fetchInvoice() { + setIsLoading(true) + const res = await fetch(`/api/supplier-invoices/${params.id}`) + const { data, error } = await res.json() + if (error) { + toast({ title: 'Fel', description: error, variant: 'destructive' }) + } else { + setInvoice(data) + setPayAmount(String(data.remaining_amount)) + } + setIsLoading(false) + } + + async function fetchEntityType() { + try { + const res = await fetch('/api/settings') + const { data } = await res.json() + if (data?.entity_type) { + setEntityType(data.entity_type) + } + } catch { + // Default to enskild_firma + } + } + + async function handleApprove() { + setIsProcessing(true) + const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' }) + const result = await res.json() + if (!res.ok) { + toast({ title: 'Fel', description: result.error, variant: 'destructive' }) + } else { + toast({ title: 'Godkänd', description: 'Utgiften har godkänts' }) + fetchInvoice() + } + setIsProcessing(false) + } + + async function handleMarkPaid() { + setIsProcessing(true) + const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: parseFloat(payAmount) }), + }) + const result = await res.json() + if (!res.ok) { + toast({ title: 'Fel', description: result.error, variant: 'destructive' }) + } else { + toast({ + title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad', + description: `${formatAmount(parseFloat(payAmount))} kr registrerat`, + }) + setIsPayDialogOpen(false) + fetchInvoice() + } + setIsProcessing(false) + } + + async function handleCredit() { + const ok = await confirmAction({ + title: 'Registrera kreditfaktura', + description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.', + confirmLabel: 'Registrera kreditfaktura', + variant: 'warning', + }) + if (!ok) return + setIsProcessing(true) + const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' }) + const result = await res.json() + if (!res.ok) { + toast({ title: 'Fel', description: result.error, variant: 'destructive' }) + } else { + toast({ title: 'Kreditfaktura registrerad' }) + fetchInvoice() + } + setIsProcessing(false) + } + + async function handleDelete() { + const ok = await confirmAction({ + title: 'Ta bort utgift', + description: 'Utgiften och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.', + confirmLabel: 'Ta bort', + variant: 'destructive', + }) + if (!ok) return + const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' }) + const result = await res.json() + if (!res.ok) { + toast({ title: 'Fel', description: result.error, variant: 'destructive' }) + } else { + toast({ title: 'Borttagen' }) + router.push('/expenses') + } + } + + if (isLoading) { + return ( +
+
+ +
+ ) + } + + if (!invoice) { + return ( +
+

Utgiften hittades inte

+ +
+ ) + } + + const items = (invoice.items || []) as SupplierInvoiceItem[] + const payments = (invoice.payments || []) as SupplierInvoicePayment[] + const status = statusConfig[invoice.status] || { label: invoice.status, color: '' } + + return ( +
+ {/* Header */} +
+
+ +
+
+

+ Utgift #{invoice.arrival_number} +

+ + {status.label} + +
+

+ {invoice.supplier?.name} · Faktura {invoice.supplier_invoice_number} +

+
+
+ + {/* Context-aware actions */} +
+ {invoice.status === 'registered' && ( + <> + + + + )} + {['approved', 'overdue'].includes(invoice.status) && ( + <> + + + + )} + {invoice.status === 'partially_paid' && ( + + )} +
+
+ + {/* Card 1: Fakturadetaljer */} + + + Fakturadetaljer + + + {/* Info grid */} +
+
+ Leverantör +

+ {invoice.supplier ? ( + + {invoice.supplier.name} + + ) : '-'} +

+ {invoice.supplier?.org_number && ( +

Org.nr: {invoice.supplier.org_number}

+ )} +
+
+ Fakturanummer +

{invoice.supplier_invoice_number}

+
+
+ Ankomstnummer +

{invoice.arrival_number}

+
+
+ Fakturadatum +

{invoice.invoice_date}

+
+
+ Förfallodatum +

{invoice.due_date}

+
+ {invoice.delivery_date && ( +
+ Leveransdatum +

{invoice.delivery_date}

+
+ )} + {invoice.payment_reference && ( +
+ OCR/referens +

{invoice.payment_reference}

+
+ )} +
+ + {invoice.reverse_charge && ( + Omvänd skattskyldighet + )} + + {/* Line items */} + {items.length > 0 && ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
BeskrivningKontoMoms%BeloppMoms
{item.description}{Math.round(item.vat_rate * 100)}%{formatAmount(item.line_total)}{formatAmount(item.vat_amount)}
+ + {/* Amounts summary */} +
+
+ Netto (exkl. moms) + {formatAmount(invoice.subtotal)} {invoice.currency} +
+
+ Moms + {formatAmount(invoice.vat_amount)} {invoice.currency} +
+
+ Totalt + {formatAmount(invoice.total)} {invoice.currency} +
+
+ Betalt + {formatAmount(invoice.paid_amount)} {invoice.currency} +
+
+ Kvar att betala + {formatAmount(invoice.remaining_amount)} {invoice.currency} +
+
+
+ )} + + {/* Notes inline */} + {invoice.notes && ( +
+

{invoice.notes}

+
+ )} +
+
+ + {/* Card 2: Betalningar & bokföring (only if data exists) */} + {(payments.length > 0 || invoice.registration_journal_entry_id) && ( + + + Betalningar & bokföring + + + {/* Payment history */} + {payments.length > 0 && ( +
+

Betalningshistorik

+ + + + + + + + + + + {payments.map((p) => ( + + + + + + + ))} + +
DatumBeloppVerifikationAnteckning
{p.payment_date}{formatAmount(p.amount)} {p.currency} + {p.journal_entry_id ? ( + + {p.journal_entry_id.substring(0, 8)}... + + ) : '-'} + {p.notes || '-'}
+
+ )} + + {/* Journal entry links */} +
+

Verifikationer

+ {invoice.registration_journal_entry_id ? ( +
+ Registreringsverifikation + + {invoice.registration_journal_entry_id.substring(0, 8)}... + +
+ ) : ( +

Ingen registreringsverifikation

+ )} + {invoice.payment_journal_entry_id && ( +
+ Betalningsverifikation + + {invoice.payment_journal_entry_id.substring(0, 8)}... + +
+ )} +
+
+
+ )} + + + + {/* Pay Dialog */} + + + + Markera som betald + +
+
+ + setPayAmount(e.target.value)} + /> +

+ Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency} +

+
+
+ + +
+
+
+
+
+ ) +} diff --git a/app/(dashboard)/expenses/new/page.tsx b/app/(dashboard)/expenses/new/page.tsx new file mode 100644 index 00000000..e6fdf294 --- /dev/null +++ b/app/(dashboard)/expenses/new/page.tsx @@ -0,0 +1,746 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useForm, Controller, useFieldArray } from 'react-hook-form' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { useToast } from '@/components/ui/use-toast' +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' +import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' +import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2 } from 'lucide-react' +import type { Supplier, BASAccount, VatTreatment, EntityType } from '@/types' + +interface LineItem { + description: string + amount: number + account_number: string + vat_rate: number +} + +interface FormData { + supplier_id: string + supplier_invoice_number: string + invoice_date: string + due_date: string + delivery_date: string + currency: string + exchange_rate: string + reverse_charge: boolean + payment_reference: string + notes: string + items: LineItem[] +} + +interface NewSupplierForm { + name: string + supplier_type: string + org_number: string + bankgiro: string + plusgiro: string + default_expense_account: string +} + +function formatAmount(amount: number): string { + return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment { + if (reverseCharge) return 'reverse_charge' + + const rates = new Set(items.map((i) => i.vat_rate)) + if (rates.size === 1) { + const rate = rates.values().next().value! + if (rate === 0.25) return 'standard_25' + if (rate === 0.12) return 'reduced_12' + if (rate === 0.06) return 'reduced_6' + if (rate === 0) return 'exempt' + } + + return 'standard_25' +} + +export default function NewExpensePage() { + const router = useRouter() + const { toast } = useToast() + const [suppliers, setSuppliers] = useState([]) + const [accounts, setAccounts] = useState([]) + const [entityType, setEntityType] = useState('enskild_firma') + const [isSubmitting, setIsSubmitting] = useState(false) + const [showReview, setShowReview] = useState(false) + const [pendingData, setPendingData] = useState(null) + const [showNewSupplier, setShowNewSupplier] = useState(false) + const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [newSupplier, setNewSupplier] = useState({ + name: '', + supplier_type: 'swedish_business', + org_number: '', + bankgiro: '', + plusgiro: '', + default_expense_account: '', + }) + + const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm({ + defaultValues: { + supplier_id: '', + supplier_invoice_number: '', + invoice_date: new Date().toISOString().split('T')[0], + due_date: '', + delivery_date: '', + currency: 'SEK', + exchange_rate: '', + reverse_charge: false, + payment_reference: '', + notes: '', + items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }], + }, + }) + + useUnsavedChanges(isDirty) + + const { fields, append, remove } = useFieldArray({ control, name: 'items' }) + const watchedItems = watch('items') + const watchedSupplierId = watch('supplier_id') + const watchedCurrency = watch('currency') + + const isEF = entityType === 'enskild_firma' + + useEffect(() => { + fetchSuppliers() + fetchAccounts() + fetchEntityType() + }, []) + + // Auto-fill due date and defaults when supplier is selected + useEffect(() => { + if (watchedSupplierId) { + const supplier = suppliers.find((s) => s.id === watchedSupplierId) + if (supplier) { + const invoiceDate = watch('invoice_date') + if (invoiceDate) { + const due = new Date(invoiceDate) + due.setDate(due.getDate() + supplier.default_payment_terms) + setValue('due_date', due.toISOString().split('T')[0]) + } + if (supplier.default_expense_account && fields.length > 0) { + setValue('items.0.account_number', supplier.default_expense_account) + } + if (supplier.default_currency) { + setValue('currency', supplier.default_currency) + } + if (supplier.supplier_type === 'eu_business') { + setValue('reverse_charge', true) + } + } + } + }, [watchedSupplierId, suppliers]) + + async function fetchSuppliers() { + const res = await fetch('/api/suppliers') + const { data } = await res.json() + setSuppliers(data || []) + } + + async function fetchAccounts() { + const res = await fetch('/api/bookkeeping/accounts') + const { data } = await res.json() + setAccounts(data || []) + } + + async function fetchEntityType() { + try { + const res = await fetch('/api/settings') + const { data } = await res.json() + if (data?.entity_type) { + setEntityType(data.entity_type) + } + } catch { + // Default to enskild_firma + } + } + + function handleAccountChange(index: number, accountNumber: string) { + setValue(`items.${index}.account_number`, accountNumber) + const currentDesc = watch(`items.${index}.description`) + if (!currentDesc && accountNumber.length === 4) { + const desc = getAccountDescription(accountNumber) + if (desc) { + setValue(`items.${index}.description`, desc.name) + } + } + } + + // Calculate totals + const itemTotals = (watchedItems || []).map((item) => { + const lineTotal = Math.round((item.amount || 0) * 100) / 100 + const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100 + return { lineTotal, vatAmount } + }) + + const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) + const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0) + const total = Math.round((subtotal + totalVat) * 100) / 100 + + async function handleCreateSupplier() { + if (!newSupplier.name.trim()) { + toast({ title: 'Fel', description: 'Ange leverantörsnamn', variant: 'destructive' }) + return + } + setIsCreatingSupplier(true) + + const payload: Record = { + name: newSupplier.name, + supplier_type: newSupplier.supplier_type, + } + if (newSupplier.org_number) payload.org_number = newSupplier.org_number + if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro + if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro + if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account + + const res = await fetch('/api/suppliers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const result = await res.json() + + if (!res.ok) { + toast({ title: 'Kunde inte skapa leverantör', description: result.error, variant: 'destructive' }) + } else { + const created = result.data as Supplier + setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) + setValue('supplier_id', created.id) + setShowNewSupplier(false) + setNewSupplier({ name: '', supplier_type: 'swedish_business', org_number: '', bankgiro: '', plusgiro: '', default_expense_account: '' }) + toast({ title: 'Leverantör skapad', description: created.name }) + } + + setIsCreatingSupplier(false) + } + + function onSubmit(data: FormData) { + if (!data.supplier_id) { + toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' }) + return + } + if (!data.supplier_invoice_number) { + toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' }) + return + } + + if (isEF) { + // EF: submit directly (auto-approve after create) + setPendingData(data) + handleDirectSubmit(data) + } else { + // AB: show review dialog first + setPendingData(data) + setShowReview(true) + } + } + + function buildPayload(data: FormData) { + const vatTreatment = inferVatTreatment(data.items, data.reverse_charge) + return { + supplier_id: data.supplier_id, + supplier_invoice_number: data.supplier_invoice_number, + invoice_date: data.invoice_date, + due_date: data.due_date, + delivery_date: data.delivery_date || undefined, + currency: data.currency, + exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined, + vat_treatment: vatTreatment, + reverse_charge: data.reverse_charge, + payment_reference: data.payment_reference || undefined, + notes: data.notes || undefined, + items: data.items.map((item) => ({ + description: item.description, + amount: item.amount, + account_number: item.account_number, + vat_rate: item.vat_rate, + })), + } + } + + // EF flow: create + auto-approve + async function handleDirectSubmit(data: FormData) { + setIsSubmitting(true) + + const res = await fetch('/api/supplier-invoices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildPayload(data)), + }) + const result = await res.json() + + if (!res.ok) { + toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' }) + setIsSubmitting(false) + return + } + + // Auto-approve for EF + const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' }) + if (!approveRes.ok) { + toast({ + title: 'Varning', + description: 'Utgiften skapades men kunde inte godkännas automatiskt', + variant: 'destructive', + }) + router.push(`/expenses/${result.data.id}`) + } else { + toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` }) + router.push('/expenses') + } + + setIsSubmitting(false) + } + + // AB flow: create after review dialog confirmation + async function handleConfirm() { + if (!pendingData) return + setIsSubmitting(true) + + const res = await fetch('/api/supplier-invoices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildPayload(pendingData)), + }) + const result = await res.json() + + if (!res.ok) { + toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' }) + } else { + toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` }) + setShowReview(false) + router.push(`/expenses/${result.data.id}`) + } + + setIsSubmitting(false) + } + + return ( +
+
+ +
+

Ny utgift

+

+ Registrera en inkommande faktura +

+
+
+ +
+ {/* Section 1: Faktura */} + + + Faktura + + +
+
+ + ( + + )} + /> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Section 2: Kontering */} + + + Kontering + + + + + + + + + + + + + + + + {fields.map((field, index) => ( + + + + + + + + + ))} + +
KontoBeskrivningBelopp (exkl.)MomssatsMoms
+ ( + handleAccountChange(index, val)} + /> + )} + /> + + + + + + ( + + )} + /> + + {formatAmount(itemTotals[index]?.vatAmount || 0)} + + {fields.length > 1 && ( + + )} +
+ + {/* Totals */} +
+
+ Netto (exkl. moms) + {formatAmount(subtotal)} kr +
+
+ Moms + {formatAmount(totalVat)} kr +
+
+ Totalt + {formatAmount(total)} kr +
+
+
+
+ + {/* Section 3: Övrigt (collapsible) */} + + setAdvancedOpen(!advancedOpen)} + > +
+ Övrigt + +
+
+ {advancedOpen && ( + +
+
+ + ( + + )} + /> +
+ {watchedCurrency !== 'SEK' && ( +
+ + +
+ )} +
+
+ + +
+
+ ( + + )} + /> + +
+
+ +