* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
155 lines
5.3 KiB
TypeScript
155 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import dynamic from 'next/dynamic'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Button } from '@/components/ui/button'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import {
|
|
buildInvoiceCopyInitial,
|
|
canCopyInvoice,
|
|
type InvoiceCopyInitial,
|
|
type InvoiceCopySource,
|
|
} from '@/lib/invoices/copy-invoice'
|
|
|
|
// Deferred: the editor (and its framer-motion dependency) is a large chunk
|
|
// that would otherwise ship with the invoice LIST bundle: it is only needed
|
|
// once this dialog actually opens.
|
|
const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="space-y-4 p-6">
|
|
<Skeleton className="h-8 w-1/3" />
|
|
<Skeleton className="h-32 w-full" />
|
|
<Skeleton className="h-32 w-full" />
|
|
</div>
|
|
),
|
|
})
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
copyFromId?: string | null
|
|
}
|
|
|
|
/**
|
|
* "Ny faktura" as a modal: mirrors NewJournalEntryDialog. Wraps the bare
|
|
* InvoiceEditor; the editor's own review/confirm/send dialogs stack on top of
|
|
* this one, and every successful create navigates to the invoice detail page
|
|
* (unmounting the host list page and this dialog with it).
|
|
*
|
|
* The accessible title is visually hidden: the bare editor renders its own
|
|
* live heading, which tracks document type (faktura/proforma/följesedel) and
|
|
* shows the invoice-number preview: a static DialogTitle would duplicate or
|
|
* contradict it.
|
|
*/
|
|
export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) {
|
|
const t = useTranslations('invoice_editor')
|
|
const { company } = useCompany()
|
|
const supabase = useMemo(() => createClient(), [])
|
|
const [copyLoad, setCopyLoad] = useState<{
|
|
sourceId: string | null
|
|
initial: InvoiceCopyInitial | null
|
|
failed: boolean
|
|
}>({ sourceId: null, initial: null, failed: false })
|
|
|
|
useEffect(() => {
|
|
if (!open || !copyFromId) return
|
|
if (!company?.id) return
|
|
|
|
let cancelled = false
|
|
|
|
const loadCopySource = async () => {
|
|
const { data, error } = await supabase
|
|
.from('invoices')
|
|
.select('*')
|
|
.eq('id', copyFromId)
|
|
.eq('company_id', company.id)
|
|
.single()
|
|
|
|
let items: Record<string, unknown>[] = []
|
|
if (!error && data) {
|
|
try {
|
|
items = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
|
|
supabase
|
|
.from('invoice_items')
|
|
.select('*')
|
|
.eq('invoice_id', copyFromId)
|
|
.eq('company_id', company.id)
|
|
.order('id', { ascending: true })
|
|
.range(from, to),
|
|
)
|
|
} catch {
|
|
if (!cancelled) setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
|
|
return
|
|
}
|
|
}
|
|
|
|
if (cancelled) return
|
|
const source = data ? { ...data, items } : null
|
|
if (error || !source || !canCopyInvoice(source)) {
|
|
setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
|
|
return
|
|
}
|
|
setCopyLoad({
|
|
sourceId: copyFromId,
|
|
initial: buildInvoiceCopyInitial(source as InvoiceCopySource),
|
|
failed: false,
|
|
})
|
|
}
|
|
|
|
void loadCopySource()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [company?.id, copyFromId, open, supabase])
|
|
|
|
const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
|
|
const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className="sm:max-w-5xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
|
// A half-typed invoice must survive an accidental backdrop click or a
|
|
// stray Escape (nested comboboxes and date pickers portal outside the
|
|
// dialog). Closing is explicit: the header X. Same convention as
|
|
// NewJournalEntryDialog.
|
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
onPointerDownOutside={(e) => e.preventDefault()}
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<DialogTitle className="sr-only">
|
|
{copyFromId ? t('title_copy') : t('title_invoice')}
|
|
</DialogTitle>
|
|
{copyFromId ? (
|
|
copyLoadFailed ? (
|
|
<div className="space-y-4 p-6 text-center">
|
|
<p className="font-medium">{t('copy_load_failed_title')}</p>
|
|
<p className="text-sm text-muted-foreground">{t('copy_load_failed_description')}</p>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
{t('close')}
|
|
</Button>
|
|
</div>
|
|
) : copyInitial ? (
|
|
<InvoiceEditor key={copyFromId} mode="copy" initial={copyInitial} bare />
|
|
) : (
|
|
<div className="space-y-4 p-6">
|
|
<Skeleton className="h-8 w-1/3" />
|
|
<Skeleton className="h-32 w-full" />
|
|
<Skeleton className="h-32 w-full" />
|
|
</div>
|
|
)
|
|
) : (
|
|
<InvoiceEditor mode="create" bare />
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|