GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
14.9 kB
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>
);
}