ceo-web/src/app/dashboard/projects/page.tsx

209 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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 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 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 [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: tasks = [], isLoading: loadT } = useQuery({
queryKey: ['projects-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
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-goals', tenantId] });
setShowAdd(false);
setForm({ title: '', description: '', targetDate: '' });
},
});
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-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">Project Tracker</h1>
<p className="text-sm text-ink-faint mt-1">
{projects.filter((p) => p.status === 'active').length} active · {projects.filter((p) => p.status === 'completed').length} finalizate
</p>
</div>
<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>
{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={() => 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>
)}
{loadG || loadT ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
) : projects.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">🗂️</p>
<p className="text-sm text-ink-faint">Niciun proiect. Adaugă sau marchează goals existente cu tag „proiect".</p>
</div>
) : (
<div className="space-y-3">
{['active', 'paused', 'completed', 'cancelled'].map((statusGroup) => {
const groupProjects = byStatus[statusGroup] ?? [];
if (groupProjects.length === 0) return null;
return (
<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>
)}
</div>
);
}