Fix/usr fdbck ch (#1105)

* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
This commit is contained in:
Mattsson
2026-07-22 18:49:57 +02:00
committed by GitHub
parent 3e1ea29d02
commit 321e684523
58 changed files with 3742 additions and 285 deletions
@@ -0,0 +1,159 @@
'use client'
import { useFormatter, useTranslations } from 'next-intl'
import { ExternalLink, Mail, Send } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type { InvoiceDelivery } from '@/types'
export type InvoiceDeliveryView = Pick<
InvoiceDelivery,
| 'id'
| 'channel'
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'sent_at'
| 'failed_at'
| 'created_at'
> & {
status: 'pending' | 'sent' | 'failed' | 'marked_sent'
}
interface InvoiceDeliveryHistoryProps {
deliveries: InvoiceDeliveryView[]
showLegacyEmptyState: boolean
}
const statusVariant = {
pending: 'secondary',
sent: 'success',
failed: 'destructive',
marked_sent: 'outline',
} as const
export function InvoiceDeliveryHistory({
deliveries,
showLegacyEmptyState,
}: InvoiceDeliveryHistoryProps) {
const t = useTranslations('invoice_detail')
const format = useFormatter()
if (deliveries.length === 0 && !showLegacyEmptyState) return null
const formatTimestamp = (value: string) =>
format.dateTime(new Date(value), {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
return (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t('delivery_history_title')}
</CardTitle>
<CardDescription>{t('delivery_history_description')}</CardDescription>
</CardHeader>
<CardContent>
{deliveries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
</div>
) : (
<div className="space-y-3">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
return (
<details key={delivery.id} className="group rounded-lg border bg-card">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatTimestamp(occurredAt)}
</span>
</span>
<Badge variant={statusVariant[delivery.status]}>
{t(`delivery_status_${delivery.status}`)}
</Badge>
</summary>
<div className="px-4 pb-4">
<Separator className="mb-4" />
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<div className="space-y-4 text-sm">
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
</dl>
{delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full">
<a
href={`/api/documents/${delivery.document_attachment_id}/inline`}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
<span className="truncate">
{t('delivery_open_pdf', { filename: t('delivery_pdf_fallback') })}
</span>
</a>
</Button>
)}
{(delivery.provider || delivery.error_code) && (
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
{delivery.provider && (
<>
<dt>{t('delivery_provider_label')}</dt>
<dd>{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt>{t('delivery_error_label')}</dt>
<dd>{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
)}
</div>
</details>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
+14 -1
View File
@@ -27,6 +27,7 @@ import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
@@ -77,7 +78,13 @@ export default function PaymentBookingDialog({
}
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
const accountNameByNumber = useMemo(() => {
const names = new Map(catalog.map((account) => [account.account_number, account.account_name]))
for (const account of accounts) names.set(account.account_number, account.account_name)
return names
}, [accounts, catalog])
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
@@ -108,7 +115,10 @@ export default function PaymentBookingDialog({
async function init() {
try {
// Fetch accounts
const accountsRes = await fetch('/api/bookkeeping/accounts')
const [accountsRes, fetchedCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
const fetchedAccounts: BASAccount[] = accountsData.data || []
@@ -126,6 +136,7 @@ export default function PaymentBookingDialog({
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -424,6 +435,7 @@ export default function PaymentBookingDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Button
@@ -490,6 +502,7 @@ export default function PaymentBookingDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Input
+16 -1
View File
@@ -30,6 +30,7 @@ import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -68,8 +69,14 @@ export default function SendInvoiceDialog({
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
const [deferBooking, setDeferBooking] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false)
const accountNameByNumber = useMemo(() => {
const names = new Map(catalog.map((account) => [account.account_number, account.account_name]))
for (const account of accounts) names.set(account.account_number, account.account_name)
return names
}, [accounts, catalog])
// The accrual book-at-issue path (both email send and manual mark-sent)
// lets the user adjust the proposed lines before booking (same editor as
@@ -133,16 +140,22 @@ export default function SendInvoiceDialog({
// Line editing needs the chart of accounts; only the accrual
// book-at-issue path renders the editor, so skip the fetch elsewhere.
let fetchedAccounts: BASAccount[] = []
let fetchedCatalog: CatalogAccount[] = []
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
const accountsRes = await fetch('/api/bookkeeping/accounts')
const [accountsRes, catalogResult] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
fetchedAccounts = accountsData.data || []
fetchedCatalog = catalogResult
}
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
setPeriodName(periodResult.data?.name || '')
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
@@ -427,6 +440,7 @@ export default function SendInvoiceDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Button
@@ -504,6 +518,7 @@ export default function SendInvoiceDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Input