UX optimization
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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)} – {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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1220,6 +1220,7 @@ export type NotificationType =
|
||||
| 'receipt_extracted'
|
||||
| 'receipt_matched'
|
||||
| 'invoice_sent'
|
||||
| 'missing_underlag'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
|
||||
Reference in New Issue
Block a user