From dee20197c857a903dbad79c80774d743f4f174da Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 17:51:19 +0000 Subject: [PATCH] feat(CC-089): add OKR Tracker page (Objectives + Key Results via goals with okr/key-result tags) --- src/app/dashboard/okr/page.tsx | 185 +++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/app/dashboard/okr/page.tsx diff --git a/src/app/dashboard/okr/page.tsx b/src/app/dashboard/okr/page.tsx new file mode 100644 index 0000000..a995d0e --- /dev/null +++ b/src/app/dashboard/okr/page.tsx @@ -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(null); + const [oForm, setOForm] = useState({ title: '', description: '' }); + const [krForm, setKrForm] = useState({ title: '', targetDate: '' }); + + const { data: goals = [], isLoading } = useQuery({ + queryKey: ['okr', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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 ( +
+
+
+

OKR Tracker

+

+ {QUARTER} · {objectives.length} Objectives · {Object.values(keyResults).flat().length} Key Results +

+
+ +
+ + {showAddO && ( +
+

Obiectiv nou ({QUARTER})

+ 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" /> +