grantforge-api / frontend-react /src /components /project /RegionalProgramsPanel.tsx
GrantForge Bot
Deploy sha-3d4ebe3261a5907e50617c6c64d42c59bd3d4877 — source build (no GHCR)
61d1e19
Raw
History Blame Contribute Delete
6.84 kB
import React, { useEffect, useState } from 'react';
import { MapPin, ExternalLink, CalendarPlus, Filter } from 'lucide-react';
import toast from 'react-hot-toast';
import { getRegionalPrograms, RegionalProgram, RegionalProgramsResponse, createPipelineEntry } from '../../api/client';
interface Props {
projectId?: string;
compact?: boolean;
}
const RegionalProgramsPanel: React.FC<Props> = ({ projectId, compact = false }) => {
const [programs, setPrograms] = useState<RegionalProgram[]>([]);
const [voivodeships, setVoivodeships] = useState<string[]>([]);
const [bipLiveCount, setBipLiveCount] = useState(0);
const [bipParserVoivodeships, setBipParserVoivodeships] = useState<string[]>([]);
const [selectedVoivodeship, setSelectedVoivodeship] = useState('');
const [loading, setLoading] = useState(true);
const load = async (voivodeship?: string) => {
try {
const data: RegionalProgramsResponse = await getRegionalPrograms(voivodeship || undefined);
setPrograms(data.programs || []);
setVoivodeships(data.voivodeships || []);
setBipLiveCount(data.bip_live_count ?? 0);
setBipParserVoivodeships(data.bip_parser_voivodeships || []);
} catch {
toast.error('Nie udało się załadować katalogu RPO');
} finally {
setLoading(false);
}
};
useEffect(() => {
setLoading(true);
load(selectedVoivodeship || undefined);
}, [selectedVoivodeship]);
const handleAddToPipeline = async (prog: RegionalProgram) => {
if (!projectId) {
toast.error('Wybierz projekt, aby dodać do pipeline');
return;
}
try {
await createPipelineEntry(projectId, {
grant_name: prog.name,
grant_source_id: prog.id,
operator: prog.operator,
status: 'planned',
deadline_alert: true,
notes: `RPO ${prog.voivodeship} — katalog regionalny`,
});
toast.success(`„${prog.program}” dodano do pipeline`);
} catch {
toast.error('Błąd dodawania do pipeline');
}
};
if (loading) {
return <p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Ładowanie programów RPO…</p>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<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' }}>
<MapPin size={18} color="#a78bfa" />
<span style={{ fontWeight: 700, fontSize: compact ? '0.95rem' : '1.05rem' }}>
Katalog RPO wojewódzkich
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>({programs.length})</span>
{bipLiveCount > 0 && (
<span
style={{
fontSize: '0.7rem',
background: 'rgba(34,197,94,0.12)',
color: '#4ade80',
padding: '2px 8px',
borderRadius: 999,
}}
title="Programy z live cache BIP (credibility gate)"
>
BIP live: {bipLiveCount}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{bipParserVoivodeships.length > 0 && (
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>
Parser BIP: {bipParserVoivodeships.join(', ')}
</span>
)}
<Filter size={14} color="var(--text-muted)" />
<select
className="form-input"
value={selectedVoivodeship}
onChange={(e) => setSelectedVoivodeship(e.target.value)}
style={{ fontSize: '0.85rem', padding: '0.35rem 0.6rem', minWidth: 160 }}
>
<option value="">Wszystkie województwa</option>
{voivodeships.map((v) => (
<option key={v} value={v}>{v.charAt(0).toUpperCase() + v.slice(1)}</option>
))}
</select>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', maxHeight: compact ? 320 : undefined, overflowY: compact ? 'auto' : undefined }}>
{programs.map((prog) => (
<div
key={prog.id}
style={{
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: compact ? '0.65rem 0.75rem' : '0.85rem 1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: '0.9rem', marginBottom: '0.25rem' }}>{prog.name}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.35rem' }}>
{prog.operator} • {prog.voivodeship}
</div>
{prog.topics && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{prog.topics.slice(0, 3).map((t) => (
<span
key={t}
style={{
fontSize: '0.65rem',
background: 'rgba(167,139,250,0.15)',
color: '#a78bfa',
padding: '2px 6px',
borderRadius: 4,
}}
>
{t}
</span>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.35rem', flexShrink: 0 }}>
{prog.url && (
<a
href={prog.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
style={{ padding: '0.35rem', fontSize: '0.75rem' }}
title="Strona operatora"
>
<ExternalLink size={14} />
</a>
)}
{projectId && (
<button
className="btn btn-secondary"
onClick={() => handleAddToPipeline(prog)}
style={{ padding: '0.35rem', fontSize: '0.75rem' }}
title="Dodaj do pipeline"
>
<CalendarPlus size={14} />
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default RegionalProgramsPanel;