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