feat(CC-085): add Eligibility & Fit page (auto-match criteria from obs/goals/tasks + manual override)

This commit is contained in:
admin-valentin 2026-08-02 17:34:56 +00:00
parent c0c2c15919
commit aee1e2844c

View file

@ -0,0 +1,129 @@
'use client';
import { useMemo, useState } 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; tags: string[]; }
interface Observation { id: string; metric: string; value: string; source: string | null; }
interface Task { id: string; title: string; status: string; tags: string[]; }
const CRITERIA_PRESETS = [
{ id: 'startup', label: 'Startup Accelerator', criteria: ['pitch deck', 'mvp', 'revenue', 'team', 'market'] },
{ id: 'grant', label: 'Grant / Finanțare EU', criteria: ['business plan', 'impact', 'budget', 'timeline', 'co-finantare'] },
{ id: 'job', label: 'Rol Tech / CTO', criteria: ['leadership', 'technical depth', 'product sense', 'english', 'remote'] },
{ id: 'speaking', label: 'Speaker / Conferință', criteria: ['bio', 'abstract', 'past talks', 'slides', 'availability'] },
];
export default function EligibilityFitPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [preset, setPreset] = useState(CRITERIA_PRESETS[0]);
const [custom, setCustom] = useState('');
const [checks, setChecks] = useState<Record<string, boolean>>({});
const { data: goals = [] } = useQuery({ queryKey: ['fit-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const { data: observations = [] } = useQuery({ queryKey: ['fit-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const { data: tasks = [] } = useQuery({ queryKey: ['fit-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const allCriteria = useMemo(() => {
const extra = custom.split(',').map((s) => s.trim()).filter(Boolean);
return [...preset.criteria, ...extra];
}, [preset.criteria, custom]);
const autoMatch = useMemo(() => {
const matches: Record<string, { matched: boolean; source: string }> = {};
for (const crit of allCriteria) {
const lc = crit.toLowerCase();
const fromObs = observations.some((o) => o.metric.toLowerCase().includes(lc) || o.value.toLowerCase().includes(lc) || (o.source ?? '').toLowerCase().includes(lc));
const fromGoal = goals.some((g) => g.title.toLowerCase().includes(lc) && g.status === 'completed');
const fromTask = tasks.some((t) => t.title.toLowerCase().includes(lc) && t.status === 'completed');
matches[crit] = { matched: fromObs || fromGoal || fromTask, source: fromObs ? 'observație' : fromGoal ? 'obiectiv' : fromTask ? 'task' : '' };
}
return matches;
}, [allCriteria, observations, goals, tasks]);
const manualCheck = (crit: string) => setChecks((p) => ({ ...p, [crit]: !p[crit] }));
const score = useMemo(() => {
if (allCriteria.length === 0) return 0;
const met = allCriteria.filter((c) => autoMatch[c]?.matched || checks[c]).length;
return Math.round((met / allCriteria.length) * 100);
}, [allCriteria, autoMatch, checks]);
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<nav className="text-xs text-ink-faint mb-1">
<Link href="/dashboard/opportunities" className="hover:underline">Oportunități</Link> / Eligibilitate & Fit
</nav>
<h1 className="font-display text-2xl font-semibold text-ink">Eligibilitate & Fit</h1>
<p className="text-sm text-ink-faint mt-1">Analiză rapidă cât de bine te potrivești pentru o oportunitate.</p>
</div>
{/* Preset selector */}
<div className="card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Tip oportunitate</p>
<div className="flex flex-wrap gap-2">
{CRITERIA_PRESETS.map((p) => (
<button key={p.id} onClick={() => { setPreset(p); setChecks({}); }}
className={`rounded-full px-3 py-1.5 text-xs font-medium border transition-colors ${preset.id === p.id ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{p.label}
</button>
))}
</div>
<div className="space-y-1">
<label className="text-[10px] text-ink-faint">Criterii suplimentare (virgulă-separate)</label>
<input value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="ex: german, 3 ani experienta, portofoliu public"
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>
</div>
{/* Score */}
<div className="card p-5 flex items-center gap-6">
<div className="text-center w-24 shrink-0">
<p className={`font-display text-5xl font-bold ${score >= 70 ? 'text-signal-ok' : score >= 40 ? 'text-warn' : 'text-signal-danger'}`}>{score}%</p>
<p className="text-[10px] text-ink-faint mt-1">fit estimat</p>
</div>
<div className="flex-1 space-y-2">
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full transition-all ${score >= 70 ? 'bg-signal-ok' : score >= 40 ? 'bg-warn' : 'bg-signal-danger'}`}
style={{ width: `${score}%` }} />
</div>
<p className="text-xs text-ink-faint">
{score >= 70 ? '✅ Profil solid — aplică!' : score >= 40 ? '⚠ Profil parțial — pregătire recomandată' : '❌ Lipsesc mai mulți indicatori cheie'}
</p>
</div>
</div>
{/* Criteria checklist */}
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Criterii ({allCriteria.length})</p>
{allCriteria.map((crit) => {
const auto = autoMatch[crit];
const done = auto?.matched || checks[crit];
return (
<label key={crit} className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={done} onChange={() => manualCheck(crit)}
className="h-4 w-4 rounded border-border text-primary focus:ring-ring" />
<div className="flex-1">
<span className={`text-sm font-medium ${done ? 'text-signal-ok' : 'text-ink'}`}>{crit}</span>
{auto?.matched && <span className="ml-2 text-[9px] text-ink-faint bg-signal-ok/10 px-1 rounded">auto: {auto.source}</span>}
</div>
</label>
);
})}
</div>
<div className="card p-3 bg-warn/5 border-warn/30">
<p className="text-[10px] text-ink-faint">
Potrivirea auto se bazează pe datele din CEO OS (observații, obiective, taskuri).
Bifează manual criteriile confirmate extern (portofoliu, documente, recomandări).
</p>
</div>
</div>
);
}