GrantForge Bot
Deploy to Hugging Face
3b7f713
import { useState, useEffect, useMemo } from 'react';
import { Search, RefreshCw, ExternalLink, Filter, TrendingUp, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { getGrantNabory } from '../api/client';
import toast from 'react-hot-toast';
interface Nabor {
id: string;
name: string;
program: string;
subprogram?: string;
type?: string;
status: string;
deadline?: string;
max_dofinansowanie_pln?: number;
min_dofinansowanie_pln?: number;
dofinansowanie_pct_max?: number;
eligible_regions?: string[];
eligible_company_sizes?: string[];
description?: string;
url?: string;
source?: string;
legal_source?: string;
fetched_at?: string;
last_verified?: string;
is_outdated_warning?: boolean;
eurlex_url?: string;
official_doc_url?: string;
}
const BadgeProgram: React.FC<{ program: string }> = ({ program }) => {
const colors: Record<string, string> = {
PARP: '#3b82f6',
NCBR: '#8b5cf6',
FENG: '#10b981',
KPO: '#f59e0b',
POPW: '#ec4899',
};
const color = colors[program] || '#6b7280';
return (
<span style={{
background: `${color}22`, color, border: `1px solid ${color}44`,
borderRadius: '6px', padding: '2px 8px', fontSize: '0.7rem', fontWeight: 700,
letterSpacing: '0.05em', textTransform: 'uppercase',
}}>{program}</span>
);
};
const formatPLN = (v?: number) =>
v !== undefined ? `${(v / 1_000_000).toFixed(1)} mln zł` : '—';
const NaborCard: React.FC<{ nabor: Nabor }> = ({ nabor }) => {
const [expanded, setExpanded] = useState(false);
const daysLeft = useMemo(() => {
if (!nabor.deadline) return null;
const time = new Date(nabor.deadline).getTime();
if (isNaN(time)) return null;
return Math.ceil((time - Date.now()) / 86400000);
}, [nabor.deadline]);
return (
<div style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border-subtle)',
borderRadius: '14px', padding: '1.5rem', transition: 'border-color 0.2s, box-shadow 0.2s',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.borderColor = 'var(--border-strong)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 20px rgba(0,0,0,0.15)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.borderColor = 'var(--border-subtle)';
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
<BadgeProgram program={nabor.program} />
{nabor.type && (
<span style={{
background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)',
borderRadius: '6px', padding: '2px 8px', fontSize: '0.7rem', color: 'var(--text-muted)'
}}>{nabor.type}</span>
)}
</div>
<h3 style={{ fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-primary)', margin: 0, lineHeight: 1.4 }}>
{nabor.name}
</h3>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{daysLeft !== null && (
<div style={{
fontSize: '0.75rem', fontWeight: 700,
color: daysLeft < 30 ? '#f87171' : daysLeft < 90 ? '#fbbf24' : '#34d399',
}}>
{daysLeft > 0 ? `${daysLeft} dni` : 'Upłynął'}
</div>
)}
{nabor.deadline && (
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>
do {nabor.deadline}
</div>
)}
</div>
</div>
{/* Stats row */}
<div style={{ display: 'flex', gap: '1.5rem', margin: '0.75rem 0', flexWrap: 'wrap' }}>
{nabor.dofinansowanie_pct_max && (
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Dofinansowanie</div>
<div style={{ fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent-green)' }}>
do {nabor.dofinansowanie_pct_max}%
</div>
</div>
)}
{nabor.max_dofinansowanie_pln && (
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Maks. wartość</div>
<div style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text-primary)' }}>
{formatPLN(nabor.max_dofinansowanie_pln)}
</div>
</div>
)}
{nabor.min_dofinansowanie_pln && (
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Min. wartość</div>
<div style={{ fontSize: '0.9rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
{formatPLN(nabor.min_dofinansowanie_pln)}
</div>
</div>
)}
</div>
{/* Tags */}
{nabor.eligible_company_sizes && nabor.eligible_company_sizes.length > 0 && (
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', margin: '0.5rem 0' }}>
{nabor.eligible_company_sizes.map(s => (
<span key={s} style={{
background: 'rgba(139,92,246,0.08)', color: 'var(--accent-purple)',
border: '1px solid rgba(139,92,246,0.2)',
borderRadius: '4px', padding: '1px 6px', fontSize: '0.7rem'
}}>{s}</span>
))}
{nabor.eligible_regions?.slice(0, 2).map(r => (
<span key={r} style={{
background: 'rgba(59,130,246,0.08)', color: 'var(--accent-blue)',
border: '1px solid rgba(59,130,246,0.2)',
borderRadius: '4px', padding: '1px 6px', fontSize: '0.7rem'
}}>📍 {r}</span>
))}
</div>
)}
{/* Expandable description */}
{nabor.description && (
<>
<button onClick={() => setExpanded(v => !v)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-muted)', fontSize: '0.8rem',
display: 'flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0', marginTop: '0.25rem',
}}>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{expanded ? 'Zwiń opis' : 'Rozwiń opis'}
</button>
{expanded && (
<p style={{ color: 'var(--text-secondary)', fontSize: '0.85rem', lineHeight: 1.7, margin: '0.5rem 0 0' }}>
{nabor.description}
</p>
)}
</>
)}
{/* Legal Basis (EUR-Lex) */}
{nabor.legal_source && (
<div style={{
marginTop: '1rem', padding: '0.75rem',
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderRadius: '8px',
display: 'flex', gap: '0.5rem', alignItems: 'flex-start'
}}>
<AlertCircle size={16} color="var(--accent-green)" style={{ flexShrink: 0, marginTop: '2px' }} />
<div>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--accent-green)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '2px' }}>
Weryfikacja Prawna (EUR-Lex)
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{nabor.legal_source}
</div>
</div>
</div>
)}
{/* Outdated Warning */}
{nabor.is_outdated_warning && (
<div style={{
marginTop: '1rem', padding: '0.75rem',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '8px',
display: 'flex', gap: '0.5rem', alignItems: 'flex-start'
}}>
<AlertCircle size={16} color="#f87171" style={{ flexShrink: 0, marginTop: '2px' }} />
<div>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#f87171', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '2px' }}>
Ostrzeżenie o aktualności
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Ten nabór mógł zostać niedawno zakończony lub wstrzymany (wykryto w automatycznej analizie treści).
</div>
</div>
</div>
)}
{/* Actions */}
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{nabor.url && (
<a href={nabor.url.startsWith('http') ? nabor.url : `https://${nabor.url}`} target="_blank" rel="noopener noreferrer" style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
color: 'var(--accent-green)', fontSize: '0.8rem', fontWeight: 600,
textDecoration: 'none',
}}>
<ExternalLink size={13} /> Strona programu
</a>
)}
{nabor.eurlex_url && (
<a href={nabor.eurlex_url} target="_blank" rel="noopener noreferrer" style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
color: 'var(--accent-purple)', fontSize: '0.8rem', fontWeight: 600,
textDecoration: 'none',
}}>
<Search size={13} /> EUR-Lex
</a>
)}
{nabor.official_doc_url && (
<a href={nabor.official_doc_url} target="_blank" rel="noopener noreferrer" style={{
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
color: 'var(--accent-blue)', fontSize: '0.8rem', fontWeight: 600,
textDecoration: 'none',
}}>
<Search size={13} /> Baza Funduszy
</a>
)}
</div>
{nabor.last_verified && (
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>
Dane aktualne na: {nabor.last_verified}
</div>
)}
</div>
</div>
);
};
const PROGRAMS = ['Wszystkie', 'PARP', 'NCBR', 'FENG', 'KPO', 'POPW'];
const SIZES = ['Wszystkie', 'mikro', 'małe', 'średnie', 'duże', 'MŚP'];
const Nabory: React.FC = () => {
const [nabory, setNabory] = useState<Nabor[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filterProgram, setFilterProgram] = useState('Wszystkie');
const [filterSize, setFilterSize] = useState('Wszystkie');
const [lastFetch, setLastFetch] = useState<string | null>(null);
const loadNabory = async (forceRefresh = false) => {
try {
forceRefresh ? setRefreshing(true) : setLoading(true);
const result = await getGrantNabory(forceRefresh);
setNabory(result.nabory || []);
setLastFetch(new Date().toLocaleTimeString('pl-PL'));
setError(null);
if (forceRefresh) toast.success('Cache odświeżony!');
} catch (e: any) {
setError('Nie udało się pobrać listy naborów. Sprawdź połączenie z serwerem.');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => { loadNabory(); }, []);
const filtered = useMemo(() => {
return nabory.filter(n => {
const q = search.toLowerCase();
if (q && !n.name.toLowerCase().includes(q) && !n.description?.toLowerCase().includes(q)) return false;
if (filterProgram !== 'Wszystkie' && n.program !== filterProgram && !n.name.includes(filterProgram)) return false;
if (filterSize !== 'Wszystkie') {
const sizes = n.eligible_company_sizes?.map(s => s.toLowerCase()) || [];
if (sizes.length > 0 && !sizes.includes(filterSize.toLowerCase()) && !sizes.includes('mśp')) return false;
}
return true;
});
}, [nabory, search, filterProgram, filterSize]);
return (
<div style={{ width: '100%', height: '100%', overflowY: 'auto' }}>
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h1 style={{ fontSize: '1.8rem', fontWeight: 800, margin: 0, color: 'var(--text-primary)' }}>
🏦 Aktywne Nabory Dotacji
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', margin: '0.3rem 0 0' }}>
Źródła: PARP + NCBR • Dane odświeżane co 24h
{lastFetch && <> • Ostatnia aktualizacja: {lastFetch}</>}
</p>
</div>
<button
onClick={() => loadNabory(true)}
disabled={refreshing}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
background: 'var(--bg-elevated)', border: '1px solid var(--border-strong)',
borderRadius: '10px', padding: '0.6rem 1.2rem',
color: 'var(--text-secondary)', cursor: refreshing ? 'wait' : 'pointer',
fontSize: '0.875rem', fontWeight: 600,
}}
>
<RefreshCw size={15} style={{ animation: refreshing ? 'spin 1s linear infinite' : 'none' }} />
{refreshing ? 'Odświeżam...' : 'Odśwież dane'}
</button>
</div>
</div>
{/* Filtry */}
<div style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border-subtle)',
borderRadius: '12px', padding: '1rem 1.25rem', marginBottom: '1.5rem',
display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center'
}}>
<div style={{ position: 'relative', flex: '1 1 200px' }}>
<Search size={15} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
placeholder="Szukaj po nazwie lub opisie..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{
width: '100%', paddingLeft: '2rem',
background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)',
borderRadius: '8px', padding: '0.5rem 0.75rem 0.5rem 2rem',
color: 'var(--text-primary)', fontSize: '0.875rem',
}}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Filter size={14} style={{ color: 'var(--text-muted)' }} />
<select value={filterProgram} onChange={e => setFilterProgram(e.target.value)} style={{
background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)',
borderRadius: '8px', padding: '0.4rem 0.75rem',
color: 'var(--text-primary)', fontSize: '0.875rem', cursor: 'pointer'
}}>
{PROGRAMS.map(p => <option key={p} value={p} style={{ color: '#000', backgroundColor: '#fff' }}>{p}</option>)}
</select>
<select value={filterSize} onChange={e => setFilterSize(e.target.value)} style={{
background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)',
borderRadius: '8px', padding: '0.4rem 0.75rem',
color: 'var(--text-primary)', fontSize: '0.875rem', cursor: 'pointer'
}}>
{SIZES.map(s => <option key={s} value={s} style={{ color: '#000', backgroundColor: '#fff' }}>{s}</option>)}
</select>
</div>
</div>
{/* Stats śummary */}
{!loading && nabory.length > 0 && (
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{[
{ label: 'Wszystkich naborów', value: nabory.length, color: 'var(--accent-blue)' },
{ label: 'Po filtrach', value: filtered.length, color: 'var(--accent-green)' },
{ label: 'Programów', value: [...new Set(nabory.map(n => n.program))].length, color: 'var(--accent-purple)' },
].map(stat => (
<div key={stat.label} style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border-subtle)',
borderRadius: '10px', padding: '0.75rem 1.25rem', display: 'flex', gap: '0.75rem', alignItems: 'center'
}}>
<TrendingUp size={16} style={{ color: stat.color }} />
<div>
<div style={{ fontSize: '1.2rem', fontWeight: 800, color: stat.color }}>{stat.value}</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{stat.label}</div>
</div>
</div>
))}
</div>
)}
{/* Content */}
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{[1, 2, 3].map((i: number) => (
<div key={i} style={{
background: 'var(--bg-elevated)', border: '1px solid var(--border-subtle)',
borderRadius: '14px', padding: '1.5rem', height: '140px',
animation: 'pulse 1.5s ease-in-out infinite',
opacity: 0.6,
}} />
))}
</div>
) : error ? (
<div style={{
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '12px', padding: '1.5rem',
display: 'flex', gap: '1rem', alignItems: 'center', color: '#f87171'
}}>
<AlertCircle size={20} />
<div>
<strong>Błąd połączenia</strong>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.875rem', opacity: 0.8 }}>{error}</p>
</div>
</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<Search size={40} style={{ opacity: 0.3, marginBottom: '1rem' }} />
<p>Brak naborów pasujących do kryteriów wyszukiwania.</p>
<button onClick={() => { setSearch(''); setFilterProgram('Wszystkie'); setFilterSize('Wszystkie'); }}
style={{ marginTop: '0.5rem', background: 'none', border: 'none', color: 'var(--accent-purple)', cursor: 'pointer', fontSize: '0.875rem' }}>
Wyczyść filtry
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '1.5rem' }}>
{filtered.map(n => <NaborCard key={n.id} nabor={n} />)}
</div>
)}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.3; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
</div>
);
};
export default Nabory;