Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState } from 'react'; | |
| import { Calendar, CheckCircle, Clock, Loader2, Plus, Trash2, Bell } from 'lucide-react'; | |
| import toast from 'react-hot-toast'; | |
| import { | |
| createPostAwardMilestone, | |
| deletePostAwardMilestone, | |
| getNotificationInbox, | |
| getPostAwardMilestones, | |
| ProjectMilestone, | |
| updatePostAwardMilestone, | |
| } from '../../api/client'; | |
| interface Props { | |
| projectId: string; | |
| userId?: string; | |
| } | |
| const PostAwardPanel: React.FC<Props> = ({ projectId, userId }) => { | |
| const [milestones, setMilestones] = useState<ProjectMilestone[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [saving, setSaving] = useState(false); | |
| const [title, setTitle] = useState(''); | |
| const [dueDate, setDueDate] = useState(''); | |
| const [description, setDescription] = useState(''); | |
| const [alerts, setAlerts] = useState<any[]>([]); | |
| const load = async () => { | |
| setLoading(true); | |
| try { | |
| const [ms, inbox] = await Promise.all([ | |
| getPostAwardMilestones(projectId), | |
| getNotificationInbox(userId, true).catch(() => ({ notifications: [] })), | |
| ]); | |
| setMilestones(ms.milestones || []); | |
| const related = (inbox.notifications || []).filter( | |
| (n: any) => n.project_id === projectId && n.type === 'post_award_deadline', | |
| ); | |
| setAlerts(related); | |
| } catch { | |
| toast.error('Nie udało się załadować kamieni milowych.'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (projectId) load(); | |
| }, [projectId]); | |
| const handleAdd = async () => { | |
| if (!title.trim() || !dueDate) { | |
| toast.error('Podaj tytuł i termin.'); | |
| return; | |
| } | |
| setSaving(true); | |
| try { | |
| await createPostAwardMilestone(projectId, { | |
| title: title.trim(), | |
| due_date: new Date(dueDate).toISOString(), | |
| description: description.trim() || undefined, | |
| }); | |
| setTitle(''); | |
| setDueDate(''); | |
| setDescription(''); | |
| toast.success('Kamień milowy dodany.'); | |
| await load(); | |
| } catch (e: any) { | |
| toast.error(e.response?.data?.detail || 'Błąd zapisu.'); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const toggleComplete = async (m: ProjectMilestone) => { | |
| try { | |
| await updatePostAwardMilestone(projectId, m.id, { | |
| status: m.status === 'completed' ? 'pending' : 'completed', | |
| }); | |
| await load(); | |
| } catch { | |
| toast.error('Nie udało się zaktualizować statusu.'); | |
| } | |
| }; | |
| const handleDelete = async (id: string) => { | |
| try { | |
| await deletePostAwardMilestone(projectId, id); | |
| toast.success('Usunięto.'); | |
| await load(); | |
| } catch { | |
| toast.error('Nie udało się usunąć.'); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}> | |
| <Loader2 className="animate-spin" size={32} style={{ margin: '0 auto' }} /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div style={{ maxWidth: '900px', margin: '0 auto', padding: '2rem' }}> | |
| <h2 style={{ fontSize: '1.6rem', fontWeight: 800, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <Calendar size={22} color="var(--accent-blue)" /> Post-award — śledzenie terminów | |
| </h2> | |
| <p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}> | |
| Kamienie milowe po przyznaniu dotacji (raporty, rozliczenia). Scheduler tworzy alerty w skrzynce powiadomień. | |
| </p> | |
| {alerts.length > 0 && ( | |
| <div className="glass-card" style={{ marginBottom: '1.5rem', padding: '1rem', borderLeft: '3px solid #f59e0b' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem', color: '#f59e0b', fontWeight: 700 }}> | |
| <Bell size={16} /> Zbliżające się terminy ({alerts.length}) | |
| </div> | |
| {alerts.map((a) => ( | |
| <div key={a.id} style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}> | |
| {a.title} — {a.deadline ? new Date(a.deadline).toLocaleDateString('pl-PL') : ''} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| <div className="glass-card" style={{ padding: '1.5rem', marginBottom: '2rem' }}> | |
| <h3 style={{ marginBottom: '1rem', fontSize: '1rem' }}>Nowy kamień milowy</h3> | |
| <div style={{ display: 'grid', gap: '0.75rem' }}> | |
| <input | |
| placeholder="np. Raport okresowy M1" | |
| value={title} | |
| onChange={(e) => setTitle(e.target.value)} | |
| style={{ padding: '0.6rem 0.75rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.2)', color: '#fff' }} | |
| /> | |
| <input | |
| type="datetime-local" | |
| value={dueDate} | |
| onChange={(e) => setDueDate(e.target.value)} | |
| style={{ padding: '0.6rem 0.75rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.2)', color: '#fff' }} | |
| /> | |
| <textarea | |
| placeholder="Opis (opcjonalnie)" | |
| value={description} | |
| onChange={(e) => setDescription(e.target.value)} | |
| rows={2} | |
| style={{ padding: '0.6rem 0.75rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.2)', color: '#fff' }} | |
| /> | |
| <button className="btn btn-primary" disabled={saving} onClick={handleAdd} style={{ alignSelf: 'flex-start', display: 'flex', gap: '0.4rem', alignItems: 'center' }}> | |
| {saving ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />} Dodaj | |
| </button> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {milestones.length === 0 ? ( | |
| <p style={{ color: 'var(--text-muted)' }}>Brak zdefiniowanych kamieni milowych.</p> | |
| ) : ( | |
| milestones.map((m) => ( | |
| <div key={m.id} className="glass-card" style={{ padding: '1rem 1.25rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}> | |
| <div> | |
| <div style={{ fontWeight: 700, color: m.status === 'completed' ? 'var(--accent-green)' : '#fff', textDecoration: m.status === 'completed' ? 'line-through' : 'none' }}> | |
| {m.title} | |
| </div> | |
| {m.description && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>{m.description}</div>} | |
| <div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.35rem', display: 'flex', alignItems: 'center', gap: '0.35rem' }}> | |
| <Clock size={13} /> Termin: {new Date(m.due_date).toLocaleString('pl-PL')} | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.5rem' }}> | |
| <button onClick={() => toggleComplete(m)} title="Oznacz jako ukończone" style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: m.status === 'completed' ? 'var(--accent-green)' : 'var(--text-secondary)' }}> | |
| <CheckCircle size={20} /> | |
| </button> | |
| <button onClick={() => handleDelete(m.id)} title="Usuń" style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: '#ef4444' }}> | |
| <Trash2 size={18} /> | |
| </button> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default PostAwardPanel; | |