feat(CC-088): add Strategy Map page (goals by Balanced Scorecard perspectives + avg progress)
This commit is contained in:
parent
4739293077
commit
e225a6b756
1 changed files with 145 additions and 0 deletions
145
src/app/dashboard/strategy-map/page.tsx
Normal file
145
src/app/dashboard/strategy-map/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue