feat(CC-090): add Skills Inventory page (domain grouping, level 1-5, add form, filter by domain)
This commit is contained in:
parent
f918b3229d
commit
803d9aaf93
1 changed files with 197 additions and 0 deletions
197
src/app/dashboard/skills/page.tsx
Normal file
197
src/app/dashboard/skills/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue