feat(CC-092): add Goal Detail dynamic page ([id]) with tasks, progress slider, notes
This commit is contained in:
parent
fa588afbe4
commit
bbd76779ae
1 changed files with 239 additions and 0 deletions
239
src/app/dashboard/goals/[id]/page.tsx
Normal file
239
src/app/dashboard/goals/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue