From 24afa9a8e8b7b7543e89b8381a277789d2245834 Mon Sep 17 00:00:00 2001 From: TopheC Date: Sun, 24 May 2026 21:05:03 +0200 Subject: [PATCH] Ajout gitignore pour les opencode stuff --- .gitignore | 4 ++ src/components/ErrorBoundary.jsx | 34 +++++++++ src/components/matrix/SkillMatrixFilters.jsx | 43 ++++++++++++ src/components/matrix/SkillMatrixTable.jsx | 74 ++++++++++++++++++++ src/components/skills/CategoryCard.jsx | 38 ++++++++++ src/hooks/useCategories.js | 19 +++++ src/hooks/useHistory.js | 68 ++++++++++++++++++ src/hooks/useMembers.js | 19 +++++ src/hooks/useSkillLevels.js | 71 +++++++++++++++++++ src/hooks/useSkills.js | 19 +++++ 10 files changed, 389 insertions(+) create mode 100644 src/components/ErrorBoundary.jsx create mode 100644 src/components/matrix/SkillMatrixFilters.jsx create mode 100644 src/components/matrix/SkillMatrixTable.jsx create mode 100644 src/components/skills/CategoryCard.jsx create mode 100644 src/hooks/useCategories.js create mode 100644 src/hooks/useHistory.js create mode 100644 src/hooks/useMembers.js create mode 100644 src/hooks/useSkillLevels.js create mode 100644 src/hooks/useSkills.js 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 ( +
+ + + + + {skills.map((s) => ( + + ))} + + + + {visibleMembers.map((m) => ( + + + {skills.map((s) => { + const key = `${m.id}-${s.id}` + const level = levels[key] + const isEditing = editing === key + return ( + + ) + })} + + ))} + +
Membre{s.name}
+ {m.full_name || m.email} + + {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 } +}