feat(CC-076): add Education & Development page (certifications + courses + study hours)
This commit is contained in:
parent
e18f751980
commit
68697c15b0
1 changed files with 219 additions and 0 deletions
219
src/app/dashboard/education/page.tsx
Normal file
219
src/app/dashboard/education/page.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Observation {
|
||||
id: string; subjectType: string; subjectId: string; metric: string;
|
||||
value: string; unit: string | null; source: string;
|
||||
confidence: number | null; observedAt: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
const EDU_TYPES = ['education', 'course', 'certification', 'skill', 'learning'];
|
||||
|
||||
const CERT_ISSUERS_ICONS: Record<string, string> = {
|
||||
harvard: '🎓', columbia: '🎓', nyif: '🏦', ibm: '💻', google: '🔵',
|
||||
coursera: '📚', udemy: '📖', microsoft: '🪟', aws: '☁️', default: '🏆',
|
||||
};
|
||||
|
||||
function issuerIcon(source: string) {
|
||||
const lower = source.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(CERT_ISSUERS_ICONS)) {
|
||||
if (key !== 'default' && lower.includes(key)) return icon;
|
||||
}
|
||||
return CERT_ISSUERS_ICONS.default;
|
||||
}
|
||||
|
||||
const QUICK_METRICS = [
|
||||
{ metric: 'curs completat', unit: '', icon: '📚', subjectId: 'learning', placeholder: 'Numele cursului' },
|
||||
{ metric: 'certificare obtinuta', unit: '', icon: '🏆', subjectId: 'certification', placeholder: 'Certif. + emitent' },
|
||||
{ metric: 'ore studiu', unit: 'h', icon: '⏱️', subjectId: 'daily', placeholder: '2.5' },
|
||||
{ metric: 'competenta dobandita', unit: '/10', icon: '⚡', subjectId: 'skill', placeholder: '7' },
|
||||
];
|
||||
|
||||
export default function EducationPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<'overview' | 'log'>('overview');
|
||||
const [quickVals, setQuickVals] = useState<Record<string, string>>({});
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ metric: '', value: '', unit: '', source: '', subjectId: 'education', subjectType: 'education' });
|
||||
|
||||
const { data: rawObs = [], isLoading } = useQuery({
|
||||
queryKey: ['education-obs', tenantId],
|
||||
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const eduObs = rawObs.filter((o) => EDU_TYPES.includes(o.subjectType) || EDU_TYPES.some(t => (o.metric ?? '').includes(t)));
|
||||
|
||||
const certs = eduObs.filter((o) => o.metric.includes('certificare') || o.metric.includes('certification') || o.subjectId === 'certification');
|
||||
const courses = eduObs.filter((o) => o.metric.includes('curs') || o.metric.includes('course'));
|
||||
const studyHours = eduObs.filter((o) => o.metric.includes('ore') || o.metric.includes('studiu') || o.unit === 'h');
|
||||
const totalHours = studyHours.reduce((s, o) => s + (parseFloat(o.value) || 0), 0);
|
||||
|
||||
const logMut = useMutation({
|
||||
mutationFn: ({ metric, value, unit, source, subjectId, subjectType }: { metric: string; value: string; unit: string; source: string; subjectId: string; subjectType: string }) =>
|
||||
apiFetch<Observation>('/v1/observations', {
|
||||
tenantId, method: 'POST',
|
||||
body: { subjectType, subjectId, metric, value, unit, source, confidence: '0.95' },
|
||||
}),
|
||||
onSuccess: (_, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['education-obs', tenantId] });
|
||||
setQuickVals((prev) => ({ ...prev, [vars.metric]: '' }));
|
||||
setForm({ metric: '', value: '', unit: '', source: '', subjectId: 'education', subjectType: 'education' });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Educație & Dezvoltare</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${certs.length} certificări · ${courses.length} cursuri · ${totalHours.toFixed(0)}h studiu total`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Înregistrează
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border/50">
|
||||
<button onClick={() => setTab('overview')}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === 'overview' ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
Portofoliu
|
||||
</button>
|
||||
<button onClick={() => setTab('log')}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === 'log' ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
Jurnal ({eduObs.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick log */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Înregistrare rapidă</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{QUICK_METRICS.map((qm) => (
|
||||
<div key={qm.metric} className="space-y-1">
|
||||
<p className="text-[10px] text-ink-faint">{qm.icon} {qm.metric}</p>
|
||||
<div className="flex gap-1">
|
||||
<input placeholder={qm.placeholder} value={quickVals[qm.metric] ?? ''}
|
||||
onChange={(e) => setQuickVals({ ...quickVals, [qm.metric]: e.target.value })}
|
||||
className="flex-1 min-w-0 rounded border bg-card px-2 py-1 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
<button
|
||||
onClick={() => logMut.mutate({ metric: qm.metric, value: quickVals[qm.metric] ?? '', unit: qm.unit, source: 'self', subjectId: qm.subjectId, subjectType: 'education' })}
|
||||
disabled={!quickVals[qm.metric] || logMut.isPending}
|
||||
className="rounded border px-1.5 text-xs text-primary hover:bg-primary/5 disabled:opacity-40">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-ink">Înregistrare detaliată</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Metric * (ex: curs completat)" value={form.metric}
|
||||
onChange={(e) => setForm({ ...form, metric: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Valoare *" value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Unitate (h, /10, certificate)" value={form.unit}
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Emitent (Harvard, IBM, etc.)" value={form.source}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => logMut.mutate({ metric: form.metric, value: form.value, unit: form.unit, source: form.source || 'self', subjectId: form.subjectId, subjectType: form.subjectType })}
|
||||
disabled={!form.metric || !form.value || logMut.isPending}
|
||||
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
||||
{logMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Se încarcă…</div>
|
||||
) : tab === 'overview' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Certificări', value: certs.length, icon: '🏆' },
|
||||
{ label: 'Cursuri finalizate', value: courses.length, icon: '📚' },
|
||||
{ label: 'Ore studiu', value: `${totalHours.toFixed(0)}h`, icon: '⏱️' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="card p-4 text-center space-y-1">
|
||||
<p className="text-2xl">{s.icon}</p>
|
||||
<p className="font-display text-2xl font-bold text-ink">{s.value}</p>
|
||||
<p className="text-[10px] text-ink-faint">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Certifications */}
|
||||
{certs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Certificări & Diplome</p>
|
||||
<div className="card divide-y divide-border/50">
|
||||
{certs.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3 p-3">
|
||||
<span className="text-xl">{issuerIcon(c.source)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{c.value}</p>
|
||||
<p className="text-[10px] text-ink-faint">{c.source} · {new Date(c.observedAt ?? c.createdAt).toLocaleDateString('ro-RO', { year: 'numeric', month: 'short' })}</p>
|
||||
</div>
|
||||
{c.confidence != null && (
|
||||
<span className="text-[10px] text-ink-faint">{Math.round(c.confidence * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eduObs.length === 0 && (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🎓</p>
|
||||
<p className="text-sm text-ink-faint">Nicio activitate educațională înregistrată.</p>
|
||||
<p className="text-xs text-ink-faint">Adaugă cursuri, certificări și ore de studiu cu butonul de mai sus.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Log tab */
|
||||
<div className="card divide-y divide-border/50">
|
||||
{eduObs.length === 0 ? (
|
||||
<div className="p-8 text-center"><p className="text-sm text-ink-faint">Niciun jurnal.</p></div>
|
||||
) : eduObs.slice(0, 50).map((o) => (
|
||||
<div key={o.id} className="flex items-start gap-3 p-3">
|
||||
<span>{issuerIcon(o.source)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-ink">{o.metric}</p>
|
||||
<p className="text-xs text-ink-faint">{o.value}{o.unit ? ` ${o.unit}` : ''} · {o.source}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-faint shrink-0">
|
||||
{new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue