Ajout gitignore pour les opencode stuff
This commit is contained in:
@@ -26,3 +26,7 @@ dist-ssr
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# OpenCode Stuff
|
||||||
|
.agents/*
|
||||||
|
opencode*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user