Spaces:
Sleeping
Sleeping
| 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; | |