grantforge-api / frontend-react /src /components /project /AdvancedMatcherModal.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useEffect } from 'react';
import { X, Loader2, Search, CheckCircle, ArrowRight, MessageSquare, Briefcase, AlertTriangle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { matchGrantsForProject, UserAnswer } from '../../api/client';
interface GrantMatchResult {
program_id: string;
program_name: string;
score: number;
rationale: string;
is_recommended: boolean;
requires_verification?: boolean;
source?: string;
legal_basis?: string;
confidence_score?: number;
}
interface AdvancedMatcherModalProps {
projectId: string;
onClose: () => void;
onMatchesSaved: (matches: GrantMatchResult[]) => void;
}
export default function AdvancedMatcherModal({ projectId, onClose, onMatchesSaved }: AdvancedMatcherModalProps) {
const [step, setStep] = useState<'loading' | 'questions' | 'results'>('loading');
const [questions, setQuestions] = useState<string[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [matches, setMatches] = useState<GrantMatchResult[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [userAnswersHistory, setUserAnswersHistory] = useState<UserAnswer[]>([]);
useEffect(() => {
runMatcher([]);
}, [projectId]);
const runMatcher = async (currentAnswers: UserAnswer[]) => {
setStep('loading');
setError(null);
try {
const data = await matchGrantsForProject(projectId, currentAnswers);
if (data.status === 'error') {
setError('Wyst膮pi艂 b艂膮d podczas analizy dopasowa艅.');
setStep('questions'); // fallback
return;
}
if (data.needs_more_info && data.clarifying_questions && data.clarifying_questions.length > 0) {
setQuestions(data.clarifying_questions);
// Inicjalizuj puste odpowiedzi
const initialAnswers: Record<string, string> = {};
data.clarifying_questions.forEach(q => {
initialAnswers[q] = '';
});
setAnswers(initialAnswers);
setStep('questions');
} else {
setMatches(data.matches || []);
onMatchesSaved(data.matches || []);
setStep('results');
}
} catch (err) {
console.error('Error in matchGrantsForProject:', err);
setError('B艂膮d po艂膮czenia z serwerem AI.');
setStep('questions');
}
};
const handleAnswerSubmit = async () => {
setIsSubmitting(true);
// Zbierz nowe odpowiedzi
const newAnswers: UserAnswer[] = questions.map(q => ({
question: q,
answer: answers[q] || 'Brak odpowiedzi'
}));
const combinedAnswers = [...userAnswersHistory, ...newAnswers];
setUserAnswersHistory(combinedAnswers);
await runMatcher(combinedAnswers);
setIsSubmitting(false);
};
const modalContent = (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)', backdropFilter: 'blur(5px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 99999,
padding: '2rem 1rem', // Add vertical padding to prevent cutting off
boxSizing: 'border-box',
overflowY: 'auto'
}}>
<div className="glass-card" style={{
width: '100%', maxWidth: '600px', maxHeight: '90vh',
display: 'flex', flexDirection: 'column',
overflow: 'hidden', padding: 0, margin: 'auto',
boxSizing: 'border-box',
transform: 'none', // Override any hover transforms from glass-card
animation: 'none'
}}>
{/* Header */}
<div style={{
padding: '1.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: 'rgba(255,255,255,0.02)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: '36px', height: '36px', borderRadius: '8px',
background: 'rgba(139, 92, 246, 0.1)', color: 'var(--accent-purple)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Search size={20} />
</div>
<div>
<h2 style={{ fontSize: '1.2rem', margin: 0, color: 'var(--text-primary)' }}>Advanced AI Matcher</h2>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>Wieloetapowe dopasowanie program贸w</p>
</div>
</div>
<button onClick={onClose} style={{
background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer',
padding: '0.5rem'
}} className="hover-lift">
<X size={20} />
</button>
</div>
{/* Content */}
<div style={{ padding: '2rem', overflowY: 'auto', flex: 1 }}>
{step === 'loading' && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem 0' }}>
<Loader2 size={48} color="var(--accent-purple)" style={{ animation: 'spin 2s linear infinite', marginBottom: '1rem' }} />
<h3 style={{ color: 'var(--text-primary)', marginBottom: '0.5rem' }}>Analizuj臋 opis projektu...</h3>
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '400px' }}>
AI skanuje aktualne nabory w PARP i NCBR, aby znale藕膰 najlepsze dopasowania lub przygotowa膰 pytania doprecyzowuj膮ce.
</p>
</div>
)}
{step === 'questions' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{error && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', borderLeft: '3px solid var(--accent-red)', borderRadius: '4px', color: '#FECACA', fontSize: '0.9rem' }}>
{error}
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem', background: 'rgba(59, 130, 246, 0.1)', padding: '1.5rem', borderRadius: '12px', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
<MessageSquare size={24} color="var(--accent-blue)" style={{ flexShrink: 0 }} />
<div>
<h4 style={{ margin: '0 0 0.5rem 0', color: 'var(--accent-blue)' }}>AI potrzebuje wi臋cej informacji</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Aby dok艂adnie dobra膰 programy dotacyjne, prosz臋 odpowiedz na poni偶sze pytania. Twoje odpowiedzi zostan膮 zapisane i wykorzystane podczas tworzenia wniosku.
</p>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{questions.map((q, idx) => (
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label style={{ fontSize: '0.95rem', color: 'var(--text-primary)', fontWeight: 500 }}>
{idx + 1}. {q}
</label>
<textarea
value={answers[q] || ''}
onChange={(e) => setAnswers(prev => ({ ...prev, [q]: e.target.value }))}
placeholder="Twoja odpowied藕..."
style={{
background: 'rgba(0,0,0,0.2)', border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px', padding: '0.75rem', color: '#fff',
minHeight: '80px', resize: 'vertical', fontSize: '0.9rem', fontFamily: 'inherit'
}}
/>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button
onClick={handleAnswerSubmit}
disabled={isSubmitting}
className="btn btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
{isSubmitting ? <Loader2 size={16} className="spin" /> : <ArrowRight size={16} />}
Prze艣lij odpowiedzi
</button>
</div>
</div>
)}
{step === 'results' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem', background: 'rgba(16, 185, 129, 0.1)', padding: '1.5rem', borderRadius: '12px', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<CheckCircle size={24} color="var(--accent-green)" style={{ flexShrink: 0 }} />
<div>
<h4 style={{ margin: '0 0 0.5rem 0', color: 'var(--accent-green)' }}>Analiza zako艅czona sukcesem</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Znaleziono {matches.length} potencjalnych program贸w. Rekomendowane opcje zosta艂y oznaczone najwy偶szym wynikiem.
</p>
</div>
</div>
{matches.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
Brak pasuj膮cych program贸w dla podanych kryteri贸w.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{matches.sort((a,b) => b.score - a.score).map((m, idx) => (
<div key={idx} style={{
background: 'rgba(255,255,255,0.03)',
border: `1px solid ${m.is_recommended ? 'rgba(16, 185, 129, 0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '10px', padding: '1.25rem',
display: 'flex', flexDirection: 'column', gap: '0.75rem'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<h4 style={{ margin: 0, fontSize: '1.05rem', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Briefcase size={16} color="var(--text-muted)" />
{m.program_name}
</h4>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{m.requires_verification && (
<div style={{
background: 'rgba(245, 158, 11, 0.1)',
color: '#F59E0B',
padding: '0.25rem 0.75rem', borderRadius: '12px', fontSize: '0.85rem', fontWeight: 600,
display: 'flex', alignItems: 'center', gap: '0.25rem'
}}>
<AlertTriangle size={14} /> Wymaga weryfikacji
</div>
)}
<div style={{
background: m.score >= 70 ? 'rgba(16, 185, 129, 0.2)' : 'rgba(255,255,255,0.1)',
color: m.score >= 70 ? 'var(--accent-green)' : 'var(--text-secondary)',
padding: '0.25rem 0.75rem', borderRadius: '12px', fontSize: '0.85rem', fontWeight: 700
}}>
{m.score}% Match
</div>
</div>
</div>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{m.rationale}
</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{m.legal_basis && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<strong>Podstawa prawna:</strong> {m.legal_basis}
</div>
)}
{m.source && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<strong>殴r贸d艂o:</strong> {m.source}
</div>
)}
{m.confidence_score !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<strong>Pewno艣膰 AI:</strong> {m.confidence_score}%
</div>
)}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button onClick={onClose} className="btn btn-secondary">
Zamknij
</button>
</div>
</div>
)}
</div>
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin { animation: spin 1s linear infinite; }
`}</style>
</div>
);
return createPortal(modalContent, document.body);
}