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 */} +
+ + {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} +
+
+
+ + + + +
+
+ ) +} + +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 {