Ajout de visualisations pratiques de la matrice
This commit is contained in:
@@ -1,9 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
|
||||
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||
|
||||
export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, onEdit, onUpdate, onCancel }) {
|
||||
const visibleMembers = skills.length === 0 ? [] : members
|
||||
export function SkillMatrixTable({ categories, skills, members, levels, isAdmin, currentUserId, editing, onEdit, onUpdate, onCancel }) {
|
||||
const [collapsed, setCollapsed] = useState(new Set())
|
||||
|
||||
if (visibleMembers.length === 0) {
|
||||
const grouped = categories
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
catSkills: skills.filter((s) => s.category_id === cat.id),
|
||||
}))
|
||||
.filter((g) => g.catSkills.length > 0)
|
||||
|
||||
const allCollapsed = grouped.every((g) => collapsed.has(g.id))
|
||||
|
||||
function toggleCategory(catId) {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(catId)) next.delete(catId)
|
||||
else next.add(catId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allCollapsed) {
|
||||
setCollapsed(new Set())
|
||||
} else {
|
||||
setCollapsed(new Set(grouped.map((g) => g.id)))
|
||||
}
|
||||
}
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
Aucun résultat
|
||||
@@ -16,25 +44,72 @@ export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, on
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">Membre</th>
|
||||
{skills.map((s) => (
|
||||
<th key={s.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">{s.name}</th>
|
||||
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-2"
|
||||
onClick={toggleAll}
|
||||
title={allCollapsed ? 'Tout dérouler' : 'Tout replier'}
|
||||
>
|
||||
<ListCollapse className={`h-3.5 w-3.5 transition-transform ${allCollapsed ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
Compétence
|
||||
</th>
|
||||
{members.map((m) => (
|
||||
<th key={m.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">
|
||||
{m.full_name || m.email}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleMembers.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||
<span className="text-sm">{m.full_name || m.email}</span>
|
||||
{grouped.map((g) => {
|
||||
const isCollapsed = collapsed.has(g.id)
|
||||
return (
|
||||
<>
|
||||
<tr key={g.id} className="bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<td className="p-2 border dark:border-gray-700 font-semibold text-sm sticky left-0 bg-gray-50 dark:bg-gray-800/50 cursor-pointer" onClick={() => toggleCategory(g.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: g.color }} />
|
||||
{g.name}
|
||||
</span>
|
||||
</td>
|
||||
{skills.map((s) => {
|
||||
{members.map((m) => {
|
||||
const dotColors = ['bg-gray-200', 'bg-blue-200', 'bg-amber-200', 'bg-green-200']
|
||||
const counts = [0, 0, 0, 0]
|
||||
g.catSkills.forEach((s) => {
|
||||
const lvl = levels[`${m.id}-${s.id}`]?.level
|
||||
if (lvl) counts[lvl - 1]++
|
||||
})
|
||||
return (
|
||||
<td key={m.id} className="p-2 border dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||
<span className="inline-flex items-center gap-2 text-xs">
|
||||
{counts.map((c, i) =>
|
||||
c > 0 ? (
|
||||
<span key={i} className="inline-flex items-center gap-0.5">
|
||||
<span className={'inline-block w-2.5 h-2.5 rounded-full ' + dotColors[i]} />
|
||||
<span className="font-semibold">{c}</span>
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
{!isCollapsed && g.catSkills.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||
<span className="text-sm pl-6">{s.name}</span>
|
||||
</td>
|
||||
{members.map((m) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
const level = levels[key]
|
||||
const canEditCell = isAdmin || currentUserId === m.id
|
||||
const isEditing = editing === key
|
||||
return (
|
||||
<td key={s.id} className="p-2 border dark:border-gray-700 text-center">
|
||||
{isEditing && isAdmin ? (
|
||||
<td key={m.id} className={`p-2 border dark:border-gray-700 text-center ${currentUserId === m.id ? 'bg-blue-50 dark:bg-blue-950/20' : ''}`}>
|
||||
{isEditing && canEditCell ? (
|
||||
<SkillLevelSelect
|
||||
value={level?.level || 1}
|
||||
onChange={(v) => onUpdate(m.id, s.id, v)}
|
||||
@@ -43,30 +118,28 @@ export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, on
|
||||
level ? (
|
||||
<SkillLevelBadge
|
||||
level={level.level}
|
||||
onClick={() => isAdmin && onEdit(key)}
|
||||
onClick={() => canEditCell && onEdit(key)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer"
|
||||
onClick={() => isAdmin && onEdit(key)}
|
||||
onClick={() => canEditCell && onEdit(key)}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{isEditing && isAdmin && (
|
||||
<button
|
||||
className="ml-1 text-xs text-red-500"
|
||||
onClick={onCancel}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{isEditing && canEditCell && (
|
||||
<button className="ml-1 text-xs text-red-500" onClick={onCancel}>✕</button>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +50,12 @@ export function History() {
|
||||
<li key={h.id} className="p-4 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{h.member?.full_name}</span>
|
||||
{' → '}<span className="font-medium">{h.skill?.name}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">Membre :</span>
|
||||
{' '}<span className="font-medium">{h.member?.full_name || h.member_id?.slice(0, 8)}</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Compétence :</span>
|
||||
{' '}<span className="font-medium">{h.skill?.name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Par {h.changer?.full_name} — {new Date(h.created_at).toLocaleString()}
|
||||
|
||||
+58
-11
@@ -6,11 +6,15 @@ import { useMembers } from '@/hooks/useMembers'
|
||||
import { useSkillLevels } from '@/hooks/useSkillLevels'
|
||||
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
|
||||
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
|
||||
import { SkillMemberForm } from '@/components/matrix/SkillMemberForm'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SkillMatrix() {
|
||||
const { profile } = useAuth()
|
||||
const isAdmin = profile?.role === 'admin'
|
||||
const currentUserId = profile?.id
|
||||
const { categories } = useCategories()
|
||||
const { skills } = useSkills()
|
||||
const { members } = useMembers()
|
||||
@@ -50,9 +54,34 @@ export function SkillMatrix() {
|
||||
toast.success('Niveau mis à jour')
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const header = ['Compétence', ...visibleMembers.map((m) => m.full_name || m.email)].join(',')
|
||||
const rows = filteredSkills.map((s) => {
|
||||
const levelsRow = visibleMembers.map((m) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
return levels[key]?.level || ''
|
||||
})
|
||||
return [`"${s.name}"`, ...levelsRow].join(',')
|
||||
})
|
||||
const blob = new Blob(['\uFEFF' + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'matrice-competences.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Fichier CSV téléchargé')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Matrice des compétences</h1>
|
||||
<Button variant="outline" onClick={exportCSV}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SkillMatrixFilters
|
||||
categories={categories}
|
||||
@@ -63,23 +92,41 @@ export function SkillMatrix() {
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
<SkillMatrixTable
|
||||
skills={filteredSkills}
|
||||
members={visibleMembers}
|
||||
levels={levels}
|
||||
isAdmin={isAdmin}
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
onUpdate={handleUpdate}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-gray-200 mr-1" /> Débutant</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-blue-200 mr-1" /> Intermédiaire</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-amber-200 mr-1" /> Avancé</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-green-200 mr-1" /> Expert</span>
|
||||
</div>
|
||||
|
||||
{filterMember !== 'all' && visibleMembers.length === 1 ? (
|
||||
<SkillMemberForm
|
||||
member={visibleMembers[0]}
|
||||
categories={categories}
|
||||
skills={filteredSkills}
|
||||
levels={levels}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={currentUserId}
|
||||
onSave={(memberId, changes) => {
|
||||
Promise.all(changes.map((c) => updateLevel(memberId, c.skillId, c.newLevel, profile.id)))
|
||||
.then(() => toast.success('Compétences mises à jour'))
|
||||
.catch(() => toast.error('Erreur lors de la mise à jour'))
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SkillMatrixTable
|
||||
categories={categories}
|
||||
skills={filteredSkills}
|
||||
members={visibleMembers}
|
||||
levels={levels}
|
||||
isAdmin={isAdmin}
|
||||
currentUserId={currentUserId}
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
onUpdate={handleUpdate}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user