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>