Ajout gitignore pour les opencode stuff

This commit is contained in:
2026-05-24 21:05:03 +02:00
parent 2ee7decbfd
commit 24afa9a8e8
10 changed files with 389 additions and 0 deletions
+4
View File
@@ -26,3 +26,7 @@ dist-ssr
# Environment # Environment
.env .env
.env.local .env.local
# OpenCode Stuff
.agents/*
opencode*
+34
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold text-red-600">Une erreur est survenue</h1>
<p className="text-gray-600">
{this.state.error?.message || 'Erreur inattendue'}
</p>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => window.location.reload()}
>
Recharger la page
</button>
</div>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,43 @@
export function SkillMatrixFilters({ categories, members, filterCat, filterMember, filterMinLevel, onFilterChange }) {
return (
<div className="flex gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 dark:text-gray-400">Catégorie :</label>
<select
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filterCat}
onChange={(e) => onFilterChange('cat', e.target.value)}
>
<option value="all">Toutes</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 dark:text-gray-400">Membre :</label>
<select
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filterMember}
onChange={(e) => onFilterChange('member', e.target.value)}
>
<option value="all">Tous</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 dark:text-gray-400">Niveau min. :</label>
<select
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filterMinLevel}
onChange={(e) => onFilterChange('minLevel', Number(e.target.value))}
>
<option value={0}>Aucun</option>
{[1, 2, 3, 4].map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
)
}
@@ -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 (
<div className="p-8 text-center text-gray-400">
Aucun résultat
</div>
)
}
return (
<div className="overflow-auto">
<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>
))}
</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>
</td>
{skills.map((s) => {
const key = `${m.id}-${s.id}`
const level = levels[key]
const isEditing = editing === key
return (
<td key={s.id} className="p-2 border dark:border-gray-700 text-center">
{isEditing && isAdmin ? (
<SkillLevelSelect
value={level?.level || 1}
onChange={(v) => onUpdate(m.id, s.id, v)}
/>
) : (
level ? (
<SkillLevelBadge
level={level.level}
onClick={() => isAdmin && onEdit(key)}
/>
) : (
<span
className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer"
onClick={() => isAdmin && onEdit(key)}
>
</span>
)
)}
{isEditing && isAdmin && (
<button
className="ml-1 text-xs text-red-500"
onClick={onCancel}
>
</button>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
+38
View File
@@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: category.color }} />
{category.name}
<Badge variant="secondary" className="ml-2">{skills.length}</Badge>
</CardTitle>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => onEdit(category)}>
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(category.id)}>
🗑
</Button>
</div>
</CardHeader>
<CardContent>
{skills.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">Aucune compétence dans cette catégorie</p>
) : (
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<Badge key={s.id} variant="outline" className="pr-1">
{s.name}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)
}
+19
View File
@@ -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 }
}
+68
View File
@@ -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,
}
}
+19
View File
@@ -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 }
}
+71
View File
@@ -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 }
}
+19
View File
@@ -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 }
}