'use client' import { useEffect, useRef, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table' import type { ScbCandidate } from '@/lib/parties/scb/client' import type { RegistryCandidatesResult } from '@/lib/parties/registry-search' import type { RegisterRow } from '@/lib/parties/register' import { formatOrgNumber } from '@/lib/utils' import { regionName } from './SuggestionQueue' /** * One list instead of one picker per row. For every suggestion without an * org number the server plans a query from the voucher text and asks SCB; * a row whose query matches exactly one active company is shown with that * company ticked, and the person approves the whole list at once. Zero or * several matches, and foreign companies, are listed underneath with the * per-row picker still available. Nothing is chosen without the click. * * SCB allows ten calls per ten seconds and one search is up to two calls, * so rows are asked one at a time with a pause between them; results appear * as they land and approval can start before the last row is in. */ /** Spacing between SCB rounds: two calls per search under ten per ten seconds. */ export const REVIEW_STEP_MS = 2200 export type ReviewOutcome = | { kind: 'match'; candidate: ScbCandidate; aiRead: RegistryCandidatesResult['aiRead'] } | { kind: 'choose'; count: number; aiRead: RegistryCandidatesResult['aiRead'] } | { kind: 'none'; aiRead: RegistryCandidatesResult['aiRead'] } | { kind: 'foreign'; name: string; country: string | null } | { kind: 'failed' } export interface ReviewState { row: RegisterRow outcome: ReviewOutcome | null approved: boolean saved: 'pending' | 'saving' | 'done' | 'failed' } export function outcomeOf(result: RegistryCandidatesResult): ReviewOutcome { if (result.foreign && result.candidates.length === 0) return { kind: 'foreign', name: result.foreign.name, country: result.foreign.country ?? null } const active = result.candidates.filter((c) => c.active) if (!result.truncated && active.length === 1 && result.candidates.length === 1) return { kind: 'match', candidate: active[0]!, aiRead: result.aiRead } if (result.candidates.length === 0) return { kind: 'none', aiRead: result.aiRead } return { kind: 'choose', count: result.truncated ? result.total : result.candidates.length, aiRead: result.aiRead } } export function RegistryReviewDialog({ open, rows, onOpenChange, onChoose, onApproved, delayMs = REVIEW_STEP_MS, }: { open: boolean rows: RegisterRow[] onOpenChange: (open: boolean) => void /** Open the per-row picker for a row the list could not settle. */ onChoose: (row: RegisterRow) => void /** Called once after the approved org numbers are saved. */ onApproved: (saved: number, failed: number) => void delayMs?: number }) { const t = useTranslations('parties') const tCommon = useTranslations('common') const locale = useLocale() const [states, setStates] = useState([]) const [searched, setSearched] = useState(0) const [saving, setSaving] = useState(false) const runId = useRef(0) // Ask SCB row by row while the dialog is open; a closed dialog stops the loop. useEffect(() => { if (!open) return const id = ++runId.current const initial: ReviewState[] = rows.map((row) => ({ row, outcome: null, approved: false, saved: 'pending' })) setStates(initial) setSearched(0) let cancelled = false void (async () => { for (let i = 0; i < rows.length; i += 1) { if (cancelled || runId.current !== id) return const row = rows[i]! let outcome: ReviewOutcome try { const res = await fetch(`/api/parties/${row.id}/enrich/candidates`) if (!res.ok) throw new Error(String(res.status)) const json = (await res.json()) as { data: RegistryCandidatesResult } outcome = outcomeOf(json.data) } catch { outcome = { kind: 'failed' } } if (cancelled || runId.current !== id) return setStates((prev) => prev.map((s) => (s.row.id === row.id ? { ...s, outcome, approved: outcome.kind === 'match' } : s))) setSearched(i + 1) if (i < rows.length - 1) await new Promise((r) => setTimeout(r, delayMs)) } })() return () => { cancelled = true } // rows is the snapshot the dialog was opened with; re-running on every // register reload would restart the SCB loop mid-way. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, delayMs]) const searching = open && searched < rows.length const matches = states.filter((s) => s.outcome?.kind === 'match') const others = states.filter((s) => s.outcome && s.outcome.kind !== 'match') const approvedCount = matches.filter((s) => s.approved && s.saved === 'pending').length async function approve() { const chosen = matches.filter((s) => s.approved && s.saved === 'pending') if (chosen.length === 0) return setSaving(true) let saved = 0 let failed = 0 for (let i = 0; i < chosen.length; i += 1) { const s = chosen[i]! const candidate = (s.outcome as Extract).candidate setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: 'saving' } : x))) let ok = false try { const res = await fetch(`/api/parties/${s.row.id}/enrich`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orgNumber: candidate.orgNumber }), }) ok = res.ok } catch { ok = false } if (ok) saved += 1 else failed += 1 setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, saved: ok ? 'done' : 'failed' } : x))) if (i < chosen.length - 1) await new Promise((r) => setTimeout(r, delayMs / 2)) } setSaving(false) onApproved(saved, failed) } return ( (!saving ? onOpenChange(o) : undefined)}> {t('review_title')} {searching ? t('review_searching', { done: searched, total: rows.length }) : t('review_intro')}
{matches.length > 0 ? (
{matches.map((s) => { const o = s.outcome as Extract return ( ) })}
{t('review_col_book')} {t('review_col_match')}
setStates((prev) => prev.map((x) => (x.row.id === s.row.id ? { ...x, approved: v === true } : x)))} aria-label={s.row.displayName} /> {s.row.displayName} {o.aiRead ? {t('review_ai_read', { name: o.aiRead.name })} : null} {o.candidate.name} {[formatOrgNumber(o.candidate.orgNumber), o.candidate.city, o.candidate.industry].filter(Boolean).join(' ยท ')} {s.saved === 'done' ? {t('review_saved')} : s.saved === 'failed' ? {t('review_failed')} : null}
) : null} {others.length > 0 ? (

{t('review_others')}

    {others.map((s) => { const o = s.outcome! return (
  • {s.row.displayName} {'aiRead' in o && o.aiRead ? {t('review_ai_read', { name: o.aiRead.name })} : null} {o.kind === 'foreign' ? t('review_foreign', { place: o.country ? ` (${regionName(o.country, locale)})` : '' }) : o.kind === 'choose' ? t('review_choose_n', { count: o.count }) : o.kind === 'failed' ? t('registry_unavailable_title') : t('review_none')} {o.kind === 'choose' || o.kind === 'none' ? ( ) : null}
  • ) })}
) : null} {!searching && matches.length === 0 && others.length === 0 ?

{t('review_empty')}

: null}
) }