Ajout de visualisations pratiques de la matrice

This commit is contained in:
2026-05-25 01:34:18 +02:00
parent 24afa9a8e8
commit ac1b35b1d9
3 changed files with 187 additions and 63 deletions
+95 -22
View File
@@ -1,9 +1,37 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge' import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, onEdit, onUpdate, onCancel }) { export function SkillMatrixTable({ categories, skills, members, levels, isAdmin, currentUserId, editing, onEdit, onUpdate, onCancel }) {
const visibleMembers = skills.length === 0 ? [] : members 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 ( return (
<div className="p-8 text-center text-gray-400"> <div className="p-8 text-center text-gray-400">
Aucun résultat Aucun résultat
@@ -16,25 +44,72 @@ export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, on
<table className="w-full border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
<tr> <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> <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]">
{skills.map((s) => ( <button
<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> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleMembers.map((m) => ( {grouped.map((g) => {
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50"> const isCollapsed = collapsed.has(g.id)
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950"> return (
<span className="text-sm">{m.full_name || m.email}</span> <>
<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> </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 key = `${m.id}-${s.id}`
const level = levels[key] const level = levels[key]
const canEditCell = isAdmin || currentUserId === m.id
const isEditing = editing === key const isEditing = editing === key
return ( return (
<td key={s.id} className="p-2 border dark:border-gray-700 text-center"> <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 && isAdmin ? ( {isEditing && canEditCell ? (
<SkillLevelSelect <SkillLevelSelect
value={level?.level || 1} value={level?.level || 1}
onChange={(v) => onUpdate(m.id, s.id, v)} onChange={(v) => onUpdate(m.id, s.id, v)}
@@ -43,30 +118,28 @@ export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, on
level ? ( level ? (
<SkillLevelBadge <SkillLevelBadge
level={level.level} level={level.level}
onClick={() => isAdmin && onEdit(key)} onClick={() => canEditCell && onEdit(key)}
/> />
) : ( ) : (
<span <span
className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer" className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer"
onClick={() => isAdmin && onEdit(key)} onClick={() => canEditCell && onEdit(key)}
> >
</span> </span>
) )
)} )}
{isEditing && isAdmin && ( {isEditing && canEditCell && (
<button <button className="ml-1 text-xs text-red-500" onClick={onCancel}></button>
className="ml-1 text-xs text-red-500"
onClick={onCancel}
>
</button>
)} )}
</td> </td>
) )
})} })}
</tr> </tr>
))} ))}
</>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+6 -2
View File
@@ -50,8 +50,12 @@ export function History() {
<li key={h.id} className="p-4 flex items-center justify-between"> <li key={h.id} className="p-4 flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">{h.member?.full_name}</span> <span className="text-gray-500 dark:text-gray-400">Membre :</span>
{' '}<span className="font-medium">{h.skill?.name}</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>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
Par {h.changer?.full_name} {new Date(h.created_at).toLocaleString()} Par {h.changer?.full_name} {new Date(h.created_at).toLocaleString()}
+58 -11
View File
@@ -6,11 +6,15 @@ import { useMembers } from '@/hooks/useMembers'
import { useSkillLevels } from '@/hooks/useSkillLevels' import { useSkillLevels } from '@/hooks/useSkillLevels'
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters' import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable' 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' import { toast } from 'sonner'
export function SkillMatrix() { export function SkillMatrix() {
const { profile } = useAuth() const { profile } = useAuth()
const isAdmin = profile?.role === 'admin' const isAdmin = profile?.role === 'admin'
const currentUserId = profile?.id
const { categories } = useCategories() const { categories } = useCategories()
const { skills } = useSkills() const { skills } = useSkills()
const { members } = useMembers() const { members } = useMembers()
@@ -50,9 +54,34 @@ export function SkillMatrix() {
toast.success('Niveau mis à jour') 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Matrice des compétences</h1> <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 <SkillMatrixFilters
categories={categories} categories={categories}
@@ -63,23 +92,41 @@ export function SkillMatrix() {
onFilterChange={onFilterChange} 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"> <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-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-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-amber-200 mr-1" /> Avancé</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-green-200 mr-1" /> Expert</span> <span><span className="inline-block w-3 h-3 rounded-full bg-green-200 mr-1" /> Expert</span>
</div> </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> </div>
) )
} }