This commit is contained in:
2026-07-01 12:39:59 +02:00
parent b054e36df0
commit 8a0fa70c10
26 changed files with 1804 additions and 724 deletions
+12 -7
View File
@@ -6,11 +6,12 @@ Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base
## ✨ Fonctionnalités ## ✨ Fonctionnalités
- 🔐 **Connexion / inscription** par email + mot de passe - 🔐 **Connexion / inscription** par email + mot de passe
- 👥 **Équipe & rôles** (programmeur, artiste, game designer, sound, scénariste, QA…) - 👥 **É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â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
- 🎯 **Jalons & planning** — échéances, progression par jalon (Prototype, Alpha, Beta, Release…) - ⏱️ **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, assignation - 🐛 **Suivi de bugs** — sévérité, priorité, statut, pôle, assignation
- 📊 **Tableau de bord** — avancement global, répartition des tâches, prochaines échéances - 🔔 **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 - 🔄 **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. 3. Attends ~2 min que le projet se crée.
4. Dans le menu de gauche : **SQL Editor → New query**. 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**. 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 : 6. Menu **Settings (⚙️) → API**. Note deux valeurs :
- **Project URL** → ressemble à `https://xxxxx.supabase.co` - **Project URL** → ressemble à `https://xxxxx.supabase.co`
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...` - **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 ```text
├─ index.html # point d'entrée du front ├─ index.html # point d'entrée du front
├─ src/ # application React (front-end) ├─ 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 │ ├─ components/ # Layout, UI, animations
│ └─ lib/ # client Supabase, types, contexte global, config │ └─ lib/ # client Supabase, types, contexte global, config
├─ src-tauri/ # enveloppe desktop Tauri (Rust) ├─ src-tauri/ # enveloppe desktop Tauri (Rust)
-10
View File
@@ -74,7 +74,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -1531,7 +1530,6 @@
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1549,7 +1547,6 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1710,7 +1707,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.38", "baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
@@ -2161,7 +2157,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -2433,7 +2428,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2603,7 +2597,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -2616,7 +2609,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -2937,7 +2929,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3036,7 +3027,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
+13 -3
View File
@@ -5,11 +5,11 @@ import { AppProvider, useApp } from './lib/AppContext'
import { PageFade } from './components/motion' import { PageFade } from './components/motion'
import Setup from './pages/Setup' import Setup from './pages/Setup'
import Login from './pages/Login' import Login from './pages/Login'
import Onboarding from './pages/Onboarding'
import Layout, { Page } from './components/Layout' import Layout, { Page } from './components/Layout'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Kanban from './pages/Kanban' import Kanban from './pages/Kanban'
import Planning from './pages/Planning' import Planning from './pages/Planning'
import Milestones from './pages/Milestones'
import Bugs from './pages/Bugs' import Bugs from './pages/Bugs'
import Members from './pages/Members' import Members from './pages/Members'
import Admin from './pages/Admin' import Admin from './pages/Admin'
@@ -18,14 +18,13 @@ const PAGES: Record<Page, () => JSX.Element> = {
dashboard: Dashboard, dashboard: Dashboard,
kanban: Kanban, kanban: Kanban,
planning: Planning, planning: Planning,
milestones: Milestones,
bugs: Bugs, bugs: Bugs,
members: Members, members: Members,
admin: Admin admin: Admin
} }
function Shell(): JSX.Element { function Shell(): JSX.Element {
const { stage } = useApp() const { stage, profile } = useApp()
const [page, setPage] = useState<Page>('dashboard') const [page, setPage] = useState<Page>('dashboard')
if (stage === 'loading') { if (stage === 'loading') {
@@ -38,6 +37,17 @@ function Shell(): JSX.Element {
if (stage === 'setup') return <Setup /> if (stage === 'setup') return <Setup />
if (stage === 'login') return <Login /> 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] const ActivePage = PAGES[page]
return ( return (
<Layout page={page} setPage={setPage}> <Layout page={page} setPage={setPage}>
+9 -8
View File
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion'
import { import {
LayoutDashboard, LayoutDashboard,
KanbanSquare, KanbanSquare,
Target, Clock,
Bug, Bug,
Users, Users,
Plus, Plus,
@@ -12,7 +12,6 @@ import {
Settings, Settings,
Gamepad2, Gamepad2,
ShieldCheck, ShieldCheck,
CalendarRange,
Sun, Sun,
Moon Moon
} from 'lucide-react' } from 'lucide-react'
@@ -21,12 +20,12 @@ import { supa } from '../lib/supabase'
import { ROLE_LABELS } from '../lib/types' import { ROLE_LABELS } from '../lib/types'
import { Theme, getTheme, setTheme } from '../lib/theme' import { Theme, getTheme, setTheme } from '../lib/theme'
import { Avatar, Modal } from './ui' import { Avatar, Modal } from './ui'
import NotificationBell from './NotificationBell'
export type Page = export type Page =
| 'dashboard' | 'dashboard'
| 'kanban' | 'kanban'
| 'planning' | 'planning'
| 'milestones'
| 'bugs' | 'bugs'
| 'members' | 'members'
| 'admin' | 'admin'
@@ -34,8 +33,7 @@ export type Page =
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [ const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard }, { id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare }, { id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
{ id: 'planning', label: 'Planning', icon: CalendarRange }, { id: 'planning', label: 'Feuille de temps', icon: Clock },
{ id: 'milestones', label: 'Jalons', icon: Target },
{ id: 'bugs', label: 'Bugs', icon: Bug }, { id: 'bugs', label: 'Bugs', icon: Bug },
{ id: 'members', label: 'Équipe', icon: Users }, { id: 'members', label: 'Équipe', icon: Users },
{ id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true } { id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true }
@@ -114,14 +112,16 @@ export default function Layout({
<button <button
key={n.id} key={n.id}
onClick={() => setPage(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 ${ 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-ink' : 'text-muted hover:bg-panel2 hover:text-ink' page === n.id
? 'text-accent'
: 'text-muted hover:bg-accent/10 hover:text-accent'
}`} }`}
> >
{page === n.id && ( {page === n.id && (
<motion.span <motion.span
layoutId="nav-active" 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 }} transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/> />
)} )}
@@ -141,6 +141,7 @@ export default function Layout({
{profile ? ROLE_LABELS[profile.role] : ''} {profile ? ROLE_LABELS[profile.role] : ''}
</div> </div>
</div> </div>
<NotificationBell setPage={setPage} />
<button <button
onClick={toggleTheme} onClick={toggleTheme}
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'} title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
+170
View File
@@ -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>
)
}
+90 -45
View File
@@ -5,19 +5,21 @@ import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback' import { useFeedback } from '../lib/feedback'
import { notifyPole } from '../lib/notify'
import { import {
Task, Task,
TaskStatus, TaskStatus,
Priority, Priority,
Milestone, Pole,
TimeLog, TimeLog,
Profile, Profile,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
PRIORITY_LABELS, PRIORITY_LABELS,
memberPoles,
formatMinutes formatMinutes
} from '../lib/types' } from '../lib/types'
import { Modal, Select, TagInput, Avatar } from './ui' import { Modal, Select, Avatar, PoleChips } from './ui'
export default function TaskModal({ export default function TaskModal({
task, task,
@@ -40,20 +42,13 @@ export default function TaskModal({
const [description, setDescription] = useState(task?.description ?? '') const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus) const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium') const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [assignee, setAssignee] = useState(task?.assignee_id ?? '') const [pole, setPole] = useState<Pole | ''>(task?.pole ?? '')
const [milestone, setMilestone] = useState(task?.milestone_id ?? '') const [assignees, setAssignees] = useState<string[]>(task?.assignee_ids ?? [])
const [dueDate, setDueDate] = useState(task?.due_date ?? '') const [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [tags, setTags] = useState<string[]>(task?.tags ?? [])
const [milestones, setMilestones] = useState<Milestone[]>([])
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
useEffect(() => { const toggleAssignee = (id: string): void =>
supa() setAssignees((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
.from('milestones')
.select('*')
.eq('project_id', projectId)
.then(({ data }) => setMilestones((data ?? []) as Milestone[]))
}, [projectId])
const save = async (): Promise<void> => { const save = async (): Promise<void> => {
if (!title.trim()) return if (!title.trim()) return
@@ -64,13 +59,39 @@ export default function TaskModal({
description: description.trim() || null, description: description.trim() || null,
status, status,
priority, priority,
assignee_id: assignee || null, pole: pole || null,
milestone_id: milestone || null, assignee_ids: assignees,
due_date: dueDate || null, due_date: dueDate || null
tags
} }
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() await onSaved()
setBusy(false) setBusy(false)
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success') 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)} 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"> <div className="mb-3">
<TagInput value={tags} onChange={setTags} /> <PoleChips
selected={pole ? [pole] : []}
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -135,28 +160,6 @@ export default function TaskModal({
))} ))}
</Select> </Select>
</div> </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"> <div className="col-span-2">
<label className="label">Échéance</label> <label className="label">Échéance</label>
<input <input
@@ -168,8 +171,41 @@ export default function TaskModal({
</div> </div>
</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 */} {/* 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"> <div className="mt-5 flex items-center justify-between">
{task ? ( {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 { profile } = useApp()
const { toast, confirm } = useFeedback() const { toast, confirm } = useFeedback()
const [logs, setLogs] = useState<TimeLog[]>([]) const [logs, setLogs] = useState<TimeLog[]>([])
@@ -224,6 +268,7 @@ function TimeSection({ taskId, members }: { taskId: string; members: Profile[] }
setBusy(true) setBusy(true)
await supa().from('time_logs').insert({ await supa().from('time_logs').insert({
task_id: taskId, task_id: taskId,
project_id: projectId,
user_id: profile?.id ?? null, user_id: profile?.id ?? null,
minutes: mins, minutes: mins,
note: note.trim() || null note: note.trim() || null
+58 -1
View File
@@ -1,7 +1,17 @@
import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react' import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { X, Plus } from 'lucide-react' 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({ export function Modal({
title, title,
@@ -48,6 +58,18 @@ export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?
.slice(0, 2) .slice(0, 2)
.join('') .join('')
.toUpperCase() .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 ( return (
<div <div
title={profile?.full_name ?? 'Non assigné'} 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> 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 ». */ /** Champ d'édition de tags avec suggestions de « jobs ». */
export function TagInput({ export function TagInput({
value, value,
+38 -21
View File
@@ -3,34 +3,37 @@
@tailwind utilities; @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 { :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-panel: 255 255 255;
--c-panel2: 243 242 240; --c-panel2: 244 243 246;
--c-sidebar: 247 246 243; --c-sidebar: 248 247 250;
--c-border: 233 233 231; --c-border: 232 231 235;
--c-ink: 55 53 47; --c-ink: 45 43 56;
--c-muted: 115 114 110; --c-muted: 112 110 122;
--c-subtle: 155 154 151; --c-subtle: 152 150 162;
--c-accent: 55 53 47; /* noir Notion */ --c-accent: 99 90 232; /* indigo/violet */
--c-accentfg: 255 255 255; --c-accentfg: 255 255 255;
--c-accent2: 55 53 47; --c-accent2: 13 148 136; /* teal */
} }
.dark { .dark {
--c-bg: 25 25 25; color-scheme: dark;
--c-panel: 32 32 32; --c-bg: 23 23 27;
--c-panel2: 40 40 40; --c-panel: 31 31 37;
--c-sidebar: 20 20 20; --c-panel2: 40 40 48;
--c-border: 50 50 50; --c-sidebar: 19 19 24;
--c-ink: 235 235 233; --c-border: 51 51 60;
--c-muted: 158 158 154; --c-ink: 234 234 238;
--c-subtle: 122 122 119; --c-muted: 160 158 172;
--c-accent: 235 235 233; /* blanc en sombre */ --c-subtle: 124 122 136;
--c-accentfg: 25 25 25; --c-accent: 129 121 248; /* indigo clair sur fond sombre */
--c-accent2: 235 235 233; --c-accentfg: 255 255 255;
--c-accent2: 45 212 191; /* teal clair */
} }
@layer base { @layer base {
@@ -78,6 +81,20 @@
.input { .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; @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 { .label {
@apply mb-1 block text-xs font-medium text-muted; @apply mb-1 block text-xs font-medium text-muted;
} }
+3 -2
View File
@@ -8,6 +8,7 @@ export interface AdminUser {
email: string email: string
full_name: string full_name: string
role: MemberRole role: MemberRole
roles: MemberRole[]
is_admin: boolean is_admin: boolean
created_at: string created_at: string
} }
@@ -43,7 +44,7 @@ export const adminCreateUser = (input: {
email: string email: string
password: string password: string
full_name: string full_name: string
role: MemberRole roles: MemberRole[]
is_admin: boolean is_admin: boolean
}): Promise<{ ok: true; id: string }> => call('create', input) }): 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 = ( export const adminUpdateUser = (
id: string, 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 }) ): Promise<{ ok: true }> => call('update', { id, ...patch })
+15
View File
@@ -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
}
+35
View File
@@ -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
View File
@@ -21,6 +21,50 @@ export const ROLE_LABELS: Record<MemberRole, string> = {
other: 'Autre' 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). // Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres).
export const JOB_TAGS: string[] = [ export const JOB_TAGS: string[] = [
'Programmation', 'Programmation',
@@ -70,14 +114,6 @@ export const PRIORITY_LABELS: Record<Priority, string> = {
urgent: 'Urgente' 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 type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
export const SEVERITY_LABELS: Record<BugSeverity, string> = { export const SEVERITY_LABELS: Record<BugSeverity, string> = {
@@ -99,12 +135,21 @@ export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
export interface Profile { export interface Profile {
id: string id: string
full_name: 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_color: string
avatar_url: string | null // photo de profil (sinon initiales + couleur)
is_admin: boolean is_admin: boolean
onboarded: boolean // a terminé l'accueil (nom, avatar, pôles)
created_at: string 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 { export interface Project {
id: string id: string
name: string name: string
@@ -113,16 +158,6 @@ export interface Project {
created_at: string 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 { export interface Task {
id: string id: string
project_id: string project_id: string
@@ -130,8 +165,8 @@ export interface Task {
description: string | null description: string | null
status: TaskStatus status: TaskStatus
priority: Priority priority: Priority
assignee_id: string | null pole: Pole | null
milestone_id: string | null assignee_ids: string[]
due_date: string | null due_date: string | null
position: number position: number
tags: string[] tags: string[]
@@ -142,7 +177,9 @@ export interface Task {
export interface TimeLog { export interface TimeLog {
id: string 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 user_id: string | null
minutes: number minutes: number
note: string | null note: string | null
@@ -168,8 +205,24 @@ export interface Bug {
severity: BugSeverity severity: BugSeverity
status: BugStatus status: BugStatus
priority: Priority priority: Priority
pole: Pole | null
assignee_id: string | null assignee_id: string | null
reporter_id: string | null reporter_id: string | null
created_at: string created_at: string
updated_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
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react' import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback' import { useFeedback } from '../lib/feedback'
import { MemberRole, ROLE_LABELS } from '../lib/types' import { MemberRole, memberPoles } from '../lib/types'
import { import {
AdminUser, AdminUser,
adminListUsers, adminListUsers,
@@ -10,7 +10,7 @@ import {
adminDeleteUser, adminDeleteUser,
adminUpdateUser adminUpdateUser
} from '../lib/admin' } from '../lib/admin'
import { Modal, Select, EmptyState } from '../components/ui' import { Modal, EmptyState, PoleChips } from '../components/ui'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
export default function Admin(): JSX.Element { export default function Admin(): JSX.Element {
@@ -36,10 +36,16 @@ export default function Admin(): JSX.Element {
load() load()
}, []) }, [])
const setRole = async (u: AdminUser, role: MemberRole): Promise<void> => { const toggleRole = async (u: AdminUser, pole: MemberRole): Promise<void> => {
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, role } : x))) 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 { try {
await adminUpdateUser(u.id, { role }) await adminUpdateUser(u.id, { roles })
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message)
load() load()
@@ -99,7 +105,7 @@ export default function Admin(): JSX.Element {
<thead className="bg-panel2 text-left text-xs uppercase text-subtle"> <thead className="bg-panel2 text-left text-xs uppercase text-subtle">
<tr> <tr>
<th className="px-4 py-3">Membre</th> <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">Admin</th>
<th className="px-4 py-3 text-right">Actions</th> <th className="px-4 py-3 text-right">Actions</th>
</tr> </tr>
@@ -119,17 +125,7 @@ export default function Admin(): JSX.Element {
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Select <PoleChips selected={memberPoles(u)} onToggle={(pole) => toggleRole(u, pole)} />
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>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button
@@ -190,11 +186,14 @@ function CreateUserModal({
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('') const [fullName, setFullName] = useState('')
const [role, setRole] = useState<MemberRole>('programmer') const [roles, setRoles] = useState<MemberRole[]>(['programmer'])
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') 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> => { const submit = async (): Promise<void> => {
setError('') setError('')
if (!email.trim() || password.length < 6) { if (!email.trim() || password.length < 6) {
@@ -207,7 +206,7 @@ function CreateUserModal({
email: email.trim(), email: email.trim(),
password, password,
full_name: fullName.trim(), full_name: fullName.trim(),
role, roles,
is_admin: isAdmin is_admin: isAdmin
}) })
await onCreated() await onCreated()
@@ -242,14 +241,10 @@ function CreateUserModal({
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<label className="label">Rôle</label> <label className="label">Pôles (un ou plusieurs)</label>
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}> <div className="mb-3">
{Object.entries(ROLE_LABELS).map(([v, l]) => ( <PoleChips selected={roles} onToggle={toggleRole} />
<option key={v} value={v}> </div>
{l}
</option>
))}
</Select>
<label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600"> <label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<input <input
type="checkbox" type="checkbox"
+50 -4
View File
@@ -3,16 +3,20 @@ import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers' import { useMembers } from '../lib/useMembers'
import { notifyPole } from '../lib/notify'
import { import {
Bug, Bug,
BugSeverity, BugSeverity,
BugStatus, BugStatus,
Priority, Priority,
Pole,
SEVERITY_LABELS, SEVERITY_LABELS,
BUG_STATUS_LABELS, BUG_STATUS_LABELS,
PRIORITY_LABELS PRIORITY_LABELS,
POLE_LABELS,
poleColor
} from '../lib/types' } 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' import PageHeader from '../components/PageHeader'
const SEVERITY_STYLES: Record<BugSeverity, string> = { 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"> <thead className="text-left text-xs uppercase text-subtle">
<tr> <tr>
<th className="px-3">Bug</th> <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">Sévérité</th>
<th className="px-3">Priorité</th> <th className="px-3">Priorité</th>
<th className="px-3">Statut</th> <th className="px-3">Statut</th>
@@ -96,6 +101,13 @@ export default function Bugs(): JSX.Element {
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
> >
<td className="rounded-l-lg px-3 py-3 font-medium text-ink">{b.title}</td> <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"> <td className="px-3">
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}> <span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
{SEVERITY_LABELS[b.severity]} {SEVERITY_LABELS[b.severity]}
@@ -156,6 +168,7 @@ function BugModal({
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium') const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open') const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium') const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
const [pole, setPole] = useState<Pole | ''>(bug?.pole ?? '')
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '') const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -169,10 +182,37 @@ function BugModal({
severity, severity,
status, status,
priority, priority,
pole: pole || null,
assignee_id: assignee || 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() await onSaved()
setBusy(false) setBusy(false)
onClose() onClose()
@@ -244,6 +284,12 @@ function BugModal({
</Select> </Select>
</div> </div>
</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"> <div className="mt-5 flex items-center justify-between">
{bug ? ( {bug ? (
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}> <button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
+44 -29
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { motion } from 'framer-motion' 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 { format, isPast, differenceInCalendarDays } from 'date-fns'
import { fr } from 'date-fns/locale' import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
@@ -8,38 +8,53 @@ import { useApp } from '../lib/AppContext'
import { import {
Task, Task,
Bug, Bug,
Milestone, TimeLog,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TaskStatus TaskStatus,
formatMinutes
} from '../lib/types' } from '../lib/types'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import { Stagger, staggerItem } from '../components/motion' import { Stagger, staggerItem } from '../components/motion'
const COL_COLORS: Record<TaskStatus, string> = { const COL_COLORS: Record<TaskStatus, string> = {
todo: 'bg-slate-400', todo: 'bg-slate-400',
in_progress: 'bg-sky-400', in_progress: 'bg-sky-500',
review: 'bg-amber-400', review: 'bg-amber-500',
done: 'bg-emerald-400' done: 'bg-emerald-500'
} }
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const { currentProject } = useApp() const { currentProject } = useApp()
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [bugs, setBugs] = useState<Bug[]>([]) const [bugs, setBugs] = useState<Bug[]>([])
const [milestones, setMilestones] = useState<Milestone[]>([]) const [totalMinutes, setTotalMinutes] = useState(0)
const projectId = currentProject?.id const projectId = currentProject?.id
const load = useCallback(async () => { const load = useCallback(async () => {
if (!projectId) return 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('tasks').select('*').eq('project_id', projectId),
supa().from('bugs').select('*').eq('project_id', projectId), supa().from('bugs').select('*').eq('project_id', projectId)
supa().from('milestones').select('*').eq('project_id', projectId)
]) ])
setTasks((t ?? []) as Task[]) const taskList = (t ?? []) as Task[]
setTasks(taskList)
setBugs((b ?? []) as Bug[]) 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]) }, [projectId])
useEffect(() => { useEffect(() => {
@@ -49,10 +64,10 @@ export default function Dashboard(): JSX.Element {
const doneTasks = tasks.filter((t) => t.status === 'done').length const doneTasks = tasks.filter((t) => t.status === 'done').length
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0 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 openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
const upcoming = milestones const upcoming = tasks
.filter((m) => m.status !== 'done' && m.due_date) .filter((t) => t.status !== 'done' && t.due_date)
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? '')) .sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
.slice(0, 4) .slice(0, 5)
return ( return (
<div className="flex h-full flex-col"> <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={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-600" />
<StatCard <StatCard
icon={<Target />} icon={<Clock />}
label="Jalons" label="Temps total"
value={`${milestones.filter((m) => m.status === 'done').length}/${milestones.length}`} value={formatMinutes(totalMinutes)}
color="text-accent2" color="text-accent2"
/> />
</Stagger> </Stagger>
@@ -115,32 +130,32 @@ export default function Dashboard(): JSX.Element {
</div> </div>
</div> </div>
{/* Prochaines échéances */} {/* Prochaines échéances (tâches) */}
<div className="card"> <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 ? ( {upcoming.length === 0 ? (
<p className="py-6 text-center text-sm text-subtle">Aucune échéance à venir.</p> <p className="py-6 text-center text-sm text-subtle">Aucune échéance à venir.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{upcoming.map((m) => { {upcoming.map((t) => {
const overdue = isPast(new Date(m.due_date! + 'T23:59:59')) const overdue = isPast(new Date(t.due_date! + 'T23:59:59'))
const d = differenceInCalendarDays(new Date(m.due_date!), new Date()) const d = differenceInCalendarDays(new Date(t.due_date!), new Date())
return ( return (
<li <li
key={m.id} key={t.id}
className="flex items-center justify-between rounded-lg bg-panel2 px-3 py-2" 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"> <span className="flex items-center gap-2 truncate text-sm text-ink">
<Target size={14} className="text-accent2" /> <span className={`h-2 w-2 shrink-0 rounded-full ${COL_COLORS[t.status]}`} />
{m.title} <span className="truncate">{t.title}</span>
</span> </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' overdue ? 'text-rose-600' : 'text-muted'
}`} }`}
> >
<CalendarClock size={12} /> <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`} {overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
</span> </span>
+78 -40
View File
@@ -1,5 +1,4 @@
import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react' import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react'
import { motion } from 'framer-motion'
import { Plus, CalendarDays, Clock, Filter, X } from 'lucide-react' import { Plus, CalendarDays, Clock, Filter, X } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { fr } from 'date-fns/locale' import { fr } from 'date-fns/locale'
@@ -12,12 +11,23 @@ import {
TimeLog, TimeLog,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
POLE_LABELS,
POLE_ORDER,
poleColor,
formatMinutes formatMinutes
} from '../lib/types' } from '../lib/types'
import { Avatar, PriorityBadge, Tag, Select } from '../components/ui' import { Avatar, PriorityBadge, Select } from '../components/ui'
import TaskModal from '../components/TaskModal' import TaskModal from '../components/TaskModal'
import PageHeader from '../components/PageHeader' 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 { export default function Kanban(): JSX.Element {
const { currentProject } = useApp() const { currentProject } = useApp()
const { members, byId } = useMembers() const { members, byId } = useMembers()
@@ -26,10 +36,11 @@ export default function Kanban(): JSX.Element {
const [editing, setEditing] = useState<Task | null>(null) const [editing, setEditing] = useState<Task | null>(null)
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null) const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
const [dragId, setDragId] = useState<string | null>(null) const [dragId, setDragId] = useState<string | null>(null)
const [overStatus, setOverStatus] = useState<TaskStatus | null>(null)
// Filtres // Filtres
const [fAssignee, setFAssignee] = useState('') const [fAssignee, setFAssignee] = useState('')
const [fTag, setFTag] = useState('') const [fPole, setFPole] = useState('')
const projectId = currentProject?.id const projectId = currentProject?.id
@@ -53,7 +64,7 @@ export default function Kanban(): JSX.Element {
) )
const totals: Record<string, number> = {} const totals: Record<string, number> = {}
;((tl ?? []) as Pick<TimeLog, 'task_id' | 'minutes'>[]).forEach((l) => { ;((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) setTimes(totals)
} else { } else {
@@ -96,24 +107,17 @@ export default function Kanban(): JSX.Element {
setDragId(null) 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( const visible = useMemo(
() => () =>
tasks.filter( tasks.filter(
(t) => (t) =>
(!fAssignee || t.assignee_id === fAssignee) && (!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) &&
(!fTag || (t.tags ?? []).includes(fTag)) (!fPole || t.pole === fPole)
), ),
[tasks, fAssignee, fTag] [tasks, fAssignee, fPole]
) )
const hasFilter = fAssignee || fTag const hasFilter = fAssignee || fPole
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -142,13 +146,13 @@ export default function Kanban(): JSX.Element {
</Select> </Select>
<Select <Select
className="!w-auto !py-1 text-xs" className="!w-auto !py-1 text-xs"
value={fTag} value={fPole}
onChange={(e) => setFTag(e.target.value)} onChange={(e) => setFPole(e.target.value)}
> >
<option value="">Tous les tags</option> <option value="">Tous les pôles</option>
{allTags.map((t) => ( {POLE_ORDER.map((p) => (
<option key={t} value={t}> <option key={p} value={p}>
{t} {POLE_LABELS[p]}
</option> </option>
))} ))}
</Select> </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" className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
onClick={() => { onClick={() => {
setFAssignee('') setFAssignee('')
setFTag('') setFPole('')
}} }}
> >
<X size={12} /> Réinitialiser <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"> <div className="flex flex-1 gap-3 overflow-x-auto p-6">
{TASK_STATUS_ORDER.map((status) => { {TASK_STATUS_ORDER.map((status) => {
const colTasks = visible.filter((t) => t.status === status) const colTasks = visible.filter((t) => t.status === status)
const accent = STATUS_ACCENT[status]
return ( return (
<div <div
key={status} key={status}
className="flex min-w-[240px] flex-1 flex-col rounded-xl border border-border bg-panel2/50" className={`flex min-w-[240px] flex-1 flex-col overflow-hidden rounded-xl border bg-panel2/50 transition-colors ${
onDragOver={(e) => e.preventDefault()} overStatus === status && dragId
onDrop={(e) => onDrop(e, status)} ? '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"> <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]} {TASK_STATUS_LABELS[status]}
</span> </span>
<span className="badge bg-panel2 text-muted">{colTasks.length}</span> <span className={`badge ${accent.badge}`}>{colTasks.length}</span>
</div> </div>
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2"> <div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
{colTasks.map((t) => { {colTasks.map((t) => {
const mins = times[t.id] ?? 0 const mins = times[t.id] ?? 0
return ( return (
<motion.div <div
key={t.id} key={t.id}
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2 }}
draggable 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)} 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"> <div className="mb-2 flex items-start justify-between gap-2">
<p className="text-sm font-medium text-ink">{t.title}</p> <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> </div>
{t.tags?.length > 0 && ( {t.pole && (
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
{t.tags.map((tag) => ( <span className={`badge ${poleColor(t.pole)}`}>
<Tag key={tag} label={tag} /> {POLE_LABELS[t.pole]}
))} </span>
</div> </div>
)} )}
@@ -224,7 +262,7 @@ export default function Kanban(): JSX.Element {
</span> </span>
)} )}
</div> </div>
</motion.div> </div>
) )
})} })}
<button <button
+86 -17
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
import { Users, Pencil } from 'lucide-react' import { Users, Pencil, Camera, Loader2 } from 'lucide-react'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { Profile, MemberRole, ROLE_LABELS } from '../lib/types' import { useFeedback } from '../lib/feedback'
import { Avatar, Modal, Select, EmptyState } from '../components/ui' 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' import PageHeader from '../components/PageHeader'
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b'] const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
@@ -42,7 +44,17 @@ export default function Members(): JSX.Element {
{m.full_name} {m.full_name}
{m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>} {m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>}
</div> </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>
</div> </div>
))} ))}
@@ -73,35 +85,90 @@ function EditProfileModal({
onClose: () => void onClose: () => void
onSaved: () => Promise<void> onSaved: () => Promise<void>
}): JSX.Element { }): JSX.Element {
const { toast } = useFeedback()
const [name, setName] = useState(profile.full_name) 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 [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 [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> => { const save = async (): Promise<void> => {
setBusy(true) setBusy(true)
await supa() await supa()
.from('profiles') .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) .eq('id', profile.id)
await onSaved() await onSaved()
setBusy(false) setBusy(false)
onClose() onClose()
} }
const preview = { full_name: name || '?', avatar_color: color, avatar_url: avatarUrl } as Profile
return ( return (
<Modal title="Mon profil" onClose={onClose}> <Modal title="Mon profil" onClose={onClose}>
<label className="label">Nom complet</label> <label className="label">Nom complet</label>
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} /> <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)}> <label className="label">Photo de profil</label>
{Object.entries(ROLE_LABELS).map(([v, l]) => ( <div className="mb-3 flex items-center gap-3">
<option key={v} value={v}> <div className="relative">
{l} <Avatar profile={preview} size={56} />
</option> {uploading && (
))} <div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
</Select> <Loader2 className="animate-spin text-white" size={18} />
<label className="label">Couleur d'avatar</label> </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
className="text-xs text-muted hover:text-rose-600"
onClick={() => setAvatarUrl(null)}
>
Retirer
</button>
)}
</div>
<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"> <div className="mb-4 flex flex-wrap gap-2">
{COLORS.map((c) => ( {COLORS.map((c) => (
<button <button
@@ -112,7 +179,9 @@ function EditProfileModal({
/> />
))} ))}
</div> </div>
<button className="btn-primary w-full" onClick={save} disabled={busy}> </>
)}
<button className="btn-primary w-full" onClick={save} disabled={busy || uploading}>
Enregistrer Enregistrer
</button> </button>
</Modal> </Modal>
-296
View File
@@ -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>
)
}
+299
View File
@@ -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>
)
}
+329 -146
View File
@@ -1,235 +1,418 @@
import { useEffect, useState, useCallback, useMemo } from 'react' 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 { import {
format, format,
startOfMonth,
endOfMonth,
startOfWeek, startOfWeek,
endOfWeek, endOfWeek,
eachDayOfInterval,
addMonths,
addWeeks, addWeeks,
isSameMonth, eachDayOfInterval,
isSameDay,
isToday isToday
} from 'date-fns' } from 'date-fns'
import { fr } from 'date-fns/locale' import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers' import { useMembers } from '../lib/useMembers'
import { Task, TaskStatus } from '../lib/types' import { useFeedback } from '../lib/feedback'
import { Select } from '../components/ui' import { Task, TimeLog, formatMinutes } from '../lib/types'
import TaskModal from '../components/TaskModal' import { Avatar, Modal, Select } from '../components/ui'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
type ViewMode = 'month' | 'week' const DAY_KEY = (d: Date): string => format(d, 'yyyy-MM-dd')
const STATUS_DOT: Record<TaskStatus, string> = {
todo: 'bg-slate-400',
in_progress: 'bg-sky-400',
review: 'bg-amber-400',
done: 'bg-emerald-400'
}
export default function Planning(): JSX.Element { export default function Planning(): JSX.Element {
const { currentProject } = useApp() const { currentProject, profile } = useApp()
const { members } = useMembers() const { members, byId } = useMembers()
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [view, setView] = useState<ViewMode>('month') const [logs, setLogs] = useState<TimeLog[]>([])
const [cursor, setCursor] = useState<Date>(() => startOfMonth(new Date())) const [cursor, setCursor] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }))
const [editing, setEditing] = useState<Task | null>(null) const [fMember, setFMember] = useState('')
const [fAssignee, setFAssignee] = useState('') // Modale de saisie : soit une nouvelle card sur un jour, soit l'édition d'une.
const [fTag, setFTag] = useState('') const [modal, setModal] = useState<{ day: string; log: TimeLog | null } | null>(null)
const projectId = currentProject?.id 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 () => { const load = useCallback(async () => {
if (!projectId) return if (!projectId) return
const { data } = await supa().from('tasks').select('*').eq('project_id', projectId) const [{ data: ts }, { data: tl }] = await Promise.all([
setTasks((data ?? []) as Task[]) supa().from('tasks').select('*').eq('project_id', projectId),
}, [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(() => { useEffect(() => {
load() load()
}, [load]) }, [load])
const allTags = useMemo(() => { // Synchro temps réel des entrées de temps.
const set = new Set<string>() useEffect(() => {
tasks.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag))) if (!projectId) return
return Array.from(set).sort() const ch = supa()
}, [tasks]) .channel(`timesheet-${projectId}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'time_logs' }, () => load())
.subscribe()
return () => {
supa().removeChannel(ch)
}
}, [projectId, load])
const visible = useMemo( const taskById = (id: string): Task | undefined => tasks.find((t) => t.id === id)
() =>
tasks.filter( const visibleLogs = useMemo(
(t) => () => logs.filter((l) => !fMember || l.user_id === fMember),
t.due_date && [logs, fMember]
(!fAssignee || t.assignee_id === fAssignee) &&
(!fTag || (t.tags ?? []).includes(fTag))
),
[tasks, fAssignee, fTag]
) )
const tasksOn = (day: Date): Task[] => const logsOn = (day: Date): TimeLog[] =>
visible.filter((t) => t.due_date && isSameDay(new Date(t.due_date), day)) visibleLogs.filter((l) => l.logged_at === DAY_KEY(day))
// Jours affichés selon la vue const totalOn = (day: Date): number => logsOn(day).reduce((s, l) => s + l.minutes, 0)
const days = useMemo(() => { const weekTotal = visibleLogs.reduce((s, l) => s + l.minutes, 0)
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 move = (dir: number): void => const title = `Semaine du ${format(weekStart, 'd MMM', { locale: fr })} au ${format(
setCursor((c) => (view === 'month' ? addMonths(c, dir) : addWeeks(c, dir))) weekEnd,
'd MMM yyyy',
const title = { locale: fr }
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
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader title="Planning" subtitle={currentProject?.name}> <PageHeader title="Feuille de temps" subtitle={currentProject?.name}>
<div className="flex rounded-lg border border-border p-0.5"> <span className="hidden items-center gap-1.5 text-sm text-accent2 sm:flex">
{(['month', 'week'] as ViewMode[]).map((v) => ( <Clock size={15} /> {formatMinutes(weekTotal)} cette semaine
</span>
<button <button
key={v} className="btn-primary"
onClick={() => setView(v)} onClick={() => setModal({ day: DAY_KEY(new Date()), log: null })}
className={`rounded-md px-3 py-1 text-sm ${
view === v ? 'bg-accent text-ink' : 'text-muted hover:text-ink'
}`}
> >
{v === 'month' ? 'Mois' : 'Semaine'} <Plus size={16} /> Nouvelle entrée
</button> </button>
))}
</div>
</PageHeader> </PageHeader>
{/* Navigation + filtres */} {/* Navigation semaine + filtre */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-6 py-2.5"> <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} /> <ChevronLeft size={16} />
</button> </button>
<button <button
className="rounded-lg px-2 py-1 text-sm text-gray-600 hover:bg-panel2" className="rounded-lg px-2 py-1 text-sm text-muted hover:bg-panel2"
onClick={() => setCursor(view === 'month' ? startOfMonth(new Date()) : new Date())} onClick={() => setCursor(startOfWeek(new Date(), { weekStartsOn: 1 }))}
> >
Aujourd'hui Cette semaine
</button> </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} /> <ChevronRight size={16} />
</button> </button>
<span className="ml-1 text-sm font-medium capitalize text-ink">{title}</span> <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"> <span className="ml-auto flex items-center gap-1.5 text-xs text-subtle">
<Filter size={14} /> Filtrer <Filter size={14} /> Membre
</span> </span>
<Select <Select
className="!w-auto !py-1 text-xs" className="!w-auto !py-1 text-xs"
value={fAssignee} value={fMember}
onChange={(e) => setFAssignee(e.target.value)} onChange={(e) => setFMember(e.target.value)}
> >
<option value="">Tous les membres</option> <option value="">Toute l'équipe</option>
{members.map((m) => ( {members.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.full_name} {m.full_name}
</option> </option>
))} ))}
</Select> </Select>
<Select {fMember && (
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 && (
<button <button
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink" className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
onClick={() => { onClick={() => setFMember('')}
setFAssignee('')
setFTag('')
}}
> >
<X size={12} /> Réinitialiser <X size={12} /> Réinitialiser
</button> </button>
)} )}
</div> </div>
{/* Grille calendrier */} {/* Grille : 7 colonnes de jours */}
<div className="flex flex-1 flex-col overflow-hidden p-6"> <div className="flex flex-1 gap-2 overflow-x-auto 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}
>
{days.map((day) => { {days.map((day) => {
const dayTasks = tasksOn(day) const dayLogs = logsOn(day)
const muted = view === 'month' && !isSameMonth(day, cursor) const total = totalOn(day)
const today = isToday(day)
return ( return (
<div <div
key={day.toISOString()} key={DAY_KEY(day)}
className={`flex flex-col gap-1 rounded-lg border border-border/60 p-1.5 ${ className={`flex min-w-[170px] flex-1 flex-col rounded-xl border bg-panel2/40 ${
muted ? 'opacity-40' : '' today ? 'border-accent' : 'border-border'
} ${isToday(day) ? 'ring-1 ring-accent' : ''}`}
>
<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')} <div className="flex items-center justify-between px-3 py-2">
</span> <div>
</div> <div
<div className="flex flex-col gap-1 overflow-y-auto"> className={`text-xs font-semibold uppercase ${
{dayTasks.map((t) => ( today ? 'text-accent' : 'text-muted'
<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"
> >
<span className={`h-2 w-2 shrink-0 rounded-full ${STATUS_DOT[t.status]}`} /> {format(day, 'EEE', { locale: fr })}
<span className="truncate">{t.title}</span> </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> </button>
))}
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
</div>
{editing && ( {modal && (
<TaskModal <TimeCardModal
task={editing} day={modal.day}
defaultStatus={editing.status} log={modal.log}
tasks={tasks}
projectId={projectId!} projectId={projectId!}
members={members} userId={profile?.id ?? null}
onClose={() => setEditing(null)} onClose={() => setModal(null)}
onSaved={load} onSaved={load}
/> />
)} )}
</div> </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>
)
}
+25 -5
View File
@@ -68,11 +68,18 @@ Deno.serve(async (req) => {
const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr])) const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr]))
const users = list.users.map((u) => { const users = list.users.map((u) => {
const pr: any = byId.get(u.id) ?? {} const pr: any = byId.get(u.id) ?? {}
const roles =
Array.isArray(pr.roles) && pr.roles.length
? pr.roles
: pr.role
? [pr.role]
: []
return { return {
id: u.id, id: u.id,
email: u.email ?? '', email: u.email ?? '',
full_name: pr.full_name ?? '', full_name: pr.full_name ?? '',
role: pr.role ?? 'other', role: pr.role ?? 'other',
roles,
is_admin: pr.is_admin ?? false, is_admin: pr.is_admin ?? false,
created_at: pr.created_at ?? u.created_at created_at: pr.created_at ?? u.created_at
} }
@@ -81,17 +88,21 @@ Deno.serve(async (req) => {
} }
case 'create': { 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 (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
if (String(password).length < 6) if (String(password).length < 6)
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400) 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). // Crée le compte (email déjà confirmé : connexion immédiate possible).
const { data: created, error } = await admin.auth.admin.createUser({ const { data: created, error } = await admin.auth.admin.createUser({
email, email,
password, password,
email_confirm: true, 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) if (error) return json({ error: error.message }, 400)
@@ -100,7 +111,8 @@ Deno.serve(async (req) => {
.from('profiles') .from('profiles')
.update({ .update({
full_name: full_name || email.split('@')[0], full_name: full_name || email.split('@')[0],
role: role || 'other', role: primary,
roles: roleList.length ? roleList : [primary],
is_admin: !!is_admin is_admin: !!is_admin
}) })
.eq('id', created.user.id) .eq('id', created.user.id)
@@ -119,15 +131,23 @@ Deno.serve(async (req) => {
} }
case 'update': { 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) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id && is_admin === false) if (id === user.id && is_admin === false)
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400) return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
const patch: Record<string, unknown> = {} const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name if (full_name !== undefined) patch.full_name = full_name
if (role !== undefined) patch.role = role
if (is_admin !== undefined) patch.is_admin = !!is_admin 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) if (Object.keys(patch).length === 0) return json({ error: 'Rien à modifier.' }, 400)
const { error } = await admin.from('profiles').update(patch).eq('id', id) const { error } = await admin.from('profiles').update(patch).eq('id', id)
+70
View File
@@ -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 $$;
+25
View File
@@ -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);
+18
View File
@@ -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
View File
@@ -17,10 +17,6 @@ do $$ begin
create type priority_level as enum ('low','medium','high','urgent'); create type priority_level as enum ('low','medium','high','urgent');
exception when duplicate_object then null; end $$; 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 do $$ begin
create type bug_severity as enum ('low','medium','high','critical'); create type bug_severity as enum ('low','medium','high','critical');
exception when duplicate_object then null; end $$; 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 ( create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade, id uuid primary key references auth.users(id) on delete cascade,
full_name text not null default 'Membre', 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_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() 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() 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 --------------------------------------------------- -- ---------- 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 ( create table if not exists public.tasks (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade, 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, description text,
status task_status not null default 'todo', status task_status not null default 'todo',
priority priority_level not null default 'medium', priority priority_level not null default 'medium',
assignee_id uuid references public.profiles(id) on delete set null, pole member_role,
milestone_id uuid references public.milestones(id) on delete set null, assignee_ids uuid[] not null default '{}',
due_date date, due_date date,
position double precision not null default 0, position double precision not null default 0,
created_by uuid references public.profiles(id) on delete set null, 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', severity bug_severity not null default 'medium',
status bug_status not null default 'open', status bug_status not null default 'open',
priority priority_level not null default 'medium', priority priority_level not null default 'medium',
pole member_role,
assignee_id uuid references public.profiles(id) on delete set null, assignee_id uuid references public.profiles(id) on delete set null,
reporter_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(), 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_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_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 ---------------------------- -- ---------- Maj automatique de updated_at ----------------------------
create or replace function public.touch_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 ------------------ -- ---------- Création auto du profil à l'inscription ------------------
create or replace function public.handle_new_user() create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$ returns trigger language plpgsql security definer set search_path = public as $$
declare r member_role;
begin 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 ( values (
new.id, new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)), 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; on conflict (id) do nothing;
return new; 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. -- Équipe privée et de confiance : tout membre connecté a accès complet.
alter table public.profiles enable row level security; alter table public.profiles enable row level security;
alter table public.projects 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.tasks enable row level security;
alter table public.bugs enable row level security; alter table public.bugs enable row level security;
do $$ do $$
declare t text; declare t text;
begin 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('drop policy if exists %I_all on public.%I', t, t);
execute format( execute format(
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)', '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 do $$ begin
alter publication supabase_realtime add table public.bugs; alter publication supabase_realtime add table public.bugs;
exception when duplicate_object then null; end $$; 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 do $$ begin
alter publication supabase_realtime add table public.profiles; alter publication supabase_realtime add table public.profiles;
exception when duplicate_object then null; end $$; 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 alter table public.tasks
add column if not exists tags text[] not null default '{}'; 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 ( create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(), 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, user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0), minutes integer not null check (minutes > 0),
note text, 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_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); create index if not exists idx_time_logs_user on public.time_logs(user_id);
alter table public.time_logs enable row level security; alter table public.time_logs enable row level security;
@@ -245,3 +238,64 @@ create policy time_logs_all on public.time_logs
do $$ begin do $$ begin
alter publication supabase_realtime add table public.time_logs; alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$; 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');
+145
View File
@@ -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');