Spaces:
Sleeping
Sleeping
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | |
| import { | |
| AlertTriangle, | |
| Building2, | |
| CheckCircle2, | |
| Landmark, | |
| Loader2, | |
| MapPin, | |
| RefreshCw, | |
| Rocket, | |
| Scale, | |
| Sparkles, | |
| Target, | |
| } from 'lucide-react'; | |
| import toast from 'react-hot-toast'; | |
| import { | |
| getStrategyRecommend, | |
| postStrategyRecommend, | |
| type StrategyRecommendResult, | |
| } from '../../api/client'; | |
| import { | |
| formatScore, | |
| formatTaxChecklistId, | |
| hasTaxChecklists, | |
| isBlockedPath, | |
| normalizeStrategyResult, | |
| pathsForCards, | |
| resolveAutoSelectedPath, | |
| type StrategyPathView, | |
| type StrategyResultView, | |
| } from './strategyCascadeModel'; | |
| export type StrategyCascadePanelProps = { | |
| /** Company NIP — enables GET with profile spine cache */ | |
| nip?: string; | |
| /** Initial project goal (B+R, inwestycja, …) */ | |
| initialGoal?: string; | |
| /** Initial region / voivodeship */ | |
| initialRegion?: string; | |
| /** Optional company_data for POST when no profile spine yet */ | |
| companyData?: Record<string, unknown>; | |
| /** Prefer POST (wizard) vs GET (profile page with saved spine) */ | |
| mode?: 'get' | 'post' | 'auto'; | |
| /** Compact layout for wizard / sidebar */ | |
| compact?: boolean; | |
| /** Auto-fetch when nip/goal/region settle */ | |
| autoLoad?: boolean; | |
| /** Notify parent of selected path id (user click) */ | |
| onPathSelect?: (pathId: string, path: StrategyPathView) => void; | |
| /** Currently selected path (controlled); defaults to primary */ | |
| selectedPathId?: string | null; | |
| }; | |
| const PATH_ICONS: Record<string, React.ReactNode> = { | |
| tax_psi_ulgi: <Scale size={20} color="#a78bfa" />, | |
| regional_fst: <MapPin size={20} color="#34d399" />, | |
| national_feng: <Sparkles size={20} color="#38bdf8" />, | |
| debt_loan_guarantee: <Landmark size={20} color="#fbbf24" />, | |
| direct_eu_prep: <Rocket size={20} color="#f472b6" />, | |
| }; | |
| function pathIcon(id: string) { | |
| return PATH_ICONS[id] || <Target size={20} color="var(--accent-blue)" />; | |
| } | |
| /** | |
| * Strategy cascade 2026 — ≥4 path cards from GET|POST /api/company/strategy/recommend. | |
| * Marks primary; shows de_minimis_blocked paths; goal + region inputs. | |
| */ | |
| const StrategyCascadePanel: React.FC<StrategyCascadePanelProps> = ({ | |
| nip = '', | |
| initialGoal = '', | |
| initialRegion = '', | |
| companyData, | |
| mode = 'auto', | |
| compact = false, | |
| autoLoad = true, | |
| onPathSelect, | |
| selectedPathId: controlledSelected, | |
| }) => { | |
| const [goal, setGoal] = useState(initialGoal); | |
| const [region, setRegion] = useState(initialRegion); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [view, setView] = useState<StrategyResultView | null>(null); | |
| const [localSelected, setLocalSelected] = useState<string | null>(null); | |
| /** True after the user clicks a path card (not auto-primary). */ | |
| const userPickedRef = useRef(false); | |
| const localSelectedRef = useRef<string | null>(null); | |
| const onPathSelectRef = useRef(onPathSelect); | |
| onPathSelectRef.current = onPathSelect; | |
| const controlledRef = useRef(controlledSelected); | |
| controlledRef.current = controlledSelected; | |
| // Sync when parent changes initial goal/region (wizard step transitions) | |
| useEffect(() => { | |
| setGoal(initialGoal); | |
| }, [initialGoal]); | |
| useEffect(() => { | |
| setRegion(initialRegion); | |
| }, [initialRegion]); | |
| const applyResult = useCallback( | |
| (strategy: StrategyRecommendResult | Record<string, unknown>) => { | |
| const next = normalizeStrategyResult(strategy as Record<string, unknown>); | |
| setView(next); | |
| const controlled = controlledRef.current; | |
| const current = | |
| controlled !== undefined ? controlled : localSelectedRef.current; | |
| const auto = resolveAutoSelectedPath(next, current, userPickedRef.current); | |
| if (auto) { | |
| localSelectedRef.current = auto.id; | |
| setLocalSelected(auto.id); | |
| // Notify parent of primary (or fallback) when nothing usable was selected yet. | |
| // Fixes create-project omitting strategy_path when user never clicked a card. | |
| onPathSelectRef.current?.(auto.id, auto); | |
| } else if (!current && next.primaryPathId) { | |
| localSelectedRef.current = next.primaryPathId; | |
| setLocalSelected(next.primaryPathId); | |
| } | |
| }, | |
| [], | |
| ); | |
| const load = useCallback( | |
| async (overrides?: { goal?: string; region?: string }) => { | |
| const goalEff = overrides?.goal ?? goal; | |
| const regionEff = overrides?.region ?? region; | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const cleanedNip = (nip || '').replace(/\D/g, ''); | |
| const usePost = | |
| mode === 'post' || | |
| (mode === 'auto' && | |
| (!!companyData || (!cleanedNip && (!!goalEff || !!regionEff)))); | |
| let strategy: StrategyRecommendResult; | |
| if (usePost) { | |
| const res = await postStrategyRecommend({ | |
| nip: cleanedNip || undefined, | |
| goal: goalEff || '', | |
| region: regionEff || '', | |
| company_data: companyData, | |
| }); | |
| strategy = res.strategy; | |
| } else { | |
| const res = await getStrategyRecommend({ | |
| nip: cleanedNip || undefined, | |
| goal: goalEff || '', | |
| region: regionEff || '', | |
| }); | |
| strategy = res.strategy; | |
| } | |
| applyResult(strategy); | |
| } catch (e: unknown) { | |
| console.error('strategy/recommend failed', e); | |
| const msg = | |
| (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || | |
| 'Nie udało się pobrać kaskady strategii.'; | |
| setError(String(msg)); | |
| toast.error('Błąd kaskady strategii'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, | |
| [nip, goal, region, companyData, mode, applyResult], | |
| ); | |
| // Auto-load on mount / when nip or parent goal/region seed changes (not every keystroke). | |
| useEffect(() => { | |
| if (!autoLoad) return; | |
| setGoal(initialGoal); | |
| setRegion(initialRegion); | |
| const t = window.setTimeout(() => { | |
| void load({ goal: initialGoal, region: initialRegion }); | |
| }, 250); | |
| return () => window.clearTimeout(t); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps -- goal/region edits use Odśwież or Enter | |
| }, [autoLoad, nip, initialGoal, initialRegion, mode]); | |
| const selectedId = controlledSelected !== undefined ? controlledSelected : localSelected; | |
| const cards = view ? pathsForCards(view) : []; | |
| const handleSelect = (path: StrategyPathView) => { | |
| if (isBlockedPath(path)) { | |
| toast.error('Ścieżka zablokowana (de minimis) — wybierz inną rekomendację.'); | |
| return; | |
| } | |
| userPickedRef.current = true; | |
| localSelectedRef.current = path.id; | |
| setLocalSelected(path.id); | |
| onPathSelect?.(path.id, path); | |
| }; | |
| return ( | |
| <div | |
| style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: compact ? '0.75rem' : '1rem', | |
| }} | |
| data-testid="strategy-cascade-panel" | |
| > | |
| <div | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| flexWrap: 'wrap', | |
| gap: '0.75rem', | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> | |
| <Target size={18} color="#a78bfa" /> | |
| <span style={{ fontWeight: 700, fontSize: compact ? '0.95rem' : '1.05rem' }}> | |
| Kaskada strategii 2026 | |
| </span> | |
| {view?.deMinimisExhausted && ( | |
| <span | |
| style={{ | |
| fontSize: '0.7rem', | |
| background: 'rgba(239,68,68,0.15)', | |
| color: '#f87171', | |
| padding: '2px 8px', | |
| borderRadius: 999, | |
| fontWeight: 600, | |
| }} | |
| title="Limit de minimis wyczerpany — instrumenty oparte o de minimis są zablokowane" | |
| > | |
| de minimis wyczerpany | |
| </span> | |
| )} | |
| {view?.mspStatus && ( | |
| <span | |
| style={{ | |
| fontSize: '0.7rem', | |
| background: 'rgba(56,189,248,0.12)', | |
| color: '#7dd3fc', | |
| padding: '2px 8px', | |
| borderRadius: 999, | |
| }} | |
| > | |
| MŚP: {view.mspStatus} | |
| </span> | |
| )} | |
| </div> | |
| <button | |
| type="button" | |
| className="btn btn-secondary" | |
| onClick={() => void load()} | |
| disabled={loading} | |
| style={{ | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| fontSize: '0.8rem', | |
| padding: '0.4rem 0.75rem', | |
| }} | |
| > | |
| {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} | |
| Odśwież | |
| </button> | |
| </div> | |
| <div | |
| style={{ | |
| display: 'grid', | |
| gridTemplateColumns: compact ? '1fr' : '1fr 1fr', | |
| gap: '0.75rem', | |
| }} | |
| > | |
| <div> | |
| <label | |
| style={{ | |
| display: 'block', | |
| fontSize: '0.75rem', | |
| color: 'var(--text-muted)', | |
| marginBottom: 4, | |
| fontWeight: 600, | |
| }} | |
| > | |
| Cel projektu | |
| </label> | |
| <div style={{ position: 'relative' }}> | |
| <Building2 | |
| size={14} | |
| color="var(--text-muted)" | |
| style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)' }} | |
| /> | |
| <input | |
| type="text" | |
| className="form-input" | |
| value={goal} | |
| onChange={(e) => setGoal(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && void load()} | |
| placeholder="np. B+R, inwestycja, OZE, deeptech…" | |
| style={{ | |
| width: '100%', | |
| padding: '0.55rem 0.75rem 0.55rem 2rem', | |
| background: 'rgba(255,255,255,0.03)', | |
| border: '1px solid rgba(255,255,255,0.1)', | |
| borderRadius: 8, | |
| color: '#fff', | |
| fontSize: '0.9rem', | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label | |
| style={{ | |
| display: 'block', | |
| fontSize: '0.75rem', | |
| color: 'var(--text-muted)', | |
| marginBottom: 4, | |
| fontWeight: 600, | |
| }} | |
| > | |
| Region / województwo | |
| </label> | |
| <div style={{ position: 'relative' }}> | |
| <MapPin | |
| size={14} | |
| color="var(--text-muted)" | |
| style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)' }} | |
| /> | |
| <input | |
| type="text" | |
| className="form-input" | |
| value={region} | |
| onChange={(e) => setRegion(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && void load()} | |
| placeholder="np. śląskie, małopolskie…" | |
| style={{ | |
| width: '100%', | |
| padding: '0.55rem 0.75rem 0.55rem 2rem', | |
| background: 'rgba(255,255,255,0.03)', | |
| border: '1px solid rgba(255,255,255,0.1)', | |
| borderRadius: 8, | |
| color: '#fff', | |
| fontSize: '0.9rem', | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {error && ( | |
| <div | |
| style={{ | |
| background: 'rgba(239,68,68,0.1)', | |
| border: '1px solid rgba(239,68,68,0.3)', | |
| borderRadius: 8, | |
| padding: '0.75rem 1rem', | |
| color: '#fca5a5', | |
| fontSize: '0.85rem', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 8, | |
| }} | |
| > | |
| <AlertTriangle size={16} /> | |
| {error} | |
| </div> | |
| )} | |
| {loading && !view && ( | |
| <p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', display: 'flex', alignItems: 'center', gap: 8 }}> | |
| <Loader2 size={16} className="spin" /> Liczenie kaskady ścieżek… | |
| </p> | |
| )} | |
| {cards.length > 0 && ( | |
| <div | |
| style={{ | |
| display: 'grid', | |
| gridTemplateColumns: compact | |
| ? '1fr' | |
| : 'repeat(auto-fit, minmax(200px, 1fr))', | |
| gap: '0.65rem', | |
| }} | |
| data-testid="strategy-path-cards" | |
| > | |
| {cards.map((path) => { | |
| const blocked = isBlockedPath(path); | |
| const isPrimary = Boolean(path.primary); | |
| const isSelected = selectedId === path.id; | |
| return ( | |
| <button | |
| key={path.id} | |
| type="button" | |
| data-testid={`strategy-path-${path.id}`} | |
| data-primary={isPrimary ? 'true' : 'false'} | |
| data-blocked={blocked ? 'true' : 'false'} | |
| onClick={() => handleSelect(path)} | |
| disabled={blocked} | |
| style={{ | |
| textAlign: 'left', | |
| cursor: blocked ? 'not-allowed' : 'pointer', | |
| opacity: blocked ? 0.55 : 1, | |
| background: isPrimary | |
| ? 'rgba(16,185,129,0.08)' | |
| : isSelected | |
| ? 'rgba(59,130,246,0.08)' | |
| : 'rgba(255,255,255,0.025)', | |
| border: isPrimary | |
| ? '1px solid rgba(16,185,129,0.45)' | |
| : isSelected | |
| ? '1px solid rgba(59,130,246,0.45)' | |
| : blocked | |
| ? '1px dashed rgba(239,68,68,0.35)' | |
| : '1px solid rgba(255,255,255,0.06)', | |
| borderRadius: 12, | |
| padding: compact ? '0.7rem 0.8rem' : '0.85rem 1rem', | |
| color: 'inherit', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '0.45rem', | |
| boxShadow: isPrimary ? '0 0 16px rgba(16,185,129,0.12)' : 'none', | |
| }} | |
| > | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> | |
| <div | |
| style={{ | |
| padding: 8, | |
| borderRadius: 10, | |
| background: 'rgba(255,255,255,0.06)', | |
| display: 'flex', | |
| }} | |
| > | |
| {pathIcon(path.id)} | |
| </div> | |
| <div> | |
| <div style={{ fontWeight: 700, fontSize: '0.88rem', lineHeight: 1.3 }}> | |
| {path.label} | |
| </div> | |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: 2 }}> | |
| {path.id} | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| style={{ | |
| fontSize: '0.75rem', | |
| fontWeight: 700, | |
| padding: '2px 8px', | |
| borderRadius: 6, | |
| background: isPrimary ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)', | |
| color: isPrimary ? '#6ee7b7' : 'var(--text-secondary)', | |
| flexShrink: 0, | |
| }} | |
| > | |
| {formatScore(path.score)} | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> | |
| {isPrimary && ( | |
| <span | |
| style={{ | |
| fontSize: '0.68rem', | |
| fontWeight: 700, | |
| background: 'rgba(16,185,129,0.2)', | |
| color: '#6ee7b7', | |
| padding: '2px 7px', | |
| borderRadius: 999, | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 4, | |
| }} | |
| > | |
| <CheckCircle2 size={11} /> Primary | |
| </span> | |
| )} | |
| {blocked && ( | |
| <span | |
| style={{ | |
| fontSize: '0.68rem', | |
| fontWeight: 700, | |
| background: 'rgba(239,68,68,0.18)', | |
| color: '#fca5a5', | |
| padding: '2px 7px', | |
| borderRadius: 999, | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 4, | |
| }} | |
| > | |
| <AlertTriangle size={11} /> de minimis blocked | |
| </span> | |
| )} | |
| {!blocked && view?.recommendedPathIds.includes(path.id) && !isPrimary && ( | |
| <span | |
| style={{ | |
| fontSize: '0.68rem', | |
| color: '#93c5fd', | |
| background: 'rgba(59,130,246,0.12)', | |
| padding: '2px 7px', | |
| borderRadius: 999, | |
| }} | |
| > | |
| recommended | |
| </span> | |
| )} | |
| {hasTaxChecklists(path) && | |
| (path.checklist_ids || []).map((cid) => ( | |
| <span | |
| key={cid} | |
| data-testid={`tax-checklist-${cid}`} | |
| title={ | |
| cid === 'psi' && path.never_say_submit_to_parp | |
| ? 'PSI — nie składaj do PARP (decyzja strefowa)' | |
| : `Checklist: ${formatTaxChecklistId(cid)}` | |
| } | |
| style={{ | |
| fontSize: '0.65rem', | |
| fontWeight: 600, | |
| color: '#c4b5fd', | |
| background: 'rgba(167,139,250,0.14)', | |
| border: '1px solid rgba(167,139,250,0.28)', | |
| padding: '2px 7px', | |
| borderRadius: 999, | |
| }} | |
| > | |
| {formatTaxChecklistId(cid)} | |
| </span> | |
| ))} | |
| </div> | |
| {hasTaxChecklists(path) && path.never_say_submit_to_parp && !compact && ( | |
| <div | |
| data-testid="tax-psi-no-parp" | |
| style={{ | |
| fontSize: '0.7rem', | |
| color: '#c4b5fd', | |
| lineHeight: 1.35, | |
| opacity: 0.95, | |
| }} | |
| > | |
| PSI: nie składaj do PARP — decyzja strefowa / ulga, nie nabór. | |
| </div> | |
| )} | |
| {path.reasons.slice(0, compact ? 1 : 2).map((r, i) => ( | |
| <div | |
| key={i} | |
| style={{ | |
| fontSize: '0.75rem', | |
| color: 'var(--text-secondary)', | |
| lineHeight: 1.4, | |
| }} | |
| > | |
| {r} | |
| </div> | |
| ))} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {!loading && view && cards.length === 0 && ( | |
| <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}> | |
| Brak ścieżek strategii — spróbuj odświeżyć lub uzupełnij cel/region. | |
| </p> | |
| )} | |
| {view?.notes && view.notes.length > 0 && !compact && ( | |
| <ul | |
| style={{ | |
| margin: 0, | |
| paddingLeft: '1.1rem', | |
| color: 'var(--text-muted)', | |
| fontSize: '0.75rem', | |
| lineHeight: 1.5, | |
| }} | |
| > | |
| {view.notes.map((n, i) => ( | |
| <li key={i}>{n}</li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default StrategyCascadePanel; | |