feat(CC-089): add OKR Tracker page (Objectives + Key Results via goals with okr/key-result tags)
This commit is contained in:
parent
6dd3c0224f
commit
dee20197c8
1 changed files with 185 additions and 0 deletions
185
src/app/dashboard/okr/page.tsx
Normal file
185
src/app/dashboard/okr/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue