V1
This commit is contained in:
@@ -6,11 +6,12 @@ Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- 🔐 **Connexion / inscription** par email + mot de passe
|
||||
- 👥 **Équipe & rôles** (programmeur, artiste, game designer, sound, scénariste, QA…)
|
||||
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*
|
||||
- 🎯 **Jalons & planning** — échéances, progression par jalon (Prototype, Alpha, Beta, Release…)
|
||||
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, assignation
|
||||
- 📊 **Tableau de bord** — avancement global, répartition des tâches, prochaines échéances
|
||||
- 👥 **Équipe, rôles & pôles** — le rôle d'un membre (programmeur, artiste, game designer…) définit son **pôle**
|
||||
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*, tâche confiée à un **pôle** avec une ou **plusieurs personnes** assignées
|
||||
- ⏱️ **Feuille de temps** — vue par semaine (7 jours en colonnes) : consigne les heures faites chaque jour sur une tâche **ou** une activité libre (réunion, veille…)
|
||||
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, pôle, assignation
|
||||
- 🔔 **Notifications** — quand une tâche/un bug est confié à un pôle, ses membres reçoivent une notif (cloche 🔔 dans la barre latérale)
|
||||
- 📊 **Tableau de bord** — avancement global, répartition des tâches, temps total, échéances
|
||||
- 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour
|
||||
|
||||
---
|
||||
@@ -24,7 +25,11 @@ Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base
|
||||
3. Attends ~2 min que le projet se crée.
|
||||
4. Dans le menu de gauche : **SQL Editor → New query**.
|
||||
5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**.
|
||||
> Cela crée les tables (profils, projets, tâches, jalons, bugs), la sécurité et la synchro temps réel.
|
||||
> Cela crée les tables (profils, projets, tâches, bugs, suivi du temps, notifications), la sécurité et la synchro temps réel.
|
||||
>
|
||||
> 🆙 **Tu avais déjà installé une version précédente (avec les jalons) ?** Exécute plutôt, dans l'ordre, [`supabase/migration_v2.sql`](supabase/migration_v2.sql) (supprime les jalons, ajoute pôles + assignation multiple + notifications), [`supabase/migration_v3.sql`](supabase/migration_v3.sql) (feuille de temps : entrées libres type réunion) puis [`supabase/migration_v4.sql`](supabase/migration_v4.sql) (plusieurs pôles par membre) — **sans perdre tes données**.
|
||||
>
|
||||
> 🔁 Après la migration v4, **redéploie l'Edge Function** pour que l'admin puisse gérer les pôles multiples : `supabase functions deploy admin-users --no-verify-jwt`.
|
||||
6. Menu **Settings (⚙️) → API**. Note deux valeurs :
|
||||
- **Project URL** → ressemble à `https://xxxxx.supabase.co`
|
||||
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...`
|
||||
@@ -73,7 +78,7 @@ Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la m
|
||||
```text
|
||||
├─ index.html # point d'entrée du front
|
||||
├─ src/ # application React (front-end)
|
||||
│ ├─ pages/ # Dashboard, Kanban, Milestones, Bugs, Members, Login, Setup
|
||||
│ ├─ pages/ # Dashboard, Kanban, Planning (feuille de temps), Bugs, Members, Admin, Login, Setup
|
||||
│ ├─ components/ # Layout, UI, animations
|
||||
│ └─ lib/ # client Supabase, types, contexte global, config
|
||||
├─ src-tauri/ # enveloppe desktop Tauri (Rust)
|
||||
|
||||
Generated
-10
@@ -74,7 +74,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1531,7 +1530,6 @@
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1549,7 +1547,6 @@
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1710,7 +1707,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
@@ -2161,7 +2157,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -2433,7 +2428,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2603,7 +2597,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2616,7 +2609,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2937,7 +2929,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3036,7 +3027,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
+13
-3
@@ -5,11 +5,11 @@ import { AppProvider, useApp } from './lib/AppContext'
|
||||
import { PageFade } from './components/motion'
|
||||
import Setup from './pages/Setup'
|
||||
import Login from './pages/Login'
|
||||
import Onboarding from './pages/Onboarding'
|
||||
import Layout, { Page } from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Kanban from './pages/Kanban'
|
||||
import Planning from './pages/Planning'
|
||||
import Milestones from './pages/Milestones'
|
||||
import Bugs from './pages/Bugs'
|
||||
import Members from './pages/Members'
|
||||
import Admin from './pages/Admin'
|
||||
@@ -18,14 +18,13 @@ const PAGES: Record<Page, () => JSX.Element> = {
|
||||
dashboard: Dashboard,
|
||||
kanban: Kanban,
|
||||
planning: Planning,
|
||||
milestones: Milestones,
|
||||
bugs: Bugs,
|
||||
members: Members,
|
||||
admin: Admin
|
||||
}
|
||||
|
||||
function Shell(): JSX.Element {
|
||||
const { stage } = useApp()
|
||||
const { stage, profile } = useApp()
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
|
||||
if (stage === 'loading') {
|
||||
@@ -38,6 +37,17 @@ function Shell(): JSX.Element {
|
||||
if (stage === 'setup') return <Setup />
|
||||
if (stage === 'login') return <Login />
|
||||
|
||||
// Session ouverte mais profil pas encore chargé.
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted">
|
||||
<Loader2 className="animate-spin" size={28} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Première connexion : accueil (nom, avatar, pôles) avant d'entrer dans l'app.
|
||||
if (!profile.onboarded) return <Onboarding />
|
||||
|
||||
const ActivePage = PAGES[page]
|
||||
return (
|
||||
<Layout page={page} setPage={setPage}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
KanbanSquare,
|
||||
Target,
|
||||
Clock,
|
||||
Bug,
|
||||
Users,
|
||||
Plus,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Settings,
|
||||
Gamepad2,
|
||||
ShieldCheck,
|
||||
CalendarRange,
|
||||
Sun,
|
||||
Moon
|
||||
} from 'lucide-react'
|
||||
@@ -21,12 +20,12 @@ import { supa } from '../lib/supabase'
|
||||
import { ROLE_LABELS } from '../lib/types'
|
||||
import { Theme, getTheme, setTheme } from '../lib/theme'
|
||||
import { Avatar, Modal } from './ui'
|
||||
import NotificationBell from './NotificationBell'
|
||||
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'kanban'
|
||||
| 'planning'
|
||||
| 'milestones'
|
||||
| 'bugs'
|
||||
| 'members'
|
||||
| 'admin'
|
||||
@@ -34,8 +33,7 @@ export type Page =
|
||||
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
|
||||
{ id: 'planning', label: 'Planning', icon: CalendarRange },
|
||||
{ id: 'milestones', label: 'Jalons', icon: Target },
|
||||
{ id: 'planning', label: 'Feuille de temps', icon: Clock },
|
||||
{ id: 'bugs', label: 'Bugs', icon: Bug },
|
||||
{ id: 'members', label: 'Équipe', icon: Users },
|
||||
{ id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true }
|
||||
@@ -114,14 +112,16 @@ export default function Layout({
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => setPage(n.id)}
|
||||
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
page === n.id ? 'text-ink' : 'text-muted hover:bg-panel2 hover:text-ink'
|
||||
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
page === n.id
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:bg-accent/10 hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{page === n.id && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
className="absolute inset-0 rounded-lg bg-accent/20"
|
||||
className="absolute inset-0 rounded-lg bg-accent/15 ring-1 ring-inset ring-accent/25"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
@@ -141,6 +141,7 @@ export default function Layout({
|
||||
{profile ? ROLE_LABELS[profile.role] : ''}
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell setPage={setPage} />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Bell, Check, KanbanSquare, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { AppNotification } from '../lib/types'
|
||||
import type { Page } from './Layout'
|
||||
|
||||
export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element {
|
||||
const { profile } = useApp()
|
||||
const [items, setItems] = useState<AppNotification[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const uid = profile?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!uid) return
|
||||
const { data } = await supa()
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', uid)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
setItems((data ?? []) as AppNotification[])
|
||||
}, [uid])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Synchro temps réel : nouvelles notifications qui me sont destinées.
|
||||
useEffect(() => {
|
||||
if (!uid) return
|
||||
const ch = supa()
|
||||
.channel(`notifications-${uid}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
|
||||
() => load()
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [uid, load])
|
||||
|
||||
// Fermeture au clic extérieur.
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [])
|
||||
|
||||
const unread = items.filter((n) => !n.read).length
|
||||
|
||||
const markAllRead = async (): Promise<void> => {
|
||||
if (!uid || unread === 0) return
|
||||
setItems((list) => list.map((n) => ({ ...n, read: true })))
|
||||
await supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
|
||||
}
|
||||
|
||||
const openItem = async (n: AppNotification): Promise<void> => {
|
||||
setOpen(false)
|
||||
if (!n.read) {
|
||||
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
|
||||
await supa().from('notifications').update({ read: true }).eq('id', n.id)
|
||||
}
|
||||
setPage(n.kind === 'bug' ? 'bugs' : 'kanban')
|
||||
}
|
||||
|
||||
const clearAll = async (): Promise<void> => {
|
||||
if (!uid || items.length === 0) return
|
||||
setItems([])
|
||||
await supa().from('notifications').delete().eq('user_id', uid)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Notifications"
|
||||
className="relative text-muted hover:text-ink"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 26 }}
|
||||
className="absolute bottom-8 left-0 z-40 w-80 overflow-hidden rounded-xl border border-border bg-panel shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-semibold text-ink">Notifications</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
title="Tout marquer comme lu"
|
||||
className="rounded p-1 text-muted hover:bg-panel2 hover:text-ink"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
title="Tout effacer"
|
||||
className="rounded p-1 text-muted hover:bg-panel2 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-subtle">Aucune notification.</p>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => openItem(n)}
|
||||
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left last:border-0 hover:bg-panel2 ${
|
||||
n.read ? '' : 'bg-accent/5'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${
|
||||
n.kind === 'bug'
|
||||
? 'bg-rose-100 text-rose-600'
|
||||
: 'bg-accent/15 text-accent'
|
||||
}`}
|
||||
>
|
||||
{n.kind === 'bug' ? <BugIcon size={14} /> : <KanbanSquare size={14} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-ink">{n.title}</span>
|
||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||
</span>
|
||||
{n.body && <span className="block truncate text-xs text-muted">{n.body}</span>}
|
||||
<span className="block text-[11px] text-subtle">
|
||||
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, locale: fr })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,19 +5,21 @@ import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { notifyPole } from '../lib/notify'
|
||||
import {
|
||||
Task,
|
||||
TaskStatus,
|
||||
Priority,
|
||||
Milestone,
|
||||
Pole,
|
||||
TimeLog,
|
||||
Profile,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
memberPoles,
|
||||
formatMinutes
|
||||
} from '../lib/types'
|
||||
import { Modal, Select, TagInput, Avatar } from './ui'
|
||||
import { Modal, Select, Avatar, PoleChips } from './ui'
|
||||
|
||||
export default function TaskModal({
|
||||
task,
|
||||
@@ -40,20 +42,13 @@ export default function TaskModal({
|
||||
const [description, setDescription] = useState(task?.description ?? '')
|
||||
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
|
||||
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
|
||||
const [assignee, setAssignee] = useState(task?.assignee_id ?? '')
|
||||
const [milestone, setMilestone] = useState(task?.milestone_id ?? '')
|
||||
const [pole, setPole] = useState<Pole | ''>(task?.pole ?? '')
|
||||
const [assignees, setAssignees] = useState<string[]>(task?.assignee_ids ?? [])
|
||||
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
|
||||
const [tags, setTags] = useState<string[]>(task?.tags ?? [])
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
supa()
|
||||
.from('milestones')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.then(({ data }) => setMilestones((data ?? []) as Milestone[]))
|
||||
}, [projectId])
|
||||
const toggleAssignee = (id: string): void =>
|
||||
setAssignees((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!title.trim()) return
|
||||
@@ -64,13 +59,39 @@ export default function TaskModal({
|
||||
description: description.trim() || null,
|
||||
status,
|
||||
priority,
|
||||
assignee_id: assignee || null,
|
||||
milestone_id: milestone || null,
|
||||
due_date: dueDate || null,
|
||||
tags
|
||||
pole: pole || null,
|
||||
assignee_ids: assignees,
|
||||
due_date: dueDate || null
|
||||
}
|
||||
if (task) await supa().from('tasks').update(payload).eq('id', task.id)
|
||||
else await supa().from('tasks').insert({ ...payload, created_by: profile?.id ?? null })
|
||||
|
||||
// Notifier le pôle si on lui confie la tâche (création, ou pôle modifié).
|
||||
const poleChanged = pole && (!task || task.pole !== pole)
|
||||
let entityId = task?.id ?? null
|
||||
|
||||
if (task) {
|
||||
await supa().from('tasks').update(payload).eq('id', task.id)
|
||||
} else {
|
||||
const { data } = await supa()
|
||||
.from('tasks')
|
||||
.insert({ ...payload, created_by: profile?.id ?? null })
|
||||
.select('id')
|
||||
.single()
|
||||
entityId = (data as { id: string } | null)?.id ?? null
|
||||
}
|
||||
|
||||
if (poleChanged && pole && entityId) {
|
||||
await notifyPole({
|
||||
projectId,
|
||||
pole,
|
||||
kind: 'task',
|
||||
title: 'Nouvelle tâche',
|
||||
body: title.trim(),
|
||||
entityId,
|
||||
actorId: profile?.id ?? null,
|
||||
members
|
||||
})
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success')
|
||||
@@ -109,9 +130,13 @@ export default function TaskModal({
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="label">Tags (jobs)</label>
|
||||
{/* Pôle — sélection par pastilles (clique pour choisir, reclique pour retirer) */}
|
||||
<label className="label">Pôle</label>
|
||||
<div className="mb-3">
|
||||
<TagInput value={tags} onChange={setTags} />
|
||||
<PoleChips
|
||||
selected={pole ? [pole] : []}
|
||||
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -135,28 +160,6 @@ export default function TaskModal({
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Assigné à</label>
|
||||
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
|
||||
<option value="">Personne</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.full_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Jalon</label>
|
||||
<Select value={milestone} onChange={(e) => setMilestone(e.target.value)}>
|
||||
<option value="">Aucun</option>
|
||||
{milestones.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">Échéance</label>
|
||||
<input
|
||||
@@ -168,8 +171,41 @@ export default function TaskModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personnes assignées (une ou plusieurs) */}
|
||||
<div className="mt-3">
|
||||
<label className="label">
|
||||
Assigné(s) {assignees.length > 0 && <span className="text-subtle">· {assignees.length}</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.length === 0 && (
|
||||
<span className="text-xs text-subtle">Aucun membre dans l'équipe.</span>
|
||||
)}
|
||||
{members.map((m) => {
|
||||
const on = assignees.includes(m.id)
|
||||
const inPole = pole && memberPoles(m).includes(pole)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleAssignee(m.id)}
|
||||
title={inPole ? `${m.full_name} · ce pôle` : m.full_name}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-sm transition-colors ${
|
||||
on
|
||||
? 'border-accent bg-accent/15 text-ink'
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<Avatar profile={m} size={22} />
|
||||
<span>{m.full_name}</span>
|
||||
{inPole && <span className="h-1.5 w-1.5 rounded-full bg-accent2" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suivi du temps — disponible une fois la tâche créée */}
|
||||
{task && <TimeSection taskId={task.id} members={members} />}
|
||||
{task && <TimeSection taskId={task.id} projectId={projectId} members={members} />}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{task ? (
|
||||
@@ -188,7 +224,15 @@ export default function TaskModal({
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSection({ taskId, members }: { taskId: string; members: Profile[] }): JSX.Element {
|
||||
function TimeSection({
|
||||
taskId,
|
||||
projectId,
|
||||
members
|
||||
}: {
|
||||
taskId: string
|
||||
projectId: string
|
||||
members: Profile[]
|
||||
}): JSX.Element {
|
||||
const { profile } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
const [logs, setLogs] = useState<TimeLog[]>([])
|
||||
@@ -224,6 +268,7 @@ function TimeSection({ taskId, members }: { taskId: string; members: Profile[] }
|
||||
setBusy(true)
|
||||
await supa().from('time_logs').insert({
|
||||
task_id: taskId,
|
||||
project_id: projectId,
|
||||
user_id: profile?.id ?? null,
|
||||
minutes: mins,
|
||||
note: note.trim() || null
|
||||
|
||||
+58
-1
@@ -1,7 +1,17 @@
|
||||
import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import { Profile, Priority, PRIORITY_LABELS, JOB_TAGS, tagColor } from '../lib/types'
|
||||
import {
|
||||
Profile,
|
||||
Priority,
|
||||
PRIORITY_LABELS,
|
||||
JOB_TAGS,
|
||||
tagColor,
|
||||
Pole,
|
||||
POLE_ORDER,
|
||||
POLE_LABELS,
|
||||
poleColor
|
||||
} from '../lib/types'
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
@@ -48,6 +58,18 @@ export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
if (profile?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={profile.avatar_url}
|
||||
alt={profile.full_name}
|
||||
title={profile.full_name}
|
||||
draggable={false}
|
||||
className="shrink-0 rounded-full object-cover"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
title={profile?.full_name ?? 'Non assigné'}
|
||||
@@ -93,6 +115,41 @@ export function Tag({ label }: { label: string }): JSX.Element {
|
||||
return <span className={`badge ${tagColor(label)}`}>{label}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélection de pôle(s) par pastilles cliquables (comme les tags).
|
||||
* Utilisé pour une tâche/bug (un pôle) comme pour un membre (plusieurs pôles).
|
||||
*/
|
||||
export function PoleChips({
|
||||
selected,
|
||||
onToggle
|
||||
}: {
|
||||
selected: Pole[] | null | undefined
|
||||
onToggle: (pole: Pole) => void
|
||||
}): JSX.Element {
|
||||
const sel = selected ?? []
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{POLE_ORDER.map((p) => {
|
||||
const on = sel.includes(p)
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onToggle(p)}
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
on
|
||||
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{POLE_LABELS[p]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Champ d'édition de tags avec suggestions de « jobs ». */
|
||||
export function TagInput({
|
||||
value,
|
||||
|
||||
+38
-21
@@ -3,34 +3,37 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Thèmes — accent monochrome (noir/blanc), clair + sombre
|
||||
Thèmes — base épurée (Notion) réchauffée par un accent coloré
|
||||
accent = indigo (actions), accent2 = teal (temps / progression)
|
||||
============================================================ */
|
||||
:root {
|
||||
--c-bg: 255 255 255;
|
||||
color-scheme: light; /* aligne selects/inputs/scrollbars natifs sur le thème */
|
||||
--c-bg: 251 251 250;
|
||||
--c-panel: 255 255 255;
|
||||
--c-panel2: 243 242 240;
|
||||
--c-sidebar: 247 246 243;
|
||||
--c-border: 233 233 231;
|
||||
--c-ink: 55 53 47;
|
||||
--c-muted: 115 114 110;
|
||||
--c-subtle: 155 154 151;
|
||||
--c-accent: 55 53 47; /* noir Notion */
|
||||
--c-panel2: 244 243 246;
|
||||
--c-sidebar: 248 247 250;
|
||||
--c-border: 232 231 235;
|
||||
--c-ink: 45 43 56;
|
||||
--c-muted: 112 110 122;
|
||||
--c-subtle: 152 150 162;
|
||||
--c-accent: 99 90 232; /* indigo/violet */
|
||||
--c-accentfg: 255 255 255;
|
||||
--c-accent2: 55 53 47;
|
||||
--c-accent2: 13 148 136; /* teal */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--c-bg: 25 25 25;
|
||||
--c-panel: 32 32 32;
|
||||
--c-panel2: 40 40 40;
|
||||
--c-sidebar: 20 20 20;
|
||||
--c-border: 50 50 50;
|
||||
--c-ink: 235 235 233;
|
||||
--c-muted: 158 158 154;
|
||||
--c-subtle: 122 122 119;
|
||||
--c-accent: 235 235 233; /* blanc en sombre */
|
||||
--c-accentfg: 25 25 25;
|
||||
--c-accent2: 235 235 233;
|
||||
color-scheme: dark;
|
||||
--c-bg: 23 23 27;
|
||||
--c-panel: 31 31 37;
|
||||
--c-panel2: 40 40 48;
|
||||
--c-sidebar: 19 19 24;
|
||||
--c-border: 51 51 60;
|
||||
--c-ink: 234 234 238;
|
||||
--c-muted: 160 158 172;
|
||||
--c-subtle: 124 122 136;
|
||||
--c-accent: 129 121 248; /* indigo clair sur fond sombre */
|
||||
--c-accentfg: 255 255 255;
|
||||
--c-accent2: 45 212 191; /* teal clair */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -78,6 +81,20 @@
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/15 placeholder:text-subtle;
|
||||
}
|
||||
/* Selects : flèche maison + options aux couleurs de l'appli (au lieu du style natif brut) */
|
||||
select.input {
|
||||
@apply cursor-pointer pr-8;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
background-size: 1.05rem;
|
||||
}
|
||||
select.input option {
|
||||
background: rgb(var(--c-panel));
|
||||
color: rgb(var(--c-ink));
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium text-muted;
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ export interface AdminUser {
|
||||
email: string
|
||||
full_name: string
|
||||
role: MemberRole
|
||||
roles: MemberRole[]
|
||||
is_admin: boolean
|
||||
created_at: string
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export const adminCreateUser = (input: {
|
||||
email: string
|
||||
password: string
|
||||
full_name: string
|
||||
role: MemberRole
|
||||
roles: MemberRole[]
|
||||
is_admin: boolean
|
||||
}): Promise<{ ok: true; id: string }> => call('create', input)
|
||||
|
||||
@@ -51,5 +52,5 @@ export const adminDeleteUser = (id: string): Promise<{ ok: true }> => call('dele
|
||||
|
||||
export const adminUpdateUser = (
|
||||
id: string,
|
||||
patch: { full_name?: string; role?: MemberRole; is_admin?: boolean }
|
||||
patch: { full_name?: string; roles?: MemberRole[]; is_admin?: boolean }
|
||||
): Promise<{ ok: true }> => call('update', { id, ...patch })
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { supa } from './supabase'
|
||||
|
||||
/**
|
||||
* Envoie une image de profil dans le bucket "avatars" et renvoie son URL
|
||||
* publique. Le chemin est préfixé par l'id du membre.
|
||||
*/
|
||||
export async function uploadAvatar(file: File, userId: string): Promise<string> {
|
||||
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
|
||||
const path = `${userId}/${Date.now()}.${ext}`
|
||||
const { error } = await supa()
|
||||
.storage.from('avatars')
|
||||
.upload(path, file, { upsert: true, cacheControl: '3600' })
|
||||
if (error) throw error
|
||||
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { supa } from './supabase'
|
||||
import { Pole, Profile, NotificationKind, memberPoles } from './types'
|
||||
|
||||
/**
|
||||
* Crée une notification pour chaque membre du pôle destinataire
|
||||
* (sauf l'auteur de l'action). Utilisé lorsqu'une tâche ou un bug
|
||||
* est confié à un pôle.
|
||||
*/
|
||||
export async function notifyPole(opts: {
|
||||
projectId: string
|
||||
pole: Pole
|
||||
kind: NotificationKind
|
||||
title: string // ex : « Nouvelle tâche »
|
||||
body: string // titre de la tâche / du bug
|
||||
entityId: string
|
||||
actorId: string | null
|
||||
members: Profile[]
|
||||
}): Promise<void> {
|
||||
const recipients = opts.members.filter(
|
||||
(m) => memberPoles(m).includes(opts.pole) && m.id !== opts.actorId
|
||||
)
|
||||
if (recipients.length === 0) return
|
||||
|
||||
const rows = recipients.map((m) => ({
|
||||
user_id: m.id,
|
||||
project_id: opts.projectId,
|
||||
kind: opts.kind,
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
entity_id: opts.entityId,
|
||||
pole: opts.pole
|
||||
}))
|
||||
|
||||
await supa().from('notifications').insert(rows)
|
||||
}
|
||||
+75
-22
@@ -21,6 +21,50 @@ export const ROLE_LABELS: Record<MemberRole, string> = {
|
||||
other: 'Autre'
|
||||
}
|
||||
|
||||
// Un « pôle » = un métier/équipe. On réutilise les rôles comme pôles :
|
||||
// une tâche/bug est confiée à un pôle, et la notification part à tous les
|
||||
// membres de ce pôle. Les libellés sont ceux des rôles.
|
||||
export type Pole = MemberRole
|
||||
|
||||
export const POLE_LABELS: Record<Pole, string> = ROLE_LABELS
|
||||
|
||||
// Ordre d'affichage des pôles dans les listes déroulantes.
|
||||
export const POLE_ORDER: Pole[] = [
|
||||
'programmer',
|
||||
'artist',
|
||||
'game_designer',
|
||||
'sound',
|
||||
'writer',
|
||||
'qa',
|
||||
'lead',
|
||||
'other'
|
||||
]
|
||||
|
||||
// Une couleur DISTINCTE par pôle (pas de collision entre deux rôles).
|
||||
const POLE_COLORS: Record<Pole, string> = {
|
||||
lead: 'bg-amber-100 text-amber-700',
|
||||
programmer: 'bg-sky-100 text-sky-700',
|
||||
artist: 'bg-fuchsia-100 text-fuchsia-700',
|
||||
game_designer: 'bg-violet-100 text-violet-700',
|
||||
sound: 'bg-emerald-100 text-emerald-700',
|
||||
writer: 'bg-orange-100 text-orange-700',
|
||||
qa: 'bg-cyan-100 text-cyan-700',
|
||||
other: 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
|
||||
export function poleColor(pole: Pole): string {
|
||||
return POLE_COLORS[pole] ?? POLE_COLORS.other
|
||||
}
|
||||
|
||||
// Pôles « critiques » : donnent un accès élargi, attribués par un admin
|
||||
// uniquement (jamais auto-assignables lors de l'onboarding).
|
||||
export const CRITICAL_POLES: Pole[] = ['lead']
|
||||
|
||||
// Pôles qu'un membre peut se donner lui-même à l'inscription.
|
||||
export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter(
|
||||
(p) => !CRITICAL_POLES.includes(p)
|
||||
)
|
||||
|
||||
// Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres).
|
||||
export const JOB_TAGS: string[] = [
|
||||
'Programmation',
|
||||
@@ -70,14 +114,6 @@ export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
urgent: 'Urgente'
|
||||
}
|
||||
|
||||
export type MilestoneStatus = 'planned' | 'in_progress' | 'done'
|
||||
|
||||
export const MILESTONE_STATUS_LABELS: Record<MilestoneStatus, string> = {
|
||||
planned: 'Planifié',
|
||||
in_progress: 'En cours',
|
||||
done: 'Atteint'
|
||||
}
|
||||
|
||||
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
|
||||
@@ -99,12 +135,21 @@ export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
|
||||
export interface Profile {
|
||||
id: string
|
||||
full_name: string
|
||||
role: MemberRole
|
||||
role: MemberRole // rôle principal (= roles[0]) conservé pour l'affichage simple
|
||||
roles: MemberRole[] // tous les pôles auxquels le membre appartient
|
||||
avatar_color: string
|
||||
avatar_url: string | null // photo de profil (sinon initiales + couleur)
|
||||
is_admin: boolean
|
||||
onboarded: boolean // a terminé l'accueil (nom, avatar, pôles)
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Liste des pôles d'un membre (avec repli sur le rôle principal). */
|
||||
export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pole[] {
|
||||
if (p.roles && p.roles.length) return p.roles
|
||||
return p.role ? [p.role] : []
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
@@ -113,16 +158,6 @@ export interface Project {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Milestone {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
status: MilestoneStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
@@ -130,8 +165,8 @@ export interface Task {
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
priority: Priority
|
||||
assignee_id: string | null
|
||||
milestone_id: string | null
|
||||
pole: Pole | null
|
||||
assignee_ids: string[]
|
||||
due_date: string | null
|
||||
position: number
|
||||
tags: string[]
|
||||
@@ -142,7 +177,9 @@ export interface Task {
|
||||
|
||||
export interface TimeLog {
|
||||
id: string
|
||||
task_id: string
|
||||
project_id: string | null
|
||||
task_id: string | null // null = activité libre (réunion, etc.)
|
||||
label: string | null // libellé quand il n'y a pas de tâche liée
|
||||
user_id: string | null
|
||||
minutes: number
|
||||
note: string | null
|
||||
@@ -168,8 +205,24 @@ export interface Bug {
|
||||
severity: BugSeverity
|
||||
status: BugStatus
|
||||
priority: Priority
|
||||
pole: Pole | null
|
||||
assignee_id: string | null
|
||||
reporter_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type NotificationKind = 'task' | 'bug'
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
kind: NotificationKind
|
||||
title: string
|
||||
body: string | null
|
||||
entity_id: string | null
|
||||
pole: Pole | null
|
||||
read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
+22
-27
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { MemberRole, ROLE_LABELS } from '../lib/types'
|
||||
import { MemberRole, memberPoles } from '../lib/types'
|
||||
import {
|
||||
AdminUser,
|
||||
adminListUsers,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
adminDeleteUser,
|
||||
adminUpdateUser
|
||||
} from '../lib/admin'
|
||||
import { Modal, Select, EmptyState } from '../components/ui'
|
||||
import { Modal, EmptyState, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
export default function Admin(): JSX.Element {
|
||||
@@ -36,10 +36,16 @@ export default function Admin(): JSX.Element {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const setRole = async (u: AdminUser, role: MemberRole): Promise<void> => {
|
||||
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, role } : x)))
|
||||
const toggleRole = async (u: AdminUser, pole: MemberRole): Promise<void> => {
|
||||
const current = memberPoles(u)
|
||||
const roles = current.includes(pole)
|
||||
? current.filter((r) => r !== pole)
|
||||
: [...current, pole]
|
||||
setUsers((list) =>
|
||||
list.map((x) => (x.id === u.id ? { ...x, roles, role: roles[0] ?? 'other' } : x))
|
||||
)
|
||||
try {
|
||||
await adminUpdateUser(u.id, { role })
|
||||
await adminUpdateUser(u.id, { roles })
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
load()
|
||||
@@ -99,7 +105,7 @@ export default function Admin(): JSX.Element {
|
||||
<thead className="bg-panel2 text-left text-xs uppercase text-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Membre</th>
|
||||
<th className="px-4 py-3">Rôle</th>
|
||||
<th className="px-4 py-3">Pôles</th>
|
||||
<th className="px-4 py-3">Admin</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
@@ -119,17 +125,7 @@ export default function Admin(): JSX.Element {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Select
|
||||
className="!py-1 text-xs"
|
||||
value={u.role}
|
||||
onChange={(e) => setRole(u, e.target.value as MemberRole)}
|
||||
>
|
||||
{Object.entries(ROLE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<PoleChips selected={memberPoles(u)} onToggle={(pole) => toggleRole(u, pole)} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
@@ -190,11 +186,14 @@ function CreateUserModal({
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [role, setRole] = useState<MemberRole>('programmer')
|
||||
const [roles, setRoles] = useState<MemberRole[]>(['programmer'])
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const toggleRole = (pole: MemberRole): void =>
|
||||
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!email.trim() || password.length < 6) {
|
||||
@@ -207,7 +206,7 @@ function CreateUserModal({
|
||||
email: email.trim(),
|
||||
password,
|
||||
full_name: fullName.trim(),
|
||||
role,
|
||||
roles,
|
||||
is_admin: isAdmin
|
||||
})
|
||||
await onCreated()
|
||||
@@ -242,14 +241,10 @@ function CreateUserModal({
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<label className="label">Rôle</label>
|
||||
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}>
|
||||
{Object.entries(ROLE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<label className="label">Pôles (un ou plusieurs)</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
</div>
|
||||
<label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
+50
-4
@@ -3,16 +3,20 @@ import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useMembers } from '../lib/useMembers'
|
||||
import { notifyPole } from '../lib/notify'
|
||||
import {
|
||||
Bug,
|
||||
BugSeverity,
|
||||
BugStatus,
|
||||
Priority,
|
||||
Pole,
|
||||
SEVERITY_LABELS,
|
||||
BUG_STATUS_LABELS,
|
||||
PRIORITY_LABELS
|
||||
PRIORITY_LABELS,
|
||||
POLE_LABELS,
|
||||
poleColor
|
||||
} from '../lib/types'
|
||||
import { Avatar, Modal, Select, EmptyState, PriorityBadge } from '../components/ui'
|
||||
import { Avatar, Modal, Select, EmptyState, PriorityBadge, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
const SEVERITY_STYLES: Record<BugSeverity, string> = {
|
||||
@@ -82,6 +86,7 @@ export default function Bugs(): JSX.Element {
|
||||
<thead className="text-left text-xs uppercase text-subtle">
|
||||
<tr>
|
||||
<th className="px-3">Bug</th>
|
||||
<th className="px-3">Pôle</th>
|
||||
<th className="px-3">Sévérité</th>
|
||||
<th className="px-3">Priorité</th>
|
||||
<th className="px-3">Statut</th>
|
||||
@@ -96,6 +101,13 @@ export default function Bugs(): JSX.Element {
|
||||
onClick={() => setEditing(b)}
|
||||
>
|
||||
<td className="rounded-l-lg px-3 py-3 font-medium text-ink">{b.title}</td>
|
||||
<td className="px-3">
|
||||
{b.pole ? (
|
||||
<span className={`badge ${poleColor(b.pole)}`}>{POLE_LABELS[b.pole]}</span>
|
||||
) : (
|
||||
<span className="text-xs text-subtle">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
|
||||
{SEVERITY_LABELS[b.severity]}
|
||||
@@ -156,6 +168,7 @@ function BugModal({
|
||||
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
|
||||
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
|
||||
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
|
||||
const [pole, setPole] = useState<Pole | ''>(bug?.pole ?? '')
|
||||
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -169,10 +182,37 @@ function BugModal({
|
||||
severity,
|
||||
status,
|
||||
priority,
|
||||
pole: pole || null,
|
||||
assignee_id: assignee || null
|
||||
}
|
||||
if (bug) await supa().from('bugs').update(payload).eq('id', bug.id)
|
||||
else await supa().from('bugs').insert({ ...payload, reporter_id: reporterId })
|
||||
|
||||
const poleChanged = pole && (!bug || bug.pole !== pole)
|
||||
let entityId = bug?.id ?? null
|
||||
|
||||
if (bug) {
|
||||
await supa().from('bugs').update(payload).eq('id', bug.id)
|
||||
} else {
|
||||
const { data } = await supa()
|
||||
.from('bugs')
|
||||
.insert({ ...payload, reporter_id: reporterId })
|
||||
.select('id')
|
||||
.single()
|
||||
entityId = (data as { id: string } | null)?.id ?? null
|
||||
}
|
||||
|
||||
if (poleChanged && pole && entityId) {
|
||||
await notifyPole({
|
||||
projectId,
|
||||
pole,
|
||||
kind: 'bug',
|
||||
title: 'Nouveau bug',
|
||||
body: title.trim(),
|
||||
entityId,
|
||||
actorId: reporterId,
|
||||
members
|
||||
})
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
onClose()
|
||||
@@ -244,6 +284,12 @@ function BugModal({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="label mt-3">Pôle</label>
|
||||
<PoleChips
|
||||
selected={pole ? [pole] : []}
|
||||
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{bug ? (
|
||||
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
|
||||
|
||||
+44
-29
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { KanbanSquare, Bug as BugIcon, Target, CheckCircle2, CalendarClock } from 'lucide-react'
|
||||
import { KanbanSquare, Bug as BugIcon, Clock, CheckCircle2, CalendarClock } from 'lucide-react'
|
||||
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
@@ -8,38 +8,53 @@ import { useApp } from '../lib/AppContext'
|
||||
import {
|
||||
Task,
|
||||
Bug,
|
||||
Milestone,
|
||||
TimeLog,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_LABELS,
|
||||
TaskStatus
|
||||
TaskStatus,
|
||||
formatMinutes
|
||||
} from '../lib/types'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { Stagger, staggerItem } from '../components/motion'
|
||||
|
||||
const COL_COLORS: Record<TaskStatus, string> = {
|
||||
todo: 'bg-slate-400',
|
||||
in_progress: 'bg-sky-400',
|
||||
review: 'bg-amber-400',
|
||||
done: 'bg-emerald-400'
|
||||
in_progress: 'bg-sky-500',
|
||||
review: 'bg-amber-500',
|
||||
done: 'bg-emerald-500'
|
||||
}
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const { currentProject } = useApp()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [bugs, setBugs] = useState<Bug[]>([])
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [totalMinutes, setTotalMinutes] = useState(0)
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const [{ data: t }, { data: b }, { data: m }] = await Promise.all([
|
||||
const [{ data: t }, { data: b }] = await Promise.all([
|
||||
supa().from('tasks').select('*').eq('project_id', projectId),
|
||||
supa().from('bugs').select('*').eq('project_id', projectId),
|
||||
supa().from('milestones').select('*').eq('project_id', projectId)
|
||||
supa().from('bugs').select('*').eq('project_id', projectId)
|
||||
])
|
||||
setTasks((t ?? []) as Task[])
|
||||
const taskList = (t ?? []) as Task[]
|
||||
setTasks(taskList)
|
||||
setBugs((b ?? []) as Bug[])
|
||||
setMilestones((m ?? []) as Milestone[])
|
||||
|
||||
if (taskList.length) {
|
||||
const { data: tl } = await supa()
|
||||
.from('time_logs')
|
||||
.select('minutes, task_id')
|
||||
.in(
|
||||
'task_id',
|
||||
taskList.map((x) => x.id)
|
||||
)
|
||||
setTotalMinutes(
|
||||
((tl ?? []) as Pick<TimeLog, 'minutes'>[]).reduce((s, l) => s + l.minutes, 0)
|
||||
)
|
||||
} else {
|
||||
setTotalMinutes(0)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,10 +64,10 @@ export default function Dashboard(): JSX.Element {
|
||||
const doneTasks = tasks.filter((t) => t.status === 'done').length
|
||||
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
|
||||
const openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
|
||||
const upcoming = milestones
|
||||
.filter((m) => m.status !== 'done' && m.due_date)
|
||||
const upcoming = tasks
|
||||
.filter((t) => t.status !== 'done' && t.due_date)
|
||||
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
|
||||
.slice(0, 4)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -69,9 +84,9 @@ export default function Dashboard(): JSX.Element {
|
||||
/>
|
||||
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-600" />
|
||||
<StatCard
|
||||
icon={<Target />}
|
||||
label="Jalons"
|
||||
value={`${milestones.filter((m) => m.status === 'done').length}/${milestones.length}`}
|
||||
icon={<Clock />}
|
||||
label="Temps total"
|
||||
value={formatMinutes(totalMinutes)}
|
||||
color="text-accent2"
|
||||
/>
|
||||
</Stagger>
|
||||
@@ -115,32 +130,32 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prochaines échéances */}
|
||||
{/* Prochaines échéances (tâches) */}
|
||||
<div className="card">
|
||||
<h3 className="mb-3 font-medium text-ink">Prochaines échéances</h3>
|
||||
<h3 className="mb-3 font-medium text-ink">Tâches à échéance</h3>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-subtle">Aucune échéance à venir.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{upcoming.map((m) => {
|
||||
const overdue = isPast(new Date(m.due_date! + 'T23:59:59'))
|
||||
const d = differenceInCalendarDays(new Date(m.due_date!), new Date())
|
||||
{upcoming.map((t) => {
|
||||
const overdue = isPast(new Date(t.due_date! + 'T23:59:59'))
|
||||
const d = differenceInCalendarDays(new Date(t.due_date!), new Date())
|
||||
return (
|
||||
<li
|
||||
key={m.id}
|
||||
key={t.id}
|
||||
className="flex items-center justify-between rounded-lg bg-panel2 px-3 py-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm text-ink">
|
||||
<Target size={14} className="text-accent2" />
|
||||
{m.title}
|
||||
<span className="flex items-center gap-2 truncate text-sm text-ink">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${COL_COLORS[t.status]}`} />
|
||||
<span className="truncate">{t.title}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 text-xs ${
|
||||
className={`flex shrink-0 items-center gap-1 text-xs ${
|
||||
overdue ? 'text-rose-600' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
<CalendarClock size={12} />
|
||||
{format(new Date(m.due_date!), 'd MMM', { locale: fr })}
|
||||
{format(new Date(t.due_date!), 'd MMM', { locale: fr })}
|
||||
{' · '}
|
||||
{overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
|
||||
</span>
|
||||
|
||||
+78
-40
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Plus, CalendarDays, Clock, Filter, X } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
@@ -12,12 +11,23 @@ import {
|
||||
TimeLog,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_LABELS,
|
||||
POLE_LABELS,
|
||||
POLE_ORDER,
|
||||
poleColor,
|
||||
formatMinutes
|
||||
} from '../lib/types'
|
||||
import { Avatar, PriorityBadge, Tag, Select } from '../components/ui'
|
||||
import { Avatar, PriorityBadge, Select } from '../components/ui'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
// Couleur d'accent par colonne (en-tête, compteur, liseré supérieur).
|
||||
const STATUS_ACCENT: Record<TaskStatus, { dot: string; badge: string; bar: string }> = {
|
||||
todo: { dot: 'bg-slate-400', badge: 'bg-slate-100 text-slate-600', bar: 'bg-slate-400' },
|
||||
in_progress: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700', bar: 'bg-sky-500' },
|
||||
review: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700', bar: 'bg-amber-500' },
|
||||
done: { dot: 'bg-emerald-500', badge: 'bg-emerald-100 text-emerald-700', bar: 'bg-emerald-500' }
|
||||
}
|
||||
|
||||
export default function Kanban(): JSX.Element {
|
||||
const { currentProject } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
@@ -26,10 +36,11 @@ export default function Kanban(): JSX.Element {
|
||||
const [editing, setEditing] = useState<Task | null>(null)
|
||||
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const [overStatus, setOverStatus] = useState<TaskStatus | null>(null)
|
||||
|
||||
// Filtres
|
||||
const [fAssignee, setFAssignee] = useState('')
|
||||
const [fTag, setFTag] = useState('')
|
||||
const [fPole, setFPole] = useState('')
|
||||
|
||||
const projectId = currentProject?.id
|
||||
|
||||
@@ -53,7 +64,7 @@ export default function Kanban(): JSX.Element {
|
||||
)
|
||||
const totals: Record<string, number> = {}
|
||||
;((tl ?? []) as Pick<TimeLog, 'task_id' | 'minutes'>[]).forEach((l) => {
|
||||
totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes
|
||||
if (l.task_id) totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes
|
||||
})
|
||||
setTimes(totals)
|
||||
} else {
|
||||
@@ -96,24 +107,17 @@ export default function Kanban(): JSX.Element {
|
||||
setDragId(null)
|
||||
}
|
||||
|
||||
// Tags présents (pour le filtre)
|
||||
const allTags = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
tasks.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag)))
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
tasks.filter(
|
||||
(t) =>
|
||||
(!fAssignee || t.assignee_id === fAssignee) &&
|
||||
(!fTag || (t.tags ?? []).includes(fTag))
|
||||
(!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) &&
|
||||
(!fPole || t.pole === fPole)
|
||||
),
|
||||
[tasks, fAssignee, fTag]
|
||||
[tasks, fAssignee, fPole]
|
||||
)
|
||||
|
||||
const hasFilter = fAssignee || fTag
|
||||
const hasFilter = fAssignee || fPole
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -142,13 +146,13 @@ export default function Kanban(): JSX.Element {
|
||||
</Select>
|
||||
<Select
|
||||
className="!w-auto !py-1 text-xs"
|
||||
value={fTag}
|
||||
onChange={(e) => setFTag(e.target.value)}
|
||||
value={fPole}
|
||||
onChange={(e) => setFPole(e.target.value)}
|
||||
>
|
||||
<option value="">Tous les tags</option>
|
||||
{allTags.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
<option value="">Tous les pôles</option>
|
||||
{POLE_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{POLE_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -157,7 +161,7 @@ export default function Kanban(): JSX.Element {
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
|
||||
onClick={() => {
|
||||
setFAssignee('')
|
||||
setFTag('')
|
||||
setFPole('')
|
||||
}}
|
||||
>
|
||||
<X size={12} /> Réinitialiser
|
||||
@@ -168,44 +172,78 @@ export default function Kanban(): JSX.Element {
|
||||
<div className="flex flex-1 gap-3 overflow-x-auto p-6">
|
||||
{TASK_STATUS_ORDER.map((status) => {
|
||||
const colTasks = visible.filter((t) => t.status === status)
|
||||
const accent = STATUS_ACCENT[status]
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex min-w-[240px] flex-1 flex-col rounded-xl border border-border bg-panel2/50"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => onDrop(e, status)}
|
||||
className={`flex min-w-[240px] flex-1 flex-col overflow-hidden rounded-xl border bg-panel2/50 transition-colors ${
|
||||
overStatus === status && dragId
|
||||
? 'border-accent ring-2 ring-accent/30'
|
||||
: 'border-border'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
if (overStatus !== status) setOverStatus(status)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
onDrop(e, status)
|
||||
setOverStatus(null)
|
||||
}}
|
||||
>
|
||||
<div className={`h-1 w-full ${accent.bar}`} />
|
||||
<div className="flex items-center justify-between px-3 py-3">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<span className={`h-2 w-2 rounded-full ${accent.dot}`} />
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
</span>
|
||||
<span className="badge bg-panel2 text-muted">{colTasks.length}</span>
|
||||
<span className={`badge ${accent.badge}`}>{colTasks.length}</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
|
||||
{colTasks.map((t) => {
|
||||
const mins = times[t.id] ?? 0
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -2 }}
|
||||
draggable
|
||||
onDragStart={() => setDragId(t.id)}
|
||||
onDragStart={(e) => {
|
||||
setDragId(t.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', t.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragId(null)
|
||||
setOverStatus(null)
|
||||
}}
|
||||
onClick={() => setEditing(t)}
|
||||
className="card cursor-pointer p-3 hover:border-accent"
|
||||
className={`card cursor-pointer p-3 transition-all hover:-translate-y-0.5 hover:border-accent ${
|
||||
dragId === t.id ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-ink">{t.title}</p>
|
||||
<Avatar profile={byId(t.assignee_id)} size={24} />
|
||||
{t.assignee_ids?.length ? (
|
||||
<div className="flex shrink-0 -space-x-1.5">
|
||||
{t.assignee_ids.slice(0, 3).map((id) => (
|
||||
<span key={id} className="rounded-full ring-2 ring-panel">
|
||||
<Avatar profile={byId(id)} size={24} />
|
||||
</span>
|
||||
))}
|
||||
{t.assignee_ids.length > 3 && (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-panel2 text-[10px] font-medium text-muted ring-2 ring-panel">
|
||||
+{t.assignee_ids.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Avatar profile={undefined} size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.tags?.length > 0 && (
|
||||
{t.pole && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{t.tags.map((tag) => (
|
||||
<Tag key={tag} label={tag} />
|
||||
))}
|
||||
<span className={`badge ${poleColor(t.pole)}`}>
|
||||
{POLE_LABELS[t.pole]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -224,7 +262,7 @@ export default function Kanban(): JSX.Element {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
|
||||
+94
-25
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users, Pencil } from 'lucide-react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Users, Pencil, Camera, Loader2 } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { Profile, MemberRole, ROLE_LABELS } from '../lib/types'
|
||||
import { Avatar, Modal, Select, EmptyState } from '../components/ui'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { uploadAvatar } from '../lib/avatar'
|
||||
import { Profile, MemberRole, POLE_LABELS, poleColor, memberPoles } from '../lib/types'
|
||||
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||
@@ -42,7 +44,17 @@ export default function Members(): JSX.Element {
|
||||
{m.full_name}
|
||||
{m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-muted">{ROLE_LABELS[m.role]}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{memberPoles(m).length === 0 ? (
|
||||
<span className="text-sm text-subtle">Aucun pôle</span>
|
||||
) : (
|
||||
memberPoles(m).map((p) => (
|
||||
<span key={p} className={`badge ${poleColor(p)}`}>
|
||||
{POLE_LABELS[p]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -73,46 +85,103 @@ function EditProfileModal({
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const [name, setName] = useState(profile.full_name)
|
||||
const [role, setRole] = useState<MemberRole>(profile.role)
|
||||
const [roles, setRoles] = useState<MemberRole[]>(memberPoles(profile))
|
||||
const [color, setColor] = useState(profile.avatar_color)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile.avatar_url ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleRole = (pole: MemberRole): void =>
|
||||
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
|
||||
|
||||
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
setAvatarUrl(await uploadAvatar(file, profile.id))
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
await supa()
|
||||
.from('profiles')
|
||||
.update({ full_name: name.trim() || 'Membre', role, avatar_color: color })
|
||||
.update({
|
||||
full_name: name.trim() || 'Membre',
|
||||
roles,
|
||||
role: roles[0] ?? 'other',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl
|
||||
})
|
||||
.eq('id', profile.id)
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const preview = { full_name: name || '?', avatar_color: color, avatar_url: avatarUrl } as Profile
|
||||
|
||||
return (
|
||||
<Modal title="Mon profil" onClose={onClose}>
|
||||
<label className="label">Nom complet</label>
|
||||
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<label className="label">Rôle</label>
|
||||
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}>
|
||||
{Object.entries(ROLE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<label className="label">Couleur d'avatar</label>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
|
||||
<label className="label">Photo de profil</label>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar profile={preview} size={56} />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<Loader2 className="animate-spin text-white" size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
|
||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
className="text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn-primary w-full" onClick={save} disabled={busy}>
|
||||
|
||||
<label className="label">Mes pôles (un ou plusieurs)</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
</div>
|
||||
{!avatarUrl && (
|
||||
<>
|
||||
<label className="label">Couleur d'avatar (sans photo)</label>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button className="btn-primary w-full" onClick={save} disabled={busy || uploading}>
|
||||
Enregistrer
|
||||
</button>
|
||||
</Modal>
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, Target, Trash2, CalendarClock, Clock } from 'lucide-react'
|
||||
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import {
|
||||
Milestone,
|
||||
MilestoneStatus,
|
||||
Task,
|
||||
TimeLog,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
formatMinutes
|
||||
} from '../lib/types'
|
||||
import { Modal, Select, EmptyState, Tag } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
const STATUS_STYLES: Record<MilestoneStatus, string> = {
|
||||
planned: 'bg-slate-100 text-slate-600',
|
||||
in_progress: 'bg-sky-100 text-sky-700',
|
||||
done: 'bg-emerald-100 text-emerald-700'
|
||||
}
|
||||
|
||||
export default function Milestones(): JSX.Element {
|
||||
const { currentProject } = useApp()
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [times, setTimes] = useState<Record<string, number>>({})
|
||||
const [editing, setEditing] = useState<Milestone | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const [{ data: ms }, { data: ts }] = await Promise.all([
|
||||
supa().from('milestones').select('*').eq('project_id', projectId),
|
||||
supa().from('tasks').select('*').eq('project_id', projectId)
|
||||
])
|
||||
const sorted = ((ms ?? []) as Milestone[]).sort((a, b) =>
|
||||
(a.due_date ?? '9999').localeCompare(b.due_date ?? '9999')
|
||||
)
|
||||
setMilestones(sorted)
|
||||
const taskList = (ts ?? []) as Task[]
|
||||
setTasks(taskList)
|
||||
// Temps par tâche → agrégé par jalon plus bas
|
||||
if (taskList.length) {
|
||||
const { data: tl } = await supa()
|
||||
.from('time_logs')
|
||||
.select('task_id, minutes')
|
||||
.in(
|
||||
'task_id',
|
||||
taskList.map((t) => t.id)
|
||||
)
|
||||
const totals: Record<string, number> = {}
|
||||
;((tl ?? []) as Pick<TimeLog, 'task_id' | 'minutes'>[]).forEach((l) => {
|
||||
totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes
|
||||
})
|
||||
setTimes(totals)
|
||||
} else {
|
||||
setTimes({})
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const progressOf = (m: Milestone): { done: number; total: number; pct: number } => {
|
||||
const linked = tasks.filter((t) => t.milestone_id === m.id)
|
||||
const done = linked.filter((t) => t.status === 'done').length
|
||||
const pct = linked.length ? Math.round((done / linked.length) * 100) : 0
|
||||
return { done, total: linked.length, pct }
|
||||
}
|
||||
|
||||
const timeOf = (m: Milestone): number =>
|
||||
tasks
|
||||
.filter((t) => t.milestone_id === m.id)
|
||||
.reduce((s, t) => s + (times[t.id] ?? 0), 0)
|
||||
|
||||
const tagsOf = (m: Milestone): string[] => {
|
||||
const set = new Set<string>()
|
||||
tasks
|
||||
.filter((t) => t.milestone_id === m.id)
|
||||
.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag)))
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Jalons & planning" subtitle={currentProject?.name}>
|
||||
<button className="btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={16} /> Nouveau jalon
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{milestones.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Target size={40} />}
|
||||
text="Aucun jalon. Crée des jalons pour structurer ton planning (ex : Prototype, Alpha, Beta, Release)."
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
{milestones.map((m) => {
|
||||
const { done, total, pct } = progressOf(m)
|
||||
const mins = timeOf(m)
|
||||
const mTags = tagsOf(m)
|
||||
const overdue =
|
||||
m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59'))
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="card cursor-pointer hover:border-accent"
|
||||
onClick={() => setEditing(m)}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-ink">{m.title}</h3>
|
||||
{m.description && (
|
||||
<p className="mt-0.5 text-sm text-muted">{m.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`badge ${STATUS_STYLES[m.status]}`}>
|
||||
{MILESTONE_STATUS_LABELS[m.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mTags.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{mTags.map((tag) => (
|
||||
<Tag key={tag} label={tag} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 h-2 overflow-hidden rounded-full bg-panel2">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent2 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>
|
||||
{done}/{total} tâche(s) — {pct}%
|
||||
</span>
|
||||
{mins > 0 && (
|
||||
<span className="flex items-center gap-1 text-accent2">
|
||||
<Clock size={12} /> {formatMinutes(mins)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{m.due_date && (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${overdue ? 'text-rose-600' : ''}`}
|
||||
>
|
||||
<CalendarClock size={12} />
|
||||
{format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })}
|
||||
{m.status !== 'done' && ` (${daysLabel(m.due_date)})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(creating || editing) && (
|
||||
<MilestoneModal
|
||||
milestone={editing}
|
||||
projectId={projectId!}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function daysLabel(due: string): string {
|
||||
const d = differenceInCalendarDays(new Date(due), new Date())
|
||||
if (d < 0) return `en retard de ${-d} j`
|
||||
if (d === 0) return "aujourd'hui"
|
||||
return `dans ${d} j`
|
||||
}
|
||||
|
||||
function MilestoneModal({
|
||||
milestone,
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
milestone: Milestone | null
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const [title, setTitle] = useState(milestone?.title ?? '')
|
||||
const [description, setDescription] = useState(milestone?.description ?? '')
|
||||
const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned')
|
||||
const [dueDate, setDueDate] = useState(milestone?.due_date ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const { toast, confirm } = useFeedback()
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!title.trim()) return
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
status,
|
||||
due_date: dueDate || null
|
||||
}
|
||||
if (milestone) await supa().from('milestones').update(payload).eq('id', milestone.id)
|
||||
else await supa().from('milestones').insert(payload)
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
toast(milestone ? 'Jalon enregistré.' : 'Jalon créé.', 'success')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!milestone) return
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer ce jalon ?',
|
||||
message: `« ${milestone.title} » sera supprimé (les tâches liées seront déliées).`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
await supa().from('milestones').delete().eq('id', milestone.id)
|
||||
await onSaved()
|
||||
toast('Jalon supprimé.', 'success')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={milestone ? 'Modifier le jalon' : 'Nouveau jalon'} onClose={onClose}>
|
||||
<label className="label">Titre</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Alpha jouable"
|
||||
/>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
className="input mb-3 h-20 resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Statut</label>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value as MilestoneStatus)}>
|
||||
{Object.entries(MILESTONE_STATUS_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date cible</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{milestone ? (
|
||||
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
|
||||
<Trash2 size={16} /> Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
|
||||
{milestone ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Gamepad2, ArrowRight, ArrowLeft, Check, Loader2, Sparkles, Camera } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { uploadAvatar } from '../lib/avatar'
|
||||
import {
|
||||
MemberRole,
|
||||
Profile,
|
||||
POLE_LABELS,
|
||||
poleColor,
|
||||
SELF_ASSIGNABLE_POLES,
|
||||
CRITICAL_POLES,
|
||||
memberPoles
|
||||
} from '../lib/types'
|
||||
import { Avatar } from '../components/ui'
|
||||
|
||||
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||
|
||||
export default function Onboarding(): JSX.Element {
|
||||
const { profile, refreshProfile } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
|
||||
// Rôles critiques déjà attribués par l'admin (ex : chef de projet) : on les
|
||||
// conserve, mais le membre ne peut pas se les donner lui-même.
|
||||
const keptCritical = memberPoles(profile ?? {}).filter((p) => CRITICAL_POLES.includes(p))
|
||||
|
||||
// Découpe le nom existant (« Prénom Nom ») s'il y en a un.
|
||||
const existingName = profile && profile.full_name !== 'Membre' ? profile.full_name : ''
|
||||
const [step, setStep] = useState(0)
|
||||
const [firstName, setFirstName] = useState(existingName.split(' ')[0] ?? '')
|
||||
const [lastName, setLastName] = useState(existingName.split(' ').slice(1).join(' '))
|
||||
const [color, setColor] = useState(profile?.avatar_color ?? COLORS[0])
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [poles, setPoles] = useState<MemberRole[]>(
|
||||
memberPoles(profile ?? {}).filter((p) => SELF_ASSIGNABLE_POLES.includes(p))
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const togglePole = (p: MemberRole): void =>
|
||||
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
||||
|
||||
const fullName = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||
const previewProfile = {
|
||||
full_name: fullName || '?',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl
|
||||
} as unknown as Profile
|
||||
|
||||
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !profile) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const url = await uploadAvatar(file, profile.id)
|
||||
setAvatarUrl(url)
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const steps = ['Ton nom', 'Ta photo', 'Tes pôles']
|
||||
const canNext = step === 0 ? firstName.trim().length > 0 : true
|
||||
|
||||
const finish = async (): Promise<void> => {
|
||||
if (!profile) return
|
||||
setBusy(true)
|
||||
const roles = Array.from(new Set([...poles, ...keptCritical]))
|
||||
const finalRoles = roles.length ? roles : (['other'] as MemberRole[])
|
||||
const { error } = await supa()
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: fullName || 'Membre',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl,
|
||||
roles: finalRoles,
|
||||
role: finalRoles[0],
|
||||
onboarded: true
|
||||
})
|
||||
.eq('id', profile.id)
|
||||
if (error) {
|
||||
setBusy(false)
|
||||
toast(error.message, 'error')
|
||||
return
|
||||
}
|
||||
await refreshProfile()
|
||||
// Le passage onboarded=true bascule automatiquement vers l'app.
|
||||
}
|
||||
|
||||
const next = (): void => {
|
||||
if (step < steps.length - 1) setStep((s) => s + 1)
|
||||
else finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||
className="card w-full max-w-lg"
|
||||
>
|
||||
{/* En-tête */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="rounded-xl bg-accent/20 p-2.5 text-accent">
|
||||
<Gamepad2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-ink">Bienvenue dans l'équipe 👋</h1>
|
||||
<p className="text-sm text-muted">Configurons ton profil en 3 étapes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de progression */}
|
||||
<div className="mb-6 flex gap-1.5">
|
||||
{steps.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= step ? 'bg-accent' : 'bg-panel2'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Prénom</label>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
placeholder="Alex"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nom</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Martin"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-subtle">
|
||||
C'est le nom que tes coéquipiers verront sur les tâches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<label className="label">Ta photo de profil</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Avatar profile={previewProfile} size={72} />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<Loader2 className="animate-spin text-white" size={22} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={pickPhoto}
|
||||
/>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Camera size={16} /> {avatarUrl ? 'Changer la photo' : 'Ajouter une photo'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
className="ml-2 text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repli : couleur des initiales si aucune photo */}
|
||||
{!avatarUrl && (
|
||||
<div className="mt-4">
|
||||
<p className="label">Ou choisis une couleur</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
|
||||
color === c ? 'ring-2 ring-ink ring-offset-2 ring-offset-panel' : ''
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-subtle">
|
||||
Optionnel — tu pourras la changer plus tard depuis « Équipe ».
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<label className="label">Dans quel(s) pôle(s) travailles-tu ?</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SELF_ASSIGNABLE_POLES.map((p) => {
|
||||
const on = poles.includes(p)
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => togglePole(p)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
on
|
||||
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{on && <Check size={13} />}
|
||||
{POLE_LABELS[p]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{keptCritical.length > 0 && (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent2">
|
||||
<Sparkles size={13} />
|
||||
Ton rôle {keptCritical.map((p) => POLE_LABELS[p]).join(', ')} t'a été attribué par
|
||||
l'administrateur.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-subtle">
|
||||
Tu recevras une notification quand une tâche est confiée à un de tes pôles.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
{step > 0 ? (
|
||||
<button className="btn-ghost" onClick={() => setStep((s) => s - 1)} disabled={busy}>
|
||||
<ArrowLeft size={16} /> Retour
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button className="btn-primary" onClick={next} disabled={!canNext || busy}>
|
||||
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||
{step < steps.length - 1 ? (
|
||||
<>
|
||||
Continuer <ArrowRight size={16} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check size={16} /> Terminer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+334
-151
@@ -1,235 +1,418 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Filter, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Filter, Plus, Trash2, Clock, Loader2, X } from 'lucide-react'
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
eachDayOfInterval,
|
||||
isToday
|
||||
} from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useMembers } from '../lib/useMembers'
|
||||
import { Task, TaskStatus } from '../lib/types'
|
||||
import { Select } from '../components/ui'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Task, TimeLog, formatMinutes } from '../lib/types'
|
||||
import { Avatar, Modal, Select } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
type ViewMode = 'month' | 'week'
|
||||
|
||||
const STATUS_DOT: Record<TaskStatus, string> = {
|
||||
todo: 'bg-slate-400',
|
||||
in_progress: 'bg-sky-400',
|
||||
review: 'bg-amber-400',
|
||||
done: 'bg-emerald-400'
|
||||
}
|
||||
const DAY_KEY = (d: Date): string => format(d, 'yyyy-MM-dd')
|
||||
|
||||
export default function Planning(): JSX.Element {
|
||||
const { currentProject } = useApp()
|
||||
const { members } = useMembers()
|
||||
const { currentProject, profile } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [view, setView] = useState<ViewMode>('month')
|
||||
const [cursor, setCursor] = useState<Date>(() => startOfMonth(new Date()))
|
||||
const [editing, setEditing] = useState<Task | null>(null)
|
||||
const [fAssignee, setFAssignee] = useState('')
|
||||
const [fTag, setFTag] = useState('')
|
||||
const [logs, setLogs] = useState<TimeLog[]>([])
|
||||
const [cursor, setCursor] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||
const [fMember, setFMember] = useState('')
|
||||
// Modale de saisie : soit une nouvelle card sur un jour, soit l'édition d'une.
|
||||
const [modal, setModal] = useState<{ day: string; log: TimeLog | null } | null>(null)
|
||||
|
||||
const projectId = currentProject?.id
|
||||
const weekStart = useMemo(() => startOfWeek(cursor, { weekStartsOn: 1 }), [cursor])
|
||||
const weekEnd = useMemo(() => endOfWeek(cursor, { weekStartsOn: 1 }), [cursor])
|
||||
const days = useMemo(
|
||||
() => eachDayOfInterval({ start: weekStart, end: weekEnd }),
|
||||
[weekStart, weekEnd]
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const { data } = await supa().from('tasks').select('*').eq('project_id', projectId)
|
||||
setTasks((data ?? []) as Task[])
|
||||
}, [projectId])
|
||||
const [{ data: ts }, { data: tl }] = await Promise.all([
|
||||
supa().from('tasks').select('*').eq('project_id', projectId),
|
||||
supa()
|
||||
.from('time_logs')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.gte('logged_at', DAY_KEY(weekStart))
|
||||
.lte('logged_at', DAY_KEY(weekEnd))
|
||||
])
|
||||
setTasks((ts ?? []) as Task[])
|
||||
setLogs((tl ?? []) as TimeLog[])
|
||||
}, [projectId, weekStart, weekEnd])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
tasks.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag)))
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
// Synchro temps réel des entrées de temps.
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
const ch = supa()
|
||||
.channel(`timesheet-${projectId}`)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'time_logs' }, () => load())
|
||||
.subscribe()
|
||||
return () => {
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [projectId, load])
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
tasks.filter(
|
||||
(t) =>
|
||||
t.due_date &&
|
||||
(!fAssignee || t.assignee_id === fAssignee) &&
|
||||
(!fTag || (t.tags ?? []).includes(fTag))
|
||||
),
|
||||
[tasks, fAssignee, fTag]
|
||||
const taskById = (id: string): Task | undefined => tasks.find((t) => t.id === id)
|
||||
|
||||
const visibleLogs = useMemo(
|
||||
() => logs.filter((l) => !fMember || l.user_id === fMember),
|
||||
[logs, fMember]
|
||||
)
|
||||
|
||||
const tasksOn = (day: Date): Task[] =>
|
||||
visible.filter((t) => t.due_date && isSameDay(new Date(t.due_date), day))
|
||||
const logsOn = (day: Date): TimeLog[] =>
|
||||
visibleLogs.filter((l) => l.logged_at === DAY_KEY(day))
|
||||
|
||||
// Jours affichés selon la vue
|
||||
const days = useMemo(() => {
|
||||
if (view === 'month') {
|
||||
const start = startOfWeek(startOfMonth(cursor), { weekStartsOn: 1 })
|
||||
const end = endOfWeek(endOfMonth(cursor), { weekStartsOn: 1 })
|
||||
return eachDayOfInterval({ start, end })
|
||||
}
|
||||
const start = startOfWeek(cursor, { weekStartsOn: 1 })
|
||||
const end = endOfWeek(cursor, { weekStartsOn: 1 })
|
||||
return eachDayOfInterval({ start, end })
|
||||
}, [view, cursor])
|
||||
const totalOn = (day: Date): number => logsOn(day).reduce((s, l) => s + l.minutes, 0)
|
||||
const weekTotal = visibleLogs.reduce((s, l) => s + l.minutes, 0)
|
||||
|
||||
const move = (dir: number): void =>
|
||||
setCursor((c) => (view === 'month' ? addMonths(c, dir) : addWeeks(c, dir)))
|
||||
|
||||
const title =
|
||||
view === 'month'
|
||||
? format(cursor, 'MMMM yyyy', { locale: fr })
|
||||
: `Semaine du ${format(startOfWeek(cursor, { weekStartsOn: 1 }), 'd MMM', { locale: fr })}`
|
||||
|
||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
const hasFilter = fAssignee || fTag
|
||||
const title = `Semaine du ${format(weekStart, 'd MMM', { locale: fr })} au ${format(
|
||||
weekEnd,
|
||||
'd MMM yyyy',
|
||||
{ locale: fr }
|
||||
)}`
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Planning" subtitle={currentProject?.name}>
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['month', 'week'] as ViewMode[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`rounded-md px-3 py-1 text-sm ${
|
||||
view === v ? 'bg-accent text-ink' : 'text-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{v === 'month' ? 'Mois' : 'Semaine'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PageHeader title="Feuille de temps" subtitle={currentProject?.name}>
|
||||
<span className="hidden items-center gap-1.5 text-sm text-accent2 sm:flex">
|
||||
<Clock size={15} /> {formatMinutes(weekTotal)} cette semaine
|
||||
</span>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setModal({ day: DAY_KEY(new Date()), log: null })}
|
||||
>
|
||||
<Plus size={16} /> Nouvelle entrée
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Navigation + filtres */}
|
||||
{/* Navigation semaine + filtre */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-6 py-2.5">
|
||||
<button className="btn-ghost !px-2 !py-1" onClick={() => move(-1)}>
|
||||
<button className="btn-ghost !px-2 !py-1" onClick={() => setCursor((c) => addWeeks(c, -1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg px-2 py-1 text-sm text-gray-600 hover:bg-panel2"
|
||||
onClick={() => setCursor(view === 'month' ? startOfMonth(new Date()) : new Date())}
|
||||
className="rounded-lg px-2 py-1 text-sm text-muted hover:bg-panel2"
|
||||
onClick={() => setCursor(startOfWeek(new Date(), { weekStartsOn: 1 }))}
|
||||
>
|
||||
Aujourd'hui
|
||||
Cette semaine
|
||||
</button>
|
||||
<button className="btn-ghost !px-2 !py-1" onClick={() => move(1)}>
|
||||
<button className="btn-ghost !px-2 !py-1" onClick={() => setCursor((c) => addWeeks(c, 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<span className="ml-1 text-sm font-medium capitalize text-ink">{title}</span>
|
||||
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-subtle">
|
||||
<Filter size={14} /> Filtrer
|
||||
<Filter size={14} /> Membre
|
||||
</span>
|
||||
<Select
|
||||
className="!w-auto !py-1 text-xs"
|
||||
value={fAssignee}
|
||||
onChange={(e) => setFAssignee(e.target.value)}
|
||||
value={fMember}
|
||||
onChange={(e) => setFMember(e.target.value)}
|
||||
>
|
||||
<option value="">Tous les membres</option>
|
||||
<option value="">Toute l'équipe</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.full_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
className="!w-auto !py-1 text-xs"
|
||||
value={fTag}
|
||||
onChange={(e) => setFTag(e.target.value)}
|
||||
>
|
||||
<option value="">Tous les tags</option>
|
||||
{allTags.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{hasFilter && (
|
||||
{fMember && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
|
||||
onClick={() => {
|
||||
setFAssignee('')
|
||||
setFTag('')
|
||||
}}
|
||||
onClick={() => setFMember('')}
|
||||
>
|
||||
<X size={12} /> Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grille calendrier */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-6">
|
||||
<div className="grid grid-cols-7 border-b border-border pb-2 text-center text-xs font-medium uppercase text-subtle">
|
||||
{weekDays.map((d) => (
|
||||
<div key={d}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`grid flex-1 grid-cols-7 gap-1 overflow-y-auto pt-1 ${
|
||||
view === 'week' ? 'auto-rows-fr' : ''
|
||||
}`}
|
||||
style={view === 'month' ? { gridAutoRows: 'minmax(7rem, 1fr)' } : undefined}
|
||||
>
|
||||
{/* Grille : 7 colonnes de jours */}
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto p-6">
|
||||
{days.map((day) => {
|
||||
const dayTasks = tasksOn(day)
|
||||
const muted = view === 'month' && !isSameMonth(day, cursor)
|
||||
const dayLogs = logsOn(day)
|
||||
const total = totalOn(day)
|
||||
const today = isToday(day)
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`flex flex-col gap-1 rounded-lg border border-border/60 p-1.5 ${
|
||||
muted ? 'opacity-40' : ''
|
||||
} ${isToday(day) ? 'ring-1 ring-accent' : ''}`}
|
||||
key={DAY_KEY(day)}
|
||||
className={`flex min-w-[170px] flex-1 flex-col rounded-xl border bg-panel2/40 ${
|
||||
today ? 'border-accent' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
|
||||
isToday(day) ? 'bg-accent font-semibold text-ink' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 overflow-y-auto">
|
||||
{dayTasks.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setEditing(t)}
|
||||
className="flex items-center gap-1.5 truncate rounded-md bg-panel2 px-1.5 py-1 text-left text-xs text-ink hover:bg-border"
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div>
|
||||
<div
|
||||
className={`text-xs font-semibold uppercase ${
|
||||
today ? 'text-accent' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${STATUS_DOT[t.status]}`} />
|
||||
<span className="truncate">{t.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{format(day, 'EEE', { locale: fr })}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-ink">{format(day, 'd MMM', { locale: fr })}</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<span className="badge bg-accent2/15 text-accent2">{formatMinutes(total)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
|
||||
{dayLogs.map((l) => {
|
||||
const t = l.task_id ? taskById(l.task_id) : undefined
|
||||
const isActivity = !l.task_id
|
||||
const heading = isActivity
|
||||
? l.label || 'Activité'
|
||||
: t?.title ?? 'Tâche supprimée'
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
onClick={() => setModal({ day: DAY_KEY(day), log: l })}
|
||||
className={`w-full rounded-lg border-l-2 border-y border-r border-border bg-panel p-2 text-left shadow-sm transition-colors hover:border-accent ${
|
||||
isActivity ? 'border-l-amber-400' : 'border-l-accent2'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
isActivity ? 'text-amber-500' : 'text-accent2'
|
||||
}`}
|
||||
>
|
||||
{formatMinutes(l.minutes)}
|
||||
</span>
|
||||
<Avatar profile={byId(l.user_id)} size={20} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-ink">{heading}</p>
|
||||
{l.note && <p className="mt-0.5 truncate text-xs text-subtle">{l.note}</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-border py-1.5 text-xs text-subtle hover:border-accent hover:text-ink"
|
||||
onClick={() => setModal({ day: DAY_KEY(day), log: null })}
|
||||
>
|
||||
<Plus size={13} /> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<TaskModal
|
||||
task={editing}
|
||||
defaultStatus={editing.status}
|
||||
{modal && (
|
||||
<TimeCardModal
|
||||
day={modal.day}
|
||||
log={modal.log}
|
||||
tasks={tasks}
|
||||
projectId={projectId!}
|
||||
members={members}
|
||||
onClose={() => setEditing(null)}
|
||||
userId={profile?.id ?? null}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeCardModal({
|
||||
day,
|
||||
log,
|
||||
tasks,
|
||||
projectId,
|
||||
userId,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
day: string
|
||||
log: TimeLog | null
|
||||
tasks: Task[]
|
||||
projectId: string
|
||||
userId: string | null
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { toast, confirm } = useFeedback()
|
||||
// « task » = lié à une tâche du Kanban ; « other » = activité libre (réunion…).
|
||||
const initialKind: 'task' | 'other' =
|
||||
log ? (log.task_id ? 'task' : 'other') : tasks.length ? 'task' : 'other'
|
||||
const [kind, setKind] = useState<'task' | 'other'>(initialKind)
|
||||
const [taskId, setTaskId] = useState(log?.task_id ?? tasks[0]?.id ?? '')
|
||||
const [label, setLabel] = useState(log?.label ?? '')
|
||||
const [date, setDate] = useState(log?.logged_at ?? day)
|
||||
const [hours, setHours] = useState(log ? String(Math.floor(log.minutes / 60)) : '')
|
||||
const [minutes, setMinutes] = useState(log ? String(log.minutes % 60) : '')
|
||||
const [note, setNote] = useState(log?.note ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
const mins = (parseInt(hours || '0', 10) || 0) * 60 + (parseInt(minutes || '0', 10) || 0)
|
||||
if (kind === 'task' && !taskId) {
|
||||
toast('Choisis une tâche.', 'error')
|
||||
return
|
||||
}
|
||||
if (kind === 'other' && !label.trim()) {
|
||||
toast("Donne un nom à l'activité (ex : Réunion).", 'error')
|
||||
return
|
||||
}
|
||||
if (mins <= 0) {
|
||||
toast('Indique une durée valide.', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
task_id: kind === 'task' ? taskId : null,
|
||||
label: kind === 'other' ? label.trim() : null,
|
||||
minutes: mins,
|
||||
note: note.trim() || null,
|
||||
logged_at: date
|
||||
}
|
||||
if (log) await supa().from('time_logs').update(payload).eq('id', log.id)
|
||||
else await supa().from('time_logs').insert({ ...payload, user_id: userId })
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
toast(log ? 'Entrée mise à jour.' : 'Temps ajouté.', 'success')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!log) return
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer cette entrée ?',
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
await supa().from('time_logs').delete().eq('id', log.id)
|
||||
await onSaved()
|
||||
toast('Entrée supprimée.', 'success')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={log ? "Modifier l'entrée" : 'Nouvelle entrée de temps'} onClose={onClose}>
|
||||
{/* Type d'entrée : tâche du Kanban ou activité libre (réunion, etc.) */}
|
||||
<div className="mb-3 flex rounded-lg border border-border p-0.5">
|
||||
{(
|
||||
[
|
||||
['task', 'Tâche'],
|
||||
['other', 'Autre activité']
|
||||
] as const
|
||||
).map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setKind(v)}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
kind === v ? 'bg-accent text-accentfg' : 'text-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{kind === 'task' ? (
|
||||
<>
|
||||
<label className="label">Tâche</label>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="mb-3 rounded-lg border border-border bg-panel2 px-3 py-2 text-xs text-subtle">
|
||||
Aucune tâche dans ce projet. Passe sur « Autre activité » ou crée une tâche dans le
|
||||
Kanban.
|
||||
</p>
|
||||
) : (
|
||||
<Select className="mb-3" value={taskId} onChange={(e) => setTaskId(e.target.value)}>
|
||||
{tasks.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">Activité</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
autoFocus
|
||||
placeholder="Réunion, veille, support…"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3">
|
||||
<label className="label">Jour</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Heures</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input"
|
||||
placeholder="0"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Minutes</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
className="input"
|
||||
placeholder="0"
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="label mt-3">Note (optionnel)</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Ce que tu as fait…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{log ? (
|
||||
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
|
||||
<Trash2 size={16} /> Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button className="btn-primary" onClick={save} disabled={busy}>
|
||||
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||
{log ? 'Enregistrer' : 'Ajouter'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,11 +68,18 @@ Deno.serve(async (req) => {
|
||||
const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr]))
|
||||
const users = list.users.map((u) => {
|
||||
const pr: any = byId.get(u.id) ?? {}
|
||||
const roles =
|
||||
Array.isArray(pr.roles) && pr.roles.length
|
||||
? pr.roles
|
||||
: pr.role
|
||||
? [pr.role]
|
||||
: []
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
full_name: pr.full_name ?? '',
|
||||
role: pr.role ?? 'other',
|
||||
roles,
|
||||
is_admin: pr.is_admin ?? false,
|
||||
created_at: pr.created_at ?? u.created_at
|
||||
}
|
||||
@@ -81,17 +88,21 @@ Deno.serve(async (req) => {
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
const { email, password, full_name, role, is_admin } = p
|
||||
const { email, password, full_name, role, roles, is_admin } = p
|
||||
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
|
||||
if (String(password).length < 6)
|
||||
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
|
||||
|
||||
// Liste de pôles (rôles) ; le premier fait office de rôle principal.
|
||||
const roleList: string[] = Array.isArray(roles) && roles.length ? roles : role ? [role] : []
|
||||
const primary = roleList[0] ?? 'other'
|
||||
|
||||
// Crée le compte (email déjà confirmé : connexion immédiate possible).
|
||||
const { data: created, error } = await admin.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: full_name || email.split('@')[0], role: role || 'other' }
|
||||
user_metadata: { full_name: full_name || email.split('@')[0], role: primary }
|
||||
})
|
||||
if (error) return json({ error: error.message }, 400)
|
||||
|
||||
@@ -100,7 +111,8 @@ Deno.serve(async (req) => {
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: full_name || email.split('@')[0],
|
||||
role: role || 'other',
|
||||
role: primary,
|
||||
roles: roleList.length ? roleList : [primary],
|
||||
is_admin: !!is_admin
|
||||
})
|
||||
.eq('id', created.user.id)
|
||||
@@ -119,15 +131,23 @@ Deno.serve(async (req) => {
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const { id, full_name, role, is_admin } = p
|
||||
const { id, full_name, role, roles, is_admin } = p
|
||||
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
|
||||
if (id === user.id && is_admin === false)
|
||||
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (full_name !== undefined) patch.full_name = full_name
|
||||
if (role !== undefined) patch.role = role
|
||||
if (is_admin !== undefined) patch.is_admin = !!is_admin
|
||||
// Gestion des pôles : roles = liste complète, role = principal (roles[0]).
|
||||
if (Array.isArray(roles)) {
|
||||
const primary = roles[0] ?? 'other'
|
||||
patch.roles = roles.length ? roles : [primary]
|
||||
patch.role = primary
|
||||
} else if (role !== undefined) {
|
||||
patch.role = role
|
||||
patch.roles = [role]
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return json({ error: 'Rien à modifier.' }, 400)
|
||||
|
||||
const { error } = await admin.from('profiles').update(patch).eq('id', id)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v2
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (pour une base déjà créée avec schema.sql v1)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Suppression des JALONS (milestones)
|
||||
-- • Notion de PÔLE sur les tâches et les bugs
|
||||
-- • Plusieurs personnes assignables sur une tâche (assignee_ids)
|
||||
-- • Système de NOTIFICATIONS (assignation à un pôle)
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- Pôle sur tâches & bugs (réutilise l'enum member_role) -----
|
||||
alter table public.tasks add column if not exists pole member_role;
|
||||
alter table public.bugs add column if not exists pole member_role;
|
||||
|
||||
-- ---------- Plusieurs assignés par tâche -----------------------------
|
||||
alter table public.tasks
|
||||
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
|
||||
-- Reprend l'assigné unique existant dans le nouveau tableau.
|
||||
update public.tasks
|
||||
set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null
|
||||
and (assignee_ids is null or assignee_ids = '{}');
|
||||
|
||||
alter table public.tasks drop column if exists assignee_id;
|
||||
|
||||
-- ---------- Suppression des jalons -----------------------------------
|
||||
alter table public.tasks drop column if exists milestone_id;
|
||||
drop table if exists public.milestones cascade;
|
||||
drop type if exists milestone_status;
|
||||
|
||||
-- ---------- Notifications --------------------------------------------
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null, -- 'task' | 'bug'
|
||||
title text not null, -- ex : « Nouvelle tâche »
|
||||
body text, -- titre de la tâche / du bug
|
||||
entity_id uuid, -- id de la tâche / du bug
|
||||
pole member_role, -- pôle destinataire
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
|
||||
alter table public.notifications enable row level security;
|
||||
|
||||
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
|
||||
-- membre connecté (afin de notifier les autres membres d'un pôle).
|
||||
drop policy if exists notifications_select on public.notifications;
|
||||
drop policy if exists notifications_insert on public.notifications;
|
||||
drop policy if exists notifications_update on public.notifications;
|
||||
drop policy if exists notifications_delete on public.notifications;
|
||||
|
||||
create policy notifications_select on public.notifications
|
||||
for select to authenticated using (user_id = auth.uid());
|
||||
create policy notifications_insert on public.notifications
|
||||
for insert to authenticated with check (true);
|
||||
create policy notifications_update on public.notifications
|
||||
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
create policy notifications_delete on public.notifications
|
||||
for delete to authenticated using (user_id = auth.uid());
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.notifications;
|
||||
exception when duplicate_object then null; end $$;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v3
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (après migration_v2.sql)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Temps « libre » dans la feuille de temps (réunion, etc.) :
|
||||
-- une entrée peut ne pas être liée à une tâche du Kanban.
|
||||
-- =====================================================================
|
||||
|
||||
-- La tâche devient optionnelle ; un libellé libre la remplace si absente.
|
||||
alter table public.time_logs alter column task_id drop not null;
|
||||
alter table public.time_logs add column if not exists label text;
|
||||
|
||||
-- Rattachement direct au projet (pour retrouver aussi les entrées sans tâche).
|
||||
alter table public.time_logs
|
||||
add column if not exists project_id uuid references public.projects(id) on delete cascade;
|
||||
|
||||
-- Backfill : les entrées existantes héritent du projet de leur tâche.
|
||||
update public.time_logs tl
|
||||
set project_id = t.project_id
|
||||
from public.tasks t
|
||||
where tl.task_id = t.id and tl.project_id is null;
|
||||
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v4
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (après migration_v3.sql)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Un membre peut appartenir à PLUSIEURS pôles (rôles).
|
||||
-- profiles.role reste le rôle « principal » ; profiles.roles
|
||||
-- contient l'ensemble des pôles.
|
||||
-- =====================================================================
|
||||
|
||||
alter table public.profiles
|
||||
add column if not exists roles member_role[] not null default '{}';
|
||||
|
||||
-- Backfill : les profils existants héritent de leur rôle unique actuel.
|
||||
update public.profiles
|
||||
set roles = array[role]
|
||||
where roles = '{}';
|
||||
+82
-28
@@ -17,10 +17,6 @@ do $$ begin
|
||||
create type priority_level as enum ('low','medium','high','urgent');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
do $$ begin
|
||||
create type milestone_status as enum ('planned','in_progress','done');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
do $$ begin
|
||||
create type bug_severity as enum ('low','medium','high','critical');
|
||||
exception when duplicate_object then null; end $$;
|
||||
@@ -33,8 +29,11 @@ exception when duplicate_object then null; end $$;
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
full_name text not null default 'Membre',
|
||||
role member_role not null default 'other',
|
||||
role member_role not null default 'other', -- rôle principal (= roles[0])
|
||||
roles member_role[] not null default '{}', -- tous les pôles du membre
|
||||
avatar_color text not null default '#7c5cff',
|
||||
avatar_url text, -- photo de profil (optionnelle)
|
||||
onboarded boolean not null default false, -- a terminé l'accueil
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
@@ -47,18 +46,9 @@ create table if not exists public.projects (
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Jalons (milestones) --------------------------------------
|
||||
create table if not exists public.milestones (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
title text not null,
|
||||
description text,
|
||||
due_date date,
|
||||
status milestone_status not null default 'planned',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Tâches ---------------------------------------------------
|
||||
-- Un « pôle » (= member_role) reçoit la tâche ; une ou plusieurs
|
||||
-- personnes de ce pôle peuvent y être assignées (assignee_ids).
|
||||
create table if not exists public.tasks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
@@ -66,8 +56,8 @@ create table if not exists public.tasks (
|
||||
description text,
|
||||
status task_status not null default 'todo',
|
||||
priority priority_level not null default 'medium',
|
||||
assignee_id uuid references public.profiles(id) on delete set null,
|
||||
milestone_id uuid references public.milestones(id) on delete set null,
|
||||
pole member_role,
|
||||
assignee_ids uuid[] not null default '{}',
|
||||
due_date date,
|
||||
position double precision not null default 0,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
@@ -84,6 +74,7 @@ create table if not exists public.bugs (
|
||||
severity bug_severity not null default 'medium',
|
||||
status bug_status not null default 'open',
|
||||
priority priority_level not null default 'medium',
|
||||
pole member_role,
|
||||
assignee_id uuid references public.profiles(id) on delete set null,
|
||||
reporter_id uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
@@ -92,7 +83,6 @@ create table if not exists public.bugs (
|
||||
|
||||
create index if not exists idx_tasks_project on public.tasks(project_id);
|
||||
create index if not exists idx_bugs_project on public.bugs(project_id);
|
||||
create index if not exists idx_milestones_project on public.milestones(project_id);
|
||||
|
||||
-- ---------- Maj automatique de updated_at ----------------------------
|
||||
create or replace function public.touch_updated_at()
|
||||
@@ -110,12 +100,15 @@ create trigger trg_bugs_touch before update on public.bugs
|
||||
-- ---------- Création auto du profil à l'inscription ------------------
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger language plpgsql security definer set search_path = public as $$
|
||||
declare r member_role;
|
||||
begin
|
||||
insert into public.profiles (id, full_name, role)
|
||||
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
|
||||
insert into public.profiles (id, full_name, role, roles)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
|
||||
coalesce((new.raw_user_meta_data->>'role')::member_role, 'other')
|
||||
r,
|
||||
array[r]
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
return new;
|
||||
@@ -130,14 +123,13 @@ create trigger on_auth_user_created
|
||||
-- Équipe privée et de confiance : tout membre connecté a accès complet.
|
||||
alter table public.profiles enable row level security;
|
||||
alter table public.projects enable row level security;
|
||||
alter table public.milestones enable row level security;
|
||||
alter table public.tasks enable row level security;
|
||||
alter table public.bugs enable row level security;
|
||||
|
||||
do $$
|
||||
declare t text;
|
||||
begin
|
||||
foreach t in array array['profiles','projects','milestones','tasks','bugs'] loop
|
||||
foreach t in array array['profiles','projects','tasks','bugs'] loop
|
||||
execute format('drop policy if exists %I_all on public.%I', t, t);
|
||||
execute format(
|
||||
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
|
||||
@@ -152,9 +144,6 @@ exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.bugs;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.milestones;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.profiles;
|
||||
exception when duplicate_object then null; end $$;
|
||||
@@ -223,10 +212,13 @@ create trigger trg_profiles_guard_admin before update on public.profiles
|
||||
alter table public.tasks
|
||||
add column if not exists tags text[] not null default '{}';
|
||||
|
||||
-- ---------- Journal du temps passé (par tâche, par membre) -----------
|
||||
-- ---------- Journal du temps passé (par tâche OU activité libre) -----
|
||||
-- task_id null + label => temps « libre » (réunion, etc.).
|
||||
create table if not exists public.time_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
task_id uuid not null references public.tasks(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
task_id uuid references public.tasks(id) on delete cascade,
|
||||
label text,
|
||||
user_id uuid references public.profiles(id) on delete set null,
|
||||
minutes integer not null check (minutes > 0),
|
||||
note text,
|
||||
@@ -235,6 +227,7 @@ create table if not exists public.time_logs (
|
||||
);
|
||||
|
||||
create index if not exists idx_time_logs_task on public.time_logs(task_id);
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
create index if not exists idx_time_logs_user on public.time_logs(user_id);
|
||||
|
||||
alter table public.time_logs enable row level security;
|
||||
@@ -245,3 +238,64 @@ create policy time_logs_all on public.time_logs
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.time_logs;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- NOTIFICATIONS (assignation d'une tâche / d'un bug à un pôle)
|
||||
-- =====================================================================
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null, -- 'task' | 'bug'
|
||||
title text not null, -- ex : « Nouvelle tâche »
|
||||
body text, -- titre de la tâche / du bug
|
||||
entity_id uuid, -- id de la tâche / du bug
|
||||
pole member_role, -- pôle destinataire
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
|
||||
alter table public.notifications enable row level security;
|
||||
|
||||
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
|
||||
-- membre connecté (afin de notifier les autres membres d'un pôle).
|
||||
drop policy if exists notifications_select on public.notifications;
|
||||
drop policy if exists notifications_insert on public.notifications;
|
||||
drop policy if exists notifications_update on public.notifications;
|
||||
drop policy if exists notifications_delete on public.notifications;
|
||||
|
||||
create policy notifications_select on public.notifications
|
||||
for select to authenticated using (user_id = auth.uid());
|
||||
create policy notifications_insert on public.notifications
|
||||
for insert to authenticated with check (true);
|
||||
create policy notifications_update on public.notifications
|
||||
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
create policy notifications_delete on public.notifications
|
||||
for delete to authenticated using (user_id = auth.uid());
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.notifications;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- STOCKAGE : photos de profil (bucket "avatars")
|
||||
-- =====================================================================
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatars', 'avatars', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists avatars_read on storage.objects;
|
||||
drop policy if exists avatars_insert on storage.objects;
|
||||
drop policy if exists avatars_update on storage.objects;
|
||||
drop policy if exists avatars_delete on storage.objects;
|
||||
|
||||
create policy avatars_read on storage.objects
|
||||
for select using (bucket_id = 'avatars');
|
||||
create policy avatars_insert on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'avatars');
|
||||
create policy avatars_update on storage.objects
|
||||
for update to authenticated using (bucket_id = 'avatars');
|
||||
create policy avatars_delete on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'avatars');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
|
||||
-- À coller dans : Supabase > SQL Editor > New query > Run
|
||||
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.tasks add column if not exists pole member_role;
|
||||
alter table public.bugs add column if not exists pole member_role;
|
||||
|
||||
alter table public.tasks
|
||||
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
|
||||
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
|
||||
update public.tasks
|
||||
set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
|
||||
alter table public.tasks drop column assignee_id;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
alter table public.tasks drop column if exists milestone_id;
|
||||
drop table if exists public.milestones cascade;
|
||||
drop type if exists milestone_status;
|
||||
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null,
|
||||
title text not null,
|
||||
body text,
|
||||
entity_id uuid,
|
||||
pole member_role,
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
alter table public.notifications enable row level security;
|
||||
|
||||
drop policy if exists notifications_select on public.notifications;
|
||||
drop policy if exists notifications_insert on public.notifications;
|
||||
drop policy if exists notifications_update on public.notifications;
|
||||
drop policy if exists notifications_delete on public.notifications;
|
||||
create policy notifications_select on public.notifications
|
||||
for select to authenticated using (user_id = auth.uid());
|
||||
create policy notifications_insert on public.notifications
|
||||
for insert to authenticated with check (true);
|
||||
create policy notifications_update on public.notifications
|
||||
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
create policy notifications_delete on public.notifications
|
||||
for delete to authenticated using (user_id = auth.uid());
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.notifications;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
|
||||
create table if not exists public.time_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
task_id uuid references public.tasks(id) on delete cascade,
|
||||
label text,
|
||||
user_id uuid references public.profiles(id) on delete set null,
|
||||
minutes integer not null check (minutes > 0),
|
||||
note text,
|
||||
logged_at date not null default current_date,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
|
||||
alter table public.time_logs alter column task_id drop not null;
|
||||
alter table public.time_logs add column if not exists label text;
|
||||
alter table public.time_logs
|
||||
add column if not exists project_id uuid references public.projects(id) on delete cascade;
|
||||
|
||||
update public.time_logs tl
|
||||
set project_id = t.project_id
|
||||
from public.tasks t
|
||||
where tl.task_id = t.id and tl.project_id is null;
|
||||
|
||||
create index if not exists idx_time_logs_task on public.time_logs(task_id);
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
create index if not exists idx_time_logs_user on public.time_logs(user_id);
|
||||
|
||||
alter table public.time_logs enable row level security;
|
||||
drop policy if exists time_logs_all on public.time_logs;
|
||||
create policy time_logs_all on public.time_logs
|
||||
for all to authenticated using (true) with check (true);
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.time_logs;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v4 — Plusieurs pôles (rôles) par membre
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.profiles
|
||||
add column if not exists roles member_role[] not null default '{}';
|
||||
|
||||
update public.profiles
|
||||
set roles = array[role]
|
||||
where roles = '{}';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v5 — Accueil (onboarding) des nouveaux membres
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.profiles
|
||||
add column if not exists onboarded boolean not null default false;
|
||||
|
||||
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
|
||||
update public.profiles set onboarded = true where onboarded = false;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.profiles add column if not exists avatar_url text;
|
||||
|
||||
-- Bucket public "avatars" pour héberger les photos de profil.
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatars', 'avatars', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Lecture publique ; écriture réservée aux membres connectés.
|
||||
drop policy if exists avatars_read on storage.objects;
|
||||
drop policy if exists avatars_insert on storage.objects;
|
||||
drop policy if exists avatars_update on storage.objects;
|
||||
drop policy if exists avatars_delete on storage.objects;
|
||||
|
||||
create policy avatars_read on storage.objects
|
||||
for select using (bucket_id = 'avatars');
|
||||
create policy avatars_insert on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'avatars');
|
||||
create policy avatars_update on storage.objects
|
||||
for update to authenticated using (bucket_id = 'avatars');
|
||||
create policy avatars_delete on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'avatars');
|
||||
Reference in New Issue
Block a user