Spaces:
Running
Running
| 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'; | |
| const DEFAULT_CLARIFYING_QUESTIONS = [ | |
| 'Opisz cel projektu i planowane wydatki (inwestycja, B+R, zatrudnienie).', | |
| 'Podaj przybliżony przychód roczny i liczbę pracowników (FTE).', | |
| 'Czy projekt dotyczy głównej działalności firmy z rejestru PKD?', | |
| ]; | |
| 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[]>([]); | |
| const initAnswers = (questionList: string[]) => { | |
| const initialAnswers: Record<string, string> = {}; | |
| questionList.forEach((q) => { | |
| initialAnswers[q] = answers[q] || ''; | |
| }); | |
| setAnswers(initialAnswers); | |
| }; | |
| const applyQuestions = (questionList: string[]) => { | |
| const next = questionList.length > 0 ? questionList : DEFAULT_CLARIFYING_QUESTIONS; | |
| setQuestions(next); | |
| initAnswers(next); | |
| setStep('questions'); | |
| }; | |
| useEffect(() => { | |
| runMatcher([]); | |
| }, [projectId]); | |
| const runMatcher = async (currentAnswers: UserAnswer[]) => { | |
| setStep('loading'); | |
| setError(null); | |
| try { | |
| const data = await matchGrantsForProject(projectId, currentAnswers); | |
| if (data.status === 'error') { | |
| setError(data.detail || 'Wystąpił błąd podczas analizy dopasowań.'); | |
| applyQuestions(data.clarifying_questions || []); | |
| return; | |
| } | |
| if (data.needs_more_info && data.clarifying_questions && data.clarifying_questions.length > 0) { | |
| applyQuestions(data.clarifying_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.'); | |
| applyQuestions(DEFAULT_CLARIFYING_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.length > 0 ? questions : DEFAULT_CLARIFYING_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ź..." | |
| disabled={isSubmitting} | |
| 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', | |
| pointerEvents: isSubmitting ? 'none' : 'auto', | |
| }} | |
| /> | |
| </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); | |
| } | |