diff --git a/.gitignore b/.gitignore
index e0bcb2c..7854166 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ dist-ssr
# Environment
.env
.env.local
+
+# OpenCode Stuff
+.agents/*
+opencode*
diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000..610c6ff
--- /dev/null
+++ b/src/components/ErrorBoundary.jsx
@@ -0,0 +1,34 @@
+import { Component } from 'react'
+
+export class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props)
+ this.state = { hasError: false, error: null }
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true, error }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
Une erreur est survenue
+
+ {this.state.error?.message || 'Erreur inattendue'}
+
+
+
+
+ )
+ }
+ return this.props.children
+ }
+}
diff --git a/src/components/matrix/SkillMatrixFilters.jsx b/src/components/matrix/SkillMatrixFilters.jsx
new file mode 100644
index 0000000..a37e744
--- /dev/null
+++ b/src/components/matrix/SkillMatrixFilters.jsx
@@ -0,0 +1,43 @@
+export function SkillMatrixFilters({ categories, members, filterCat, filterMember, filterMinLevel, onFilterChange }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/matrix/SkillMatrixTable.jsx b/src/components/matrix/SkillMatrixTable.jsx
new file mode 100644
index 0000000..9e1f0c5
--- /dev/null
+++ b/src/components/matrix/SkillMatrixTable.jsx
@@ -0,0 +1,74 @@
+import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
+
+export function SkillMatrixTable({ skills, members, levels, isAdmin, editing, onEdit, onUpdate, onCancel }) {
+ const visibleMembers = skills.length === 0 ? [] : members
+
+ if (visibleMembers.length === 0) {
+ return (
+
+ Aucun résultat
+
+ )
+ }
+
+ return (
+
+
+
+
+ | Membre |
+ {skills.map((s) => (
+ {s.name} |
+ ))}
+
+
+
+ {visibleMembers.map((m) => (
+
+ |
+ {m.full_name || m.email}
+ |
+ {skills.map((s) => {
+ const key = `${m.id}-${s.id}`
+ const level = levels[key]
+ const isEditing = editing === key
+ return (
+
+ {isEditing && isAdmin ? (
+ onUpdate(m.id, s.id, v)}
+ />
+ ) : (
+ level ? (
+ isAdmin && onEdit(key)}
+ />
+ ) : (
+ isAdmin && onEdit(key)}
+ >
+ —
+
+ )
+ )}
+ {isEditing && isAdmin && (
+
+ )}
+ |
+ )
+ })}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/skills/CategoryCard.jsx b/src/components/skills/CategoryCard.jsx
new file mode 100644
index 0000000..16a80b9
--- /dev/null
+++ b/src/components/skills/CategoryCard.jsx
@@ -0,0 +1,38 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+
+export function CategoryCard({ category, skills, onEdit, onDelete }) {
+ return (
+
+
+
+
+ {category.name}
+ {skills.length}
+
+
+
+
+
+
+
+ {skills.length === 0 ? (
+ Aucune compétence dans cette catégorie
+ ) : (
+
+ {skills.map((s) => (
+
+ {s.name}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/hooks/useCategories.js b/src/hooks/useCategories.js
new file mode 100644
index 0000000..3258180
--- /dev/null
+++ b/src/hooks/useCategories.js
@@ -0,0 +1,19 @@
+import { useEffect, useState, useCallback } from 'react'
+import { supabase } from '@/lib/supabase'
+
+export function useCategories() {
+ const [categories, setCategories] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetch = useCallback(async () => {
+ const { data, error } = await supabase.from('categories').select('*').order('name')
+ if (!error && data) setCategories(data)
+ setLoading(false)
+ return { data, error }
+ }, [])
+
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ useEffect(() => { fetch() }, [fetch])
+
+ return { categories, loading, refetch: fetch }
+}
diff --git a/src/hooks/useHistory.js b/src/hooks/useHistory.js
new file mode 100644
index 0000000..e34246a
--- /dev/null
+++ b/src/hooks/useHistory.js
@@ -0,0 +1,68 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { supabase } from '@/lib/supabase'
+
+const PAGE_SIZE = 50
+
+export function useHistory() {
+ const [history, setHistory] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [count, setCount] = useState(0)
+ const [page, setPage] = useState(0)
+ const [filters, setFilters] = useState({ memberId: 'all', skillId: 'all' })
+ const channelRef = useRef(null)
+
+ const totalPages = Math.ceil(count / PAGE_SIZE)
+
+ const fetch = useCallback(async () => {
+ setLoading(true)
+
+ let query = supabase
+ .from('skill_history')
+ .select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)', { count: 'exact' })
+ .order('created_at', { ascending: false })
+ .range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1)
+
+ if (filters.memberId !== 'all') query = query.eq('member_id', filters.memberId)
+ if (filters.skillId !== 'all') query = query.eq('skill_id', filters.skillId)
+
+ const { data, count: total } = await query
+ if (data) setHistory(data)
+ if (total !== null) setCount(total)
+ setLoading(false)
+ }, [page, filters])
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ fetch()
+
+ channelRef.current = supabase
+ .channel('skill_history_changes')
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'skill_history' }, () => {
+ if (page === 0) fetch()
+ })
+ .subscribe()
+
+ return () => {
+ channelRef.current?.unsubscribe()
+ }
+ }, [fetch, page])
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ function setFilter(key, value) {
+ setFilters((f) => ({ ...f, [key]: value }))
+ setPage(0)
+ }
+
+ function nextPage() {
+ if (page < totalPages - 1) setPage((p) => p + 1)
+ }
+
+ function prevPage() {
+ if (page > 0) setPage((p) => p - 1)
+ }
+
+ return {
+ history, loading, count, page, totalPages, filters,
+ setFilter, nextPage, prevPage, refetch: fetch,
+ }
+}
diff --git a/src/hooks/useMembers.js b/src/hooks/useMembers.js
new file mode 100644
index 0000000..98166f1
--- /dev/null
+++ b/src/hooks/useMembers.js
@@ -0,0 +1,19 @@
+import { useEffect, useState, useCallback } from 'react'
+import { supabase } from '@/lib/supabase'
+
+export function useMembers() {
+ const [members, setMembers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetch = useCallback(async () => {
+ const { data, error } = await supabase.from('members').select('*').order('full_name')
+ if (!error && data) setMembers(data)
+ setLoading(false)
+ return { data, error }
+ }, [])
+
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ useEffect(() => { fetch() }, [fetch])
+
+ return { members, loading, refetch: fetch }
+}
diff --git a/src/hooks/useSkillLevels.js b/src/hooks/useSkillLevels.js
new file mode 100644
index 0000000..846aa3c
--- /dev/null
+++ b/src/hooks/useSkillLevels.js
@@ -0,0 +1,71 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { supabase } from '@/lib/supabase'
+
+export function useSkillLevels() {
+ const [levels, setLevels] = useState({})
+ const [loading, setLoading] = useState(true)
+ const channelRef = useRef(null)
+
+ const fetch = useCallback(async () => {
+ const { data, error } = await supabase.from('skill_levels').select('*')
+ if (!error && data) {
+ const map = {}
+ data.forEach((l) => { map[`${l.member_id}-${l.skill_id}`] = l })
+ setLevels(map)
+ }
+ setLoading(false)
+ return { data, error }
+ }, [])
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ fetch()
+
+ channelRef.current = supabase
+ .channel('skill_levels_changes')
+ .on('postgres_changes', { event: '*', schema: 'public', table: 'skill_levels' }, () => {
+ fetch()
+ })
+ .subscribe()
+
+ return () => {
+ channelRef.current?.unsubscribe()
+ }
+ }, [fetch])
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ async function updateLevel(memberId, skillId, newLevel, changedBy) {
+ const key = `${memberId}-${skillId}`
+ const existing = levels[key]
+ const oldLevel = existing?.level
+
+ if (existing) {
+ await supabase.from('skill_levels').update({ level: newLevel }).eq('id', existing.id)
+ } else {
+ await supabase.from('skill_levels').insert({ member_id: memberId, skill_id: skillId, level: newLevel })
+ }
+
+ if (oldLevel !== newLevel && changedBy) {
+ await supabase.from('skill_history').insert({
+ member_id: memberId,
+ skill_id: skillId,
+ old_level: oldLevel || null,
+ new_level: newLevel,
+ changed_by: changedBy,
+ })
+ }
+
+ await fetch()
+ }
+
+ async function getAverageSkillRating(skillId) {
+ const { data } = await supabase
+ .from('skill_levels')
+ .select('level')
+ .eq('skill_id', skillId)
+ if (!data || data.length === 0) return null
+ return (data.reduce((sum, l) => sum + l.level, 0) / data.length).toFixed(1)
+ }
+
+ return { levels, loading, refetch: fetch, updateLevel, getAverageSkillRating }
+}
diff --git a/src/hooks/useSkills.js b/src/hooks/useSkills.js
new file mode 100644
index 0000000..91b7955
--- /dev/null
+++ b/src/hooks/useSkills.js
@@ -0,0 +1,19 @@
+import { useEffect, useState, useCallback } from 'react'
+import { supabase } from '@/lib/supabase'
+
+export function useSkills() {
+ const [skills, setSkills] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ const fetch = useCallback(async () => {
+ const { data, error } = await supabase.from('skills').select('*, category:category_id(name)').order('name')
+ if (!error && data) setSkills(data)
+ setLoading(false)
+ return { data, error }
+ }, [])
+
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ useEffect(() => { fetch() }, [fetch])
+
+ return { skills, loading, refetch: fetch }
+}