Ajout gitignore pour les opencode stuff
This commit is contained in:
@@ -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