feat(CC-090): add Skills Inventory page (domain grouping, level 1-5, add form, filter by domain)

This commit is contained in:
admin-valentin 2026-08-02 18:00:44 +00:00
parent f918b3229d
commit 803d9aaf93

View file

@ -0,0 +1,197 @@
'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 Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; confidence: number | null; source: string | null; createdAt: string; }
const SKILL_DOMAINS: Record<string, { label: string; icon: string; keywords: string[] }> = {
tech: { label: 'Tech', icon: '💻', keywords: ['python','javascript','typescript','react','node','sql','docker','cloud','aws','ai','ml','api'] },
business:{ label: 'Business', icon: '📈', keywords: ['strategie','management','vanzari','marketing','finance','contabilitate','legal','negociere'] },
creative:{ label: 'Creative', icon: '🎨', keywords: ['design','ui','ux','figma','canva','video','content','writing','brand'] },
soft: { label: 'Soft Skills', icon: '🤝', keywords: ['leadership','comunicare','prezentare','coaching','mentoring','empatie','negociere'] },
languages:{ label: 'Limbi', icon: '🌍', keywords: ['engleza','germana','romana','spaniola','franceza','italiana','english','german','spanish'] },
};
const SKILL_METRICS = ['skill', 'competenta', 'competenta-', 'abilitate', 'cunostinte', 'certificare'];
const LEVEL_LABELS: Record<string, string> = { '1': 'Începător', '2': 'Bază', '3': 'Intermediar', '4': 'Avansat', '5': 'Expert' };
export default function SkillsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [domainFilter, setDomainFilter] = useState('all');
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ name: '', domain: 'tech', level: '3', notes: '' });
const { data: observations = [], isLoading } = useQuery({
queryKey: ['skills', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const skills = useMemo(() =>
observations.filter((o) =>
o.subjectType === 'skill' ||
SKILL_METRICS.some((m) => o.metric.toLowerCase().startsWith(m)),
),
[observations]);
function detectDomain(o: Observation): string {
const text = `${o.metric} ${o.value} ${o.source ?? ''}`.toLowerCase();
for (const [key, d] of Object.entries(SKILL_DOMAINS)) {
if (d.keywords.some((k) => text.includes(k))) return key;
}
return 'other';
}
const byDomain = useMemo(() => {
const map: Record<string, Observation[]> = {};
for (const s of skills) {
const d = detectDomain(s);
map[d] = [...(map[d] ?? []), s];
}
return map;
}, [skills]);
const domains = Object.keys(byDomain).filter((d) => d !== 'other');
const otherSkills = byDomain['other'] ?? [];
const filteredSkills = domainFilter === 'all'
? skills
: domainFilter === 'other'
? otherSkills
: byDomain[domainFilter] ?? [];
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: `skill-${form.name.toLowerCase().replace(/\s+/g, '-')}`,
value: form.level,
unit: '/5',
subjectType: 'skill',
confidence: parseInt(form.level) / 5,
source: form.domain,
observedAt: new Date().toISOString(),
}}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['skills', tenantId] });
setShowAdd(false);
setForm({ name: '', domain: 'tech', level: '3', notes: '' });
},
});
function skillName(o: Observation): string {
return o.metric.replace(/^skill-?/, '').replace(/-/g, ' ');
}
function skillLevel(o: Observation): number {
return Math.min(5, Math.max(1, parseInt(o.value) || Math.round((o.confidence ?? 0.5) * 5)));
}
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">Skills Inventory</h1>
<p className="text-sm text-ink-faint mt-1">{skills.length} competențe înregistrate</p>
</div>
<button onClick={() => setShowAdd(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Skill nou
</button>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Skill nou</p>
<div className="grid gap-3 sm:grid-cols-2">
<input placeholder="Nume skill (ex: Python, Negociere)" value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<select value={form.domain} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
{Object.entries(SKILL_DOMAINS).map(([k, d]) => <option key={k} value={k}>{d.icon} {d.label}</option>)}
<option value="other">Altele</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-ink-faint">Nivel: {LEVEL_LABELS[form.level]}</label>
<input type="range" min={1} max={5} step={1} value={form.level}
onChange={(e) => setForm((p) => ({ ...p, level: e.target.value }))}
className="w-full" />
<div className="flex justify-between text-[9px] text-ink-faint">
{Object.values(LEVEL_LABELS).map((l) => <span key={l}>{l}</span>)}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => addMut.mutate()} disabled={!form.name || addMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addMut.isPending ? '…' : 'Adaugă'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{/* Domain filters */}
<div className="flex flex-wrap gap-2">
<button onClick={() => setDomainFilter('all')}
className={`rounded-full px-3 py-1 text-xs border ${domainFilter === 'all' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
Toate ({skills.length})
</button>
{domains.map((d) => {
const info = SKILL_DOMAINS[d];
return (
<button key={d} onClick={() => setDomainFilter(d)}
className={`rounded-full px-3 py-1 text-xs border ${domainFilter === d ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{info?.icon} {info?.label} ({byDomain[d].length})
</button>
);
})}
{otherSkills.length > 0 && (
<button onClick={() => setDomainFilter('other')}
className={`rounded-full px-3 py-1 text-xs border ${domainFilter === 'other' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
Altele ({otherSkills.length})
</button>
)}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filteredSkills.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">🛠</p>
<p className="text-sm text-ink-faint">Nicio competență. Adaugă skills sau loghează observații cu <code>subjectType: skill</code>.</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filteredSkills.map((s) => {
const level = skillLevel(s);
const domain = SKILL_DOMAINS[detectDomain(s)];
return (
<div key={s.id} className="card p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-ink capitalize">{skillName(s)}</p>
<p className="text-[10px] text-ink-faint">{domain?.icon} {domain?.label ?? 'Altele'}</p>
</div>
<span className="text-xs font-bold text-primary shrink-0">{LEVEL_LABELS[level]}</span>
</div>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className={`flex-1 h-1.5 rounded-full ${i <= level ? 'bg-primary' : 'bg-muted'}`} />
))}
</div>
{s.source && s.source !== detectDomain(s) && (
<p className="text-[10px] text-ink-faint">Sursă: {s.source}</p>
)}
</div>
);
})}
</div>
)}
</div>
);
}