From 8a0fa70c1061a20229bd0a9e4f203fd34644b801 Mon Sep 17 00:00:00 2001 From: Mathew Date: Wed, 1 Jul 2026 12:39:59 +0200 Subject: [PATCH] V1 --- README.md | 19 +- package-lock.json | 10 - src/App.tsx | 16 +- src/components/Layout.tsx | 17 +- src/components/NotificationBell.tsx | 170 +++++++++ src/components/TaskModal.tsx | 135 ++++--- src/components/ui.tsx | 59 ++- src/index.css | 59 ++- src/lib/admin.ts | 5 +- src/lib/avatar.ts | 15 + src/lib/notify.ts | 35 ++ src/lib/types.ts | 97 +++-- src/pages/Admin.tsx | 49 ++- src/pages/Bugs.tsx | 54 ++- src/pages/Dashboard.tsx | 73 ++-- src/pages/Kanban.tsx | 118 ++++-- src/pages/Members.tsx | 119 ++++-- src/pages/Milestones.tsx | 296 --------------- src/pages/Onboarding.tsx | 299 +++++++++++++++ src/pages/Planning.tsx | 485 ++++++++++++++++-------- supabase/functions/admin-users/index.ts | 30 +- supabase/migration_v2.sql | 70 ++++ supabase/migration_v3.sql | 25 ++ supabase/migration_v4.sql | 18 + supabase/schema.sql | 110 ++++-- supabase/update_all.sql | 145 +++++++ 26 files changed, 1804 insertions(+), 724 deletions(-) create mode 100644 src/components/NotificationBell.tsx create mode 100644 src/lib/avatar.ts create mode 100644 src/lib/notify.ts delete mode 100644 src/pages/Milestones.tsx create mode 100644 src/pages/Onboarding.tsx create mode 100644 supabase/migration_v2.sql create mode 100644 supabase/migration_v3.sql create mode 100644 supabase/migration_v4.sql create mode 100644 supabase/update_all.sql diff --git a/README.md b/README.md index 1d4ad94..e38a8db 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base ## ✨ Fonctionnalités - 🔐 **Connexion / inscription** par email + mot de passe -- 👥 **Équipe & rôles** (programmeur, artiste, game designer, sound, scénariste, QA…) -- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé* -- 🎯 **Jalons & planning** — échéances, progression par jalon (Prototype, Alpha, Beta, Release…) -- 🐛 **Suivi de bugs** — sévérité, priorité, statut, assignation -- 📊 **Tableau de bord** — avancement global, répartition des tâches, prochaines échéances +- 👥 **Équipe, rôles & pôles** — le rôle d'un membre (programmeur, artiste, game designer…) définit son **pôle** +- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*, tâche confiée à un **pôle** avec une ou **plusieurs personnes** assignées +- ⏱️ **Feuille de temps** — vue par semaine (7 jours en colonnes) : consigne les heures faites chaque jour sur une tâche **ou** une activité libre (réunion, veille…) +- 🐛 **Suivi de bugs** — sévérité, priorité, statut, pôle, assignation +- 🔔 **Notifications** — quand une tâche/un bug est confié à un pôle, ses membres reçoivent une notif (cloche 🔔 dans la barre latérale) +- 📊 **Tableau de bord** — avancement global, répartition des tâches, temps total, échéances - 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour --- @@ -24,7 +25,11 @@ Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base 3. Attends ~2 min que le projet se crée. 4. Dans le menu de gauche : **SQL Editor → New query**. 5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**. - > Cela crée les tables (profils, projets, tâches, jalons, bugs), la sécurité et la synchro temps réel. + > Cela crée les tables (profils, projets, tâches, bugs, suivi du temps, notifications), la sécurité et la synchro temps réel. + > + > 🆙 **Tu avais déjà installé une version précédente (avec les jalons) ?** Exécute plutôt, dans l'ordre, [`supabase/migration_v2.sql`](supabase/migration_v2.sql) (supprime les jalons, ajoute pôles + assignation multiple + notifications), [`supabase/migration_v3.sql`](supabase/migration_v3.sql) (feuille de temps : entrées libres type réunion) puis [`supabase/migration_v4.sql`](supabase/migration_v4.sql) (plusieurs pôles par membre) — **sans perdre tes données**. + > + > 🔁 Après la migration v4, **redéploie l'Edge Function** pour que l'admin puisse gérer les pôles multiples : `supabase functions deploy admin-users --no-verify-jwt`. 6. Menu **Settings (⚙️) → API**. Note deux valeurs : - **Project URL** → ressemble à `https://xxxxx.supabase.co` - **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...` @@ -73,7 +78,7 @@ Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la m ```text ├─ index.html # point d'entrée du front ├─ src/ # application React (front-end) -│ ├─ pages/ # Dashboard, Kanban, Milestones, Bugs, Members, Login, Setup +│ ├─ pages/ # Dashboard, Kanban, Planning (feuille de temps), Bugs, Members, Admin, Login, Setup │ ├─ components/ # Layout, UI, animations │ └─ lib/ # client Supabase, types, contexte global, config ├─ src-tauri/ # enveloppe desktop Tauri (Rust) diff --git a/package-lock.json b/package-lock.json index cfec607..f2ecc52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1531,7 +1530,6 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1549,7 +1547,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1710,7 +1707,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -2161,7 +2157,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2433,7 +2428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2603,7 +2597,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2616,7 +2609,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2937,7 +2929,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3036,7 +3027,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/App.tsx b/src/App.tsx index 4533c12..f25c1cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,11 +5,11 @@ import { AppProvider, useApp } from './lib/AppContext' import { PageFade } from './components/motion' import Setup from './pages/Setup' import Login from './pages/Login' +import Onboarding from './pages/Onboarding' import Layout, { Page } from './components/Layout' import Dashboard from './pages/Dashboard' import Kanban from './pages/Kanban' import Planning from './pages/Planning' -import Milestones from './pages/Milestones' import Bugs from './pages/Bugs' import Members from './pages/Members' import Admin from './pages/Admin' @@ -18,14 +18,13 @@ const PAGES: Record JSX.Element> = { dashboard: Dashboard, kanban: Kanban, planning: Planning, - milestones: Milestones, bugs: Bugs, members: Members, admin: Admin } function Shell(): JSX.Element { - const { stage } = useApp() + const { stage, profile } = useApp() const [page, setPage] = useState('dashboard') if (stage === 'loading') { @@ -38,6 +37,17 @@ function Shell(): JSX.Element { if (stage === 'setup') return if (stage === 'login') return + // Session ouverte mais profil pas encore chargé. + if (!profile) { + return ( +
+ +
+ ) + } + // Première connexion : accueil (nom, avatar, pôles) avant d'entrer dans l'app. + if (!profile.onboarded) return + const ActivePage = PAGES[page] return ( diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index f6c94ee..5d20e80 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion' import { LayoutDashboard, KanbanSquare, - Target, + Clock, Bug, Users, Plus, @@ -12,7 +12,6 @@ import { Settings, Gamepad2, ShieldCheck, - CalendarRange, Sun, Moon } from 'lucide-react' @@ -21,12 +20,12 @@ import { supa } from '../lib/supabase' import { ROLE_LABELS } from '../lib/types' import { Theme, getTheme, setTheme } from '../lib/theme' import { Avatar, Modal } from './ui' +import NotificationBell from './NotificationBell' export type Page = | 'dashboard' | 'kanban' | 'planning' - | 'milestones' | 'bugs' | 'members' | 'admin' @@ -34,8 +33,7 @@ export type Page = const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [ { id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard }, { id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare }, - { id: 'planning', label: 'Planning', icon: CalendarRange }, - { id: 'milestones', label: 'Jalons', icon: Target }, + { id: 'planning', label: 'Feuille de temps', icon: Clock }, { id: 'bugs', label: 'Bugs', icon: Bug }, { id: 'members', label: 'Équipe', icon: Users }, { id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true } @@ -114,14 +112,16 @@ export default function Layout({ + + + {open && ( + +
+ Notifications +
+ {unread > 0 && ( + + )} + {items.length > 0 && ( + + )} +
+
+ +
+ {items.length === 0 ? ( +

Aucune notification.

+ ) : ( + items.map((n) => ( + + )) + )} +
+
+ )} +
+ + ) +} diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 6e711e4..8acd88a 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -5,19 +5,21 @@ import { fr } from 'date-fns/locale' import { supa } from '../lib/supabase' import { useApp } from '../lib/AppContext' import { useFeedback } from '../lib/feedback' +import { notifyPole } from '../lib/notify' import { Task, TaskStatus, Priority, - Milestone, + Pole, TimeLog, Profile, TASK_STATUS_ORDER, TASK_STATUS_LABELS, PRIORITY_LABELS, + memberPoles, formatMinutes } from '../lib/types' -import { Modal, Select, TagInput, Avatar } from './ui' +import { Modal, Select, Avatar, PoleChips } from './ui' export default function TaskModal({ task, @@ -40,20 +42,13 @@ export default function TaskModal({ const [description, setDescription] = useState(task?.description ?? '') const [status, setStatus] = useState(task?.status ?? defaultStatus) const [priority, setPriority] = useState(task?.priority ?? 'medium') - const [assignee, setAssignee] = useState(task?.assignee_id ?? '') - const [milestone, setMilestone] = useState(task?.milestone_id ?? '') + const [pole, setPole] = useState(task?.pole ?? '') + const [assignees, setAssignees] = useState(task?.assignee_ids ?? []) const [dueDate, setDueDate] = useState(task?.due_date ?? '') - const [tags, setTags] = useState(task?.tags ?? []) - const [milestones, setMilestones] = useState([]) const [busy, setBusy] = useState(false) - useEffect(() => { - supa() - .from('milestones') - .select('*') - .eq('project_id', projectId) - .then(({ data }) => setMilestones((data ?? []) as Milestone[])) - }, [projectId]) + const toggleAssignee = (id: string): void => + setAssignees((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) const save = async (): Promise => { if (!title.trim()) return @@ -64,13 +59,39 @@ export default function TaskModal({ description: description.trim() || null, status, priority, - assignee_id: assignee || null, - milestone_id: milestone || null, - due_date: dueDate || null, - tags + pole: pole || null, + assignee_ids: assignees, + due_date: dueDate || null } - if (task) await supa().from('tasks').update(payload).eq('id', task.id) - else await supa().from('tasks').insert({ ...payload, created_by: profile?.id ?? null }) + + // Notifier le pôle si on lui confie la tâche (création, ou pôle modifié). + const poleChanged = pole && (!task || task.pole !== pole) + let entityId = task?.id ?? null + + if (task) { + await supa().from('tasks').update(payload).eq('id', task.id) + } else { + const { data } = await supa() + .from('tasks') + .insert({ ...payload, created_by: profile?.id ?? null }) + .select('id') + .single() + entityId = (data as { id: string } | null)?.id ?? null + } + + if (poleChanged && pole && entityId) { + await notifyPole({ + projectId, + pole, + kind: 'task', + title: 'Nouvelle tâche', + body: title.trim(), + entityId, + actorId: profile?.id ?? null, + members + }) + } + await onSaved() setBusy(false) toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success') @@ -109,9 +130,13 @@ export default function TaskModal({ onChange={(e) => setDescription(e.target.value)} /> - + {/* Pôle — sélection par pastilles (clique pour choisir, reclique pour retirer) */} +
- + setPole((cur) => (cur === p ? '' : p))} + />
@@ -135,28 +160,6 @@ export default function TaskModal({ ))}
-
- - -
-
- - -
+ {/* Personnes assignées (une ou plusieurs) */} +
+ +
+ {members.length === 0 && ( + Aucun membre dans l'équipe. + )} + {members.map((m) => { + const on = assignees.includes(m.id) + const inPole = pole && memberPoles(m).includes(pole) + return ( + + ) + })} +
+
+ {/* Suivi du temps — disponible une fois la tâche créée */} - {task && } + {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 { toast, confirm } = useFeedback() const [logs, setLogs] = useState([]) @@ -224,6 +268,7 @@ function TimeSection({ taskId, members }: { taskId: string; members: Profile[] } setBusy(true) await supa().from('time_logs').insert({ task_id: taskId, + project_id: projectId, user_id: profile?.id ?? null, minutes: mins, note: note.trim() || null diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 5cbc953..921cde3 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -1,7 +1,17 @@ import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react' import { motion } from 'framer-motion' import { X, Plus } from 'lucide-react' -import { Profile, Priority, PRIORITY_LABELS, JOB_TAGS, tagColor } from '../lib/types' +import { + Profile, + Priority, + PRIORITY_LABELS, + JOB_TAGS, + tagColor, + Pole, + POLE_ORDER, + POLE_LABELS, + poleColor +} from '../lib/types' export function Modal({ title, @@ -48,6 +58,18 @@ export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size? .slice(0, 2) .join('') .toUpperCase() + if (profile?.avatar_url) { + return ( + {profile.full_name} + ) + } return (
{label} } +/** + * 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 ( +
+ {POLE_ORDER.map((p) => { + const on = sel.includes(p) + return ( + + ) + })} +
+ ) +} + /** Champ d'édition de tags avec suggestions de « jobs ». */ export function TagInput({ value, diff --git a/src/index.css b/src/index.css index 46e3755..049c2d6 100644 --- a/src/index.css +++ b/src/index.css @@ -3,34 +3,37 @@ @tailwind utilities; /* ============================================================ - Thèmes — accent monochrome (noir/blanc), clair + sombre + Thèmes — base épurée (Notion) réchauffée par un accent coloré + accent = indigo (actions), accent2 = teal (temps / progression) ============================================================ */ :root { - --c-bg: 255 255 255; + color-scheme: light; /* aligne selects/inputs/scrollbars natifs sur le thème */ + --c-bg: 251 251 250; --c-panel: 255 255 255; - --c-panel2: 243 242 240; - --c-sidebar: 247 246 243; - --c-border: 233 233 231; - --c-ink: 55 53 47; - --c-muted: 115 114 110; - --c-subtle: 155 154 151; - --c-accent: 55 53 47; /* noir Notion */ + --c-panel2: 244 243 246; + --c-sidebar: 248 247 250; + --c-border: 232 231 235; + --c-ink: 45 43 56; + --c-muted: 112 110 122; + --c-subtle: 152 150 162; + --c-accent: 99 90 232; /* indigo/violet */ --c-accentfg: 255 255 255; - --c-accent2: 55 53 47; + --c-accent2: 13 148 136; /* teal */ } .dark { - --c-bg: 25 25 25; - --c-panel: 32 32 32; - --c-panel2: 40 40 40; - --c-sidebar: 20 20 20; - --c-border: 50 50 50; - --c-ink: 235 235 233; - --c-muted: 158 158 154; - --c-subtle: 122 122 119; - --c-accent: 235 235 233; /* blanc en sombre */ - --c-accentfg: 25 25 25; - --c-accent2: 235 235 233; + color-scheme: dark; + --c-bg: 23 23 27; + --c-panel: 31 31 37; + --c-panel2: 40 40 48; + --c-sidebar: 19 19 24; + --c-border: 51 51 60; + --c-ink: 234 234 238; + --c-muted: 160 158 172; + --c-subtle: 124 122 136; + --c-accent: 129 121 248; /* indigo clair sur fond sombre */ + --c-accentfg: 255 255 255; + --c-accent2: 45 212 191; /* teal clair */ } @layer base { @@ -78,6 +81,20 @@ .input { @apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/15 placeholder:text-subtle; } + /* Selects : flèche maison + options aux couleurs de l'appli (au lieu du style natif brut) */ + select.input { + @apply cursor-pointer pr-8; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1.05rem; + } + select.input option { + background: rgb(var(--c-panel)); + color: rgb(var(--c-ink)); + } .label { @apply mb-1 block text-xs font-medium text-muted; } diff --git a/src/lib/admin.ts b/src/lib/admin.ts index 1031ad1..61c3c5b 100644 --- a/src/lib/admin.ts +++ b/src/lib/admin.ts @@ -8,6 +8,7 @@ export interface AdminUser { email: string full_name: string role: MemberRole + roles: MemberRole[] is_admin: boolean created_at: string } @@ -43,7 +44,7 @@ export const adminCreateUser = (input: { email: string password: string full_name: string - role: MemberRole + roles: MemberRole[] is_admin: boolean }): Promise<{ ok: true; id: string }> => call('create', input) @@ -51,5 +52,5 @@ export const adminDeleteUser = (id: string): Promise<{ ok: true }> => call('dele export const adminUpdateUser = ( id: string, - patch: { full_name?: string; role?: MemberRole; is_admin?: boolean } + patch: { full_name?: string; roles?: MemberRole[]; is_admin?: boolean } ): Promise<{ ok: true }> => call('update', { id, ...patch }) diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 0000000..f9a9fb5 --- /dev/null +++ b/src/lib/avatar.ts @@ -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 { + 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 +} diff --git a/src/lib/notify.ts b/src/lib/notify.ts new file mode 100644 index 0000000..6088d09 --- /dev/null +++ b/src/lib/notify.ts @@ -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 { + 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) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f2defd3..8515071 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -21,6 +21,50 @@ export const ROLE_LABELS: Record = { 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 = 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 = { + lead: 'bg-amber-100 text-amber-700', + programmer: 'bg-sky-100 text-sky-700', + artist: 'bg-fuchsia-100 text-fuchsia-700', + game_designer: 'bg-violet-100 text-violet-700', + sound: 'bg-emerald-100 text-emerald-700', + writer: 'bg-orange-100 text-orange-700', + qa: 'bg-cyan-100 text-cyan-700', + other: 'bg-slate-100 text-slate-600' +} + +export function poleColor(pole: Pole): string { + return POLE_COLORS[pole] ?? POLE_COLORS.other +} + +// Pôles « critiques » : donnent un accès élargi, attribués par un admin +// uniquement (jamais auto-assignables lors de l'onboarding). +export const CRITICAL_POLES: Pole[] = ['lead'] + +// Pôles qu'un membre peut se donner lui-même à l'inscription. +export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter( + (p) => !CRITICAL_POLES.includes(p) +) + // Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres). export const JOB_TAGS: string[] = [ 'Programmation', @@ -70,14 +114,6 @@ export const PRIORITY_LABELS: Record = { urgent: 'Urgente' } -export type MilestoneStatus = 'planned' | 'in_progress' | 'done' - -export const MILESTONE_STATUS_LABELS: Record = { - planned: 'Planifié', - in_progress: 'En cours', - done: 'Atteint' -} - export type BugSeverity = 'low' | 'medium' | 'high' | 'critical' export const SEVERITY_LABELS: Record = { @@ -99,12 +135,21 @@ export const BUG_STATUS_LABELS: Record = { export interface Profile { id: string full_name: string - role: MemberRole + role: MemberRole // rôle principal (= roles[0]) conservé pour l'affichage simple + roles: MemberRole[] // tous les pôles auxquels le membre appartient avatar_color: string + avatar_url: string | null // photo de profil (sinon initiales + couleur) is_admin: boolean + onboarded: boolean // a terminé l'accueil (nom, avatar, pôles) created_at: string } +/** Liste des pôles d'un membre (avec repli sur le rôle principal). */ +export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pole[] { + if (p.roles && p.roles.length) return p.roles + return p.role ? [p.role] : [] +} + export interface Project { id: string name: string @@ -113,16 +158,6 @@ export interface Project { created_at: string } -export interface Milestone { - id: string - project_id: string - title: string - description: string | null - due_date: string | null - status: MilestoneStatus - created_at: string -} - export interface Task { id: string project_id: string @@ -130,8 +165,8 @@ export interface Task { description: string | null status: TaskStatus priority: Priority - assignee_id: string | null - milestone_id: string | null + pole: Pole | null + assignee_ids: string[] due_date: string | null position: number tags: string[] @@ -142,7 +177,9 @@ export interface Task { export interface TimeLog { id: string - task_id: string + project_id: string | null + task_id: string | null // null = activité libre (réunion, etc.) + label: string | null // libellé quand il n'y a pas de tâche liée user_id: string | null minutes: number note: string | null @@ -168,8 +205,24 @@ export interface Bug { severity: BugSeverity status: BugStatus priority: Priority + pole: Pole | null assignee_id: string | null reporter_id: string | null created_at: string updated_at: string } + +export type NotificationKind = 'task' | 'bug' + +export interface AppNotification { + id: string + user_id: string + project_id: string | null + kind: NotificationKind + title: string + body: string | null + entity_id: string | null + pole: Pole | null + read: boolean + created_at: string +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 9e50563..092dca2 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react' import { useApp } from '../lib/AppContext' import { useFeedback } from '../lib/feedback' -import { MemberRole, ROLE_LABELS } from '../lib/types' +import { MemberRole, memberPoles } from '../lib/types' import { AdminUser, adminListUsers, @@ -10,7 +10,7 @@ import { adminDeleteUser, adminUpdateUser } from '../lib/admin' -import { Modal, Select, EmptyState } from '../components/ui' +import { Modal, EmptyState, PoleChips } from '../components/ui' import PageHeader from '../components/PageHeader' export default function Admin(): JSX.Element { @@ -36,10 +36,16 @@ export default function Admin(): JSX.Element { load() }, []) - const setRole = async (u: AdminUser, role: MemberRole): Promise => { - setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, role } : x))) + const toggleRole = async (u: AdminUser, pole: MemberRole): Promise => { + const current = memberPoles(u) + const roles = current.includes(pole) + ? current.filter((r) => r !== pole) + : [...current, pole] + setUsers((list) => + list.map((x) => (x.id === u.id ? { ...x, roles, role: roles[0] ?? 'other' } : x)) + ) try { - await adminUpdateUser(u.id, { role }) + await adminUpdateUser(u.id, { roles }) } catch (e) { setError((e as Error).message) load() @@ -99,7 +105,7 @@ export default function Admin(): JSX.Element { Membre - Rôle + Pôles Admin Actions @@ -119,17 +125,7 @@ export default function Admin(): JSX.Element {
- + toggleRole(u, pole)} />
+ + + setPole((cur) => (cur === p ? '' : p))} + />
{bug ? (
- {/* Prochaines échéances */} + {/* Prochaines échéances (tâches) */}
-

Prochaines échéances

+

Tâches à échéance

{upcoming.length === 0 ? (

Aucune échéance à venir.

) : (
    - {upcoming.map((m) => { - const overdue = isPast(new Date(m.due_date! + 'T23:59:59')) - const d = differenceInCalendarDays(new Date(m.due_date!), new Date()) + {upcoming.map((t) => { + const overdue = isPast(new Date(t.due_date! + 'T23:59:59')) + const d = differenceInCalendarDays(new Date(t.due_date!), new Date()) return (
  • - - - {m.title} + + + {t.title} - {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`} diff --git a/src/pages/Kanban.tsx b/src/pages/Kanban.tsx index 3d5455d..38818a4 100644 --- a/src/pages/Kanban.tsx +++ b/src/pages/Kanban.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react' -import { motion } from 'framer-motion' import { Plus, CalendarDays, Clock, Filter, X } from 'lucide-react' import { format } from 'date-fns' import { fr } from 'date-fns/locale' @@ -12,12 +11,23 @@ import { TimeLog, TASK_STATUS_ORDER, TASK_STATUS_LABELS, + POLE_LABELS, + POLE_ORDER, + poleColor, formatMinutes } from '../lib/types' -import { Avatar, PriorityBadge, Tag, Select } from '../components/ui' +import { Avatar, PriorityBadge, Select } from '../components/ui' import TaskModal from '../components/TaskModal' import PageHeader from '../components/PageHeader' +// Couleur d'accent par colonne (en-tête, compteur, liseré supérieur). +const STATUS_ACCENT: Record = { + todo: { dot: 'bg-slate-400', badge: 'bg-slate-100 text-slate-600', bar: 'bg-slate-400' }, + in_progress: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700', bar: 'bg-sky-500' }, + review: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700', bar: 'bg-amber-500' }, + done: { dot: 'bg-emerald-500', badge: 'bg-emerald-100 text-emerald-700', bar: 'bg-emerald-500' } +} + export default function Kanban(): JSX.Element { const { currentProject } = useApp() const { members, byId } = useMembers() @@ -26,10 +36,11 @@ export default function Kanban(): JSX.Element { const [editing, setEditing] = useState(null) const [creatingIn, setCreatingIn] = useState(null) const [dragId, setDragId] = useState(null) + const [overStatus, setOverStatus] = useState(null) // Filtres const [fAssignee, setFAssignee] = useState('') - const [fTag, setFTag] = useState('') + const [fPole, setFPole] = useState('') const projectId = currentProject?.id @@ -53,7 +64,7 @@ export default function Kanban(): JSX.Element { ) const totals: Record = {} ;((tl ?? []) as Pick[]).forEach((l) => { - totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes + if (l.task_id) totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes }) setTimes(totals) } else { @@ -96,24 +107,17 @@ export default function Kanban(): JSX.Element { setDragId(null) } - // Tags présents (pour le filtre) - const allTags = useMemo(() => { - const set = new Set() - tasks.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag))) - return Array.from(set).sort() - }, [tasks]) - const visible = useMemo( () => tasks.filter( (t) => - (!fAssignee || t.assignee_id === fAssignee) && - (!fTag || (t.tags ?? []).includes(fTag)) + (!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) && + (!fPole || t.pole === fPole) ), - [tasks, fAssignee, fTag] + [tasks, fAssignee, fPole] ) - const hasFilter = fAssignee || fTag + const hasFilter = fAssignee || fPole return (
    @@ -142,13 +146,13 @@ export default function Kanban(): JSX.Element { @@ -157,7 +161,7 @@ export default function Kanban(): JSX.Element { className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink" onClick={() => { setFAssignee('') - setFTag('') + setFPole('') }} > Réinitialiser @@ -168,44 +172,78 @@ export default function Kanban(): JSX.Element {
    {TASK_STATUS_ORDER.map((status) => { const colTasks = visible.filter((t) => t.status === status) + const accent = STATUS_ACCENT[status] return (
    e.preventDefault()} - onDrop={(e) => onDrop(e, status)} + className={`flex min-w-[240px] flex-1 flex-col overflow-hidden rounded-xl border bg-panel2/50 transition-colors ${ + overStatus === status && dragId + ? 'border-accent ring-2 ring-accent/30' + : 'border-border' + }`} + onDragOver={(e) => { + e.preventDefault() + if (overStatus !== status) setOverStatus(status) + }} + onDrop={(e) => { + onDrop(e, status) + setOverStatus(null) + }} > +
    - + + {TASK_STATUS_LABELS[status]} - {colTasks.length} + {colTasks.length}
    {colTasks.map((t) => { const mins = times[t.id] ?? 0 return ( - setDragId(t.id)} + onDragStart={(e) => { + setDragId(t.id) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', t.id) + }} + onDragEnd={() => { + setDragId(null) + setOverStatus(null) + }} onClick={() => setEditing(t)} - className="card cursor-pointer p-3 hover:border-accent" + className={`card cursor-pointer p-3 transition-all hover:-translate-y-0.5 hover:border-accent ${ + dragId === t.id ? 'opacity-40' : '' + }`} >

    {t.title}

    - + {t.assignee_ids?.length ? ( +
    + {t.assignee_ids.slice(0, 3).map((id) => ( + + + + ))} + {t.assignee_ids.length > 3 && ( + + +{t.assignee_ids.length - 3} + + )} +
    + ) : ( + + )}
    - {t.tags?.length > 0 && ( + {t.pole && (
    - {t.tags.map((tag) => ( - - ))} + + {POLE_LABELS[t.pole]} +
    )} @@ -224,7 +262,7 @@ export default function Kanban(): JSX.Element { )}
    - +
    ) })}
    -
    {ROLE_LABELS[m.role]}
    +
    + {memberPoles(m).length === 0 ? ( + Aucun pôle + ) : ( + memberPoles(m).map((p) => ( + + {POLE_LABELS[p]} + + )) + )} +
    ))} @@ -73,46 +85,103 @@ function EditProfileModal({ onClose: () => void onSaved: () => Promise }): JSX.Element { + const { toast } = useFeedback() const [name, setName] = useState(profile.full_name) - const [role, setRole] = useState(profile.role) + const [roles, setRoles] = useState(memberPoles(profile)) const [color, setColor] = useState(profile.avatar_color) + const [avatarUrl, setAvatarUrl] = useState(profile.avatar_url ?? null) + const [uploading, setUploading] = useState(false) const [busy, setBusy] = useState(false) + const fileRef = useRef(null) + + const toggleRole = (pole: MemberRole): void => + setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole])) + + const pickPhoto = async (e: React.ChangeEvent): Promise => { + 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 => { setBusy(true) await supa() .from('profiles') - .update({ full_name: name.trim() || 'Membre', role, avatar_color: color }) + .update({ + full_name: name.trim() || 'Membre', + roles, + role: roles[0] ?? 'other', + avatar_color: color, + avatar_url: avatarUrl + }) .eq('id', profile.id) await onSaved() setBusy(false) onClose() } + const preview = { full_name: name || '?', avatar_color: color, avatar_url: avatarUrl } as Profile + return ( setName(e.target.value)} /> - - - -
    - {COLORS.map((c) => ( + + +
    +
    + + {uploading && ( +
    + +
    + )} +
    + + + {avatarUrl && ( + )}
    -
    + + )} +
    diff --git a/src/pages/Milestones.tsx b/src/pages/Milestones.tsx deleted file mode 100644 index 24679e1..0000000 --- a/src/pages/Milestones.tsx +++ /dev/null @@ -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 = { - 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([]) - const [tasks, setTasks] = useState([]) - const [times, setTimes] = useState>({}) - const [editing, setEditing] = useState(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 = {} - ;((tl ?? []) as Pick[]).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() - tasks - .filter((t) => t.milestone_id === m.id) - .forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag))) - return Array.from(set).sort() - } - - return ( -
    - - - - -
    - {milestones.length === 0 ? ( - } - text="Aucun jalon. Crée des jalons pour structurer ton planning (ex : Prototype, Alpha, Beta, Release)." - /> - ) : ( -
    - {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 ( -
    setEditing(m)} - > -
    -
    -

    {m.title}

    - {m.description && ( -

    {m.description}

    - )} -
    - - {MILESTONE_STATUS_LABELS[m.status]} - -
    - - {mTags.length > 0 && ( -
    - {mTags.map((tag) => ( - - ))} -
    - )} - -
    -
    -
    - -
    - - - {done}/{total} tâche(s) — {pct}% - - {mins > 0 && ( - - {formatMinutes(mins)} - - )} - - {m.due_date && ( - - - {format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })} - {m.status !== 'done' && ` (${daysLabel(m.due_date)})`} - - )} -
    -
    - ) - })} -
    - )} -
    - - {(creating || editing) && ( - { - setCreating(false) - setEditing(null) - }} - onSaved={load} - /> - )} -
    - ) -} - -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 -}): JSX.Element { - const [title, setTitle] = useState(milestone?.title ?? '') - const [description, setDescription] = useState(milestone?.description ?? '') - const [status, setStatus] = useState(milestone?.status ?? 'planned') - const [dueDate, setDueDate] = useState(milestone?.due_date ?? '') - const [busy, setBusy] = useState(false) - const { toast, confirm } = useFeedback() - - const save = async (): Promise => { - 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 => { - 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 ( - - - setTitle(e.target.value)} - placeholder="Alpha jouable" - /> - -