V1
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Generated
-10
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { Bell, Check, KanbanSquare, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import { AppNotification } from '../lib/types'
|
||||||
|
import type { Page } from './Layout'
|
||||||
|
|
||||||
|
export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element {
|
||||||
|
const { profile } = useApp()
|
||||||
|
const [items, setItems] = useState<AppNotification[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const uid = profile?.id
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!uid) return
|
||||||
|
const { data } = await supa()
|
||||||
|
.from('notifications')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', uid)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(30)
|
||||||
|
setItems((data ?? []) as AppNotification[])
|
||||||
|
}, [uid])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Synchro temps réel : nouvelles notifications qui me sont destinées.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uid) return
|
||||||
|
const ch = supa()
|
||||||
|
.channel(`notifications-${uid}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
|
||||||
|
() => load()
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
return () => {
|
||||||
|
supa().removeChannel(ch)
|
||||||
|
}
|
||||||
|
}, [uid, load])
|
||||||
|
|
||||||
|
// Fermeture au clic extérieur.
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent): void => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClick)
|
||||||
|
return () => document.removeEventListener('mousedown', onClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const unread = items.filter((n) => !n.read).length
|
||||||
|
|
||||||
|
const markAllRead = async (): Promise<void> => {
|
||||||
|
if (!uid || unread === 0) return
|
||||||
|
setItems((list) => list.map((n) => ({ ...n, read: true })))
|
||||||
|
await supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openItem = async (n: AppNotification): Promise<void> => {
|
||||||
|
setOpen(false)
|
||||||
|
if (!n.read) {
|
||||||
|
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
|
||||||
|
await supa().from('notifications').update({ read: true }).eq('id', n.id)
|
||||||
|
}
|
||||||
|
setPage(n.kind === 'bug' ? 'bugs' : 'kanban')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = async (): Promise<void> => {
|
||||||
|
if (!uid || items.length === 0) return
|
||||||
|
setItems([])
|
||||||
|
await supa().from('notifications').delete().eq('user_id', uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
title="Notifications"
|
||||||
|
className="relative text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<Bell size={18} />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg">
|
||||||
|
{unread > 9 ? '9+' : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 340, damping: 26 }}
|
||||||
|
className="absolute bottom-8 left-0 z-40 w-80 overflow-hidden rounded-xl border border-border bg-panel shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<span className="text-sm font-semibold text-ink">Notifications</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{unread > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={markAllRead}
|
||||||
|
title="Tout marquer comme lu"
|
||||||
|
className="rounded p-1 text-muted hover:bg-panel2 hover:text-ink"
|
||||||
|
>
|
||||||
|
<Check size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
title="Tout effacer"
|
||||||
|
className="rounded p-1 text-muted hover:bg-panel2 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-subtle">Aucune notification.</p>
|
||||||
|
) : (
|
||||||
|
items.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => openItem(n)}
|
||||||
|
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left last:border-0 hover:bg-panel2 ${
|
||||||
|
n.read ? '' : 'bg-accent/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${
|
||||||
|
n.kind === 'bug'
|
||||||
|
? 'bg-rose-100 text-rose-600'
|
||||||
|
: 'bg-accent/15 text-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.kind === 'bug' ? <BugIcon size={14} /> : <KanbanSquare size={14} />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-ink">{n.title}</span>
|
||||||
|
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||||
|
</span>
|
||||||
|
{n.body && <span className="block truncate text-xs text-muted">{n.body}</span>}
|
||||||
|
<span className="block text-[11px] text-subtle">
|
||||||
|
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, locale: fr })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,19 +5,21 @@ import { fr } from 'date-fns/locale'
|
|||||||
import { supa } from '../lib/supabase'
|
import { 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
@@ -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
@@ -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
@@ -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 })
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { supa } from './supabase'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une image de profil dans le bucket "avatars" et renvoie son URL
|
||||||
|
* publique. Le chemin est préfixé par l'id du membre.
|
||||||
|
*/
|
||||||
|
export async function uploadAvatar(file: File, userId: string): Promise<string> {
|
||||||
|
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
|
||||||
|
const path = `${userId}/${Date.now()}.${ext}`
|
||||||
|
const { error } = await supa()
|
||||||
|
.storage.from('avatars')
|
||||||
|
.upload(path, file, { upsert: true, cacheControl: '3600' })
|
||||||
|
if (error) throw error
|
||||||
|
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { supa } from './supabase'
|
||||||
|
import { Pole, Profile, NotificationKind, memberPoles } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une notification pour chaque membre du pôle destinataire
|
||||||
|
* (sauf l'auteur de l'action). Utilisé lorsqu'une tâche ou un bug
|
||||||
|
* est confié à un pôle.
|
||||||
|
*/
|
||||||
|
export async function notifyPole(opts: {
|
||||||
|
projectId: string
|
||||||
|
pole: Pole
|
||||||
|
kind: NotificationKind
|
||||||
|
title: string // ex : « Nouvelle tâche »
|
||||||
|
body: string // titre de la tâche / du bug
|
||||||
|
entityId: string
|
||||||
|
actorId: string | null
|
||||||
|
members: Profile[]
|
||||||
|
}): Promise<void> {
|
||||||
|
const recipients = opts.members.filter(
|
||||||
|
(m) => memberPoles(m).includes(opts.pole) && m.id !== opts.actorId
|
||||||
|
)
|
||||||
|
if (recipients.length === 0) return
|
||||||
|
|
||||||
|
const rows = recipients.map((m) => ({
|
||||||
|
user_id: m.id,
|
||||||
|
project_id: opts.projectId,
|
||||||
|
kind: opts.kind,
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
entity_id: opts.entityId,
|
||||||
|
pole: opts.pole
|
||||||
|
}))
|
||||||
|
|
||||||
|
await supa().from('notifications').insert(rows)
|
||||||
|
}
|
||||||
+75
-22
@@ -21,6 +21,50 @@ export const ROLE_LABELS: Record<MemberRole, string> = {
|
|||||||
other: 'Autre'
|
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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+94
-25
@@ -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,46 +85,103 @@ 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 className="mb-4 flex flex-wrap gap-2">
|
)}
|
||||||
{COLORS.map((c) => (
|
</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
|
<button
|
||||||
key={c}
|
className="text-xs text-muted hover:text-rose-600"
|
||||||
onClick={() => setColor(c)}
|
onClick={() => setAvatarUrl(null)}
|
||||||
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
|
>
|
||||||
style={{ background: c }}
|
Retirer
|
||||||
/>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary w-full" onClick={save} disabled={busy}>
|
|
||||||
|
<label className="label">Mes pôles (un ou plusieurs)</label>
|
||||||
|
<div className="mb-3">
|
||||||
|
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||||
|
</div>
|
||||||
|
{!avatarUrl && (
|
||||||
|
<>
|
||||||
|
<label className="label">Couleur d'avatar (sans photo)</label>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary w-full" onClick={save} disabled={busy || uploading}>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { Plus, Target, Trash2, CalendarClock, Clock } from 'lucide-react'
|
|
||||||
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { supa } from '../lib/supabase'
|
|
||||||
import { useApp } from '../lib/AppContext'
|
|
||||||
import { useFeedback } from '../lib/feedback'
|
|
||||||
import {
|
|
||||||
Milestone,
|
|
||||||
MilestoneStatus,
|
|
||||||
Task,
|
|
||||||
TimeLog,
|
|
||||||
MILESTONE_STATUS_LABELS,
|
|
||||||
formatMinutes
|
|
||||||
} from '../lib/types'
|
|
||||||
import { Modal, Select, EmptyState, Tag } from '../components/ui'
|
|
||||||
import PageHeader from '../components/PageHeader'
|
|
||||||
|
|
||||||
const STATUS_STYLES: Record<MilestoneStatus, string> = {
|
|
||||||
planned: 'bg-slate-100 text-slate-600',
|
|
||||||
in_progress: 'bg-sky-100 text-sky-700',
|
|
||||||
done: 'bg-emerald-100 text-emerald-700'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Milestones(): JSX.Element {
|
|
||||||
const { currentProject } = useApp()
|
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
|
||||||
const [times, setTimes] = useState<Record<string, number>>({})
|
|
||||||
const [editing, setEditing] = useState<Milestone | null>(null)
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const projectId = currentProject?.id
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!projectId) return
|
|
||||||
const [{ data: ms }, { data: ts }] = await Promise.all([
|
|
||||||
supa().from('milestones').select('*').eq('project_id', projectId),
|
|
||||||
supa().from('tasks').select('*').eq('project_id', projectId)
|
|
||||||
])
|
|
||||||
const sorted = ((ms ?? []) as Milestone[]).sort((a, b) =>
|
|
||||||
(a.due_date ?? '9999').localeCompare(b.due_date ?? '9999')
|
|
||||||
)
|
|
||||||
setMilestones(sorted)
|
|
||||||
const taskList = (ts ?? []) as Task[]
|
|
||||||
setTasks(taskList)
|
|
||||||
// Temps par tâche → agrégé par jalon plus bas
|
|
||||||
if (taskList.length) {
|
|
||||||
const { data: tl } = await supa()
|
|
||||||
.from('time_logs')
|
|
||||||
.select('task_id, minutes')
|
|
||||||
.in(
|
|
||||||
'task_id',
|
|
||||||
taskList.map((t) => t.id)
|
|
||||||
)
|
|
||||||
const totals: Record<string, number> = {}
|
|
||||||
;((tl ?? []) as Pick<TimeLog, 'task_id' | 'minutes'>[]).forEach((l) => {
|
|
||||||
totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes
|
|
||||||
})
|
|
||||||
setTimes(totals)
|
|
||||||
} else {
|
|
||||||
setTimes({})
|
|
||||||
}
|
|
||||||
}, [projectId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const progressOf = (m: Milestone): { done: number; total: number; pct: number } => {
|
|
||||||
const linked = tasks.filter((t) => t.milestone_id === m.id)
|
|
||||||
const done = linked.filter((t) => t.status === 'done').length
|
|
||||||
const pct = linked.length ? Math.round((done / linked.length) * 100) : 0
|
|
||||||
return { done, total: linked.length, pct }
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeOf = (m: Milestone): number =>
|
|
||||||
tasks
|
|
||||||
.filter((t) => t.milestone_id === m.id)
|
|
||||||
.reduce((s, t) => s + (times[t.id] ?? 0), 0)
|
|
||||||
|
|
||||||
const tagsOf = (m: Milestone): string[] => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
tasks
|
|
||||||
.filter((t) => t.milestone_id === m.id)
|
|
||||||
.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag)))
|
|
||||||
return Array.from(set).sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<PageHeader title="Jalons & planning" subtitle={currentProject?.name}>
|
|
||||||
<button className="btn-primary" onClick={() => setCreating(true)}>
|
|
||||||
<Plus size={16} /> Nouveau jalon
|
|
||||||
</button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{milestones.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Target size={40} />}
|
|
||||||
text="Aucun jalon. Crée des jalons pour structurer ton planning (ex : Prototype, Alpha, Beta, Release)."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-3">
|
|
||||||
{milestones.map((m) => {
|
|
||||||
const { done, total, pct } = progressOf(m)
|
|
||||||
const mins = timeOf(m)
|
|
||||||
const mTags = tagsOf(m)
|
|
||||||
const overdue =
|
|
||||||
m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59'))
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
className="card cursor-pointer hover:border-accent"
|
|
||||||
onClick={() => setEditing(m)}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-ink">{m.title}</h3>
|
|
||||||
{m.description && (
|
|
||||||
<p className="mt-0.5 text-sm text-muted">{m.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`badge ${STATUS_STYLES[m.status]}`}>
|
|
||||||
{MILESTONE_STATUS_LABELS[m.status]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mTags.length > 0 && (
|
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
|
||||||
{mTags.map((tag) => (
|
|
||||||
<Tag key={tag} label={tag} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-2 h-2 overflow-hidden rounded-full bg-panel2">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-accent2 transition-all"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted">
|
|
||||||
<span className="flex items-center gap-3">
|
|
||||||
<span>
|
|
||||||
{done}/{total} tâche(s) — {pct}%
|
|
||||||
</span>
|
|
||||||
{mins > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-accent2">
|
|
||||||
<Clock size={12} /> {formatMinutes(mins)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{m.due_date && (
|
|
||||||
<span
|
|
||||||
className={`flex items-center gap-1 ${overdue ? 'text-rose-600' : ''}`}
|
|
||||||
>
|
|
||||||
<CalendarClock size={12} />
|
|
||||||
{format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })}
|
|
||||||
{m.status !== 'done' && ` (${daysLabel(m.due_date)})`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(creating || editing) && (
|
|
||||||
<MilestoneModal
|
|
||||||
milestone={editing}
|
|
||||||
projectId={projectId!}
|
|
||||||
onClose={() => {
|
|
||||||
setCreating(false)
|
|
||||||
setEditing(null)
|
|
||||||
}}
|
|
||||||
onSaved={load}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysLabel(due: string): string {
|
|
||||||
const d = differenceInCalendarDays(new Date(due), new Date())
|
|
||||||
if (d < 0) return `en retard de ${-d} j`
|
|
||||||
if (d === 0) return "aujourd'hui"
|
|
||||||
return `dans ${d} j`
|
|
||||||
}
|
|
||||||
|
|
||||||
function MilestoneModal({
|
|
||||||
milestone,
|
|
||||||
projectId,
|
|
||||||
onClose,
|
|
||||||
onSaved
|
|
||||||
}: {
|
|
||||||
milestone: Milestone | null
|
|
||||||
projectId: string
|
|
||||||
onClose: () => void
|
|
||||||
onSaved: () => Promise<void>
|
|
||||||
}): JSX.Element {
|
|
||||||
const [title, setTitle] = useState(milestone?.title ?? '')
|
|
||||||
const [description, setDescription] = useState(milestone?.description ?? '')
|
|
||||||
const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned')
|
|
||||||
const [dueDate, setDueDate] = useState(milestone?.due_date ?? '')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const { toast, confirm } = useFeedback()
|
|
||||||
|
|
||||||
const save = async (): Promise<void> => {
|
|
||||||
if (!title.trim()) return
|
|
||||||
setBusy(true)
|
|
||||||
const payload = {
|
|
||||||
project_id: projectId,
|
|
||||||
title: title.trim(),
|
|
||||||
description: description.trim() || null,
|
|
||||||
status,
|
|
||||||
due_date: dueDate || null
|
|
||||||
}
|
|
||||||
if (milestone) await supa().from('milestones').update(payload).eq('id', milestone.id)
|
|
||||||
else await supa().from('milestones').insert(payload)
|
|
||||||
await onSaved()
|
|
||||||
setBusy(false)
|
|
||||||
toast(milestone ? 'Jalon enregistré.' : 'Jalon créé.', 'success')
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = async (): Promise<void> => {
|
|
||||||
if (!milestone) return
|
|
||||||
const ok = await confirm({
|
|
||||||
title: 'Supprimer ce jalon ?',
|
|
||||||
message: `« ${milestone.title} » sera supprimé (les tâches liées seront déliées).`,
|
|
||||||
danger: true,
|
|
||||||
confirmLabel: 'Supprimer'
|
|
||||||
})
|
|
||||||
if (!ok) return
|
|
||||||
await supa().from('milestones').delete().eq('id', milestone.id)
|
|
||||||
await onSaved()
|
|
||||||
toast('Jalon supprimé.', 'success')
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title={milestone ? 'Modifier le jalon' : 'Nouveau jalon'} onClose={onClose}>
|
|
||||||
<label className="label">Titre</label>
|
|
||||||
<input
|
|
||||||
className="input mb-3"
|
|
||||||
autoFocus
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Alpha jouable"
|
|
||||||
/>
|
|
||||||
<label className="label">Description</label>
|
|
||||||
<textarea
|
|
||||||
className="input mb-3 h-20 resize-none"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="label">Statut</label>
|
|
||||||
<Select value={status} onChange={(e) => setStatus(e.target.value as MilestoneStatus)}>
|
|
||||||
{Object.entries(MILESTONE_STATUS_LABELS).map(([v, l]) => (
|
|
||||||
<option key={v} value={v}>
|
|
||||||
{l}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Date cible</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="input"
|
|
||||||
value={dueDate}
|
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
|
||||||
{milestone ? (
|
|
||||||
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
|
|
||||||
<Trash2 size={16} /> Supprimer
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
|
|
||||||
{milestone ? 'Enregistrer' : 'Créer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { Gamepad2, ArrowRight, ArrowLeft, Check, Loader2, Sparkles, Camera } from 'lucide-react'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import { useFeedback } from '../lib/feedback'
|
||||||
|
import { uploadAvatar } from '../lib/avatar'
|
||||||
|
import {
|
||||||
|
MemberRole,
|
||||||
|
Profile,
|
||||||
|
POLE_LABELS,
|
||||||
|
poleColor,
|
||||||
|
SELF_ASSIGNABLE_POLES,
|
||||||
|
CRITICAL_POLES,
|
||||||
|
memberPoles
|
||||||
|
} from '../lib/types'
|
||||||
|
import { Avatar } from '../components/ui'
|
||||||
|
|
||||||
|
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||||
|
|
||||||
|
export default function Onboarding(): JSX.Element {
|
||||||
|
const { profile, refreshProfile } = useApp()
|
||||||
|
const { toast } = useFeedback()
|
||||||
|
|
||||||
|
// Rôles critiques déjà attribués par l'admin (ex : chef de projet) : on les
|
||||||
|
// conserve, mais le membre ne peut pas se les donner lui-même.
|
||||||
|
const keptCritical = memberPoles(profile ?? {}).filter((p) => CRITICAL_POLES.includes(p))
|
||||||
|
|
||||||
|
// Découpe le nom existant (« Prénom Nom ») s'il y en a un.
|
||||||
|
const existingName = profile && profile.full_name !== 'Membre' ? profile.full_name : ''
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [firstName, setFirstName] = useState(existingName.split(' ')[0] ?? '')
|
||||||
|
const [lastName, setLastName] = useState(existingName.split(' ').slice(1).join(' '))
|
||||||
|
const [color, setColor] = useState(profile?.avatar_color ?? COLORS[0])
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [poles, setPoles] = useState<MemberRole[]>(
|
||||||
|
memberPoles(profile ?? {}).filter((p) => SELF_ASSIGNABLE_POLES.includes(p))
|
||||||
|
)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const togglePole = (p: MemberRole): void =>
|
||||||
|
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
||||||
|
|
||||||
|
const fullName = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||||
|
const previewProfile = {
|
||||||
|
full_name: fullName || '?',
|
||||||
|
avatar_color: color,
|
||||||
|
avatar_url: avatarUrl
|
||||||
|
} as unknown as Profile
|
||||||
|
|
||||||
|
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file || !profile) return
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast('Choisis un fichier image.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const url = await uploadAvatar(file, profile.id)
|
||||||
|
setAvatarUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, 'error')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = ['Ton nom', 'Ta photo', 'Tes pôles']
|
||||||
|
const canNext = step === 0 ? firstName.trim().length > 0 : true
|
||||||
|
|
||||||
|
const finish = async (): Promise<void> => {
|
||||||
|
if (!profile) return
|
||||||
|
setBusy(true)
|
||||||
|
const roles = Array.from(new Set([...poles, ...keptCritical]))
|
||||||
|
const finalRoles = roles.length ? roles : (['other'] as MemberRole[])
|
||||||
|
const { error } = await supa()
|
||||||
|
.from('profiles')
|
||||||
|
.update({
|
||||||
|
full_name: fullName || 'Membre',
|
||||||
|
avatar_color: color,
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
roles: finalRoles,
|
||||||
|
role: finalRoles[0],
|
||||||
|
onboarded: true
|
||||||
|
})
|
||||||
|
.eq('id', profile.id)
|
||||||
|
if (error) {
|
||||||
|
setBusy(false)
|
||||||
|
toast(error.message, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refreshProfile()
|
||||||
|
// Le passage onboarded=true bascule automatiquement vers l'app.
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = (): void => {
|
||||||
|
if (step < steps.length - 1) setStep((s) => s + 1)
|
||||||
|
else finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||||
|
className="card w-full max-w-lg"
|
||||||
|
>
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="mb-5 flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-accent/20 p-2.5 text-accent">
|
||||||
|
<Gamepad2 size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-ink">Bienvenue dans l'équipe 👋</h1>
|
||||||
|
<p className="text-sm text-muted">Configurons ton profil en 3 étapes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
<div className="mb-6 flex gap-1.5">
|
||||||
|
{steps.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||||
|
i <= step ? 'bg-accent' : 'bg-panel2'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={step}
|
||||||
|
initial={{ opacity: 0, x: 24 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -24 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{step === 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Prénom</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Alex"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Nom</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Martin"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-subtle">
|
||||||
|
C'est le nom que tes coéquipiers verront sur les tâches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div>
|
||||||
|
<label className="label">Ta photo de profil</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar profile={previewProfile} size={72} />
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||||
|
<Loader2 className="animate-spin text-white" size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={pickPhoto}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-ghost"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Camera size={16} /> {avatarUrl ? 'Changer la photo' : 'Ajouter une photo'}
|
||||||
|
</button>
|
||||||
|
{avatarUrl && (
|
||||||
|
<button
|
||||||
|
className="ml-2 text-xs text-muted hover:text-rose-600"
|
||||||
|
onClick={() => setAvatarUrl(null)}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repli : couleur des initiales si aucune photo */}
|
||||||
|
{!avatarUrl && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="label">Ou choisis une couleur</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
|
||||||
|
color === c ? 'ring-2 ring-ink ring-offset-2 ring-offset-panel' : ''
|
||||||
|
}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-xs text-subtle">
|
||||||
|
Optionnel — tu pourras la changer plus tard depuis « Équipe ».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div>
|
||||||
|
<label className="label">Dans quel(s) pôle(s) travailles-tu ?</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SELF_ASSIGNABLE_POLES.map((p) => {
|
||||||
|
const on = poles.includes(p)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePole(p)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
on
|
||||||
|
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
||||||
|
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <Check size={13} />}
|
||||||
|
{POLE_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{keptCritical.length > 0 && (
|
||||||
|
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent2">
|
||||||
|
<Sparkles size={13} />
|
||||||
|
Ton rôle {keptCritical.map((p) => POLE_LABELS[p]).join(', ')} t'a été attribué par
|
||||||
|
l'administrateur.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-subtle">
|
||||||
|
Tu recevras une notification quand une tâche est confiée à un de tes pôles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
{step > 0 ? (
|
||||||
|
<button className="btn-ghost" onClick={() => setStep((s) => s - 1)} disabled={busy}>
|
||||||
|
<ArrowLeft size={16} /> Retour
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button className="btn-primary" onClick={next} disabled={!canNext || busy}>
|
||||||
|
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<>
|
||||||
|
Continuer <ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check size={16} /> Terminer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+334
-151
@@ -1,235 +1,418 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { 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
|
||||||
<button
|
</span>
|
||||||
key={v}
|
<button
|
||||||
onClick={() => setView(v)}
|
className="btn-primary"
|
||||||
className={`rounded-md px-3 py-1 text-sm ${
|
onClick={() => setModal({ day: DAY_KEY(new Date()), log: null })}
|
||||||
view === v ? 'bg-accent text-ink' : 'text-muted hover:text-ink'
|
>
|
||||||
}`}
|
<Plus size={16} /> Nouvelle entrée
|
||||||
>
|
</button>
|
||||||
{v === 'month' ? 'Mois' : 'Semaine'}
|
|
||||||
</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">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
<span
|
<div>
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
|
<div
|
||||||
isToday(day) ? 'bg-accent font-semibold text-ink' : 'text-muted'
|
className={`text-xs font-semibold uppercase ${
|
||||||
}`}
|
today ? 'text-accent' : 'text-muted'
|
||||||
>
|
}`}
|
||||||
{format(day, 'd')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 overflow-y-auto">
|
|
||||||
{dayTasks.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => setEditing(t)}
|
|
||||||
className="flex items-center gap-1.5 truncate rounded-md bg-panel2 px-1.5 py-1 text-left text-xs text-ink hover:bg-border"
|
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</button>
|
<div className="text-sm font-medium text-ink">{format(day, 'd MMM', { locale: fr })}</div>
|
||||||
))}
|
</div>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="badge bg-accent2/15 text-accent2">{formatMinutes(total)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
|
||||||
|
{dayLogs.map((l) => {
|
||||||
|
const t = l.task_id ? taskById(l.task_id) : undefined
|
||||||
|
const isActivity = !l.task_id
|
||||||
|
const heading = isActivity
|
||||||
|
? l.label || 'Activité'
|
||||||
|
: t?.title ?? 'Tâche supprimée'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
onClick={() => setModal({ day: DAY_KEY(day), log: l })}
|
||||||
|
className={`w-full rounded-lg border-l-2 border-y border-r border-border bg-panel p-2 text-left shadow-sm transition-colors hover:border-accent ${
|
||||||
|
isActivity ? 'border-l-amber-400' : 'border-l-accent2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
isActivity ? 'text-amber-500' : 'text-accent2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatMinutes(l.minutes)}
|
||||||
|
</span>
|
||||||
|
<Avatar profile={byId(l.user_id)} size={20} />
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-ink">{heading}</p>
|
||||||
|
{l.note && <p className="mt-0.5 truncate text-xs text-subtle">{l.note}</p>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-border py-1.5 text-xs text-subtle hover:border-accent hover:text-ink"
|
||||||
|
onClick={() => setModal({ day: DAY_KEY(day), log: null })}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> Ajouter
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GameDev Tracker — Migration v2
|
||||||
|
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||||
|
-- (pour une base déjà créée avec schema.sql v1)
|
||||||
|
--
|
||||||
|
-- Contenu :
|
||||||
|
-- • Suppression des JALONS (milestones)
|
||||||
|
-- • Notion de PÔLE sur les tâches et les bugs
|
||||||
|
-- • Plusieurs personnes assignables sur une tâche (assignee_ids)
|
||||||
|
-- • Système de NOTIFICATIONS (assignation à un pôle)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------- Pôle sur tâches & bugs (réutilise l'enum member_role) -----
|
||||||
|
alter table public.tasks add column if not exists pole member_role;
|
||||||
|
alter table public.bugs add column if not exists pole member_role;
|
||||||
|
|
||||||
|
-- ---------- Plusieurs assignés par tâche -----------------------------
|
||||||
|
alter table public.tasks
|
||||||
|
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||||
|
|
||||||
|
-- Reprend l'assigné unique existant dans le nouveau tableau.
|
||||||
|
update public.tasks
|
||||||
|
set assignee_ids = array[assignee_id]
|
||||||
|
where assignee_id is not null
|
||||||
|
and (assignee_ids is null or assignee_ids = '{}');
|
||||||
|
|
||||||
|
alter table public.tasks drop column if exists assignee_id;
|
||||||
|
|
||||||
|
-- ---------- Suppression des jalons -----------------------------------
|
||||||
|
alter table public.tasks drop column if exists milestone_id;
|
||||||
|
drop table if exists public.milestones cascade;
|
||||||
|
drop type if exists milestone_status;
|
||||||
|
|
||||||
|
-- ---------- Notifications --------------------------------------------
|
||||||
|
create table if not exists public.notifications (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
project_id uuid references public.projects(id) on delete cascade,
|
||||||
|
kind text not null, -- 'task' | 'bug'
|
||||||
|
title text not null, -- ex : « Nouvelle tâche »
|
||||||
|
body text, -- titre de la tâche / du bug
|
||||||
|
entity_id uuid, -- id de la tâche / du bug
|
||||||
|
pole member_role, -- pôle destinataire
|
||||||
|
read boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||||
|
|
||||||
|
alter table public.notifications enable row level security;
|
||||||
|
|
||||||
|
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
|
||||||
|
-- membre connecté (afin de notifier les autres membres d'un pôle).
|
||||||
|
drop policy if exists notifications_select on public.notifications;
|
||||||
|
drop policy if exists notifications_insert on public.notifications;
|
||||||
|
drop policy if exists notifications_update on public.notifications;
|
||||||
|
drop policy if exists notifications_delete on public.notifications;
|
||||||
|
|
||||||
|
create policy notifications_select on public.notifications
|
||||||
|
for select to authenticated using (user_id = auth.uid());
|
||||||
|
create policy notifications_insert on public.notifications
|
||||||
|
for insert to authenticated with check (true);
|
||||||
|
create policy notifications_update on public.notifications
|
||||||
|
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||||
|
create policy notifications_delete on public.notifications
|
||||||
|
for delete to authenticated using (user_id = auth.uid());
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.notifications;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GameDev Tracker — Migration v3
|
||||||
|
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||||
|
-- (après migration_v2.sql)
|
||||||
|
--
|
||||||
|
-- Contenu :
|
||||||
|
-- • Temps « libre » dans la feuille de temps (réunion, etc.) :
|
||||||
|
-- une entrée peut ne pas être liée à une tâche du Kanban.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- La tâche devient optionnelle ; un libellé libre la remplace si absente.
|
||||||
|
alter table public.time_logs alter column task_id drop not null;
|
||||||
|
alter table public.time_logs add column if not exists label text;
|
||||||
|
|
||||||
|
-- Rattachement direct au projet (pour retrouver aussi les entrées sans tâche).
|
||||||
|
alter table public.time_logs
|
||||||
|
add column if not exists project_id uuid references public.projects(id) on delete cascade;
|
||||||
|
|
||||||
|
-- Backfill : les entrées existantes héritent du projet de leur tâche.
|
||||||
|
update public.time_logs tl
|
||||||
|
set project_id = t.project_id
|
||||||
|
from public.tasks t
|
||||||
|
where tl.task_id = t.id and tl.project_id is null;
|
||||||
|
|
||||||
|
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GameDev Tracker — Migration v4
|
||||||
|
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||||
|
-- (après migration_v3.sql)
|
||||||
|
--
|
||||||
|
-- Contenu :
|
||||||
|
-- • Un membre peut appartenir à PLUSIEURS pôles (rôles).
|
||||||
|
-- profiles.role reste le rôle « principal » ; profiles.roles
|
||||||
|
-- contient l'ensemble des pôles.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists roles member_role[] not null default '{}';
|
||||||
|
|
||||||
|
-- Backfill : les profils existants héritent de leur rôle unique actuel.
|
||||||
|
update public.profiles
|
||||||
|
set roles = array[role]
|
||||||
|
where roles = '{}';
|
||||||
+82
-28
@@ -17,10 +17,6 @@ do $$ begin
|
|||||||
create type priority_level as enum ('low','medium','high','urgent');
|
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');
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
|
||||||
|
-- À coller dans : Supabase > SQL Editor > New query > Run
|
||||||
|
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table public.tasks add column if not exists pole member_role;
|
||||||
|
alter table public.bugs add column if not exists pole member_role;
|
||||||
|
|
||||||
|
alter table public.tasks
|
||||||
|
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||||
|
|
||||||
|
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
|
||||||
|
do $$ begin
|
||||||
|
if exists (select 1 from information_schema.columns
|
||||||
|
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
|
||||||
|
update public.tasks
|
||||||
|
set assignee_ids = array[assignee_id]
|
||||||
|
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
|
||||||
|
alter table public.tasks drop column assignee_id;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
alter table public.tasks drop column if exists milestone_id;
|
||||||
|
drop table if exists public.milestones cascade;
|
||||||
|
drop type if exists milestone_status;
|
||||||
|
|
||||||
|
create table if not exists public.notifications (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
project_id uuid references public.projects(id) on delete cascade,
|
||||||
|
kind text not null,
|
||||||
|
title text not null,
|
||||||
|
body text,
|
||||||
|
entity_id uuid,
|
||||||
|
pole member_role,
|
||||||
|
read boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||||
|
alter table public.notifications enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists notifications_select on public.notifications;
|
||||||
|
drop policy if exists notifications_insert on public.notifications;
|
||||||
|
drop policy if exists notifications_update on public.notifications;
|
||||||
|
drop policy if exists notifications_delete on public.notifications;
|
||||||
|
create policy notifications_select on public.notifications
|
||||||
|
for select to authenticated using (user_id = auth.uid());
|
||||||
|
create policy notifications_insert on public.notifications
|
||||||
|
for insert to authenticated with check (true);
|
||||||
|
create policy notifications_update on public.notifications
|
||||||
|
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||||
|
create policy notifications_delete on public.notifications
|
||||||
|
for delete to authenticated using (user_id = auth.uid());
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.notifications;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
|
||||||
|
create table if not exists public.time_logs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
project_id uuid references public.projects(id) on delete cascade,
|
||||||
|
task_id uuid references public.tasks(id) on delete cascade,
|
||||||
|
label text,
|
||||||
|
user_id uuid references public.profiles(id) on delete set null,
|
||||||
|
minutes integer not null check (minutes > 0),
|
||||||
|
note text,
|
||||||
|
logged_at date not null default current_date,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
|
||||||
|
alter table public.time_logs alter column task_id drop not null;
|
||||||
|
alter table public.time_logs add column if not exists label text;
|
||||||
|
alter table public.time_logs
|
||||||
|
add column if not exists project_id uuid references public.projects(id) on delete cascade;
|
||||||
|
|
||||||
|
update public.time_logs tl
|
||||||
|
set project_id = t.project_id
|
||||||
|
from public.tasks t
|
||||||
|
where tl.task_id = t.id and tl.project_id is null;
|
||||||
|
|
||||||
|
create index if not exists idx_time_logs_task on public.time_logs(task_id);
|
||||||
|
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||||
|
create index if not exists idx_time_logs_user on public.time_logs(user_id);
|
||||||
|
|
||||||
|
alter table public.time_logs enable row level security;
|
||||||
|
drop policy if exists time_logs_all on public.time_logs;
|
||||||
|
create policy time_logs_all on public.time_logs
|
||||||
|
for all to authenticated using (true) with check (true);
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.time_logs;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- v4 — Plusieurs pôles (rôles) par membre
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists roles member_role[] not null default '{}';
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set roles = array[role]
|
||||||
|
where roles = '{}';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- v5 — Accueil (onboarding) des nouveaux membres
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists onboarded boolean not null default false;
|
||||||
|
|
||||||
|
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
|
||||||
|
update public.profiles set onboarded = true where onboarded = false;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table public.profiles add column if not exists avatar_url text;
|
||||||
|
|
||||||
|
-- Bucket public "avatars" pour héberger les photos de profil.
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
values ('avatars', 'avatars', true)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
-- Lecture publique ; écriture réservée aux membres connectés.
|
||||||
|
drop policy if exists avatars_read on storage.objects;
|
||||||
|
drop policy if exists avatars_insert on storage.objects;
|
||||||
|
drop policy if exists avatars_update on storage.objects;
|
||||||
|
drop policy if exists avatars_delete on storage.objects;
|
||||||
|
|
||||||
|
create policy avatars_read on storage.objects
|
||||||
|
for select using (bucket_id = 'avatars');
|
||||||
|
create policy avatars_insert on storage.objects
|
||||||
|
for insert to authenticated with check (bucket_id = 'avatars');
|
||||||
|
create policy avatars_update on storage.objects
|
||||||
|
for update to authenticated using (bucket_id = 'avatars');
|
||||||
|
create policy avatars_delete on storage.objects
|
||||||
|
for delete to authenticated using (bucket_id = 'avatars');
|
||||||
Reference in New Issue
Block a user