Spaces:
Running
Running
| import React, { useState, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { Search, Plus, Filter, FolderOpen, MoreVertical, Clock, CheckCircle, ChevronRight, Activity, Trash2, Download, Grid, List, Sparkles } from 'lucide-react'; | |
| import { useNavigate, useLocation } from 'react-router-dom'; | |
| import WizardModal from '../components/dashboard/WizardModal'; | |
| import EmptyProjectsState from '../components/dashboard/EmptyProjectsState'; | |
| import { useProjectStore } from '../store/useProjectStore'; | |
| import { deleteProject } from '../api/client'; | |
| import toast from 'react-hot-toast'; | |
| import * as XLSX from 'xlsx'; | |
| import { analytics } from '../utils/analytics'; | |
| const Projects: React.FC = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { projects, fetchProjects } = useProjectStore(); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [statusFilter, setStatusFilter] = useState('all'); | |
| const [showWizard, setShowWizard] = useState(false); | |
| const [currentPage, setCurrentPage] = useState(1); | |
| const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); | |
| const [menuOpenId, setMenuOpenId] = useState<string | null>(null); | |
| const itemsPerPage = 6; | |
| useEffect(() => { | |
| fetchProjects(); | |
| }, [fetchProjects]); | |
| // Obsługa powrotu po Stripe Checkout | |
| useEffect(() => { | |
| const params = new URLSearchParams(location.search); | |
| if (params.get('upgraded') === '1' || params.get('mock_success') === '1') { | |
| const tier = params.get('tier') || 'pro'; | |
| toast.success( | |
| `🎉 Plan ${tier === 'pro' ? 'Pro' : 'Business'} aktywny! Limity zostały zwiększone.`, | |
| { duration: 6000, icon: <Sparkles size={18} color="#10b981" /> } | |
| ); | |
| analytics.checkoutCompleted(tier); | |
| // Usuń parametry z URL bez reload | |
| window.history.replaceState({}, '', '/projects'); | |
| } | |
| }, [location.search]); | |
| // Filter logic | |
| const filteredProjects = projects.filter(p => { | |
| if (statusFilter !== 'all' && p.status !== statusFilter) return false; | |
| if (searchQuery && !p.title.toLowerCase().includes(searchQuery.toLowerCase())) return false; | |
| return true; | |
| }); | |
| const getStatusConfig = (p: any) => { | |
| let progress = 0; | |
| if (p.sections && p.sections.length > 0) { | |
| const approvedCount = p.sections.filter((s: any) => s.is_approved).length; | |
| progress = Math.round((approvedCount / p.sections.length) * 100); | |
| } else { | |
| let baseProgress = 0; | |
| if (p.external_context) { | |
| if (p.external_context.region) baseProgress += 10; | |
| if (p.external_context.company_size) baseProgress += 10; | |
| if (p.external_context.innovation_scale) baseProgress += 10; | |
| if (p.external_context.ai_matches && p.external_context.ai_matches.length > 0) baseProgress += 20; | |
| } | |
| if (p.title && p.title !== 'Nowy Projekt') baseProgress += 5; | |
| if (p.description && p.description.length > 10) baseProgress += 15; | |
| progress = Math.min(baseProgress, 99); // max 99 if no sections | |
| if (p.status === 'completed') progress = 100; | |
| } | |
| let statusToUse = p.status; | |
| if (statusToUse === 'draft') { | |
| const filledCount = p.sections?.filter((s: any) => s.content && s.content.length > 50).length || 0; | |
| const approvedCount = p.sections?.filter((s: any) => s.is_approved).length || 0; | |
| if (approvedCount > 0 || filledCount > 0 || progress > 5) statusToUse = 'in_progress'; | |
| } | |
| switch(statusToUse) { | |
| case 'draft': return { label: 'Szkic', color: 'var(--text-muted)', bg: 'rgba(255, 255, 255, 0.05)', icon: <Activity size={14}/>, progress }; | |
| case 'in_progress': return { label: 'W Trakcie', color: 'var(--accent-blue)', bg: 'rgba(59, 130, 246, 0.1)', icon: <Activity size={14}/>, progress }; | |
| case 'completed': return { label: 'Zakończony (Wygenerowany)', color: 'var(--accent-green)', bg: 'rgba(16, 185, 129, 0.1)', icon: <CheckCircle size={14}/>, progress: 100 }; | |
| default: return { label: 'Nieznany', color: 'var(--text-muted)', bg: 'var(--bg-elevated)', icon: null, progress: 0 }; | |
| } | |
| }; | |
| const formatDate = (dateString: string) => { | |
| const d = new Date(dateString); | |
| return d.toLocaleDateString('pl-PL'); | |
| }; | |
| const handleDelete = async (id: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| if (window.confirm("Czy na pewno chcesz usunąć projekt? Ta operacja jest nieodwracalna.")) { | |
| try { | |
| await deleteProject(id); | |
| toast.success("Projekt usunięty"); | |
| fetchProjects(); | |
| } catch (error) { | |
| toast.error("Błąd podczas usuwania projektu"); | |
| } | |
| } | |
| setMenuOpenId(null); | |
| }; | |
| const handleExport = () => { | |
| const dataToExport = filteredProjects.map(p => ({ | |
| "Nazwa projektu": p.title, | |
| "Program": p.program_name || 'Brak', | |
| "Status": getStatusConfig(p).label, | |
| "Data utworzenia": formatDate(p.created_at), | |
| "% ukończenia": `${getStatusConfig(p).progress}%`, | |
| })); | |
| const worksheet = XLSX.utils.json_to_sheet(dataToExport); | |
| const workbook = XLSX.utils.book_new(); | |
| XLSX.utils.book_append_sheet(workbook, worksheet, "Projekty"); | |
| XLSX.writeFile(workbook, "Moje_Projekty.xlsx"); | |
| }; | |
| return ( | |
| <div style={{ padding: '2rem 3rem', maxWidth: '1400px', margin: '0 auto', width: '100%', height: '100%', overflowY: 'auto' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3rem' }}> | |
| <div> | |
| <h1 className="display-font" style={{ fontSize: '2.5rem', color: 'var(--text-primary)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.8rem' }}> | |
| <FolderOpen size={36} color="var(--accent-green)" /> | |
| Moje Projekty | |
| </h1> | |
| <p style={{ color: 'var(--text-secondary)', fontSize: '1.05rem', marginLeft: '3.4rem' }}>Zarządzaj swoimi wnioskami dotacyjnymi i powracaj do starych sesji RAG.</p> | |
| </div> | |
| <motion.button | |
| whileHover={{ scale: 1.05, boxShadow: '0 0 25px rgba(16, 185, 129, 0.5)' }} | |
| whileTap={{ scale: 0.95 }} | |
| className="btn btn-primary" | |
| onClick={() => setShowWizard(true)} | |
| style={{ padding: '1rem 2rem', fontSize: '1.05rem', background: 'linear-gradient(90deg, var(--accent-green), #3b82f6)', boxShadow: '0 0 20px rgba(16,185,129,0.3)', border: 'none', color: '#000', fontWeight: 800 }} | |
| > | |
| <Plus size={20} /> Nowy Projekt Dotacyjny | |
| </motion.button> | |
| </div> | |
| {/* FILTRY */} | |
| <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}> | |
| <div style={{ position: 'relative', flex: 1, minWidth: '300px' }}> | |
| <Search size={18} color="var(--text-muted)" style={{ position: 'absolute', top: '50%', transform: 'translateY(-50%)', left: '1.2rem' }} /> | |
| <input | |
| type="text" | |
| placeholder="Szukaj projektu po nazwie..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| style={{ width: '100%', padding: '1rem 1rem 1rem 3rem', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', color: '#fff', outline: 'none' }} | |
| /> | |
| </div> | |
| <div style={{ display: 'flex', gap: '1rem', alignItems: 'center', background: 'rgba(255,255,255,0.03)', padding: '0.5rem', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.1)' }}> | |
| <Filter size={18} color="var(--text-muted)" style={{ marginLeft: '1rem' }} /> | |
| <select | |
| value={statusFilter} | |
| onChange={(e) => setStatusFilter(e.target.value)} | |
| style={{ background: 'transparent', border: 'none', color: '#fff', fontSize: '1rem', padding: '0.5rem 1rem', outline: 'none', cursor: 'pointer' }} | |
| > | |
| <option value="all" style={{ background: '#0B0E14', color: '#fff' }}>Wszystkie statusy</option> | |
| <option value="draft" style={{ background: '#0B0E14', color: '#fff' }}>Szkic</option> | |
| <option value="in_progress" style={{ background: '#0B0E14', color: '#fff' }}>W trakcie</option> | |
| <option value="completed" style={{ background: '#0B0E14', color: '#fff' }}>Gotowe wnioski</option> | |
| </select> | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto', alignItems: 'center' }}> | |
| <button className="btn btn-secondary" onClick={handleExport} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 1rem', fontSize: '0.9rem' }}> | |
| <Download size={16} /> Eksportuj XLSX | |
| </button> | |
| <div style={{ display: 'flex', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}> | |
| <button | |
| onClick={() => setViewMode('grid')} | |
| style={{ background: viewMode === 'grid' ? 'rgba(59,130,246,0.2)' : 'transparent', border: 'none', padding: '0.6rem 0.8rem', color: viewMode === 'grid' ? '#fff' : 'var(--text-muted)', cursor: 'pointer' }} | |
| ><Grid size={18} /></button> | |
| <button | |
| onClick={() => setViewMode('list')} | |
| style={{ background: viewMode === 'list' ? 'rgba(59,130,246,0.2)' : 'transparent', border: 'none', padding: '0.6rem 0.8rem', color: viewMode === 'list' ? '#fff' : 'var(--text-muted)', cursor: 'pointer' }} | |
| ><List size={18} /></button> | |
| </div> | |
| </div> | |
| </div> | |
| {projects.length === 0 ? ( | |
| <div style={{ display: 'flex', justifyContent: 'center', marginTop: '4rem' }}> | |
| <EmptyProjectsState onCreateClick={() => setShowWizard(true)} /> | |
| </div> | |
| ) : filteredProjects.length === 0 ? ( | |
| <div style={{ display: 'flex', justifyContent: 'center', marginTop: '4rem' }}> | |
| <div className="glass-card" style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-muted)' }}> | |
| <Search size={48} color="var(--accent-blue)" style={{ opacity: 0.5, marginBottom: '1rem' }} /> | |
| <h3>Brak wyników wyszukiwania</h3> | |
| <p>Żaden projekt nie spełnia wybranych kryteriów filtra lub wyszukiwania.</p> | |
| <button className="btn btn-secondary" onClick={() => { setSearchQuery(''); setStatusFilter('all'); }} style={{ marginTop: '1rem' }}>Zresetuj filtry</button> | |
| </div> | |
| </div> | |
| ) : viewMode === 'grid' && ( | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '1.5rem' }}> | |
| <AnimatePresence> | |
| {filteredProjects.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage).map((p, i) => { | |
| const config = getStatusConfig(p); | |
| return ( | |
| <motion.div | |
| key={p.id} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.9 }} | |
| transition={{ delay: i * 0.05 }} | |
| className="glass-card" | |
| style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', position: 'relative' }} | |
| > | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}> | |
| <div style={{ background: config.bg, color: config.color, padding: '0.4rem 0.8rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '0.4rem' }}> | |
| {config.icon} {config.label} | |
| </div> | |
| <div style={{ position: 'relative' }}> | |
| <div | |
| onClick={(e) => { e.stopPropagation(); setMenuOpenId(menuOpenId === p.id ? null : p.id); }} | |
| style={{ cursor: 'pointer', padding: '0.2rem' }} | |
| > | |
| <MoreVertical size={20} color="var(--text-muted)" /> | |
| </div> | |
| {menuOpenId === p.id && ( | |
| <div style={{ position: 'absolute', right: 0, top: '100%', background: '#1c212c', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', padding: '0.5rem', zIndex: 10, minWidth: '150px', boxShadow: '0 4px 12px rgba(0,0,0,0.5)' }}> | |
| <button | |
| onClick={(e) => handleDelete(p.id, e)} | |
| style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%', background: 'transparent', border: 'none', color: '#ef4444', padding: '0.5rem', cursor: 'pointer', textAlign: 'left', borderRadius: '4px', fontSize: '0.9rem' }} | |
| > | |
| <Trash2 size={16} /> Usuń projekt | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <h3 style={{ fontSize: '1.2rem', color: 'var(--text-primary)', marginBottom: '0.5rem', fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={p.title}>{p.title}</h3> | |
| <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <span style={{ color: 'var(--accent-blue)', fontWeight: 600 }}>{p.program_name || "Brak wybranego programu"}</span> | |
| </div> | |
| <div style={{ marginTop: 'auto' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '0.4rem' }}> | |
| <span>Postęp wniosku (AI)</span> | |
| <span style={{ fontWeight: 800, color: 'var(--text-primary)' }}>{config.progress}%</span> | |
| </div> | |
| <div className="progress-rail" style={{ height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}> | |
| <motion.div | |
| className="progress-fill" | |
| initial={{ width: 0 }} | |
| animate={{ width: `${config.progress}%` }} | |
| transition={{ duration: 1, delay: i * 0.1 }} | |
| style={{ background: config.color, borderRadius: '3px' }} | |
| /> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1.5rem', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '1.2rem' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}> | |
| <Clock size={14} /> {formatDate(p.created_at)} | |
| </div> | |
| <motion.button | |
| whileHover={{ x: 5, color: '#fff' }} | |
| onClick={() => navigate(`/projects/${p.id}`)} | |
| style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'transparent', border: 'none', color: 'var(--accent-green)', fontWeight: 800, cursor: 'pointer', fontSize: '0.95rem' }} | |
| > | |
| Otwórz Projekt <ChevronRight size={18} /> | |
| </motion.button> | |
| </div> | |
| </motion.div> | |
| ) | |
| })} | |
| </AnimatePresence> | |
| </div> | |
| )} | |
| {filteredProjects.length > 0 && viewMode === 'list' && ( | |
| <div style={{ background: 'var(--bg-elevated)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)', overflow: 'hidden' }}> | |
| <table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.95rem' }}> | |
| <thead style={{ background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}> | |
| <tr> | |
| <th style={{ padding: '1.2rem', color: 'var(--text-secondary)', fontWeight: 600 }}>Projekt</th> | |
| <th style={{ padding: '1.2rem', color: 'var(--text-secondary)', fontWeight: 600 }}>Program</th> | |
| <th style={{ padding: '1.2rem', color: 'var(--text-secondary)', fontWeight: 600 }}>Status</th> | |
| <th style={{ padding: '1.2rem', color: 'var(--text-secondary)', fontWeight: 600 }}>Data utworzenia</th> | |
| <th style={{ padding: '1.2rem', textAlign: 'right', color: 'var(--text-secondary)', fontWeight: 600 }}>Akcje</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredProjects.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage).map((p) => { | |
| const config = getStatusConfig(p); | |
| return ( | |
| <tr key={p.id} className="hover-bg-light" style={{ borderBottom: '1px solid rgba(255,255,255,0.02)' }}> | |
| <td style={{ padding: '1rem 1.2rem', color: 'var(--text-primary)', fontWeight: 700 }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem' }}> | |
| <div style={{ width: '30px', height: '30px', background: 'rgba(59,130,246,0.1)', borderRadius: '6px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent-blue)' }}> | |
| <FolderOpen size={16} /> | |
| </div> | |
| <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px', display: 'inline-block' }} title={p.title}>{p.title}</span> | |
| </div> | |
| </td> | |
| <td style={{ padding: '1rem 1.2rem', color: 'var(--accent-blue)', fontSize: '0.9rem' }}>{p.program_name || '-'}</td> | |
| <td style={{ padding: '1rem 1.2rem' }}> | |
| <div style={{ display: 'inline-flex', background: config.bg, color: config.color, padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 800, alignItems: 'center', gap: '0.3rem' }}> | |
| {config.icon} {config.label} | |
| </div> | |
| </td> | |
| <td style={{ padding: '1rem 1.2rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}>{formatDate(p.created_at)}</td> | |
| <td style={{ padding: '1rem 1.2rem', textAlign: 'right' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '1rem' }}> | |
| <button onClick={() => navigate(`/projects/${p.id}`)} style={{ background: 'transparent', border: 'none', color: 'var(--accent-green)', fontWeight: 700, cursor: 'pointer', fontSize: '0.9rem' }}>Otówrz</button> | |
| <button onClick={(e) => handleDelete(p.id, e)} style={{ background: 'transparent', border: 'none', color: '#ef4444', cursor: 'pointer' }}><Trash2 size={18} /></button> | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {filteredProjects.length > itemsPerPage && ( | |
| <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '3rem' }}> | |
| <button | |
| className="btn btn-secondary" | |
| disabled={currentPage === 1} | |
| onClick={() => setCurrentPage(p => Math.max(1, p - 1))} | |
| >Poprzednia</button> | |
| <span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}> | |
| Strona {currentPage} z {Math.ceil(filteredProjects.length / itemsPerPage)} | |
| </span> | |
| <button | |
| className="btn btn-secondary" | |
| disabled={currentPage === Math.ceil(filteredProjects.length / itemsPerPage)} | |
| onClick={() => setCurrentPage(p => p + 1)} | |
| >Następna</button> | |
| </div> | |
| )} | |
| {showWizard && <WizardModal onClose={() => setShowWizard(false)} />} | |
| </div> | |
| ); | |
| }; | |
| export default Projects; | |