grantforge-api / frontend-react /src /components /project /GenerationReadinessPanel.tsx
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
27.6 kB
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<string, string> = {
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<Props> = ({ projectId, onReadyChange }) => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [readiness, setReadiness] = useState<Readiness | null>(null);
const [schemaLabel, setSchemaLabel] = useState<string>('');
const [values, setValues] = useState<Record<string, string>>({});
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<string, string> = {};
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<string, unknown> = {};
const company: Record<string, unknown> = {};
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 (
<div className="glass-card" style={{ padding: '1rem 1.25rem', color: 'var(--text-muted)' }}>
<Loader2 size={16} className="spin" style={{ display: 'inline', marginRight: 8 }} />
Sprawdzam gotowość generacji…
</div>
);
}
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 (
<div
className="glass-card"
style={{
padding: '1.15rem 1.35rem',
border: `1px solid ${statusColor}55`,
background: `${statusColor}0d`,
}}
>
<div style={{ display: 'flex', gap: '0.65rem', alignItems: 'flex-start', marginBottom: '0.75rem' }}>
{allowed ? (
<CheckCircle2 size={22} color="#34d399" style={{ flexShrink: 0 }} />
) : (
<ClipboardList size={22} color={statusColor} style={{ flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, color: '#fff', fontSize: '1rem' }}>
Gotowość do generacji
<span style={{ marginLeft: 8, fontSize: '0.8rem', color: statusColor, fontWeight: 600 }}>
{status}
</span>
</div>
{schemaLabel && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: 2 }}>
Instrument: {schemaLabel}
{readiness.dossier_level ? ` · dossier: ${readiness.dossier_level}` : ''}
</div>
)}
{allowed ? (
<p style={{ margin: '0.4rem 0 0', color: '#a7f3d0', fontSize: '0.9rem' }}>
Dane wymagane przez instrument są kompletne — możesz uruchomić Full Autopilot.
</p>
) : (
<p style={{ margin: '0.4rem 0 0', color: '#e5e7eb', fontSize: '0.9rem', lineHeight: 1.5 }}>
Uzupełnij brakujące pola (wymagane przez instrument), zanim wygenerujemy pełny wniosek.
Unikamy wypełniania „na ślepo” placeholderami.
</p>
)}
</div>
</div>
{showEligibility && (
<div
data-testid="eligibility-section"
style={{
marginBottom: '0.9rem',
padding: '0.85rem 1rem',
borderRadius: 10,
border: `1px solid ${eligibilityBorder}`,
background: eligibilityBg,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ShieldAlert
size={18}
color={eligibilityIconColor}
style={{ flexShrink: 0 }}
/>
<div style={{ fontWeight: 700, color: '#fff', fontSize: '0.92rem' }}>
Kwalifikowalność (MŚP / de minimis)
</div>
</div>
<p
data-testid={
eligibilityBanner.tone === 'blocked'
? 'eligibility-fail-closed'
: eligibilityBanner.tone === 'info'
? 'eligibility-info'
: 'eligibility-ok'
}
style={{
margin: '0 0 0.65rem',
color: eligibilityTextColor,
fontSize: '0.85rem',
lineHeight: 1.45,
}}
>
{eligibilityBanner.text}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.65rem',
fontSize: '0.82rem',
}}
>
<div
style={{
padding: '0.55rem 0.7rem',
borderRadius: 8,
background: 'rgba(0,0,0,0.25)',
border: `1px solid ${mspOk ? '#34d39933' : '#f8717144'}`,
}}
>
<div style={{ color: 'var(--text-muted)', marginBottom: 2 }}>Status MŚP</div>
<div style={{ fontWeight: 700, color: mspOk ? '#a7f3d0' : '#fca5a5' }}>
{elig ? formatMspLabel(mspStatus) : 'brak raportu'}
{elig?.msp?.is_large ? ' · duża firma' : ''}
</div>
{elig?.msp?.source && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: 2 }}>
źródło: {elig.msp.source}
{typeof elig.msp.confidence === 'number'
? ` · conf. ${(elig.msp.confidence * 100).toFixed(0)}%`
: ''}
</div>
)}
{elig?.msp?.message ? (
<div style={{ color: '#e5e7eb', fontSize: '0.72rem', marginTop: 4 }}>
{elig.msp.message}
</div>
) : null}
</div>
<div
style={{
padding: '0.55rem 0.7rem',
borderRadius: 8,
background: 'rgba(0,0,0,0.25)',
border: `1px solid ${dmOk ? '#34d39933' : '#f8717144'}`,
}}
>
<div style={{ color: 'var(--text-muted)', marginBottom: 2 }}>De minimis</div>
<div style={{ fontWeight: 700, color: dmOk ? '#a7f3d0' : '#fca5a5' }}>
{dm
? `${formatEur(dm.used_eur)} / ${formatEur(dm.limit_eur ?? 300_000)}`
: 'brak raportu'}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: 2 }}>
pozostało:{' '}
<span style={{ color: dm?.exhausted ? '#fca5a5' : undefined }}>
{dm ? formatEur(dm.remaining_eur) : 'brak danych'}
</span>
{dm?.exhausted ? ' · wyczerpany' : ''}
</div>
{dm?.source && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: 2 }}>
źródło: {dm.source}
</div>
)}
{dm?.message ? (
<div style={{ color: '#e5e7eb', fontSize: '0.72rem', marginTop: 4 }}>
{dm.message}
</div>
) : null}
</div>
</div>
{blockers.length > 0 && (
<ul
data-testid="eligibility-blockers"
style={{
margin: '0.65rem 0 0',
paddingLeft: '1.15rem',
color: '#fecaca',
fontSize: '0.82rem',
lineHeight: 1.45,
}}
>
{blockers.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
)}
{!elig && eligibilityMissing.length > 0 && (
<p style={{ margin: '0.65rem 0 0', color: '#fde68a', fontSize: '0.8rem' }}>
Brak pełnego raportu spine — uzupełnij pola kwalifikowalności poniżej (fail-closed).
</p>
)}
</div>
)}
{(readiness.warnings || []).length > 0 && (
<ul style={{ margin: '0 0 0.75rem', paddingLeft: '1.2rem', color: '#fde68a', fontSize: '0.85rem' }}>
{readiness.warnings!.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
{missing.length > 0 && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* Eligibility fields first for visibility */}
{[...eligibilityMissing, ...otherMissing].map((m) => (
<label key={m.field} style={{ display: 'block' }}>
<span
style={{
display: 'block',
fontSize: '0.8rem',
color: m.category === 'eligibility' ? '#fca5a5' : '#fbbf24',
marginBottom: 4,
}}
>
<AlertTriangle size={12} style={{ display: 'inline', marginRight: 4 }} />
{m.label}
<span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>
{' '}
({m.category})
</span>
</span>
{m.hint && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--text-muted)',
marginBottom: 4,
}}
>
{m.hint}
</span>
)}
{m.field === 'de_minimis_exhausted' ? (
<div
style={{
padding: '0.55rem 0.75rem',
borderRadius: 8,
border: '1px solid rgba(248,113,113,0.35)',
background: 'rgba(248,113,113,0.08)',
color: '#fecaca',
fontSize: '0.85rem',
}}
>
Limit wyczerpany — nie da się „uzupełnić” w formularzu. Wybierz instrument bez de
minimis lub zmień strategię.
</div>
) : (
<input
value={values[m.field] || ''}
onChange={(e) => setValues((v) => ({ ...v, [m.field]: e.target.value }))}
placeholder={
m.field === 'msp_status'
? 'mikro | mala | srednia | duza'
: m.field === 'de_minimis_manual_eur'
? 'np. 45000'
: m.field
}
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(0,0,0,0.35)',
color: '#fff',
fontSize: '0.9rem',
}}
/>
)}
</label>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
style={{
marginTop: '1rem',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '0.55rem 1rem',
borderRadius: 8,
border: 'none',
background: '#2563eb',
color: '#fff',
fontWeight: 700,
fontSize: '0.85rem',
cursor: saving ? 'wait' : 'pointer',
}}
>
{saving ? <Loader2 size={16} className="spin" /> : <Save size={16} />}
Zapisz i sprawdź ponownie
</button>
</>
)}
</div>
);
};
export default GenerationReadinessPanel;