diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 7da3b810..586614f7 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -8,6 +8,7 @@ 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 { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
const [email, setEmail] = useState('')
@@ -30,8 +31,8 @@ export default function LoginPage() {
if (error) {
toast({
- title: 'Fel',
- description: error.message,
+ title: 'Inloggning misslyckades',
+ description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
return
@@ -42,10 +43,10 @@ export default function LoginPage() {
title: 'E-post skickad!',
description: 'Kolla din inkorg för att logga in.',
})
- } catch {
+ } catch (error) {
toast({
- title: 'Fel',
- description: 'Något gick fel. Försök igen.',
+ title: 'Inloggning misslyckades',
+ description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
} finally {
diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx
index 0d37c4fc..74afecfc 100644
--- a/app/(dashboard)/customers/[id]/page.tsx
+++ b/app/(dashboard)/customers/[id]/page.tsx
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import CustomerForm from '@/components/customers/CustomerForm'
+import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
ArrowLeft,
Building,
@@ -66,6 +67,7 @@ export default function CustomerDetailPage({
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
+ const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchCustomer()
@@ -124,7 +126,13 @@ export default function CustomerDetailPage({
async function handleDelete() {
if (!customer) return
- if (!confirm(`Ta bort "${customer.name}"? Detta kan inte angras.`)) return
+ const ok = await confirmAction({
+ title: `Ta bort ${customer.name}`,
+ description: 'Kunden och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
+ confirmLabel: 'Ta bort',
+ variant: 'destructive',
+ })
+ if (!ok) return
try {
const response = await fetch(`/api/customers/${id}`, {
@@ -352,6 +360,8 @@ export default function CustomerDetailPage({
+
+
{/* Edit dialog */}
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx
index f1d45320..ee07c535 100644
--- a/app/(dashboard)/invoices/new/page.tsx
+++ b/app/(dashboard)/invoices/new/page.tsx
@@ -21,6 +21,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { Customer, Currency, CreateInvoiceInput, InvoiceDocumentType } from '@/types'
const itemSchema = z.object({
@@ -73,7 +75,7 @@ export default function NewInvoicePage() {
handleSubmit,
watch,
setValue,
- formState: { errors },
+ formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -86,6 +88,8 @@ export default function NewInvoicePage() {
},
})
+ useUnsavedChanges(isDirty)
+
// Set date defaults on client only to avoid hydration mismatch
useEffect(() => {
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
@@ -209,7 +213,7 @@ export default function NewInvoicePage() {
const result = await response.json()
if (!response.ok) {
- throw new Error(result.error || 'Kunde inte skapa faktura')
+ throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const docLabel = watchDocumentType === 'proforma' ? 'Proformafaktura' : watchDocumentType === 'delivery_note' ? 'Följesedel' : 'Faktura'
@@ -229,8 +233,8 @@ export default function NewInvoicePage() {
}
} catch (error) {
toast({
- title: 'Fel',
- description: error instanceof Error ? error.message : 'Något gick fel',
+ title: 'Kunde inte skapa faktura',
+ description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
@@ -249,7 +253,7 @@ export default function NewInvoicePage() {
if (!response.ok) {
const result = await response.json()
- throw new Error(result.error || 'Kunde inte skicka faktura')
+ throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
@@ -258,8 +262,8 @@ export default function NewInvoicePage() {
})
} catch (error) {
toast({
- title: 'Fel vid skickning',
- description: error instanceof Error ? error.message : 'Något gick fel',
+ title: 'Kunde inte skicka faktura',
+ description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
@@ -292,7 +296,7 @@ export default function NewInvoicePage() {
if (!response.ok) {
const result = await response.json()
- throw new Error(result.error || 'Kunde inte generera förhandsgranskning')
+ throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const blob = await response.blob()
@@ -300,8 +304,8 @@ export default function NewInvoicePage() {
window.open(url, '_blank')
} catch (error) {
toast({
- title: 'Fel',
- description: error instanceof Error ? error.message : 'Kunde inte generera PDF',
+ title: 'Kunde inte generera PDF',
+ description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index bac0fa1a..51fa3067 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -170,6 +170,37 @@ export default async function DashboardPage() {
.lt('amount', 0)
.is('receipt_id', null)
+ // Count journal entries missing underlag (documents)
+ // Source types that require supporting documents
+ const needsDocSourceTypes = [
+ 'manual',
+ 'bank_transaction',
+ 'supplier_invoice_registered',
+ 'supplier_invoice_paid',
+ 'supplier_invoice_cash_payment',
+ 'import',
+ ]
+
+ const { count: postedEntriesCount } = await supabase
+ .from('journal_entries')
+ .select('*', { count: 'exact', head: true })
+ .eq('user_id', user.id)
+ .eq('status', 'posted')
+ .in('source_type', needsDocSourceTypes)
+
+ const { data: entriesWithDocs } = await supabase
+ .from('document_attachments')
+ .select('journal_entry_id')
+ .eq('user_id', user.id)
+ .eq('is_current_version', true)
+ .not('journal_entry_id', 'is', null)
+
+ const uniqueEntriesWithDocs = new Set(
+ (entriesWithDocs || []).map((d) => d.journal_entry_id)
+ ).size
+
+ const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs)
+
// Calculate receipt streak
const { data: recentReceiptActivity } = await supabase
.from('receipts')
@@ -219,6 +250,7 @@ export default async function DashboardPage() {
bankBalance,
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
+ missingUnderlagCount,
}}
onboardingProgress={onboardingProgress}
/>
diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx
index d5575cb0..49f16556 100644
--- a/app/(dashboard)/supplier-invoices/[id]/page.tsx
+++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx
@@ -12,6 +12,7 @@ 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 type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
function formatAmount(amount: number): string {
@@ -47,6 +48,7 @@ export default function SupplierInvoiceDetailPage() {
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
+ const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
@@ -100,7 +102,13 @@ export default function SupplierInvoiceDetailPage() {
}
async function handleCredit() {
- if (!confirm('Vill du registrera en kreditfaktura för denna faktura?')) return
+ 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()
@@ -114,7 +122,13 @@ export default function SupplierInvoiceDetailPage() {
}
async function handleDelete() {
- if (!confirm('Vill du ta bort denna faktura?')) return
+ const ok = await confirmAction({
+ title: 'Ta bort faktura',
+ description: 'Fakturan 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) {
@@ -410,6 +424,8 @@ export default function SupplierInvoiceDetailPage() {
)}
+
+
{/* Pay Dialog */}
diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx
index aa86afa5..12316736 100644
--- a/app/(dashboard)/supplier-invoices/new/page.tsx
+++ b/app/(dashboard)/supplier-invoices/new/page.tsx
@@ -16,6 +16,8 @@ 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 type { Supplier, BASAccount, VatTreatment } from '@/types'
interface LineItem {
@@ -67,7 +69,7 @@ export default function NewSupplierInvoicePage() {
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState(null)
- const { register, control, handleSubmit, watch, setValue } = useForm({
+ const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
@@ -90,6 +92,8 @@ export default function NewSupplierInvoicePage() {
},
})
+ useUnsavedChanges(isDirty)
+
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
@@ -208,7 +212,7 @@ export default function NewSupplierInvoicePage() {
const result = await res.json()
if (!res.ok) {
- toast({ title: 'Fel', description: result.error || 'Kunde inte registrera faktura', variant: 'destructive' })
+ toast({ title: 'Kunde inte registrera faktura', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx
index 72a12d0b..d28774d9 100644
--- a/app/(dashboard)/suppliers/[id]/page.tsx
+++ b/app/(dashboard)/suppliers/[id]/page.tsx
@@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
+import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
const supplierTypeLabels: Record = {
@@ -31,6 +32,7 @@ export default function SupplierDetailPage() {
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
+ const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchSupplier()
@@ -76,7 +78,13 @@ export default function SupplierDetailPage() {
}
async function handleDelete() {
- if (!confirm('Är du säker på att du vill ta bort denna leverantör?')) return
+ const ok = await confirmAction({
+ title: 'Ta bort leverantör',
+ description: `"${supplier?.name}" 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/suppliers/${params.id}`, { method: 'DELETE' })
const result = await res.json()
@@ -267,6 +275,8 @@ export default function SupplierDetailPage() {
+
+
{/* Edit Dialog */}
diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx
index 39d7dfa5..a1a64115 100644
--- a/app/(onboarding)/onboarding/page.tsx
+++ b/app/(onboarding)/onboarding/page.tsx
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
+import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
@@ -26,6 +27,14 @@ const STEP_TITLES = [
'Tillägg',
]
+function translatePeriodError(msg: string): string {
+ if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
+ if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
+ if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
+ if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
+ return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
+}
+
export default function OnboardingPage() {
return (
}>
@@ -210,26 +219,33 @@ function OnboardingPageContent() {
: `Räkenskapsår ${currentYear}/${currentYear + 1}`
}
- // Validate period duration (max 18 months)
- const startDate = new Date(startStr)
- const endDate = new Date(endStr)
- const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
- (endDate.getMonth() - startDate.getMonth()) + 1
- if (months > 18) {
- console.error(`Period duration ${months} months exceeds 18-month maximum`)
- } else {
- await supabase.from('fiscal_periods').upsert({
- user_id: user.id,
- name: periodName,
- period_start: startStr,
- period_end: endStr,
- }, {
- onConflict: 'user_id,period_start,period_end',
+ // Validate period duration
+ const validationError = validatePeriodDuration(startStr, endStr)
+ if (validationError) {
+ toast({
+ title: 'Ogiltigt räkenskapsår',
+ description: translatePeriodError(validationError),
+ variant: 'destructive',
})
+ setCurrentStep(3)
+ return
}
+
+ await supabase.from('fiscal_periods').upsert({
+ user_id: user.id,
+ name: periodName,
+ period_start: startStr,
+ period_end: endStr,
+ }, {
+ onConflict: 'user_id,period_start,period_end',
+ })
}
} catch (err) {
- console.error('Failed to create fiscal period:', err)
+ toast({
+ title: 'Kunde inte skapa räkenskapsår',
+ description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.',
+ variant: 'destructive',
+ })
}
}
diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts
index 12cc79ce..c4b8087c 100644
--- a/app/api/extensions/push-notifications/cron/route.ts
+++ b/app/api/extensions/push-notifications/cron/route.ts
@@ -4,6 +4,7 @@ import { loadExtensions } from '@/lib/extensions/loader'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
+ sendMissingUnderlagNotifications,
} from '@/extensions/general/push-notifications/notification-scheduler'
/**
@@ -40,13 +41,14 @@ export async function GET(request: Request) {
try {
// Send all notification types in parallel
- const [taxResult, invoiceResult] = await Promise.all([
+ const [taxResult, invoiceResult, underlagResult] = await Promise.all([
sendTaxDeadlineNotifications(supabase),
sendInvoiceNotifications(supabase),
+ sendMissingUnderlagNotifications(supabase),
])
- const totalSent = taxResult.sent + invoiceResult.sent
- const totalSkipped = taxResult.skipped + invoiceResult.skipped
+ const totalSent = taxResult.sent + invoiceResult.sent + underlagResult.sent
+ const totalSkipped = taxResult.skipped + invoiceResult.skipped + underlagResult.skipped
console.log(
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
@@ -57,6 +59,9 @@ export async function GET(request: Request) {
console.log(
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
)
+ console.log(
+ ` Missing underlag: ${underlagResult.sent} sent, ${underlagResult.skipped} skipped`
+ )
return NextResponse.json({
success: true,
@@ -65,6 +70,7 @@ export async function GET(request: Request) {
details: {
taxDeadlines: taxResult,
invoices: invoiceResult,
+ missingUnderlag: underlagResult,
},
})
} catch (error) {
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 1ae8249d..bf647d1b 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -11,6 +11,7 @@ import {
generateInvoiceEmailSubject
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
+import { uploadDocument } from '@/lib/core/documents/document-service'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -166,6 +167,7 @@ export async function POST(
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
+ let createdJournalEntryId: string | undefined
if (isRealInvoice && ((company as Record).accounting_method === 'accrual' || !(company as Record).accounting_method)) {
try {
const journalEntry = await createInvoiceJournalEntry(
@@ -174,6 +176,7 @@ export async function POST(
(company as CompanySettings).entity_type
)
if (journalEntry) {
+ createdJournalEntryId = journalEntry.id
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
@@ -185,6 +188,24 @@ export async function POST(
}
}
+ // Auto-store invoice PDF as underlag and link to journal entry
+ if (isRealInvoice) {
+ try {
+ const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
+ await uploadDocument(user.id, {
+ name: filename,
+ buffer: pdfArrayBuffer,
+ type: 'application/pdf',
+ }, {
+ upload_source: 'system',
+ journal_entry_id: createdJournalEntryId,
+ })
+ } catch (err) {
+ console.error('Failed to store invoice PDF as underlag:', err)
+ // Non-blocking — don't fail the send
+ }
+ }
+
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id },
diff --git a/app/page.tsx b/app/page.tsx
index 9143e22a..80b5e80d 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -136,6 +136,36 @@ export default async function RootPage() {
.lt('amount', 0)
.is('receipt_id', null)
+ // Count journal entries missing underlag (documents)
+ const needsDocSourceTypes = [
+ 'manual',
+ 'bank_transaction',
+ 'supplier_invoice_registered',
+ 'supplier_invoice_paid',
+ 'supplier_invoice_cash_payment',
+ 'import',
+ ]
+
+ const { count: postedEntriesCount } = await supabase
+ .from('journal_entries')
+ .select('*', { count: 'exact', head: true })
+ .eq('user_id', user.id)
+ .eq('status', 'posted')
+ .in('source_type', needsDocSourceTypes)
+
+ const { data: entriesWithDocs } = await supabase
+ .from('document_attachments')
+ .select('journal_entry_id')
+ .eq('user_id', user.id)
+ .eq('is_current_version', true)
+ .not('journal_entry_id', 'is', null)
+
+ const uniqueEntriesWithDocs = new Set(
+ (entriesWithDocs || []).map((d) => d.journal_entry_id)
+ ).size
+
+ const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs)
+
// Calculate receipt streak
const { data: recentReceiptActivity } = await supabase
.from('receipts')
@@ -188,6 +218,7 @@ export default async function RootPage() {
bankBalance,
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
+ missingUnderlagCount,
}}
/>
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx
index 1384ad0c..ede79797 100644
--- a/components/bookkeeping/JournalEntryForm.tsx
+++ b/components/bookkeeping/JournalEntryForm.tsx
@@ -6,11 +6,13 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
-import { Plus, Trash2 } from 'lucide-react'
+import { Plus, Trash2, AlertTriangle } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types'
@@ -56,11 +58,17 @@ export default function JournalEntryForm({
)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
+ const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState([])
const [accounts, setAccounts] = useState([])
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
+ const hasContent = description !== '' ||
+ lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') ||
+ uploadedFiles.length > 0
+ useUnsavedChanges(hasContent)
+
useEffect(() => {
fetchPeriods()
fetchAccounts()
@@ -118,6 +126,11 @@ export default function JournalEntryForm({
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced) return
+ const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
+ if (!embedded && !hasDocuments) {
+ setShowNoDocWarning(true)
+ return
+ }
setShowReview(true)
}
@@ -152,8 +165,8 @@ export default function JournalEntryForm({
if (result.error) {
toast({
- title: 'Fel',
- description: result.error,
+ title: 'Kunde inte skapa verifikation',
+ description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }),
variant: 'destructive',
})
} else {
@@ -161,6 +174,7 @@ export default function JournalEntryForm({
const journalEntryId = result.data?.id ?? result.journal_entry_id
if (journalEntryId && uploadedFiles.length > 0) {
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
+ let linkFailCount = 0
for (const file of filesToLink) {
try {
await fetch(`/api/documents/${file.id}/link`, {
@@ -170,8 +184,16 @@ export default function JournalEntryForm({
})
} catch (linkErr) {
console.error('[JournalEntryForm] Failed to link document:', linkErr)
+ linkFailCount++
}
}
+ if (linkFailCount > 0) {
+ toast({
+ title: 'Underlag kunde inte bifogas',
+ description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
+ variant: 'destructive',
+ })
+ }
}
toast({
@@ -379,6 +401,31 @@ export default function JournalEntryForm({
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
/>
+
+ {/* Warning dialog when no documents attached */}
+ {
+ setShowNoDocWarning(false)
+ setShowReview(true)
+ }}
+ isSubmitting={false}
+ title="Underlag saknas"
+ warningText="Ingen verifikation har bifogats. Enligt bokforingslagen (BFL) kravs underlag for varje bokforingspost."
+ confirmLabel="Fortsatt anda"
+ >
+
+
+
+
Inget underlag bifogat
+
+ Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
+ underlag. Du kan bifoga underlag nu eller fortsatta utan.
+
+
+
+
)
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 97a1362d..5d591832 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -28,6 +28,7 @@ import {
CheckCircle2,
ClipboardList,
MessageCircle,
+ FileWarning,
} from 'lucide-react'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
@@ -47,6 +48,7 @@ interface DashboardContentProps {
bankBalance: number | null
deadlines: Deadline[]
receiptQueue: ReceiptQueueSummary | null
+ missingUnderlagCount: number
}
onboardingProgress?: OnboardingProgress
}
@@ -176,6 +178,29 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)
}
+ if (summary.missingUnderlagCount > 0) {
+ alertItems.push(
+
+
+
+
+
+
+
+
Saknade underlag
+
+ {summary.missingUnderlagCount} verifikationer utan underlag
+
+
+
+
+
+
+
+
+ )
+ }
+
const MAX_VISIBLE_ALERTS = 3
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
@@ -228,6 +253,9 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
}
+ if (summary.missingUnderlagCount > 0) {
+ todoItems.push({ label: 'saknade underlag', href: '/bookkeeping?missingUnderlag=true', count: summary.missingUnderlagCount, variant: 'warning' })
+ }
if (todoItems.length === 0) return null
diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx
index e1882faf..733733a0 100644
--- a/components/onboarding/Step3TaxRegistration.tsx
+++ b/components/onboarding/Step3TaxRegistration.tsx
@@ -11,8 +11,9 @@ import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
-import { Loader2, ArrowRight, ArrowLeft, Check } from 'lucide-react'
+import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
+import { monthsBetween } from '@/lib/bookkeeping/validate-period-duration'
import type { MomsPeriod, EntityType } from '@/types'
const schema = z.object({
@@ -58,6 +59,14 @@ const monthNames = [
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
+function formatSwedishDate(dateStr: string): string {
+ const d = new Date(dateStr)
+ const day = d.getDate()
+ const month = monthNames[d.getMonth()].toLowerCase()
+ const year = d.getFullYear()
+ return `${day} ${month} ${year}`
+}
+
/**
* Get the last day of a given month (1-indexed).
*/
@@ -163,6 +172,7 @@ export default function Step3TaxRegistration({
const firstYearStart = watch('first_year_start')
const firstYearEnd = watch('first_year_end')
const fiscalYearEndMonth = watch('fiscal_year_end_month')
+ const accountingMethod = watch('accounting_method')
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState(
@@ -424,6 +434,21 @@ export default function Step3TaxRegistration({
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
)}
+
+ {firstYearStart && firstYearEnd && (
+
+
+
+ Ditt första räkenskapsår
+
+
+ {formatSwedishDate(firstYearStart)} – {formatSwedishDate(firstYearEnd)}
+
+
+ {monthsBetween(firstYearStart, firstYearEnd)} månader
+
+
+ )}
)}
@@ -464,6 +489,21 @@ export default function Step3TaxRegistration({
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
+
+ {fiscalYearEndMonth && (
+
+
+
+ Ditt räkenskapsår
+
+
+ {fiscalYearEndMonth === 12
+ ? `1 januari \u2013 31 december (kalenderår)`
+ : `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
+
+
12 månader
+
+ )}
)}
@@ -594,11 +634,22 @@ export default function Step3TaxRegistration({
)}
/>
-
- {entityType === 'aktiebolag'
- ? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
- : 'Som enskild firma med omsättning under 3 MSEK kan du välja kontantmetoden.'}
-
+
+
+
+ {accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
+
+
+ {accountingMethod === 'accrual'
+ ? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
+ : 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
+
+ {entityType === 'aktiebolag' && (
+
+ Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
+
+ )}
+
diff --git a/components/settings/CalendarFeedSettings.tsx b/components/settings/CalendarFeedSettings.tsx
index 200f80b4..eae0696e 100644
--- a/components/settings/CalendarFeedSettings.tsx
+++ b/components/settings/CalendarFeedSettings.tsx
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { Badge } from '@/components/ui/badge'
import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react'
+import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { CalendarFeed } from '@/types'
interface CalendarFeedWithUrls extends CalendarFeed {
@@ -24,6 +25,7 @@ export function CalendarFeedSettings() {
const [isRegenerating, setIsRegenerating] = useState(false)
const [feed, setFeed] = useState(null)
const [copied, setCopied] = useState(false)
+ const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchFeed()
@@ -99,9 +101,13 @@ export function CalendarFeedSettings() {
}
const regenerateToken = async () => {
- if (!confirm('Är du säker? Den gamla länken kommer sluta fungera.')) {
- return
- }
+ const ok = await confirmAction({
+ title: 'Skapa ny kalender-länk',
+ description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.',
+ confirmLabel: 'Skapa ny länk',
+ variant: 'warning',
+ })
+ if (!ok) return
setIsRegenerating(true)
@@ -334,6 +340,8 @@ export function CalendarFeedSettings() {
+
+
)
}
diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx
index a47c1357..92cdc724 100644
--- a/components/transactions/TransactionBookingDialog.tsx
+++ b/components/transactions/TransactionBookingDialog.tsx
@@ -1,9 +1,15 @@
'use client'
+import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
-import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
+import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, Paperclip } from 'lucide-react'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
+import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
+import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { TransactionWithInvoice } from './transaction-types'
@@ -38,17 +44,58 @@ export default function TransactionBookingDialog({
transaction,
onBooked,
}: TransactionBookingDialogProps) {
+ const { toast } = useToast()
+ const [uploadedFiles, setUploadedFiles] = useState([])
+ const [showUploadZone, setShowUploadZone] = useState(false)
+
if (!transaction) return null
const isIncome = transaction.amount > 0
+ const handleBooked = async (transactionId: string, journalEntryId: string) => {
+ // Link any uploaded documents to the new journal entry
+ const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
+ if (filesToLink.length > 0) {
+ let linkFailCount = 0
+ for (const file of filesToLink) {
+ try {
+ await fetch(`/api/documents/${file.id}/link`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ journal_entry_id: journalEntryId }),
+ })
+ } catch (linkErr) {
+ console.error('[TransactionBookingDialog] Failed to link document:', linkErr)
+ linkFailCount++
+ }
+ }
+ if (linkFailCount > 0) {
+ toast({
+ title: 'Underlag kunde inte bifogas',
+ description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
+ variant: 'destructive',
+ })
+ }
+ }
+
+ setUploadedFiles([])
+ setShowUploadZone(false)
+ onBooked(transactionId, journalEntryId)
+ }
+
return (
-
+ {
+ if (!o) {
+ setUploadedFiles([])
+ setShowUploadZone(false)
+ }
+ onOpenChange(o)
+ }}>
- Bokför transaktion
+ Bokfor transaktion
- Skapa en verifikation för transaktionen
+ Skapa en verifikation for transaktionen
@@ -77,6 +124,39 @@ export default function TransactionBookingDialog({
+ {/* Document upload section */}
+
+
setShowUploadZone(!showUploadZone)}
+ className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
+ >
+
+
+
Underlag (valfritt)
+ {uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
+
+ {uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
+
+ )}
+
+ {showUploadZone ? (
+
+ ) : (
+
+ )}
+
+ {showUploadZone && (
+
+
+
+ )}
+
+
onBooked(transaction.id, entryId)}
+ onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
diff --git a/components/ui/destructive-confirm-dialog.tsx b/components/ui/destructive-confirm-dialog.tsx
new file mode 100644
index 00000000..136d0803
--- /dev/null
+++ b/components/ui/destructive-confirm-dialog.tsx
@@ -0,0 +1,179 @@
+'use client'
+
+import { useState, useCallback, useRef } from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { AlertTriangle, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+interface DestructiveConfirmDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: string
+ description: string
+ confirmLabel?: string
+ cancelLabel?: string
+ variant?: 'destructive' | 'warning'
+ onConfirm: () => void | Promise
+}
+
+export function DestructiveConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel = 'Bekräfta',
+ cancelLabel = 'Avbryt',
+ variant = 'destructive',
+ onConfirm,
+}: DestructiveConfirmDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleConfirm = async () => {
+ setIsLoading(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsLoading(false)
+ onOpenChange(false)
+ }
+ }
+
+ return (
+ {
+ if (isLoading) return
+ onOpenChange(v)
+ }}
+ >
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+ onOpenChange(false)}
+ disabled={isLoading}
+ >
+ {cancelLabel}
+
+
+ {isLoading ? (
+
+ ) : null}
+ {confirmLabel}
+
+
+
+
+ )
+}
+
+interface ConfirmOptions {
+ title: string
+ description: string
+ confirmLabel?: string
+ cancelLabel?: string
+ variant?: 'destructive' | 'warning'
+}
+
+interface UseDestructiveConfirmReturn {
+ dialogProps: DestructiveConfirmDialogProps
+ confirm: (options: ConfirmOptions) => Promise
+}
+
+/**
+ * Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`.
+ * Returns `Promise` — true if user confirms, false if they cancel.
+ *
+ * Usage:
+ * ```
+ * const { dialogProps, confirm } = useDestructiveConfirm()
+ *
+ * async function handleDelete() {
+ * const ok = await confirm({ title: '...', description: '...' })
+ * if (!ok) return
+ * // proceed with deletion
+ * }
+ *
+ * return <> >
+ * ```
+ */
+export function useDestructiveConfirm(): UseDestructiveConfirmReturn {
+ const [open, setOpen] = useState(false)
+ const [options, setOptions] = useState({
+ title: '',
+ description: '',
+ })
+ const resolveRef = useRef<((value: boolean) => void) | null>(null)
+
+ const confirm = useCallback((opts: ConfirmOptions): Promise => {
+ setOptions(opts)
+ setOpen(true)
+ return new Promise((resolve) => {
+ resolveRef.current = resolve
+ })
+ }, [])
+
+ const handleOpenChange = useCallback((v: boolean) => {
+ setOpen(v)
+ if (!v && resolveRef.current) {
+ resolveRef.current(false)
+ resolveRef.current = null
+ }
+ }, [])
+
+ const handleConfirm = useCallback(() => {
+ if (resolveRef.current) {
+ resolveRef.current(true)
+ resolveRef.current = null
+ }
+ }, [])
+
+ return {
+ dialogProps: {
+ open,
+ onOpenChange: handleOpenChange,
+ title: options.title,
+ description: options.description,
+ confirmLabel: options.confirmLabel,
+ cancelLabel: options.cancelLabel,
+ variant: options.variant,
+ onConfirm: handleConfirm,
+ },
+ confirm,
+ }
+}
diff --git a/extensions/general/push-notifications/index.ts b/extensions/general/push-notifications/index.ts
index 7fbd0d98..58d2c938 100644
--- a/extensions/general/push-notifications/index.ts
+++ b/extensions/general/push-notifications/index.ts
@@ -30,6 +30,7 @@ export interface PushNotificationSettings {
invoiceSentEnabled: boolean
receiptExtractedEnabled: boolean
receiptMatchedEnabled: boolean
+ missingUnderlagEnabled: boolean
}
const DEFAULT_SETTINGS: PushNotificationSettings = {
@@ -38,6 +39,7 @@ const DEFAULT_SETTINGS: PushNotificationSettings = {
invoiceSentEnabled: false,
receiptExtractedEnabled: true,
receiptMatchedEnabled: true,
+ missingUnderlagEnabled: true,
}
/** Get settings via ExtensionContext (preferred in event handlers) */
@@ -54,7 +56,7 @@ export async function getSettings(userId: string): Promise {
+ let sent = 0
+ let skipped = 0
+
+ // Get all users who have posted entries with source types that need docs
+ const { data: entries } = await supabase
+ .from('journal_entries')
+ .select('id, user_id')
+ .eq('status', 'posted')
+ .in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES)
+
+ if (!entries || entries.length === 0) {
+ return { sent: 0, skipped: 0 }
+ }
+
+ // Get all document_attachments linked to journal entries
+ const { data: attachments } = await supabase
+ .from('document_attachments')
+ .select('journal_entry_id')
+ .eq('is_current_version', true)
+ .not('journal_entry_id', 'is', null)
+
+ const entriesWithDocs = new Set(
+ (attachments || []).map((a) => a.journal_entry_id)
+ )
+
+ // Group missing counts by user
+ const userMissingCounts = new Map()
+ for (const entry of entries) {
+ if (!entriesWithDocs.has(entry.id)) {
+ userMissingCounts.set(
+ entry.user_id,
+ (userMissingCounts.get(entry.user_id) || 0) + 1
+ )
+ }
+ }
+
+ for (const [userId, count] of userMissingCounts) {
+ // Check user setting
+ const { data: settings } = await supabase
+ .from('notification_settings')
+ .select('missing_underlag_enabled')
+ .eq('user_id', userId)
+ .single()
+
+ if (settings && settings.missing_underlag_enabled === false) {
+ skipped++
+ continue
+ }
+
+ const payload = createMissingUnderlagPayload(count)
+
+ const result = await sendNotificationToUser(
+ supabase,
+ userId,
+ payload,
+ 'missing_underlag',
+ 'weekly-check'
+ )
+
+ if (result.sent) {
+ sent++
+ } else {
+ skipped++
+ }
+ }
+
+ return { sent, skipped }
+}
diff --git a/extensions/general/push-notifications/payload-builders.ts b/extensions/general/push-notifications/payload-builders.ts
index 36ca5359..a697ec3b 100644
--- a/extensions/general/push-notifications/payload-builders.ts
+++ b/extensions/general/push-notifications/payload-builders.ts
@@ -114,6 +114,24 @@ export function createReceiptMatchedPayload(
}
}
+// ============================================================
+// Missing underlag payload
+// ============================================================
+
+export function createMissingUnderlagPayload(count: number): NotificationPayload {
+ return {
+ title: 'Saknade underlag',
+ body: `${count} verifikation(er) saknar underlag. Bifoga for att uppfylla bokforingslagen.`,
+ icon: '/icons/icon-192.png',
+ badge: '/icons/badge-72.png',
+ tag: 'missing-underlag-weekly',
+ data: {
+ url: '/bookkeeping?missingUnderlag=true',
+ type: 'missing_underlag',
+ },
+ }
+}
+
// ============================================================
// Cron-based payloads (moved from lib/push/web-push.ts)
// ============================================================
diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts
new file mode 100644
index 00000000..2909d67e
--- /dev/null
+++ b/lib/errors/get-error-message.ts
@@ -0,0 +1,200 @@
+/**
+ * Maps raw errors to user-friendly Swedish messages.
+ *
+ * Priority chain:
+ * 1. Zod validation field errors
+ * 2. Postgres error code map
+ * 3. HTTP status code map
+ * 4. Context-specific fallback
+ * 5. Generic fallback
+ */
+
+type ErrorContext =
+ | 'invoice'
+ | 'supplier_invoice'
+ | 'customer'
+ | 'supplier'
+ | 'transaction'
+ | 'journal_entry'
+ | 'settings'
+ | 'auth'
+
+interface GetErrorMessageOptions {
+ context?: ErrorContext
+ statusCode?: number
+}
+
+// Postgres error codes -> Swedish messages
+const POSTGRES_ERROR_MAP: Record = {
+ '23505': 'En post med samma uppgifter finns redan.',
+ '23503': 'Posten kan inte ändras eftersom den refereras av annan data.',
+ '23502': 'Ett obligatoriskt fält saknas.',
+ '42501': 'Du har inte behörighet att utföra denna åtgärd.',
+ '42P01': 'Resursen kunde inte hittas.',
+ '23514': 'Värdet uppfyller inte de tillåtna kraven.',
+ '40001': 'En annan ändring pågick samtidigt. Försök igen.',
+ '40P01': 'En konflikt uppstod. Försök igen.',
+ '22P02': 'Ogiltigt värde angavs.',
+ '22003': 'Värdet är utanför tillåtet intervall.',
+}
+
+// HTTP status codes -> Swedish messages
+const HTTP_STATUS_MAP: Record = {
+ 400: 'Förfrågan innehåller ogiltiga uppgifter.',
+ 401: 'Din session har gått ut. Logga in igen.',
+ 403: 'Du har inte behörighet att utföra denna åtgärd.',
+ 404: 'Resursen kunde inte hittas.',
+ 409: 'En konflikt uppstod. Ladda om sidan och försök igen.',
+ 422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.',
+ 429: 'För många förfrågningar. Vänta en stund och försök igen.',
+ 500: 'Ett oväntat serverfel uppstod. Försök igen senare.',
+ 502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
+ 503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.',
+}
+
+// Context-specific fallbacks
+const CONTEXT_FALLBACKS: Record = {
+ invoice: 'Kunde inte hantera fakturan. Försök igen.',
+ supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.',
+ customer: 'Kunde inte hantera kunden. Försök igen.',
+ supplier: 'Kunde inte hantera leverantören. Försök igen.',
+ transaction: 'Kunde inte hantera transaktionen. Försök igen.',
+ journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
+ settings: 'Kunde inte spara inställningarna. Försök igen.',
+ auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
+}
+
+const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
+
+/**
+ * Simple heuristic to detect already-translated Swedish messages.
+ * If the message contains common Swedish words/patterns, pass it through.
+ */
+function isSwedishUserMessage(message: string): boolean {
+ const swedishPatterns = [
+ /kunde inte/i,
+ /försök igen/i,
+ /ogiltigt?/i,
+ /saknas/i,
+ /måste/i,
+ /redan finns/i,
+ /gick fel/i,
+ /behörighet/i,
+ /session/i,
+ /förfrågan/i,
+ /obligatorisk/i,
+ ]
+ return swedishPatterns.some((p) => p.test(message))
+}
+
+/**
+ * Extract a user-friendly message from a Zod validation error shape.
+ * Returns null if the error is not a Zod error.
+ */
+function tryParseZodErrors(error: unknown): string | null {
+ if (typeof error !== 'object' || error === null) return null
+
+ const obj = error as Record
+
+ // Check for Zod-style field errors: { fieldName: ["message"] } or { issues: [...] }
+ if (Array.isArray(obj.issues)) {
+ const issues = obj.issues as Array<{ message?: string; path?: string[] }>
+ const messages = issues
+ .slice(0, 3)
+ .map((issue) => {
+ const field = issue.path?.join('.') || ''
+ const msg = issue.message || 'ogiltigt värde'
+ return field ? `${field}: ${msg}` : msg
+ })
+ if (messages.length > 0) return messages.join('. ')
+ }
+
+ // Check for { errors: { field: ["msg"] } } shape from validateBody
+ if (typeof obj.errors === 'object' && obj.errors !== null) {
+ const fieldErrors = obj.errors as Record
+ const messages: string[] = []
+ for (const [field, msgs] of Object.entries(fieldErrors)) {
+ if (Array.isArray(msgs) && msgs.length > 0) {
+ messages.push(`${field}: ${msgs[0]}`)
+ }
+ if (messages.length >= 3) break
+ }
+ if (messages.length > 0) return messages.join('. ')
+ }
+
+ return null
+}
+
+/**
+ * Get a user-friendly Swedish error message from a raw error.
+ *
+ * @param error - The raw error. Can be an API response body (object), Error instance, string, or unknown.
+ * @param options - Optional context and HTTP status code.
+ */
+export function getErrorMessage(
+ error: unknown,
+ options: GetErrorMessageOptions = {}
+): string {
+ const { context, statusCode } = options
+
+ // 1. If it's a string, check if it's already Swedish
+ if (typeof error === 'string' && error.trim()) {
+ if (isSwedishUserMessage(error)) return error
+ }
+
+ // 2. If it's an object, try various parsing strategies
+ if (typeof error === 'object' && error !== null) {
+ const obj = error as Record
+
+ // Try Zod validation errors
+ const zodMessage = tryParseZodErrors(obj)
+ if (zodMessage) return zodMessage
+
+ // Try Postgres error code
+ if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) {
+ return POSTGRES_ERROR_MAP[obj.code]
+ }
+
+ // Try error.message if it's already a good Swedish message
+ if (typeof obj.error === 'string' && obj.error.trim()) {
+ if (isSwedishUserMessage(obj.error)) return obj.error
+ }
+
+ if (typeof obj.message === 'string' && obj.message.trim()) {
+ if (isSwedishUserMessage(obj.message)) return obj.message
+ }
+ }
+
+ // 3. Error instance
+ if (error instanceof Error && error.message.trim()) {
+ if (isSwedishUserMessage(error.message)) return error.message
+ }
+
+ // 4. HTTP status code map
+ if (statusCode && HTTP_STATUS_MAP[statusCode]) {
+ return HTTP_STATUS_MAP[statusCode]
+ }
+
+ // 5. Context-specific fallback
+ if (context && CONTEXT_FALLBACKS[context]) {
+ return CONTEXT_FALLBACKS[context]
+ }
+
+ // 6. Generic fallback
+ return GENERIC_FALLBACK
+}
+
+/**
+ * Helper that parses a Response body and returns a user-friendly error message.
+ */
+export async function getResponseErrorMessage(
+ response: Response,
+ context?: ErrorContext
+): Promise {
+ try {
+ const body = await response.json()
+ return getErrorMessage(body, { context, statusCode: response.status })
+ } catch {
+ return getErrorMessage(null, { context, statusCode: response.status })
+ }
+}
diff --git a/lib/hooks/use-unsaved-changes.ts b/lib/hooks/use-unsaved-changes.ts
new file mode 100644
index 00000000..176dcbd9
--- /dev/null
+++ b/lib/hooks/use-unsaved-changes.ts
@@ -0,0 +1,22 @@
+'use client'
+
+import { useEffect } from 'react'
+
+/**
+ * Attaches a `beforeunload` event listener when the form has unsaved changes.
+ * This guards against browser close, tab close, and page refresh.
+ *
+ * Does NOT guard in-app Next.js navigation (App Router has no supported mechanism).
+ */
+export function useUnsavedChanges(isDirty: boolean) {
+ useEffect(() => {
+ if (!isDirty) return
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault()
+ }
+
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [isDirty])
+}
diff --git a/types/index.ts b/types/index.ts
index 1b02e726..0aed19e5 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1220,6 +1220,7 @@ export type NotificationType =
| 'receipt_extracted'
| 'receipt_matched'
| 'invoice_sent'
+ | 'missing_underlag'
// Notification log entry
export interface NotificationLog {