feat(CC-088): add Strategy Map page (goals by Balanced Scorecard perspectives + avg progress)

This commit is contained in:
admin-valentin 2026-08-02 17:45:00 +00:00
parent 4739293077
commit e225a6b756

View file

@ -0,0 +1,145 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Goal { id: string; title: string; status: string; progress: number | null; tags: string[]; description: string | null; }
const PERSPECTIVES = [
{ id: 'financial', label: 'Financiar', icon: '💰', keywords: ['financiar', 'revenue', 'profit', 'cost', 'venit', 'bani'] },
{ id: 'client', label: 'Client & Piață', icon: '🎯', keywords: ['client', 'market', 'piata', 'customer', 'vanzare', 'sales'] },
{ id: 'operational', label: 'Procese', icon: '⚙️', keywords: ['proces', 'operationa', 'eficient', 'automat', 'sistem'] },
{ id: 'growth', label: 'Creștere & Inov.', icon: '🚀', keywords: ['crestere', 'inovatie', 'learn', 'develop', 'skill', 'educatie'] },
];
export default function StrategyMapPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: goals = [], isLoading } = useQuery({
queryKey: ['strategy-goals', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const byPerspective = useMemo(() => {
return PERSPECTIVES.map((p) => {
const matched = goals.filter((g) =>
p.keywords.some((k) =>
g.title.toLowerCase().includes(k) ||
(g.description ?? '').toLowerCase().includes(k) ||
g.tags.some((t) => t.toLowerCase().includes(k))
)
);
const inProgress = matched.filter((g) => g.status === 'active');
const completed = matched.filter((g) => g.status === 'completed');
const avgProgress = inProgress.length > 0
? Math.round(inProgress.reduce((s, g) => s + (g.progress ?? 0), 0) / inProgress.length)
: 0;
return { ...p, matched, inProgress, completed, avgProgress };
});
}, [goals]);
const unclassified = useMemo(() => {
const classified = new Set(byPerspective.flatMap((p) => p.matched.map((g) => g.id)));
return goals.filter((g) => !classified.has(g.id) && g.status === 'active');
}, [goals, byPerspective]);
function statusDot(g: Goal) {
if (g.status === 'completed') return 'bg-signal-ok';
if ((g.progress ?? 0) > 50) return 'bg-primary';
if ((g.progress ?? 0) > 0) return 'bg-warn';
return 'bg-muted';
}
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">Strategy Map</h1>
<p className="text-sm text-ink-faint mt-1">
Obiectivele tale organizate pe perspectivele Balanced Scorecard.
{isLoading ? '' : ` ${goals.filter((g) => g.status === 'active').length} obiective active`}
</p>
</div>
<Link href="/dashboard/goals" className="rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50">
+ Adaugă obiectiv
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{byPerspective.map((p) => (
<div key={p.id} className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{p.icon}</span>
<p className="text-sm font-semibold text-ink">{p.label}</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-ink">{p.matched.length}</p>
<p className="text-[9px] text-ink-faint">obiective</p>
</div>
</div>
{p.inProgress.length > 0 && (
<div className="space-y-1.5">
{p.inProgress.slice(0, 4).map((g) => (
<div key={g.id} className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full shrink-0 ${statusDot(g)}`} />
<p className="text-xs text-ink flex-1 truncate">{g.title}</p>
{g.progress !== null && (
<span className="text-[9px] text-ink-faint shrink-0">{g.progress}%</span>
)}
</div>
))}
{p.inProgress.length > 4 && (
<p className="text-[10px] text-ink-faint ml-4">+{p.inProgress.length - 4} mai multe</p>
)}
</div>
)}
{p.inProgress.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-[9px] text-ink-faint">
<span>Progress mediu</span><span>{p.avgProgress}%</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-primary/50" style={{ width: `${p.avgProgress}%` }} />
</div>
</div>
)}
{p.matched.length === 0 && (
<p className="text-[10px] text-ink-faint italic">Niciun obiectiv clasificat în această perspectivă.</p>
)}
{p.completed.length > 0 && (
<p className="text-[10px] text-signal-ok"> {p.completed.length} finalizate</p>
)}
</div>
))}
</div>
{/* Unclassified */}
{unclassified.length > 0 && (
<div className="card p-4 space-y-2">
<p className="text-xs font-semibold text-ink-faint">Obiective neclasificate ({unclassified.length})</p>
<p className="text-[10px] text-ink-faint">Adaugă cuvinte cheie în titlu sau tags pentru a le clasifica automat.</p>
<div className="space-y-1">
{unclassified.slice(0, 5).map((g) => (
<p key={g.id} className="text-xs text-ink-faint"> {g.title}</p>
))}
</div>
</div>
)}
<div className="flex gap-3 flex-wrap text-xs">
<Link href="/dashboard/goals" className="text-primary hover:underline">Toate obiectivele </Link>
<Link href="/dashboard/reports/custom" className="text-primary hover:underline">Raport personalizat </Link>
</div>
</div>
);
}