'use client' import { useCallback, useEffect, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Loader2 } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { roundOre } from '@/lib/money' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import type { WebshopOrder } from '@/types' interface MarkOrderBookedDialogProps { open: boolean onOpenChange: (open: boolean) => void order: WebshopOrder onMarked: () => void } interface EntryCandidate { id: string entry_date: string description: string | null voucher_series?: string | null voucher_number?: number | null /** The list API returns full rows with nested lines; the gross is their debit sum. */ lines?: Array<{ debit_amount: number | string | null }> } /** Gross amount of a candidate = sum of its debit legs (total_amount is a DB * computed column and not part of the select the list route returns). */ function candidateGross(entry: EntryCandidate): number | null { if (!entry.lines || entry.lines.length === 0) return null const sum = entry.lines.reduce((acc, l) => acc + (Number(l.debit_amount) || 0), 0) return roundOre(sum) } // ±45 days around the order date: wide enough for a manual booking done in // the same period, narrow enough to keep the candidate list short. Typing a // search drops the window (search over all posted entries instead). const WINDOW_DAYS = 45 const CANDIDATE_LIMIT = 30 function shiftDate(isoDate: string, deltaDays: number): string { const d = new Date(isoDate) if (Number.isNaN(d.getTime())) return isoDate d.setDate(d.getDate() + deltaDays) return d.toISOString().slice(0, 10) } /** * Marks one order/refund row as already booked/handled outside the * integration (issue #1879): no verifikat is created, the row just leaves * the to-book list. Optionally links the existing posted verifikat that * covers the order, picked from a searchable candidate list. */ export default function MarkOrderBookedDialog({ open, onOpenChange, order, onMarked, }: MarkOrderBookedDialogProps) { const t = useTranslations('webshop_orders') const errorLocale = useLocale() as ErrorLocale const { toast } = useToast() const [candidates, setCandidates] = useState([]) const [loading, setLoading] = useState(false) const [search, setSearch] = useState('') const [selected, setSelected] = useState('') const [submitting, setSubmitting] = useState(false) const loadCandidates = useCallback( async (query: string, signal: { cancelled: boolean }) => { setLoading(true) try { const params = new URLSearchParams() params.set('status', 'posted') params.set('exclude_draft', 'true') params.set('limit', String(CANDIDATE_LIMIT)) // Newest first: the manual booking is usually recent relative to the // window; default voucher order would surface the year's first // vouchers and hide the relevant ones behind the cap. params.set('sort_by', 'date_desc') if (query) { params.set('search', query) } else { const anchor = order.paid_date ?? order.order_date params.set('date_from', shiftDate(anchor, -WINDOW_DAYS)) params.set('date_to', shiftDate(anchor, WINDOW_DAYS)) } const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) if (!res.ok) throw new Error(`list failed: ${res.status}`) const json = (await res.json()) as { data: EntryCandidate[] } if (!signal.cancelled) setCandidates(json.data ?? []) } catch { if (!signal.cancelled) setCandidates([]) } finally { if (!signal.cancelled) setLoading(false) } }, [order.paid_date, order.order_date], ) // (Re)load when the dialog opens or the search changes (debounced). useEffect(() => { if (!open) return const signal = { cancelled: false } const timer = setTimeout(() => void loadCandidates(search.trim(), signal), 250) return () => { signal.cancelled = true clearTimeout(timer) } }, [open, search, loadCandidates]) // Reset transient state when the dialog closes. useEffect(() => { if (open) return setCandidates([]) setSearch('') setSelected('') }, [open]) async function handleConfirm() { setSubmitting(true) try { const res = await fetch(`/api/webshop-orders/${order.id}/mark-booked`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(selected ? { journal_entry_id: selected } : {}), }) const json = await res.json() if (!res.ok || json.error) { toast({ title: t('mark_failed'), description: getErrorMessage(json, { context: 'transaction', statusCode: res.status, locale: errorLocale, }), variant: 'destructive', }) return } onMarked() } catch { toast({ title: t('mark_failed'), variant: 'destructive' }) } finally { setSubmitting(false) } } const isRefund = order.row_type === 'refund' return ( {/* data-ph-mask: the order number is user data */} {isRefund ? t('mark_refund_title', { number: order.order_number }) : t('mark_title', { number: order.order_number })} {formatDate(order.paid_date ?? order.order_date)} {' · '} {formatCurrency(order.total, order.currency)}

{t('mark_description')}

{t('mark_link_label')}

setSearch(e.target.value)} placeholder={t('mark_link_search_placeholder')} aria-label={t('mark_link_search_placeholder')} /> {loading ? (
{t('mark_link_loading')}
) : candidates.length === 0 ? (

{t('mark_link_empty')}

) : (
{candidates.map((entry) => { const active = selected === entry.id const gross = candidateGross(entry) return ( ) })}
)}

{candidates.length >= CANDIDATE_LIMIT ? t('mark_link_capped', { count: CANDIDATE_LIMIT }) : t('mark_link_optional_hint')}

) }