feat(CC-092): add Goal Detail dynamic page ([id]) with tasks, progress slider, notes

This commit is contained in:
admin-valentin 2026-08-02 18:17:09 +00:00
parent fa588afbe4
commit bbd76779ae

View file

@ -0,0 +1,239 @@
'use client';
import { useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
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; updatedAt: string; }
interface Task { id: string; title: string; status: string; priority: string | null; tags: string[]; createdAt: string; }
interface Observation { id: string; metric: string; value: string; subjectType: string; source: string | null; createdAt: string; observedAt: string | null; }
const STATUS_OPTIONS = ['active','completed','paused','cancelled','archived'];
const STATUS_COLORS: Record<string, string> = {
active:'text-signal-ok', completed:'text-primary', paused:'text-warn',
cancelled:'text-signal-danger', archived:'text-ink-faint',
};
export default function GoalDetailPage() {
const { id } = useParams<{ id: string }>();
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const router = useRouter();
const [editProgress, setEditProgress] = useState(false);
const [progressVal, setProgressVal] = useState(0);
const [noteText, setNoteText] = useState('');
const [taskTitle, setTaskTitle] = useState('');
const [editTitle, setEditTitle] = useState(false);
const [titleVal, setTitleVal] = useState('');
const { data: goal, isLoading } = useQuery({
queryKey: ['goal-detail', tenantId, id],
queryFn: () => apiFetch<Goal>(`/v1/goals/${id}`, { tenantId }),
enabled: Boolean(tenantId) && Boolean(id),
staleTime: 30_000,
});
const { data: tasks = [] } = useQuery({
queryKey: ['goal-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: observations = [] } = useQuery({
queryKey: ['goal-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const linkedTasks = useMemo(() =>
tasks.filter((t) => t.tags.includes(`goal-${id}`) || t.tags.includes(id ?? '')),
[tasks, id]);
const notes = useMemo(() =>
observations
.filter((o) => o.source === id || o.source === `goal-${id}` || o.subjectType === `goal-${id}`)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
[observations, id]);
const done = linkedTasks.filter((t) => t.status === 'completed').length;
const total = linkedTasks.length;
const taskProgress = total > 0 ? Math.round((done / total) * 100) : null;
const displayProgress = goal?.progress ?? taskProgress ?? 0;
const patchMut = useMutation({
mutationFn: (body: Partial<Goal>) => apiFetch(`/v1/goals/${id}`, { tenantId, method: 'PATCH', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['goal-detail', tenantId, id] }),
});
const addTaskMut = useMutation({
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
title: taskTitle, status: 'todo', tags: [`goal-${id}`],
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['goal-tasks', tenantId] }); setTaskTitle(''); },
});
const addNoteMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: 'goal-note', value: noteText,
subjectType: `goal-${id}`, source: id,
confidence: 1, observedAt: new Date().toISOString(),
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['goal-obs', tenantId] }); setNoteText(''); },
});
const patchTaskMut = useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: string }) =>
apiFetch(`/v1/tasks/${taskId}`, { tenantId, method: 'PATCH', body: { status } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['goal-tasks', tenantId] }),
});
if (isLoading) return <div className="p-6 text-sm text-ink-faint">Se încarcă</div>;
if (!goal) return (
<div className="p-6 space-y-2">
<p className="text-sm text-signal-danger">Obiectivul nu a fost găsit.</p>
<button onClick={() => router.back()} className="text-sm text-primary hover:underline"> Înapoi</button>
</div>
);
const daysLeft = goal.targetDate
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / 86_400_000)
: null;
return (
<div className="max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-2">
<button onClick={() => router.back()} className="text-xs text-ink-faint hover:text-primary"> Înapoi la Goals</button>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{editTitle ? (
<div className="flex gap-2">
<input value={titleVal} onChange={(e) => setTitleVal(e.target.value)}
className="flex-1 rounded-lg border bg-background px-3 py-1.5 text-lg font-semibold text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<button onClick={() => { patchMut.mutate({ title: titleVal }); setEditTitle(false); }}
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white"></button>
<button onClick={() => setEditTitle(false)} className="rounded-lg border px-3 py-1.5 text-sm text-ink"></button>
</div>
) : (
<h1 className="font-display text-2xl font-semibold text-ink cursor-pointer hover:text-primary transition-colors"
onClick={() => { setTitleVal(goal.title); setEditTitle(true); }}>
{goal.title}
</h1>
)}
{goal.description && <p className="text-sm text-ink-faint mt-1">{goal.description}</p>}
</div>
</div>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-3">
<select value={goal.status} onChange={(e) => patchMut.mutate({ status: e.target.value })}
className={`rounded-full border px-3 py-1 text-xs font-semibold bg-background focus:outline-none ${STATUS_COLORS[goal.status] ?? 'text-ink'}`}>
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
{goal.targetDate && (
<span className={`text-xs ${daysLeft !== null && daysLeft < 7 ? 'text-signal-danger font-semibold' : 'text-ink-faint'}`}>
📅 {new Date(goal.targetDate).toLocaleDateString('ro-RO')}
{daysLeft !== null && <span> ({daysLeft > 0 ? `${daysLeft} zile` : 'expirat'})</span>}
</span>
)}
<span className="text-xs text-ink-faint">Creat {new Date(goal.createdAt).toLocaleDateString('ro-RO')}</span>
{goal.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{goal.tags.map((t) => (
<span key={t} className="rounded-full bg-muted px-2 py-0.5 text-[10px] text-ink-faint">{t}</span>
))}
</div>
)}
</div>
</div>
{/* Progress */}
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-ink">Progres</p>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-primary">{displayProgress}%</span>
<button onClick={() => { setProgressVal(displayProgress); setEditProgress(!editProgress); }}
className="text-xs text-ink-faint hover:text-primary"></button>
</div>
</div>
{editProgress ? (
<div className="space-y-2">
<input type="range" min={0} max={100} step={5} value={progressVal}
onChange={(e) => setProgressVal(Number(e.target.value))} className="w-full" />
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-primary">{progressVal}%</span>
<div className="flex gap-2">
<button onClick={() => { patchMut.mutate({ progress: progressVal }); setEditProgress(false); }}
className="rounded-lg bg-primary px-3 py-1 text-xs text-white">Salvează</button>
<button onClick={() => setEditProgress(false)} className="rounded-lg border px-3 py-1 text-xs text-ink">Anulează</button>
</div>
</div>
</div>
) : (
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full transition-all ${displayProgress >= 100 ? 'bg-signal-ok' : displayProgress > 0 ? 'bg-primary' : 'bg-muted'}`}
style={{ width: `${displayProgress}%` }} />
</div>
)}
{total > 0 && <p className="text-xs text-ink-faint">{done}/{total} tasks completate</p>}
</div>
{/* Tasks */}
<div className="card p-4 space-y-3">
<p className="text-sm font-semibold text-ink">Tasks ({total})</p>
{linkedTasks.length > 0 && (
<div className="space-y-1.5">
{linkedTasks.map((t) => (
<div key={t.id} className="flex items-center gap-2">
<button onClick={() => patchTaskMut.mutate({ taskId: t.id, status: t.status === 'completed' ? 'todo' : 'completed' })}
className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${t.status === 'completed' ? 'bg-signal-ok border-signal-ok text-white' : 'border-border hover:border-primary/50'}`}>
{t.status === 'completed' && <span className="text-[10px]"></span>}
</button>
<span className={`text-sm flex-1 ${t.status === 'completed' ? 'line-through text-ink-faint' : 'text-ink'}`}>{t.title}</span>
{t.priority && t.priority !== 'normal' && (
<span className={`text-[10px] shrink-0 ${t.priority === 'urgent' ? 'text-signal-danger' : t.priority === 'high' ? 'text-warn' : 'text-ink-faint'}`}>
{t.priority}
</span>
)}
</div>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<input placeholder="Task nou…" value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && taskTitle.trim() && addTaskMut.mutate()}
className="flex-1 rounded-lg border bg-background px-3 py-1.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<button onClick={() => addTaskMut.mutate()} disabled={!taskTitle.trim() || addTaskMut.isPending}
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white disabled:opacity-50">
{addTaskMut.isPending ? '…' : '+ Add'}
</button>
</div>
</div>
{/* Notes */}
<div className="card p-4 space-y-3">
<p className="text-sm font-semibold text-ink">Note ({notes.length})</p>
{notes.map((n) => (
<div key={n.id} className="space-y-0.5 border-l-2 border-primary/20 pl-3">
<p className="text-sm text-ink whitespace-pre-line">{n.value}</p>
<p className="text-[10px] text-ink-faint">{new Date(n.createdAt).toLocaleDateString('ro-RO', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</p>
</div>
))}
<div className="space-y-2">
<textarea placeholder="Adaugă o notă…" value={noteText} rows={2}
onChange={(e) => setNoteText(e.target.value)}
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" />
<button onClick={() => addNoteMut.mutate()} disabled={!noteText.trim() || addNoteMut.isPending}
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white disabled:opacity-50">
{addNoteMut.isPending ? '…' : 'Salvează notă'}
</button>
</div>
</div>
</div>
);
}