feat(CC-091): add Project Tracker page (goals with project tag, task listing, progress, expand/collapse)
This commit is contained in:
parent
c4a7c4f07e
commit
475053cc6a
1 changed files with 159 additions and 170 deletions
|
|
@ -1,214 +1,203 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Project {
|
||||
id: string; name: string; description: string | null;
|
||||
status: string; priority: string; organizationId: string | null;
|
||||
startDate: string | null; dueDate: string | null;
|
||||
tags: string[]; createdAt: string;
|
||||
}
|
||||
interface Organization { id: string; name: string; }
|
||||
interface Goal { id: string; title: string; description: string | null; status: string; progress: number | null; tags: string[]; targetDate: string | null; createdAt: string; }
|
||||
interface Task { id: string; title: string; status: string; tags: string[]; createdAt: string; }
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
planning: { label: 'Planificare', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
|
||||
active: { label: 'Activ', cls: 'bg-signal-ok/10 text-signal-ok' },
|
||||
on_hold: { label: 'În pauză', cls: 'bg-signal-warn/10 text-signal-warn' },
|
||||
completed: { label: 'Completat', cls: 'bg-primary/10 text-primary' },
|
||||
cancelled: { label: 'Anulat', cls: 'bg-muted text-ink-faint' },
|
||||
};
|
||||
const PRIORITY_META: Record<string, { label: string; dot: string }> = {
|
||||
low: { label: 'Scăzut', dot: 'bg-sky-400' },
|
||||
medium: { label: 'Mediu', dot: 'bg-signal-warn' },
|
||||
high: { label: 'Ridicat', dot: 'bg-orange-500' },
|
||||
critical: { label: 'Critic', dot: 'bg-signal-danger' },
|
||||
const PROJECT_TAGS = ['proiect','project','initiative','program'];
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active:'text-signal-ok', completed:'text-ink-faint', cancelled:'text-signal-danger', paused:'text-warn', archived:'text-ink-faint',
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', description: '', status: 'planning', priority: 'medium',
|
||||
startDate: '', dueDate: '',
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ title: '', description: '', targetDate: '' });
|
||||
|
||||
const { data: goals = [], isLoading: loadG } = useQuery({
|
||||
queryKey: ['projects-goals', tenantId],
|
||||
queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: projects = [], isLoading } = useQuery({
|
||||
queryKey: ['projects', tenantId, filterStatus],
|
||||
queryFn: () => apiFetch<Project[]>(`/v1/projects${filterStatus ? `?status=${filterStatus}` : ''}`, { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
const { data: tasks = [], isLoading: loadT } = useQuery({
|
||||
queryKey: ['projects-tasks', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
const { data: orgs = [] } = useQuery({
|
||||
queryKey: ['orgs', tenantId],
|
||||
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
const orgMap = Object.fromEntries(orgs.map((o) => [o.id, o.name]));
|
||||
|
||||
const { mutate: createProject, isPending } = useMutation({
|
||||
mutationFn: () => apiFetch<Project>('/v1/projects', { method: 'POST', body: form, tenantId }),
|
||||
const projects = useMemo(() =>
|
||||
goals.filter((g) => g.tags.some((t) => PROJECT_TAGS.includes(t.toLowerCase()))),
|
||||
[goals]);
|
||||
|
||||
function projectTasks(projectId: string): Task[] {
|
||||
return tasks.filter((t) => t.tags.includes(`project-${projectId}`) || t.tags.includes(`goal-${projectId}`));
|
||||
}
|
||||
|
||||
function computeProgress(p: Goal): number {
|
||||
if (p.progress !== null) return p.progress;
|
||||
const pts = projectTasks(p.id);
|
||||
if (pts.length === 0) return 0;
|
||||
return Math.round((pts.filter((t) => t.status === 'completed').length / pts.length) * 100);
|
||||
}
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
apiFetch(`/v1/goals/${id}`, { tenantId, method: 'PATCH', body: { status } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['projects-goals', tenantId] }),
|
||||
});
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/goals', { tenantId, method: 'POST', body: {
|
||||
title: form.title,
|
||||
description: form.description || undefined,
|
||||
tags: ['proiect', 'project'],
|
||||
status: 'active',
|
||||
progress: 0,
|
||||
targetDate: form.targetDate || undefined,
|
||||
}}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['projects', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', description: '', status: 'planning', priority: 'medium', startDate: '', dueDate: '' });
|
||||
qc.invalidateQueries({ queryKey: ['projects-goals', tenantId] });
|
||||
setShowAdd(false);
|
||||
setForm({ title: '', description: '', targetDate: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const overdue = projects.filter((p) => p.dueDate && new Date(p.dueDate) < now && p.status !== 'completed' && p.status !== 'cancelled');
|
||||
const byStatus = useMemo(() => {
|
||||
const map: Record<string, Goal[]> = {};
|
||||
for (const p of projects) {
|
||||
const s = p.status ?? 'active';
|
||||
map[s] = [...(map[s] ?? []), p];
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Proiecte</h1>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Project Tracker</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{projects.length} proiecte{overdue.length > 0 ? ` · ${overdue.length} întârziate` : ''}
|
||||
{projects.filter((p) => p.status === 'active').length} active · {projects.filter((p) => p.status === 'completed').length} finalizate
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">
|
||||
+ Proiect nou
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Proiect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overdue alert */}
|
||||
{overdue.length > 0 && (
|
||||
<div className="rounded-xl border border-signal-danger/20 bg-signal-danger/5 p-4 space-y-1">
|
||||
<p className="text-sm font-semibold text-signal-danger">
|
||||
⚠ {overdue.length} proiect{overdue.length > 1 ? 'e cu termenul depășit' : ' cu termenul depășit'}
|
||||
</p>
|
||||
{overdue.slice(0, 3).map((p) => (
|
||||
<p key={p.id} className="text-xs text-ink-faint">
|
||||
{p.name} — termen {new Date(p.dueDate!).toLocaleDateString('ro-RO')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4 border-primary/30">
|
||||
<h2 className="text-sm font-semibold text-ink">Proiect nou</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Nume proiect *</label>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Numele proiectului…"
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
{[
|
||||
{ key: 'status', label: 'Status', opts: Object.entries(STATUS_META).map(([k, v]) => ({ value: k, label: v.label })) },
|
||||
{ key: 'priority', label: 'Prioritate', opts: Object.entries(PRIORITY_META).map(([k, v]) => ({ value: k, label: v.label })) },
|
||||
].map(({ key, label, opts }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<select value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
{[
|
||||
{ key: 'startDate', label: 'Data start' },
|
||||
{ key: 'dueDate', label: 'Termen limită' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<input type="date" value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Descriere</label>
|
||||
<textarea value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={2} className="w-full rounded-lg border bg-card px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => createProject()} disabled={isPending || !form.name.trim()}
|
||||
className="btn btn-primary text-xs px-4 py-2 disabled:opacity-50">
|
||||
{isPending ? 'Se salvează…' : 'Salvează'}
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Proiect nou</p>
|
||||
<input placeholder="Nume proiect*" value={form.title}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<textarea placeholder="Descriere / scop (opțional)" value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
rows={2} className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input type="date" value={form.targetDate}
|
||||
onChange={(e) => setForm((p) => ({ ...p, targetDate: e.target.value }))}
|
||||
className="w-48 rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMut.mutate()} disabled={!form.title || addMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{addMut.isPending ? '…' : 'Creează proiect'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-xs text-ink-faint hover:text-ink">Anulează</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
Proiectele sunt goals cu tag „proiect". Asociază tasks la proiect adăugând tag-ul <code>project-{'{id}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[['', 'Toate'], ...Object.entries(STATUS_META).map(([k, v]) => [k, v.label])].map(([s, l]) => (
|
||||
<button key={s} onClick={() => setFilterStatus(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
filterStatus === s ? 'bg-primary text-white border-primary' : 'bg-card text-ink-faint border-border hover:border-primary/40'
|
||||
}`}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
{loadG || loadT ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-3xl">🗂️</p>
|
||||
<p className="text-sm text-ink-faint">Niciun proiect înregistrat.</p>
|
||||
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">Creează primul proiect →</button>
|
||||
<p className="text-sm text-ink-faint">Niciun proiect. Adaugă sau marchează goals existente cu tag „proiect".</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map((proj) => {
|
||||
const status = STATUS_META[proj.status] ?? STATUS_META.planning;
|
||||
const prio = PRIORITY_META[proj.priority] ?? PRIORITY_META.medium;
|
||||
const isOverdue = proj.dueDate && new Date(proj.dueDate) < now && proj.status !== 'completed' && proj.status !== 'cancelled';
|
||||
<div className="space-y-3">
|
||||
{['active', 'paused', 'completed', 'cancelled'].map((statusGroup) => {
|
||||
const groupProjects = byStatus[statusGroup] ?? [];
|
||||
if (groupProjects.length === 0) return null;
|
||||
return (
|
||||
<div key={proj.id} className={`card p-4 space-y-2 ${isOverdue ? 'border-signal-danger/20' : ''}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${prio.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold text-ink">{proj.name}</p>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${status.cls}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{isOverdue && (
|
||||
<span className="text-[10px] text-signal-danger font-medium">⏰ Termen depășit</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-ink-faint mt-0.5 flex-wrap">
|
||||
{proj.organizationId && orgMap[proj.organizationId] && (
|
||||
<span>@ {orgMap[proj.organizationId]}</span>
|
||||
)}
|
||||
{proj.startDate && <span>Start: {proj.startDate}</span>}
|
||||
{proj.dueDate && (
|
||||
<span className={isOverdue ? 'text-signal-danger' : ''}>
|
||||
Termen: {proj.dueDate}
|
||||
</span>
|
||||
)}
|
||||
<span>{prio.label} prioritate</span>
|
||||
</div>
|
||||
{proj.description && (
|
||||
<p className="text-xs text-ink-faint mt-1 line-clamp-1">{proj.description}</p>
|
||||
)}
|
||||
{proj.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{proj.tags.map((tag) => (
|
||||
<span key={tag} className="text-[10px] border border-border/50 rounded px-1.5 py-0.5 text-ink-faint">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<div key={statusGroup}>
|
||||
<p className="text-xs font-semibold text-ink-faint uppercase tracking-wide mb-2 capitalize">{statusGroup} ({groupProjects.length})</p>
|
||||
<div className="space-y-2">
|
||||
{groupProjects.map((p) => {
|
||||
const pts = projectTasks(p.id);
|
||||
const prog = computeProgress(p);
|
||||
const done = pts.filter((t) => t.status === 'completed').length;
|
||||
const isOpen = expanded === p.id;
|
||||
return (
|
||||
<div key={p.id} className="card overflow-hidden">
|
||||
<button className="w-full flex items-center gap-3 p-4 text-left"
|
||||
onClick={() => setExpanded(isOpen ? null : p.id)}>
|
||||
<span className="text-lg shrink-0">🗂️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-ink truncate">{p.title}</p>
|
||||
{p.description && <p className="text-xs text-ink-faint truncate">{p.description}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-0.5">
|
||||
<p className={`text-xs font-medium capitalize ${STATUS_COLORS[p.status] ?? 'text-ink-faint'}`}>{p.status}</p>
|
||||
<p className="text-xs text-ink-faint">{prog}% · {done}/{pts.length} tasks</p>
|
||||
</div>
|
||||
<span className="text-ink-faint ml-1">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
<div className="mx-4 mb-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${prog >= 100 ? 'bg-signal-ok' : prog > 0 ? 'bg-primary' : 'bg-muted'}`}
|
||||
style={{ width: `${prog}%` }} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="border-t border-border/50 p-4 space-y-3">
|
||||
{p.targetDate && (
|
||||
<p className="text-xs text-ink-faint">🎯 Deadline: {new Date(p.targetDate).toLocaleDateString('ro-RO')}</p>
|
||||
)}
|
||||
{pts.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{pts.slice(0, 8).map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2">
|
||||
<span className={`text-xs ${t.status === 'completed' ? 'text-signal-ok' : 'text-ink-faint'}`}>
|
||||
{t.status === 'completed' ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={`text-xs ${t.status === 'completed' ? 'line-through text-ink-faint' : 'text-ink'}`}>{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
{pts.length > 8 && <p className="text-xs text-ink-faint">+{pts.length - 8} mai multe…</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-faint">Niciun task asociat. Adaugă tag <code>project-{p.id}</code> la tasks.</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
{p.status === 'active' && (
|
||||
<>
|
||||
<button onClick={() => patchMut.mutate({ id: p.id, status: 'paused' })}
|
||||
className="rounded border px-2.5 py-1 text-xs text-ink hover:border-warn/50">⏸ Pauză</button>
|
||||
<button onClick={() => patchMut.mutate({ id: p.id, status: 'completed' })}
|
||||
className="rounded border px-2.5 py-1 text-xs text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">✓ Finalizat</button>
|
||||
</>
|
||||
)}
|
||||
{p.status === 'paused' && (
|
||||
<button onClick={() => patchMut.mutate({ id: p.id, status: 'active' })}
|
||||
className="rounded border px-2.5 py-1 text-xs text-primary border-primary/30 hover:bg-primary/10">▶ Reactivează</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue