import React, { useCallback, useEffect, useState } from 'react'; import { AlertTriangle, CheckCircle2, ClipboardList, Loader2, Save, ShieldAlert } from 'lucide-react'; import toast from 'react-hot-toast'; import { getGenerationReadiness, postProjectFacts } from '../../api/client'; type MissingField = { field: string; label: string; category: string; hint?: string; }; /** Spine report slice attached by backend apply_eligibility_to_completeness. */ type MspSlice = { status?: string; confidence?: number; source?: string; is_sme?: boolean; is_large?: boolean; message?: string; }; type DeMinimisSlice = { used_eur?: number | null; limit_eur?: number; remaining_eur?: number | null; exhausted?: boolean; source?: string; configured?: boolean; message?: string; }; export type EligibilityReport = { nip?: string; msp?: MspSlice; de_minimis?: DeMinimisSlice; blockers?: string[]; ready_for_full_autopilot?: boolean; spine_enabled?: boolean; eligible_instrument_kinds?: string[]; }; export type Readiness = { status?: string; ready_to_generate?: boolean; full_autopilot_allowed?: boolean; missing_fields?: MissingField[]; warnings?: string[]; instrument_family?: string; dossier_level?: string; eligibility?: EligibilityReport; eligibility_spine_enforced?: boolean; }; type Props = { projectId: string; onReadyChange?: (ready: boolean, readiness: Readiness | null) => void; }; const MSP_LABELS: Record = { mikro: 'Mikro', mala: 'Mała', srednia: 'Średnia', duza: 'Duża', unknown: 'Nieustalony', }; /** Pure: format MŚP status for UI (fail-closed on empty/unknown). */ export function formatMspLabel(status?: string | null): string { const key = String(status || 'unknown').toLowerCase().trim(); return MSP_LABELS[key] || MSP_LABELS.unknown; } /** Pure: format EUR amounts; null/undefined → "brak danych" (fail-closed). */ export function formatEur(value?: number | null): string { if (value === null || value === undefined || Number.isNaN(Number(value))) { return 'brak danych'; } return `${Number(value).toLocaleString('pl-PL', { maximumFractionDigits: 0 })} EUR`; } /** * Pure: whether readiness payload warrants an Eligibility (MŚP / de minimis) section. * Show when: * - `readiness.eligibility` report is present (incl. blockers), or * - any `missing_fields` entry has `category === 'eligibility'`. */ export function shouldShowEligibilitySection(readiness: Readiness | null | undefined): boolean { if (!readiness) return false; if (readiness.eligibility && typeof readiness.eligibility === 'object') return true; const missing = readiness.missing_fields || []; return missing.some((m) => m.category === 'eligibility'); } /** * Pure: spine is product-gated (enforced) for this readiness payload. * Explicit `false` on either flag → informational only. * Absent flags → treat as enforced (fail-closed). */ export function isEligibilitySpineEnforced(readiness: Readiness | null | undefined): boolean { if (!readiness) return true; const elig = readiness.eligibility; if (readiness.eligibility_spine_enforced === false) return false; if (elig && typeof elig === 'object' && elig.spine_enabled === false) return false; return true; } /** * Pure: eligibility is blocking Full Autopilot messaging (fail-closed). * * Rules: * - No readiness → blocked * - No eligibility report → blocked only if missing_fields category eligibility * - Explicit blockers → always blocked (hard signal) * - Spine not enforced (`eligibility_spine_enforced === false` / `spine_enabled === false`) * → informational only (do not claim FA blocked by eligibility) * - `ready_for_full_autopilot === true` → not blocked * - `ready_for_full_autopilot === false` → blocked * - ready flag absent under enforced spine → local heuristics fail-closed * (unknown/missing MŚP, large firm, exhausted or unknown de minimis) */ export function isEligibilityBlocking(readiness: Readiness | null | undefined): boolean { if (!readiness) return true; const elig = readiness.eligibility; if (!elig || typeof elig !== 'object') { return (readiness.missing_fields || []).some((m) => m.category === 'eligibility'); } if (Array.isArray(elig.blockers) && elig.blockers.length > 0) return true; // Spine attached for transparency but not product-gated → no fail-closed FA claim if (!isEligibilitySpineEnforced(readiness)) { return false; } if (elig.ready_for_full_autopilot === true) return false; if (elig.ready_for_full_autopilot === false) return true; // ready flag absent: fail-closed local heuristics const mspStatus = String(elig.msp?.status || 'unknown').toLowerCase().trim(); if (mspStatus === 'unknown' || Boolean(elig.msp?.is_large)) return true; if (Boolean(elig.de_minimis?.exhausted)) return true; // used_eur === 0 is a valid known total — only null/undefined is missing if (elig.de_minimis?.used_eur === null || elig.de_minimis?.used_eur === undefined) { return true; } return false; } /** Pure: short banner copy for the eligibility section (fail-closed tone). */ export function eligibilityBannerMessage(readiness: Readiness | null | undefined): { tone: 'blocked' | 'ok' | 'info'; text: string; } { if (isEligibilityBlocking(readiness)) { return { tone: 'blocked', text: 'Full Autopilot zablokowany do czasu weryfikacji kwalifikowalności (fail-closed). ' + 'Brak wiarygodnego statusu MŚP lub de minimis nie jest traktowany jako „OK”.', }; } if (!isEligibilitySpineEnforced(readiness)) { return { tone: 'info', text: 'Spine kwalifikowalności w trybie informacyjnym (nie egzekwowany) — nie bramkuje Full Autopilot. ' + 'Status MŚP / de minimis poniżej tylko do wglądu.', }; } return { tone: 'ok', text: 'Spine kwalifikowalności OK — MŚP i de minimis nie blokują Full Autopilot.', }; } /** * Instrument-first HITL: show missing fields before Full Autopilot; save via project-facts. * When payload includes eligibility / blockers / missing category eligibility — show MŚP / de minimis. */ const GenerationReadinessPanel: React.FC = ({ projectId, onReadyChange }) => { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [readiness, setReadiness] = useState(null); const [schemaLabel, setSchemaLabel] = useState(''); const [values, setValues] = useState>({}); const load = useCallback(async () => { setLoading(true); try { const data = await getGenerationReadiness(projectId); const r = (data?.readiness || {}) as Readiness; setReadiness(r); const schema = data?.instrument_schema || {}; setSchemaLabel( [schema.label, schema.family, schema.budget_form].filter(Boolean).join(' · ') || '', ); const init: Record = {}; for (const m of r.missing_fields || []) { init[m.field] = values[m.field] || ''; } setValues((prev) => ({ ...init, ...prev })); onReadyChange?.(Boolean(r.full_autopilot_allowed), r); } catch { setReadiness(null); onReadyChange?.(false, null); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on projectId only }, [projectId, onReadyChange]); useEffect(() => { load(); }, [load]); const missing = readiness?.missing_fields || []; const eligibilityMissing = missing.filter((m) => m.category === 'eligibility'); const otherMissing = missing.filter((m) => m.category !== 'eligibility'); const allowed = Boolean(readiness?.full_autopilot_allowed); const status = readiness?.status || 'unknown'; const elig = readiness?.eligibility; const showEligibility = shouldShowEligibilitySection(readiness); const eligibilityBlocked = isEligibilityBlocking(readiness); const eligibilityBanner = eligibilityBannerMessage(readiness); const blockers = Array.isArray(elig?.blockers) ? elig!.blockers! : []; const statusColor = allowed || status === 'structure_only' ? '#34d399' : status === 'blocked_dossier' || eligibilityBlocked ? '#f87171' : '#fbbf24'; const eligibilityBorder = eligibilityBanner.tone === 'blocked' ? '#f8717166' : eligibilityBanner.tone === 'ok' ? '#34d39944' : '#fbbf2444'; const eligibilityBg = eligibilityBanner.tone === 'blocked' ? 'rgba(248,113,113,0.08)' : eligibilityBanner.tone === 'ok' ? 'rgba(52,211,153,0.06)' : 'rgba(251,191,36,0.08)'; const eligibilityIconColor = eligibilityBanner.tone === 'blocked' ? '#f87171' : eligibilityBanner.tone === 'ok' ? '#34d399' : '#fbbf24'; const eligibilityTextColor = eligibilityBanner.tone === 'blocked' ? '#fecaca' : eligibilityBanner.tone === 'ok' ? '#a7f3d0' : '#fde68a'; const handleSave = async () => { setSaving(true); try { const facts: Record = {}; const company: Record = {}; for (const m of missing) { const v = (values[m.field] || '').trim(); if (!v) continue; if (m.category === 'company') { if (m.field === 'company_name') company.name = v; else if (m.field === 'pkd_codes') { company.pkd_codes = v.split(/[,;]+/).map((s) => s.trim()).filter(Boolean); } else if (m.field === 'employment_fte') { company.employment_fte = Number(v) || v; } else if (m.field === 'closed_financial_year') { company.closed_financial_year = /^(1|true|tak|yes)$/i.test(v); } else { company[m.field] = v; } } else if (m.category === 'eligibility') { // Map spine HITL fields into company_data so rebuild sees them if (m.field === 'msp_status') { company.msp_status = v.toLowerCase().trim(); } else if (m.field === 'de_minimis_manual_eur') { const n = Number(String(v).replace(/\s/g, '').replace(',', '.')); company.de_minimis_manual_eur = Number.isFinite(n) ? n : v; } else if (m.field === 'nip') { company.nip = v; } else if (m.field !== 'de_minimis_exhausted') { company[m.field] = v; } } else if (m.field === 'lump_sum_amount_pln') { facts[m.field] = Number(String(v).replace(/\s/g, '').replace(',', '.')) || v; } else { facts[m.field] = v; } } if (Object.keys(company).length) facts.company = company; if (!Object.keys(facts).length) { toast.error('Wpisz wartości w brakujących polach.'); return; } const res = await postProjectFacts(projectId, facts); const r = (res?.readiness || {}) as Readiness; setReadiness(r); onReadyChange?.(Boolean(r.full_autopilot_allowed), r); if (r.full_autopilot_allowed) { toast.success('Dane uzupełnione — można uruchomić Full Autopilot.'); } else { toast.success('Zapisano. Nadal brakuje niektórych pól.'); await load(); } } catch (err: unknown) { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; toast.error(typeof detail === 'string' ? detail : 'Nie udało się zapisać faktów.'); } finally { setSaving(false); } }; if (loading) { return (
Sprawdzam gotowość generacji…
); } if (!readiness) return null; const mspStatus = elig?.msp?.status || 'unknown'; const mspOk = mspStatus !== 'unknown' && !elig?.msp?.is_large; const dm = elig?.de_minimis; const dmKnown = dm?.used_eur !== null && dm?.used_eur !== undefined; const dmOk = dmKnown && !dm?.exhausted; return (
{allowed ? ( ) : ( )}
Gotowość do generacji {status}
{schemaLabel && (
Instrument: {schemaLabel} {readiness.dossier_level ? ` · dossier: ${readiness.dossier_level}` : ''}
)} {allowed ? (

Dane wymagane przez instrument są kompletne — możesz uruchomić Full Autopilot.

) : (

Uzupełnij brakujące pola (wymagane przez instrument), zanim wygenerujemy pełny wniosek. Unikamy wypełniania „na ślepo” placeholderami.

)}
{showEligibility && (
Kwalifikowalność (MŚP / de minimis)

{eligibilityBanner.text}

Status MŚP
{elig ? formatMspLabel(mspStatus) : 'brak raportu'} {elig?.msp?.is_large ? ' · duża firma' : ''}
{elig?.msp?.source && (
źródło: {elig.msp.source} {typeof elig.msp.confidence === 'number' ? ` · conf. ${(elig.msp.confidence * 100).toFixed(0)}%` : ''}
)} {elig?.msp?.message ? (
{elig.msp.message}
) : null}
De minimis
{dm ? `${formatEur(dm.used_eur)} / ${formatEur(dm.limit_eur ?? 300_000)}` : 'brak raportu'}
pozostało:{' '} {dm ? formatEur(dm.remaining_eur) : 'brak danych'} {dm?.exhausted ? ' · wyczerpany' : ''}
{dm?.source && (
źródło: {dm.source}
)} {dm?.message ? (
{dm.message}
) : null}
{blockers.length > 0 && (
    {blockers.map((b) => (
  • {b}
  • ))}
)} {!elig && eligibilityMissing.length > 0 && (

Brak pełnego raportu spine — uzupełnij pola kwalifikowalności poniżej (fail-closed).

)}
)} {(readiness.warnings || []).length > 0 && (
    {readiness.warnings!.map((w) => (
  • {w}
  • ))}
)} {missing.length > 0 && ( <>
{/* Eligibility fields first for visibility */} {[...eligibilityMissing, ...otherMissing].map((m) => ( ))}
)}
); }; export default GenerationReadinessPanel;