Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useState } from 'react'; | |
| import { | |
| Activity, AlertTriangle, CheckCircle2, Clock, ExternalLink, | |
| Loader2, RefreshCw, Search, ShieldCheck, Sparkles, | |
| } from 'lucide-react'; | |
| import toast from 'react-hot-toast'; | |
| import { | |
| getFirmPulse, | |
| getGrantPulse, | |
| PulseItem, | |
| PulseKind, | |
| PulseResponse, | |
| } from '../api/client'; | |
| const KINDS: { id: PulseKind; label: string; hint: string; color: string }[] = [ | |
| { id: 'current', label: 'Aktualne', hint: 'Otwarte nabory', color: '#34d399' }, | |
| { id: 'new', label: 'Nowe', hint: 'Ostatnio w katalogu', color: '#60a5fa' }, | |
| { id: 'closing', label: 'Zamykające się', hint: 'Termin wkrótce', color: '#f87171' }, | |
| { id: 'planned', label: 'Planowane', hint: 'Zapowiedziane', color: '#a78bfa' }, | |
| { id: 'all', label: 'Wszystkie', hint: 'Pełny puls', color: '#94a3b8' }, | |
| ]; | |
| const FlagPill: React.FC<{ ok?: boolean; label: string }> = ({ ok, label }) => ( | |
| <span | |
| style={{ | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 4, | |
| fontSize: '0.65rem', | |
| fontWeight: 700, | |
| padding: '2px 8px', | |
| borderRadius: 6, | |
| border: `1px solid ${ok ? '#10b98155' : '#f59e0b55'}`, | |
| background: ok ? '#10b98118' : '#f59e0b18', | |
| color: ok ? '#34d399' : '#fbbf24', | |
| }} | |
| > | |
| {ok ? <CheckCircle2 size={11} /> : <AlertTriangle size={11} />} | |
| {label} | |
| </span> | |
| ); | |
| const PulseCard: React.FC<{ item: PulseItem; showMatch?: boolean }> = ({ item, showMatch }) => { | |
| const v = item.verification || {}; | |
| const daysLeft = (() => { | |
| if (!item.deadline) return null; | |
| const t = new Date(item.deadline).getTime(); | |
| if (Number.isNaN(t)) return null; | |
| return Math.ceil((t - Date.now()) / 86400000); | |
| })(); | |
| return ( | |
| <div | |
| className="glass-card" | |
| style={{ | |
| padding: '1.25rem', | |
| border: '1px solid var(--border-subtle)', | |
| borderRadius: 14, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '0.75rem', | |
| }} | |
| > | |
| <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', flexWrap: 'wrap' }}> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}> | |
| {(item.pulse_kinds || []).map((k) => ( | |
| <span | |
| key={k} | |
| style={{ | |
| fontSize: '0.65rem', | |
| fontWeight: 700, | |
| textTransform: 'uppercase', | |
| letterSpacing: '0.04em', | |
| padding: '2px 8px', | |
| borderRadius: 6, | |
| background: 'var(--bg-surface)', | |
| border: '1px solid var(--border-subtle)', | |
| color: 'var(--text-muted)', | |
| }} | |
| > | |
| {k} | |
| </span> | |
| ))} | |
| {item.alert_worthy && ( | |
| <span style={{ fontSize: '0.65rem', fontWeight: 800, color: '#10b981' }}> | |
| ALERT-READY | |
| </span> | |
| )} | |
| </div> | |
| <h3 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.4 }}> | |
| {item.name || 'Bez nazwy'} | |
| </h3> | |
| <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}> | |
| {item.operator || item.source || '—'} | |
| {item.status ? ` · ${item.status}` : ''} | |
| </div> | |
| </div> | |
| <div style={{ textAlign: 'right', flexShrink: 0 }}> | |
| {showMatch && typeof item.match_score === 'number' && ( | |
| <div style={{ fontSize: '1.1rem', fontWeight: 800, color: 'var(--accent-green)' }}> | |
| {item.match_score}% | |
| </div> | |
| )} | |
| {daysLeft !== null && ( | |
| <div | |
| style={{ | |
| fontSize: '0.75rem', | |
| fontWeight: 700, | |
| color: daysLeft < 14 ? '#f87171' : daysLeft < 45 ? '#fbbf24' : '#34d399', | |
| }} | |
| > | |
| {daysLeft > 0 ? `${daysLeft} dni` : item.continuous ? 'ciągły' : 'termin minął'} | |
| </div> | |
| )} | |
| {item.deadline ? ( | |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>do {item.deadline}</div> | |
| ) : ( | |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}> | |
| {item.continuous ? 'nabór ciągły' : 'brak terminu (uczciwie puste)'} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> | |
| <FlagPill ok={v.deadline_verified} label="termin" /> | |
| <FlagPill ok={v.status_verified} label="status" /> | |
| <FlagPill ok={v.regulation_verified} label="regulamin" /> | |
| <FlagPill ok={v.credibility_ok} label="wiarygodność" /> | |
| </div> | |
| {item.why_it_fits && ( | |
| <p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}> | |
| {item.why_it_fits} | |
| </p> | |
| )} | |
| {!!item.generation_blockers?.length && ( | |
| <ul style={{ margin: 0, paddingLeft: '1.1rem', fontSize: '0.75rem', color: '#fbbf24' }}> | |
| {item.generation_blockers.slice(0, 3).map((b) => ( | |
| <li key={b}>{b}</li> | |
| ))} | |
| </ul> | |
| )} | |
| <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> | |
| {item.url && ( | |
| <a | |
| href={item.url} | |
| target="_blank" | |
| rel="noreferrer" | |
| style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', color: 'var(--accent-blue)' }} | |
| > | |
| Strona naboru <ExternalLink size={12} /> | |
| </a> | |
| )} | |
| {item.regulation_url && ( | |
| <a | |
| href={item.regulation_url} | |
| target="_blank" | |
| rel="noreferrer" | |
| style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', color: '#a78bfa' }} | |
| > | |
| <ShieldCheck size={12} /> Regulamin | |
| </a> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default function Pulse() { | |
| const [kind, setKind] = useState<PulseKind>('current'); | |
| const [data, setData] = useState<PulseResponse | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [nip, setNip] = useState(''); | |
| const [description, setDescription] = useState(''); | |
| const [firmMode, setFirmMode] = useState(false); | |
| const [firmLoading, setFirmLoading] = useState(false); | |
| const [firmData, setFirmData] = useState<(PulseResponse & { gaps?: string[]; company?: Record<string, unknown>; alert_count?: number }) | null>(null); | |
| const loadPulse = useCallback(async (k: PulseKind) => { | |
| setLoading(true); | |
| try { | |
| const res = await getGrantPulse(k, 50); | |
| setData(res); | |
| } catch (e: unknown) { | |
| const status = (e as { response?: { status?: number } })?.response?.status; | |
| if (status === 401) { | |
| toast.error('Zaloguj się, aby zobaczyć Puls naborów.'); | |
| } else { | |
| toast.error('Nie udało się pobrać Pulsu.'); | |
| } | |
| setData(null); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| if (!firmMode) { | |
| void loadPulse(kind); | |
| } | |
| }, [kind, firmMode, loadPulse]); | |
| const runFirm = async () => { | |
| const cleanNip = nip.replace(/\D/g, ''); | |
| if (cleanNip.length !== 10 && !description.trim()) { | |
| toast.error('Podaj NIP (10 cyfr) lub opis inwestycji.'); | |
| return; | |
| } | |
| setFirmLoading(true); | |
| setFirmMode(true); | |
| try { | |
| const res = await getFirmPulse({ | |
| nip: cleanNip || undefined, | |
| description: description.trim() || undefined, | |
| kind, | |
| limit: 20, | |
| min_score: 35, | |
| }); | |
| setFirmData(res); | |
| toast.success(`Dopasowano ${res.count ?? 0} naborów`); | |
| } catch { | |
| toast.error('Firm Pulse niedostępny — sprawdź logowanie i NIP.'); | |
| setFirmData(null); | |
| } finally { | |
| setFirmLoading(false); | |
| } | |
| }; | |
| const items = firmMode ? firmData?.items || [] : data?.items || []; | |
| const counts = firmMode ? firmData?.counts : data?.counts; | |
| const quality = firmMode ? firmData?.quality : data?.quality; | |
| return ( | |
| <div style={{ maxWidth: 1100, margin: '0 auto', padding: '1.5rem 1rem 3rem' }}> | |
| <header style={{ marginBottom: '1.5rem' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}> | |
| <Activity size={22} color="var(--accent-green)" /> | |
| <h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 800, color: 'var(--text-primary)' }}> | |
| Puls naborów | |
| </h1> | |
| </div> | |
| <p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem', maxWidth: 720, lineHeight: 1.5 }}> | |
| Ciągły podgląd katalogu: nowe, planowane, zamykające się i aktualne nabory z flagami | |
| weryfikacji (termin · status · regulamin). Dane z API — bez mocków. | |
| </p> | |
| </header> | |
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: '1rem' }}> | |
| {KINDS.map((k) => ( | |
| <button | |
| key={k.id} | |
| type="button" | |
| onClick={() => { | |
| setKind(k.id); | |
| setFirmMode(false); | |
| }} | |
| className="btn" | |
| style={{ | |
| background: kind === k.id && !firmMode ? `${k.color}22` : 'transparent', | |
| border: `1px solid ${kind === k.id && !firmMode ? k.color : 'var(--border-subtle)'}`, | |
| color: kind === k.id && !firmMode ? '#fff' : 'var(--text-secondary)', | |
| borderRadius: 10, | |
| padding: '0.55rem 0.9rem', | |
| fontWeight: 700, | |
| fontSize: '0.8rem', | |
| }} | |
| > | |
| {k.label} | |
| {counts && typeof counts[k.id] === 'number' ? ` (${counts[k.id]})` : ''} | |
| </button> | |
| ))} | |
| <button | |
| type="button" | |
| className="btn" | |
| onClick={() => (firmMode ? setFirmMode(false) : void loadPulse(kind))} | |
| style={{ | |
| marginLeft: 'auto', | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| background: 'transparent', | |
| border: '1px solid var(--border-subtle)', | |
| color: 'var(--text-secondary)', | |
| }} | |
| > | |
| <RefreshCw size={14} /> Odśwież | |
| </button> | |
| </div> | |
| <div | |
| className="glass-card" | |
| style={{ | |
| padding: '1rem 1.25rem', | |
| marginBottom: '1.25rem', | |
| borderRadius: 14, | |
| border: '1px solid var(--border-subtle)', | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}> | |
| <Sparkles size={16} color="var(--accent-green)" /> | |
| <strong style={{ color: 'var(--text-primary)', fontSize: '0.9rem' }}> | |
| Doradca firmowy (Firm Pulse) | |
| </strong> | |
| </div> | |
| <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> | |
| <input | |
| value={nip} | |
| onChange={(e) => setNip(e.target.value)} | |
| placeholder="NIP (10 cyfr)" | |
| inputMode="numeric" | |
| style={{ | |
| flex: '0 0 160px', | |
| padding: '0.65rem 0.8rem', | |
| borderRadius: 10, | |
| border: '1px solid var(--border-subtle)', | |
| background: 'var(--bg-base)', | |
| color: 'var(--text-primary)', | |
| }} | |
| /> | |
| <input | |
| value={description} | |
| onChange={(e) => setDescription(e.target.value)} | |
| placeholder="Opis inwestycji / branża (opcjonalnie)" | |
| style={{ | |
| flex: 1, | |
| minWidth: 200, | |
| padding: '0.65rem 0.8rem', | |
| borderRadius: 10, | |
| border: '1px solid var(--border-subtle)', | |
| background: 'var(--bg-base)', | |
| color: 'var(--text-primary)', | |
| }} | |
| /> | |
| <button | |
| type="button" | |
| className="btn" | |
| onClick={() => void runFirm()} | |
| disabled={firmLoading} | |
| style={{ | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: 8, | |
| background: 'linear-gradient(90deg, var(--accent-green), #3b82f6)', | |
| color: '#000', | |
| fontWeight: 800, | |
| border: 'none', | |
| borderRadius: 10, | |
| padding: '0.65rem 1.1rem', | |
| }} | |
| > | |
| {firmLoading ? <Loader2 size={16} className="spin" /> : <Search size={16} />} | |
| Dopasuj | |
| </button> | |
| </div> | |
| {firmMode && firmData?.company && ( | |
| <div style={{ marginTop: 10, fontSize: '0.8rem', color: 'var(--text-muted)' }}> | |
| Firma: {(firmData.company as { name?: string }).name || '—'} · NIP{' '} | |
| {(firmData.company as { nip?: string }).nip || nip || '—'} | |
| {typeof firmData.alert_count === 'number' ? ` · alert-ready: ${firmData.alert_count}` : ''} | |
| {!!firmData.gaps?.length && ( | |
| <div style={{ color: '#fbbf24', marginTop: 4 }}> | |
| Luki rejestrów: {firmData.gaps.slice(0, 3).join(' · ')} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {quality && ( | |
| <div | |
| style={{ | |
| display: 'flex', | |
| gap: 12, | |
| flexWrap: 'wrap', | |
| marginBottom: '1rem', | |
| fontSize: '0.75rem', | |
| color: 'var(--text-muted)', | |
| }} | |
| > | |
| <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}> | |
| <Clock size={12} /> terminy: {Math.round((quality.deadline_rate || 0) * 100)}% | |
| </span> | |
| <span>regulaminy: {Math.round((quality.regulation_rate || 0) * 100)}%</span> | |
| {quality.deadline_backfilled ? ( | |
| <span>backfill z tekstu: {quality.deadline_backfilled}</span> | |
| ) : null} | |
| </div> | |
| )} | |
| {(loading || firmLoading) && ( | |
| <div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-muted)' }}> | |
| <Loader2 size={28} className="spin" style={{ marginBottom: 12 }} /> | |
| <div>Ładowanie pulsu…</div> | |
| </div> | |
| )} | |
| {!loading && !firmLoading && items.length === 0 && ( | |
| <div | |
| className="glass-card" | |
| style={{ | |
| padding: '2rem', | |
| textAlign: 'center', | |
| color: 'var(--text-muted)', | |
| borderRadius: 14, | |
| }} | |
| > | |
| Brak pozycji dla wybranego rodzaju. Spróbuj „Aktualne” lub odśwież katalog naborów. | |
| </div> | |
| )} | |
| <div style={{ display: 'grid', gap: '0.9rem' }}> | |
| {items.map((it) => ( | |
| <PulseCard key={String(it.id || it.name)} item={it} showMatch={firmMode} /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |