grantforge-api / frontend-react /src /components /company /StrategyCascadePanel.tsx
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
20.3 kB
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
? 'PSInie 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;