GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
8.76 kB
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;