feat(CC-089): add OKR Tracker page (Objectives + Key Results via goals with okr/key-result tags)

This commit is contained in:
admin-valentin 2026-08-02 17:51:19 +00:00
parent 6dd3c0224f
commit dee20197c8

View file

@ -0,0 +1,185 @@
'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; }
const OKR_TAG = 'okr';
const KR_TAG = 'key-result';
const QUARTER = `Q${Math.ceil((new Date().getMonth() + 1) / 3)}-${new Date().getFullYear()}`;
export default function OKRPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [showAddO, setShowAddO] = useState(false);
const [showAddKR, setShowAddKR] = useState<string | null>(null);
const [oForm, setOForm] = useState({ title: '', description: '' });
const [krForm, setKrForm] = useState({ title: '', targetDate: '' });
const { data: goals = [], isLoading } = useQuery({
queryKey: ['okr', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const objectives = useMemo(() =>
goals.filter((g) => g.tags.includes(OKR_TAG) && !g.tags.includes(KR_TAG)),
[goals]);
const keyResults = useMemo(() => {
const map: Record<string, Goal[]> = {};
for (const g of goals) {
if (!g.tags.includes(KR_TAG)) continue;
const oId = g.tags.find((t) => t.startsWith('obj-'));
if (oId) { map[oId] = [...(map[oId] ?? []), g]; }
}
return map;
}, [goals]);
function objProgress(oId: string): number {
const krs = keyResults[`obj-${oId}`] ?? [];
if (krs.length === 0) return 0;
return Math.round(krs.reduce((s, k) => s + (k.progress ?? 0), 0) / krs.length);
}
const addObjMut = useMutation({
mutationFn: () => apiFetch('/v1/goals', { tenantId, method: 'POST', body: {
title: oForm.title, description: oForm.description || undefined,
tags: [OKR_TAG, QUARTER], status: 'active', progress: 0,
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['okr', tenantId] }); setShowAddO(false); setOForm({ title: '', description: '' }); },
});
const addKRMut = useMutation({
mutationFn: (objectiveId: string) => apiFetch('/v1/goals', { tenantId, method: 'POST', body: {
title: krForm.title, tags: [KR_TAG, `obj-${objectiveId}`, QUARTER],
status: 'active', progress: 0, targetDate: krForm.targetDate || undefined,
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['okr', tenantId] }); setShowAddKR(null); setKrForm({ title: '', targetDate: '' }); },
});
const patchKRMut = useMutation({
mutationFn: ({ id, progress }: { id: string; progress: number }) =>
apiFetch(`/v1/goals/${id}`, { tenantId, method: 'PATCH', body: { progress } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['okr', tenantId] }),
});
function progressColor(p: number) { return p >= 70 ? 'bg-signal-ok' : p >= 40 ? 'bg-warn' : 'bg-signal-danger'; }
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">OKR Tracker</h1>
<p className="text-sm text-ink-faint mt-1">
{QUARTER} · {objectives.length} Objectives · {Object.values(keyResults).flat().length} Key Results
</p>
</div>
<button onClick={() => setShowAddO(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Obiectiv
</button>
</div>
{showAddO && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Obiectiv nou ({QUARTER})</p>
<input placeholder="Obiectiv (ex: Lansăm platforma CEO OS public)" value={oForm.title}
onChange={(e) => setOForm((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="De ce contează? (opțional)" value={oForm.description}
onChange={(e) => setOForm((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" />
<div className="flex gap-2">
<button onClick={() => addObjMut.mutate()} disabled={!oForm.title || addObjMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addObjMut.isPending ? '…' : 'Salvează'}
</button>
<button onClick={() => setShowAddO(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : objectives.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 OKR pentru {QUARTER}. Adaugă primul obiectiv.</p>
</div>
) : (
<div className="space-y-5">
{objectives.map((obj) => {
const krs = keyResults[`obj-${obj.id}`] ?? [];
const prog = objProgress(obj.id);
return (
<div key={obj.id} className="card p-5 space-y-4">
{/* Objective header */}
<div className="flex items-start gap-3">
<span className="text-xl shrink-0">🎯</span>
<div className="flex-1">
<p className="text-base font-semibold text-ink">{obj.title}</p>
{obj.description && <p className="text-xs text-ink-faint mt-0.5">{obj.description}</p>}
</div>
<span className={`text-sm font-bold shrink-0 ${prog >= 70 ? 'text-signal-ok' : prog >= 40 ? 'text-warn' : 'text-signal-danger'}`}>
{prog}%
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${progressColor(prog)}`} style={{ width: `${prog}%` }} />
</div>
{/* Key Results */}
{krs.length > 0 && (
<div className="space-y-3 pl-4 border-l border-border/50">
{krs.map((kr) => (
<div key={kr.id} className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-ink">{kr.title}</p>
<span className="text-xs font-bold text-ink-faint shrink-0">{kr.progress ?? 0}%</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${progressColor(kr.progress ?? 0)}`} style={{ width: `${kr.progress ?? 0}%` }} />
</div>
<input type="range" min={0} max={100} step={5} value={kr.progress ?? 0}
onChange={(e) => patchKRMut.mutate({ id: kr.id, progress: Number(e.target.value) })}
className="w-24 shrink-0" />
</div>
</div>
))}
</div>
)}
{/* Add KR */}
{showAddKR === obj.id ? (
<div className="pl-4 space-y-2">
<input placeholder="Key Result (ex: Atingem 50 utilizatori activi)" value={krForm.title}
onChange={(e) => setKrForm((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" />
<div className="flex gap-2">
<button onClick={() => addKRMut.mutate(obj.id)} disabled={!krForm.title || addKRMut.isPending}
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-white disabled:opacity-50">
{addKRMut.isPending ? '…' : 'Adaugă KR'}
</button>
<button onClick={() => setShowAddKR(null)} className="rounded-lg border px-3 py-1.5 text-xs text-ink">Anulează</button>
</div>
</div>
) : (
<button onClick={() => setShowAddKR(obj.id)}
className="text-xs text-ink-faint hover:text-primary pl-4 transition-colors">
+ Adaugă Key Result
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}