From 803d9aaf93096961359d4198d704eab61baea248 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:00:44 +0000 Subject: [PATCH] feat(CC-090): add Skills Inventory page (domain grouping, level 1-5, add form, filter by domain) --- src/app/dashboard/skills/page.tsx | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/app/dashboard/skills/page.tsx diff --git a/src/app/dashboard/skills/page.tsx b/src/app/dashboard/skills/page.tsx new file mode 100644 index 0000000..046a690 --- /dev/null +++ b/src/app/dashboard/skills/page.tsx @@ -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 = { + 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 = { '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('/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 = {}; + 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 ( +
+
+
+

Skills Inventory

+

{skills.length} competențe înregistrate

+
+ +
+ + {showAdd && ( +
+

Skill nou

+
+ 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" /> + +
+
+ + setForm((p) => ({ ...p, level: e.target.value }))} + className="w-full" /> +
+ {Object.values(LEVEL_LABELS).map((l) => {l})} +
+
+
+ + +
+
+ )} + + {/* Domain filters */} +
+ + {domains.map((d) => { + const info = SKILL_DOMAINS[d]; + return ( + + ); + })} + {otherSkills.length > 0 && ( + + )} +
+ + {isLoading ? ( +
Se încarcă…
+ ) : filteredSkills.length === 0 ? ( +
+

🛠️

+

Nicio competență. Adaugă skills sau loghează observații cu subjectType: skill.

+
+ ) : ( +
+ {filteredSkills.map((s) => { + const level = skillLevel(s); + const domain = SKILL_DOMAINS[detectDomain(s)]; + return ( +
+
+
+

{skillName(s)}

+

{domain?.icon} {domain?.label ?? 'Altele'}

+
+ {LEVEL_LABELS[level]} +
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ {s.source && s.source !== detectDomain(s) && ( +

Sursă: {s.source}

+ )} +
+ ); + })} +
+ )} +
+ ); +}