UX optimization

This commit is contained in:
Jakob Wennberg
2026-02-23 19:05:16 +01:00
parent c510bfab9e
commit 431f385b4b
23 changed files with 937 additions and 58 deletions
+6 -5
View File
@@ -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 {
+11 -1
View File
@@ -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({
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Edit dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+14 -10
View File
@@ -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<FormData>({
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 {
+32
View File
@@ -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}
/>
@@ -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() {
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
@@ -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<FormData | null>(null)
const { register, control, handleSubmit, watch, setValue } = useForm<FormData>({
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
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)
+11 -1
View File
@@ -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<SupplierType, string> = {
@@ -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() {
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+32 -16
View File
@@ -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 (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
@@ -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',
})
}
}
@@ -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) {
+21
View File
@@ -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<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).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 },
+31
View File
@@ -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,
}}
/>
</div>
+50 -3
View File
@@ -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<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
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}
/>
</ConfirmationDialog>
{/* Warning dialog when no documents attached */}
<ConfirmationDialog
open={showNoDocWarning}
onOpenChange={setShowNoDocWarning}
onConfirm={() => {
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"
>
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-300">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsatta utan.
</p>
</div>
</div>
</ConfirmationDialog>
</div>
)
+28
View File
@@ -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(
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
</Link>
)
}
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
+57 -6
View File
@@ -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<number>(
@@ -424,6 +434,21 @@ export default function Step3TaxRegistration({
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
</p>
)}
{firstYearStart && firstYearEnd && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt första räkenskapsår
</div>
<p className="text-sm text-muted-foreground">
{formatSwedishDate(firstYearStart)} &ndash; {formatSwedishDate(firstYearEnd)}
</p>
<p className="text-xs text-muted-foreground">
{monthsBetween(firstYearStart, firstYearEnd)} månader
</p>
</div>
)}
</div>
)}
@@ -464,6 +489,21 @@ export default function Step3TaxRegistration({
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
</p>
{fiscalYearEndMonth && (
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt räkenskapsår
</div>
<p className="text-sm text-muted-foreground">
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
</p>
<p className="text-xs text-muted-foreground">12 månader</p>
</div>
)}
</div>
)}
</div>
@@ -594,11 +634,22 @@ export default function Step3TaxRegistration({
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{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.'}
</p>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
</div>
<p className="text-sm text-muted-foreground">
{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.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-amber-700 bg-amber-50 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
</div>
</div>
</div>
+11 -3
View File
@@ -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<CalendarFeedWithUrls | null>(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() {
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
@@ -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<UploadedFile[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(o) => {
if (!o) {
setUploadedFiles([])
setShowUploadZone(false)
}
onOpenChange(o)
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogTitle>Bokfor transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation för transaktionen
Skapa en verifikation for transaktionen
</DialogDescription>
</DialogHeader>
@@ -77,6 +124,39 @@ export default function TransactionBookingDialog({
</p>
</div>
{/* Document upload section */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag (valfritt)</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
<JournalEntryForm
key={transaction.id}
embedded
@@ -86,7 +166,7 @@ export default function TransactionBookingDialog({
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => onBooked(transaction.id, entryId)}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
</DialogContent>
</Dialog>
@@ -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<void>
}
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 (
<Dialog
open={open}
onOpenChange={(v) => {
if (isLoading) return
onOpenChange(v)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-start gap-4">
<div
className={cn(
'flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full',
variant === 'destructive'
? 'bg-red-100 text-red-600'
: 'bg-amber-100 text-amber-600'
)}
>
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={isLoading}
className={
variant === 'warning'
? 'bg-amber-600 hover:bg-amber-700 text-white'
: undefined
}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ConfirmOptions {
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: 'destructive' | 'warning'
}
interface UseDestructiveConfirmReturn {
dialogProps: DestructiveConfirmDialogProps
confirm: (options: ConfirmOptions) => Promise<boolean>
}
/**
* Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`.
* Returns `Promise<boolean>` — 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 <><DestructiveConfirmDialog {...dialogProps} /></>
* ```
*/
export function useDestructiveConfirm(): UseDestructiveConfirmReturn {
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
description: '',
})
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setOpen(true)
return new Promise<boolean>((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,
}
}
@@ -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<PushNotificationSetti
const { data } = await supabase
.from('notification_settings')
.select(
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled'
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled, missing_underlag_enabled'
)
.eq('user_id', userId)
.single()
@@ -66,6 +68,7 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
invoiceSentEnabled: data.invoice_sent_enabled ?? DEFAULT_SETTINGS.invoiceSentEnabled,
receiptExtractedEnabled: data.receipt_extracted_enabled ?? DEFAULT_SETTINGS.receiptExtractedEnabled,
receiptMatchedEnabled: data.receipt_matched_enabled ?? DEFAULT_SETTINGS.receiptMatchedEnabled,
missingUnderlagEnabled: data.missing_underlag_enabled ?? DEFAULT_SETTINGS.missingUnderlagEnabled,
}
}
@@ -89,6 +92,7 @@ export async function saveSettings(
invoice_sent_enabled: merged.invoiceSentEnabled,
receipt_extracted_enabled: merged.receiptExtractedEnabled,
receipt_matched_enabled: merged.receiptMatchedEnabled,
missing_underlag_enabled: merged.missingUnderlagEnabled,
},
{ onConflict: 'user_id' }
)
@@ -16,6 +16,7 @@ import {
createTaxDeadlinePayload,
createInvoiceOverduePayload,
createInvoiceDuePayload,
createMissingUnderlagPayload,
} from './payload-builders'
/**
@@ -208,3 +209,92 @@ export async function sendInvoiceNotifications(
return { sent, skipped }
}
/**
* Source types that require supporting documents (underlag).
*/
const NEEDS_ATTACHMENT_SOURCE_TYPES = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
/**
* Send missing underlag notifications.
* Checks all users for posted journal entries without attached documents.
* Deduplicates via the 'missing-underlag-weekly' tag on the notification payload.
*/
export async function sendMissingUnderlagNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
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<string, number>()
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 }
}
@@ -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)
// ============================================================
+200
View File
@@ -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<string, string> = {
'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<number, string> = {
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<ErrorContext, string> = {
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<string, unknown>
// 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<string, string[]>
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<string, unknown>
// 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<string> {
try {
const body = await response.json()
return getErrorMessage(body, { context, statusCode: response.status })
} catch {
return getErrorMessage(null, { context, statusCode: response.status })
}
}
+22
View File
@@ -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])
}
+1
View File
@@ -1220,6 +1220,7 @@ export type NotificationType =
| 'receipt_extracted'
| 'receipt_matched'
| 'invoice_sent'
| 'missing_underlag'
// Notification log entry
export interface NotificationLog {