Panel admin, planning, tags, suivi du temps, thèmes clair/sombre

- Admin: création/suppression de comptes + rôles via Edge Function sécurisée
- Config Supabase intégrée au build (.env), écran Setup supprimé
- Page Planning (vue mois/semaine)
- Tags "jobs" sur les tâches + filtres (membre / tag)
- Suivi du temps par tâche, total par jalon
- Toasts + confirmations in-app (fini alert/confirm natifs)
- Thème Notion clair + mode sombre (accent monochrome, bascule persistée)
- .env suivi par git (public uniquement) pour synchro multi-machines

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:51:40 +02:00
parent 0806be135d
commit b054e36df0
30 changed files with 1991 additions and 411 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git check-ignore *)",
"Bash(ls src-tauri/.gitignore)",
"Bash(cat src-tauri/.gitignore)"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# Configuration Supabase intégrée à l'appli (compilée au build).
# Tes collègues n'ont RIEN à saisir : ils verront directement l'écran de connexion.
# La clé "anon" est publique par nature, c'est normal qu'elle soit ici.
VITE_SUPABASE_URL=https://cesjrlfqeqftayhnqjkb.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNlc2pybGZxZXFmdGF5aG5xamtiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODI4NDI1NDIsImV4cCI6MjA5ODQxODU0Mn0.jvTz1Lv8fPRNGljtXoCU54CUfofVgyCuVcaPGw81x5M
+5 -1
View File
@@ -3,5 +3,9 @@ out/
dist/ dist/
*.local *.local
.DS_Store .DS_Store
.env
.vite .vite
# .env est VOLONTAIREMENT suivi par git : il ne contient que l'URL Supabase
# et la clé "anon" (toutes deux publiques). Ça permet de passer d'une machine
# à l'autre sans le recréer. Ne mets JAMAIS de secret ici : pour un vrai
# secret, utilise .env.local (ignoré via *.local).
+137
View File
@@ -0,0 +1,137 @@
# Guide de mise en place — Comptes & Panel Admin
Ce guide explique, **étape par étape**, comment activer le panel d'administration
(création / suppression de comptes, gestion des rôles) pour ton équipe.
Tu ne fais ça **qu'une seule fois**. Ensuite, tout se gère depuis l'appli.
---
## Vue d'ensemble
| Qui | Voit quoi |
|-----|-----------|
| Toi (admin) | Tout + un menu **« Administration »** pour gérer les comptes |
| Tes collègues | L'appli normale, juste un écran **« Se connecter »** |
Les clés Supabase sont **déjà intégrées** dans l'appli (fichier `.env`) : tes
collègues n'ont **rien** à configurer.
---
## Étape 1 — Vérifier le fichier `.env`
Le fichier `.env` (à la racine du projet) contient déjà ton URL et ta clé :
```
VITE_SUPABASE_URL=https://cesjrlfqeqftayhnqjkb.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGci...
```
✅ Rien à faire si c'est correct. (L'URL ne doit PAS contenir `/rest/v1/`.)
---
## Étape 2 — Mettre à jour la base de données
1. Va sur [supabase.com](https://supabase.com) → ton projet.
2. Menu **SQL Editor****New query**.
3. Copie **tout** le contenu du fichier `supabase/schema.sql` et colle-le.
4. Clique **Run**.
> C'est sans danger même si tu l'as déjà exécuté : tout est « ré-exécutable ».
> Ça ajoute le champ `is_admin` et les sécurités du panel admin.
---
## Étape 3 — Créer TON compte administrateur
Comme l'inscription publique est désactivée, on crée le **tout premier compte**
(le tien) à la main :
1. Dans Supabase → menu **Authentication****Users** → bouton **Add user**.
2. Mets ton **email** + un **mot de passe**, et coche **Auto Confirm User**.
3. Clique **Create user**.
Puis on te donne les droits admin :
4. Retourne dans **SQL Editor****New query**, colle ceci en remplaçant l'email :
```sql
update public.profiles set is_admin = true
where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
```
5. Clique **Run**.
🎉 Tu es maintenant administrateur.
---
## Étape 4 — Déployer l'Edge Function (le « serveur de confiance »)
C'est elle qui crée/supprime les comptes en sécurité. **Deux méthodes au choix.**
### Méthode A — En ligne de commande (recommandée)
Dans un terminal, à la racine du projet :
```bash
# 1. Se connecter à Supabase (ouvre le navigateur)
npx supabase login
# 2. Déployer la fonction
npx supabase functions deploy admin-users ^
--project-ref cesjrlfqeqftayhnqjkb --no-verify-jwt
```
> `--no-verify-jwt` est normal ici : la fonction vérifie elle-même que
> l'appelant est bien un admin connecté (voir le code).
> La clé `service_role` est fournie automatiquement par Supabase, tu n'as
> rien à copier.
### Méthode B — Par le tableau de bord (sans terminal)
1. Supabase → menu **Edge Functions****Create a new function**.
2. Nom : `admin-users`.
3. Colle le contenu de `supabase/functions/admin-users/index.ts`.
4. Déploie.
5. Dans les **réglages** de la fonction, **désactive « Enforce JWT Verification »**.
---
## Étape 5 — Construire et distribuer l'appli
```bash
npm run tauri:build
```
L'installateur se trouve ensuite dans :
`src-tauri/target/release/bundle/`
Envoie ce fichier à tes collègues. Les clés sont dedans : ils n'ont **rien** à régler.
---
## Au quotidien — Gérer les comptes
1. Ouvre l'appli, connecte-toi avec ton compte admin.
2. Menu **Administration** (visible uniquement pour les admins).
3. Tu peux :
- **Créer un compte** : nom, email, mot de passe provisoire, rôle, (admin ?)
- **Changer le rôle** d'un membre
- **Promouvoir / rétrograder** un admin
- **Supprimer** un compte
Communique l'email + le mot de passe provisoire à chaque membre. Il se connecte,
c'est tout. ✅
---
## Notes de sécurité
- La clé `anon` dans l'appli est **publique par nature** : aucun risque.
- La clé `service_role` (toute-puissante) **n'est jamais dans l'appli** : elle
reste cachée dans l'Edge Function, côté serveur.
- Un membre normal **ne peut pas** se promouvoir admin lui-même (bloqué côté base).
- Tu ne peux pas supprimer ni rétrograder **ton propre** compte admin (anti-blocage).
+10 -54
View File
@@ -74,6 +74,7 @@
"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",
@@ -903,9 +904,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -920,9 +918,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -937,9 +932,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -954,9 +946,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -971,9 +960,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -988,9 +974,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1005,9 +988,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1022,9 +1002,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1039,9 +1016,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1056,9 +1030,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1073,9 +1044,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1090,9 +1058,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1107,9 +1072,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1383,9 +1345,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1403,9 +1362,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1423,9 +1379,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1443,9 +1396,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1463,9 +1413,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1584,6 +1531,7 @@
"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"
} }
@@ -1601,6 +1549,7 @@
"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"
@@ -1761,6 +1710,7 @@
} }
], ],
"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",
@@ -2211,6 +2161,7 @@
"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"
} }
@@ -2482,6 +2433,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2651,6 +2603,7 @@
"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"
}, },
@@ -2663,6 +2616,7 @@
"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"
@@ -2983,6 +2937,7 @@
"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"
}, },
@@ -3081,6 +3036,7 @@
"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",
+6 -2
View File
@@ -8,16 +8,20 @@ import Login from './pages/Login'
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 Milestones from './pages/Milestones' 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'
const PAGES: Record<Page, () => JSX.Element> = { const PAGES: Record<Page, () => JSX.Element> = {
dashboard: Dashboard, dashboard: Dashboard,
kanban: Kanban, kanban: Kanban,
planning: Planning,
milestones: Milestones, milestones: Milestones,
bugs: Bugs, bugs: Bugs,
members: Members members: Members,
admin: Admin
} }
function Shell(): JSX.Element { function Shell(): JSX.Element {
@@ -26,7 +30,7 @@ function Shell(): JSX.Element {
if (stage === 'loading') { if (stage === 'loading') {
return ( return (
<div className="flex h-full items-center justify-center text-gray-400"> <div className="flex h-full items-center justify-center text-muted">
<Loader2 className="animate-spin" size={28} /> <Loader2 className="animate-spin" size={28} />
</div> </div>
) )
+45 -17
View File
@@ -10,21 +10,35 @@ import {
ChevronDown, ChevronDown,
LogOut, LogOut,
Settings, Settings,
Gamepad2 Gamepad2,
ShieldCheck,
CalendarRange,
Sun,
Moon
} from 'lucide-react' } from 'lucide-react'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { supa } from '../lib/supabase' 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 { Avatar, Modal } from './ui' import { Avatar, Modal } from './ui'
export type Page = 'dashboard' | 'kanban' | 'milestones' | 'bugs' | 'members' export type Page =
| 'dashboard'
| 'kanban'
| 'planning'
| 'milestones'
| 'bugs'
| 'members'
| 'admin'
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard }[] = [ 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: 'milestones', label: 'Jalons & planning', icon: Target }, { id: 'planning', label: 'Planning', icon: CalendarRange },
{ 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 }
] ]
export default function Layout({ export default function Layout({
@@ -39,14 +53,21 @@ export default function Layout({
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp() const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
const [picker, setPicker] = useState(false) const [picker, setPicker] = useState(false)
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [theme, setThemeState] = useState<Theme>(getTheme())
const toggleTheme = (): void => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
setThemeState(next)
}
return ( return (
<div className="flex h-full"> <div className="flex h-full">
{/* Barre latérale */} {/* Barre latérale */}
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-panel"> <aside className="flex w-64 shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-center gap-2 px-4 py-4 text-accent"> <div className="flex items-center gap-2 px-4 py-4 text-accent">
<Gamepad2 size={22} /> <Gamepad2 size={22} />
<span className="font-semibold text-white">GameDev Tracker</span> <span className="font-semibold text-ink">GameDev Tracker</span>
</div> </div>
{/* Sélecteur de projet */} {/* Sélecteur de projet */}
@@ -56,10 +77,10 @@ export default function Layout({
onClick={() => setPicker((v) => !v)} onClick={() => setPicker((v) => !v)}
> >
<span className="truncate"> <span className="truncate">
<span className="block text-[10px] uppercase text-gray-500">Projet</span> <span className="block text-[10px] uppercase text-subtle">Projet</span>
<span className="font-medium text-white">{currentProject?.name ?? 'Aucun projet'}</span> <span className="font-medium text-ink">{currentProject?.name ?? 'Aucun projet'}</span>
</span> </span>
<ChevronDown size={16} className="text-gray-400" /> <ChevronDown size={16} className="text-muted" />
</button> </button>
{picker && ( {picker && (
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl"> <div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
@@ -89,12 +110,12 @@ export default function Layout({
</div> </div>
<nav className="mt-4 flex-1 space-y-1 px-3"> <nav className="mt-4 flex-1 space-y-1 px-3">
{NAV.map((n) => ( {NAV.filter((n) => !n.adminOnly || profile?.is_admin).map((n) => (
<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 transition-colors ${
page === n.id ? 'text-white' : 'text-gray-400 hover:bg-panel2 hover:text-white' page === n.id ? 'text-ink' : 'text-muted hover:bg-panel2 hover:text-ink'
}`} }`}
> >
{page === n.id && ( {page === n.id && (
@@ -115,12 +136,19 @@ export default function Layout({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar profile={profile} /> <Avatar profile={profile} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{profile?.full_name}</div> <div className="truncate text-sm font-medium text-ink">{profile?.full_name}</div>
<div className="truncate text-xs text-gray-500"> <div className="truncate text-xs text-subtle">
{profile ? ROLE_LABELS[profile.role] : ''} {profile ? ROLE_LABELS[profile.role] : ''}
</div> </div>
</div> </div>
<button onClick={signOut} title="Se déconnecter" className="text-gray-400 hover:text-rose-400"> <button
onClick={toggleTheme}
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
className="text-muted hover:text-ink"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button onClick={signOut} title="Se déconnecter" className="text-muted hover:text-rose-600">
<LogOut size={18} /> <LogOut size={18} />
</button> </button>
</div> </div>
@@ -129,10 +157,10 @@ export default function Layout({
{/* Contenu */} {/* Contenu */}
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
{currentProject ? ( {currentProject || page === 'admin' ? (
children children
) : ( ) : (
<div className="flex h-full flex-col items-center justify-center gap-4 text-gray-400"> <div className="flex h-full flex-col items-center justify-center gap-4 text-muted">
<Settings size={40} className="text-gray-600" /> <Settings size={40} className="text-gray-600" />
<p>Aucun projet pour le moment.</p> <p>Aucun projet pour le moment.</p>
<button className="btn-primary" onClick={() => setShowNew(true)}> <button className="btn-primary" onClick={() => setShowNew(true)}>
+2 -2
View File
@@ -12,8 +12,8 @@ export default function PageHeader({
return ( return (
<header className="flex items-center justify-between border-b border-border px-6 py-4"> <header className="flex items-center justify-between border-b border-border px-6 py-4">
<div> <div>
<h1 className="text-lg font-semibold text-white">{title}</h1> <h1 className="text-lg font-semibold text-ink">{title}</h1>
{subtitle && <p className="text-sm text-gray-400">{subtitle}</p>} {subtitle && <p className="text-sm text-muted">{subtitle}</p>}
</div> </div>
<div className="flex items-center gap-2">{children}</div> <div className="flex items-center gap-2">{children}</div>
</header> </header>
+321
View File
@@ -0,0 +1,321 @@
import { useEffect, useState } from 'react'
import { Trash2, Clock, Plus, Loader2 } from 'lucide-react'
import { format } 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 {
Task,
TaskStatus,
Priority,
Milestone,
TimeLog,
Profile,
TASK_STATUS_ORDER,
TASK_STATUS_LABELS,
PRIORITY_LABELS,
formatMinutes
} from '../lib/types'
import { Modal, Select, TagInput, Avatar } from './ui'
export default function TaskModal({
task,
defaultStatus,
projectId,
members,
onClose,
onSaved
}: {
task: Task | null
defaultStatus: TaskStatus
projectId: string
members: Profile[]
onClose: () => void
onSaved: () => Promise<void> | void
}): JSX.Element {
const { profile } = useApp()
const { toast, confirm } = useFeedback()
const [title, setTitle] = useState(task?.title ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [assignee, setAssignee] = useState(task?.assignee_id ?? '')
const [milestone, setMilestone] = useState(task?.milestone_id ?? '')
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [tags, setTags] = useState<string[]>(task?.tags ?? [])
const [milestones, setMilestones] = useState<Milestone[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
supa()
.from('milestones')
.select('*')
.eq('project_id', projectId)
.then(({ data }) => setMilestones((data ?? []) as Milestone[]))
}, [projectId])
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
status,
priority,
assignee_id: assignee || null,
milestone_id: milestone || 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 })
await onSaved()
setBusy(false)
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success')
onClose()
}
const remove = async (): Promise<void> => {
if (!task) return
const ok = await confirm({
title: 'Supprimer cette tâche ?',
message: `« ${task.title} » sera définitivement supprimée.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
await supa().from('tasks').delete().eq('id', task.id)
await onSaved()
toast('Tâche supprimée.', 'success')
onClose()
}
return (
<Modal title={task ? 'Modifier la tâche' : 'Nouvelle tâche'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Implémenter le saut du personnage"
/>
<label className="label">Description</label>
<textarea
className="input mb-3 h-24 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<label className="label">Tags (jobs)</label>
<div className="mb-3">
<TagInput value={tags} onChange={setTags} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as TaskStatus)}>
{TASK_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{TASK_STATUS_LABELS[s]}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Assigné à</label>
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
<option value="">Personne</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
</div>
<div>
<label className="label">Jalon</label>
<Select value={milestone} onChange={(e) => setMilestone(e.target.value)}>
<option value="">Aucun</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className="label">Échéance</label>
<input
type="date"
className="input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
{/* Suivi du temps — disponible une fois la tâche créée */}
{task && <TimeSection taskId={task.id} members={members} />}
<div className="mt-5 flex items-center justify-between">
{task ? (
<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()}>
{busy && <Loader2 className="animate-spin" size={16} />}
{task ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
function TimeSection({ taskId, members }: { taskId: string; members: Profile[] }): JSX.Element {
const { profile } = useApp()
const { toast, confirm } = useFeedback()
const [logs, setLogs] = useState<TimeLog[]>([])
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const byId = (id: string | null): Profile | undefined =>
id ? members.find((m) => m.id === id) : undefined
const load = async (): Promise<void> => {
const { data } = await supa()
.from('time_logs')
.select('*')
.eq('task_id', taskId)
.order('logged_at', { ascending: false })
setLogs((data ?? []) as TimeLog[])
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId])
const total = logs.reduce((s, l) => s + l.minutes, 0)
const add = async (): Promise<void> => {
const mins = (parseInt(hours || '0', 10) || 0) * 60 + (parseInt(minutes || '0', 10) || 0)
if (mins <= 0) {
toast('Indique une durée valide.', 'error')
return
}
setBusy(true)
await supa().from('time_logs').insert({
task_id: taskId,
user_id: profile?.id ?? null,
minutes: mins,
note: note.trim() || null
})
setHours('')
setMinutes('')
setNote('')
await load()
setBusy(false)
toast('Temps ajouté.', 'success')
}
const del = async (id: string): Promise<void> => {
const ok = await confirm({ title: 'Supprimer cette entrée de temps ?', danger: true, confirmLabel: 'Supprimer' })
if (!ok) return
await supa().from('time_logs').delete().eq('id', id)
await load()
}
return (
<div className="mt-5 rounded-xl border border-border bg-panel2/40 p-3">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-ink">
<Clock size={16} className="text-accent2" /> Temps passé
</span>
<span className="text-sm font-semibold text-accent2">{formatMinutes(total)}</span>
</div>
{/* Ajout */}
<div className="mb-3 flex flex-wrap items-end gap-2">
<div className="w-16">
<label className="label">Heures</label>
<input
type="number"
min={0}
className="input"
placeholder="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
/>
</div>
<div className="w-16">
<label className="label">Min</label>
<input
type="number"
min={0}
max={59}
className="input"
placeholder="0"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
/>
</div>
<input
className="input flex-1"
placeholder="Note (optionnel)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<button className="btn-ghost" onClick={add} disabled={busy}>
<Plus size={15} /> Ajouter
</button>
</div>
{/* Liste */}
{logs.length === 0 ? (
<p className="py-2 text-center text-xs text-subtle">
Aucun temps enregistré. Note combien de temps cette tâche t'a pris.
</p>
) : (
<ul className="space-y-1.5">
{logs.map((l) => (
<li
key={l.id}
className="group flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-1.5 text-sm"
>
<Avatar profile={byId(l.user_id)} size={22} />
<span className="font-medium text-ink">{formatMinutes(l.minutes)}</span>
{l.note && <span className="truncate text-muted">— {l.note}</span>}
<span className="ml-auto text-xs text-subtle">
{format(new Date(l.logged_at), 'd MMM', { locale: fr })}
</span>
<button
onClick={() => del(l.id)}
className="text-gray-600 opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
</div>
)
}
+83 -11
View File
@@ -1,7 +1,7 @@
import { ReactNode, SelectHTMLAttributes } from 'react' import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { X } from 'lucide-react' import { X, Plus } from 'lucide-react'
import { Profile, Priority, PRIORITY_LABELS } from '../lib/types' import { Profile, Priority, PRIORITY_LABELS, JOB_TAGS, tagColor } from '../lib/types'
export function Modal({ export function Modal({
title, title,
@@ -30,8 +30,8 @@ export function Modal({
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{title}</h2> <h2 className="text-lg font-semibold text-ink">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white"> <button onClick={onClose} className="text-muted hover:text-ink">
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -51,7 +51,7 @@ export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?
return ( return (
<div <div
title={profile?.full_name ?? 'Non assigné'} title={profile?.full_name ?? 'Non assigné'}
className="flex shrink-0 items-center justify-center rounded-full font-semibold text-white" className="flex shrink-0 items-center justify-center rounded-full font-semibold text-ink"
style={{ style={{
width: size, width: size,
height: size, height: size,
@@ -65,10 +65,10 @@ export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?
} }
const PRIORITY_STYLES: Record<Priority, string> = { const PRIORITY_STYLES: Record<Priority, string> = {
low: 'bg-slate-500/20 text-slate-300', low: 'bg-slate-100 text-slate-600',
medium: 'bg-sky-500/20 text-sky-300', medium: 'bg-sky-100 text-sky-700',
high: 'bg-amber-500/20 text-amber-300', high: 'bg-amber-100 text-amber-700',
urgent: 'bg-rose-500/20 text-rose-300' urgent: 'bg-rose-100 text-rose-700'
} }
export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element { export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element {
@@ -81,9 +81,81 @@ export function Select(props: SelectHTMLAttributes<HTMLSelectElement>): JSX.Elem
export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element { export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-gray-500"> <div className="flex flex-col items-center justify-center gap-3 py-16 text-subtle">
{icon} {icon}
<p className="text-sm">{text}</p> <p className="text-sm">{text}</p>
</div> </div>
) )
} }
/** Petite pastille colorée pour un tag (« job »). */
export function Tag({ label }: { label: string }): JSX.Element {
return <span className={`badge ${tagColor(label)}`}>{label}</span>
}
/** Champ d'édition de tags avec suggestions de « jobs ». */
export function TagInput({
value,
onChange
}: {
value: string[]
onChange: (tags: string[]) => void
}): JSX.Element {
const [draft, setDraft] = useState('')
const add = (raw: string): void => {
const tag = raw.trim()
if (tag && !value.some((v) => v.toLowerCase() === tag.toLowerCase())) {
onChange([...value, tag])
}
setDraft('')
}
const remove = (tag: string): void => onChange(value.filter((t) => t !== tag))
const onKey = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add(draft)
} else if (e.key === 'Backspace' && !draft && value.length) {
remove(value[value.length - 1])
}
}
const suggestions = JOB_TAGS.filter((s) => !value.some((v) => v.toLowerCase() === s.toLowerCase()))
return (
<div>
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-panel2 px-2 py-2">
{value.map((t) => (
<span key={t} className={`badge ${tagColor(t)}`}>
{t}
<button onClick={() => remove(t)} className="ml-0.5 opacity-70 hover:opacity-100">
<X size={11} />
</button>
</span>
))}
<input
className="min-w-[100px] flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-subtle"
placeholder={value.length ? 'Ajouter…' : 'Tape un tag puis Entrée'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
onBlur={() => draft && add(draft)}
/>
</div>
{suggestions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
onClick={() => add(s)}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-ink"
>
<Plus size={10} /> {s}
</button>
))}
</div>
)}
</div>
)
}
+11
View File
@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL du projet Supabase intégrée au build (ex: https://xxxxx.supabase.co). */
readonly VITE_SUPABASE_URL?: string
/** Clé "anon public" Supabase intégrée au build. */
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+49 -11
View File
@@ -2,6 +2,37 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ============================================================
Thèmes — accent monochrome (noir/blanc), clair + sombre
============================================================ */
:root {
--c-bg: 255 255 255;
--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-accentfg: 255 255 255;
--c-accent2: 55 53 47;
}
.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;
}
@layer base { @layer base {
html, html,
body, body,
@@ -9,44 +40,51 @@
height: 100%; height: 100%;
} }
body { body {
@apply bg-bg text-gray-200 antialiased; @apply bg-bg text-ink antialiased;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; font-family: ui-sans-serif, 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
letter-spacing: -0.01em;
}
h1,
h2,
h3 {
letter-spacing: -0.02em;
} }
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #2a2f3d transparent; scrollbar-color: rgb(var(--c-border)) transparent;
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
@apply bg-border rounded-full; background: rgb(var(--c-border));
border-radius: 9999px;
} }
} }
@layer components { @layer components {
.btn { .btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed; @apply inline-flex items-center justify-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.btn-primary { .btn-primary {
@apply btn bg-accent text-white hover:bg-accent/90; @apply btn bg-accent text-accentfg shadow-sm hover:opacity-90 active:scale-[0.98];
} }
.btn-ghost { .btn-ghost {
@apply btn bg-panel2 text-gray-200 hover:bg-border; @apply btn bg-panel2 text-ink hover:bg-border;
} }
.input { .input {
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-gray-100 outline-none focus:border-accent placeholder:text-gray-500; @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;
} }
.label { .label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-gray-400; @apply mb-1 block text-xs font-medium text-muted;
} }
.card { .card {
@apply rounded-xl border border-border bg-panel p-4; @apply rounded-xl border border-border bg-panel p-4 shadow-sm transition-colors;
} }
.badge { .badge {
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium; @apply inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium;
} }
} }
+55
View File
@@ -0,0 +1,55 @@
// Client de l'espace administration : appelle l'Edge Function "admin-users".
// La clé service_role n'est JAMAIS ici — tout passe par la fonction serveur.
import { supa } from './supabase'
import { MemberRole } from './types'
export interface AdminUser {
id: string
email: string
full_name: string
role: MemberRole
is_admin: boolean
created_at: string
}
/** Appel générique de l'Edge Function avec remontée d'erreur lisible. */
async function call<T>(action: string, payload: Record<string, unknown> = {}): Promise<T> {
const { data, error } = await supa().functions.invoke('admin-users', {
body: { action, ...payload }
})
if (error) {
let msg = error.message
const ctx = (error as unknown as { context?: { json?: () => Promise<{ error?: string }> } })
.context
if (ctx?.json) {
try {
const body = await ctx.json()
if (body?.error) msg = body.error
} catch {
/* ignore */
}
}
throw new Error(msg)
}
if (data && typeof data === 'object' && 'error' in data) {
throw new Error((data as { error: string }).error)
}
return data as T
}
export const adminListUsers = (): Promise<AdminUser[]> => call<AdminUser[]>('list')
export const adminCreateUser = (input: {
email: string
password: string
full_name: string
role: MemberRole
is_admin: boolean
}): Promise<{ ok: true; id: string }> => call('create', input)
export const adminDeleteUser = (id: string): Promise<{ ok: true }> => call('delete', { id })
export const adminUpdateUser = (
id: string,
patch: { full_name?: string; role?: MemberRole; is_admin?: boolean }
): Promise<{ ok: true }> => call('update', { id, ...patch })
+18
View File
@@ -8,7 +8,25 @@ export interface AppConfig {
const KEY = 'gamedev-tracker-config' const KEY = 'gamedev-tracker-config'
/**
* Config intégrée au moment du build (fichier .env).
* Si elle existe, l'appli se connecte automatiquement à la base : tes collègues
* n'ont rien à saisir et voient directement l'écran de connexion.
*/
function getBakedConfig(): AppConfig | null {
const url = import.meta.env.VITE_SUPABASE_URL
const key = import.meta.env.VITE_SUPABASE_ANON_KEY
if (url && key) return { supabaseUrl: url, supabaseAnonKey: key }
return null
}
export function hasBakedConfig(): boolean {
return getBakedConfig() !== null
}
export function getConfig(): AppConfig { export function getConfig(): AppConfig {
const baked = getBakedConfig()
if (baked) return baked
try { try {
return JSON.parse(localStorage.getItem(KEY) ?? '{}') return JSON.parse(localStorage.getItem(KEY) ?? '{}')
} catch { } catch {
+138
View File
@@ -0,0 +1,138 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
// =====================================================================
// Notifications (toasts) + confirmations in-app — remplace alert/confirm
// =====================================================================
type ToastKind = 'success' | 'error' | 'info'
interface Toast {
id: number
kind: ToastKind
message: string
}
interface ConfirmOptions {
title: string
message?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}
interface ConfirmState extends ConfirmOptions {
resolve: (ok: boolean) => void
}
interface FeedbackApi {
toast: (message: string, kind?: ToastKind) => void
confirm: (options: ConfirmOptions) => Promise<boolean>
}
const Ctx = createContext<FeedbackApi | null>(null)
export function useFeedback(): FeedbackApi {
const v = useContext(Ctx)
if (!v) throw new Error('useFeedback doit être utilisé dans <FeedbackProvider>')
return v
}
let nextId = 1
export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Element {
const [toasts, setToasts] = useState<Toast[]>([])
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId++
setToasts((t) => [...t, { id, kind, message }])
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3800)
}, [])
const confirm = useCallback(
(options: ConfirmOptions): Promise<boolean> =>
new Promise((resolve) => setConfirmState({ ...options, resolve })),
[]
)
const closeConfirm = (ok: boolean): void => {
confirmState?.resolve(ok)
setConfirmState(null)
}
return (
<Ctx.Provider value={{ toast, confirm }}>
{children}
{/* Toasts (coin bas-droite) */}
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, x: 40, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
className="pointer-events-auto flex items-start gap-2 rounded-xl border border-border bg-panel px-3 py-2.5 shadow-xl"
>
<span className="mt-0.5 shrink-0">
{t.kind === 'success' && <CheckCircle2 size={18} className="text-emerald-600" />}
{t.kind === 'error' && <AlertTriangle size={18} className="text-rose-600" />}
{t.kind === 'info' && <Info size={18} className="text-sky-600" />}
</span>
<p className="flex-1 text-sm text-ink">{t.message}</p>
<button
onClick={() => setToasts((list) => list.filter((x) => x.id !== t.id))}
className="text-subtle hover:text-ink"
>
<X size={14} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Confirmation */}
<AnimatePresence>
{confirmState && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onMouseDown={() => closeConfirm(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
className="card w-full max-w-sm"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-2 flex items-center gap-2">
{confirmState.danger && <AlertTriangle size={20} className="text-rose-600" />}
<h2 className="text-base font-semibold text-ink">{confirmState.title}</h2>
</div>
{confirmState.message && (
<p className="mb-4 text-sm text-muted">{confirmState.message}</p>
)}
<div className="mt-2 flex justify-end gap-2">
<button className="btn-ghost" onClick={() => closeConfirm(false)}>
{confirmState.cancelLabel ?? 'Annuler'}
</button>
<button
className={confirmState.danger ? 'btn bg-rose-500 text-ink hover:bg-rose-600' : 'btn-primary'}
onClick={() => closeConfirm(true)}
>
{confirmState.confirmLabel ?? 'Confirmer'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Ctx.Provider>
)
}
+17
View File
@@ -0,0 +1,17 @@
// Gestion du thème clair / sombre (persisté localement).
export type Theme = 'light' | 'dark'
const KEY = 'gamedev-tracker-theme'
export function getTheme(): Theme {
return localStorage.getItem(KEY) === 'dark' ? 'dark' : 'light'
}
export function applyTheme(t: Theme): void {
document.documentElement.classList.toggle('dark', t === 'dark')
}
export function setTheme(t: Theme): void {
localStorage.setItem(KEY, t)
applyTheme(t)
}
+51
View File
@@ -21,6 +21,35 @@ export const ROLE_LABELS: Record<MemberRole, string> = {
other: 'Autre' other: 'Autre'
} }
// Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres).
export const JOB_TAGS: string[] = [
'Programmation',
'Art',
'Game Design',
'Audio',
'Écriture',
'QA',
'Level Design',
'UI/UX'
]
// Couleur stable d'un tag à partir de son texte (pour l'affichage).
const TAG_PALETTE = [
'bg-violet-100 text-violet-700',
'bg-sky-100 text-sky-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
'bg-cyan-100 text-cyan-700',
'bg-fuchsia-100 text-fuchsia-700',
'bg-lime-100 text-lime-700'
]
export function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) >>> 0
return TAG_PALETTE[h % TAG_PALETTE.length]
}
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done' export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = { export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
@@ -72,6 +101,7 @@ export interface Profile {
full_name: string full_name: string
role: MemberRole role: MemberRole
avatar_color: string avatar_color: string
is_admin: boolean
created_at: string created_at: string
} }
@@ -104,11 +134,32 @@ export interface Task {
milestone_id: string | null milestone_id: string | null
due_date: string | null due_date: string | null
position: number position: number
tags: string[]
created_by: string | null created_by: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
export interface TimeLog {
id: string
task_id: string
user_id: string | null
minutes: number
note: string | null
logged_at: string
created_at: string
}
/** Convertit des minutes en libellé court : 90 -> "1 h 30", 45 -> "45 min". */
export function formatMinutes(min: number): string {
if (!min) return '0 min'
const h = Math.floor(min / 60)
const m = min % 60
if (h === 0) return `${m} min`
if (m === 0) return `${h} h`
return `${h} h ${String(m).padStart(2, '0')}`
}
export interface Bug { export interface Bug {
id: string id: string
project_id: string project_id: string
+8 -1
View File
@@ -1,10 +1,17 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { FeedbackProvider } from './lib/feedback'
import { applyTheme, getTheme } from './lib/theme'
import './index.css' import './index.css'
// Applique le thème enregistré avant le premier rendu (évite le flash).
applyTheme(getTheme())
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <FeedbackProvider>
<App />
</FeedbackProvider>
</React.StrictMode> </React.StrictMode>
) )
+274
View File
@@ -0,0 +1,274 @@
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 {
AdminUser,
adminListUsers,
adminCreateUser,
adminDeleteUser,
adminUpdateUser
} from '../lib/admin'
import { Modal, Select, EmptyState } from '../components/ui'
import PageHeader from '../components/PageHeader'
export default function Admin(): JSX.Element {
const { profile } = useApp()
const { toast, confirm } = useFeedback()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [creating, setCreating] = useState(false)
const load = async (): Promise<void> => {
setLoading(true)
setError('')
try {
setUsers(await adminListUsers())
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const setRole = async (u: AdminUser, role: MemberRole): Promise<void> => {
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, role } : x)))
try {
await adminUpdateUser(u.id, { role })
} catch (e) {
setError((e as Error).message)
load()
}
}
const toggleAdmin = async (u: AdminUser): Promise<void> => {
try {
await adminUpdateUser(u.id, { is_admin: !u.is_admin })
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, is_admin: !u.is_admin } : x)))
} catch (e) {
setError((e as Error).message)
}
}
const remove = async (u: AdminUser): Promise<void> => {
const ok = await confirm({
title: 'Supprimer ce compte ?',
message: `${u.full_name} (${u.email}) sera définitivement supprimé.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
try {
await adminDeleteUser(u.id)
setUsers((list) => list.filter((x) => x.id !== u.id))
toast('Compte supprimé.', 'success')
} catch (e) {
setError((e as Error).message)
}
}
return (
<div className="flex h-full flex-col">
<PageHeader title="Administration" subtitle={`${users.length} compte(s)`}>
<button className="btn-primary" onClick={() => setCreating(true)}>
<UserPlus size={16} /> Créer un compte
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{error && (
<div className="mb-4 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-16 text-muted">
<Loader2 className="animate-spin" size={28} />
</div>
) : users.length === 0 ? (
<EmptyState icon={<ShieldCheck size={40} />} text="Aucun compte." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-panel2 text-left text-xs uppercase text-subtle">
<tr>
<th className="px-4 py-3">Membre</th>
<th className="px-4 py-3">Rôle</th>
<th className="px-4 py-3">Admin</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isMe = u.id === profile?.id
return (
<tr key={u.id} className="border-t border-border">
<td className="px-4 py-3">
<div className="font-medium text-ink">
{u.full_name}
{isMe && <span className="ml-1 text-xs text-accent2">(moi)</span>}
</div>
<div className="flex items-center gap-1 text-xs text-subtle">
<Mail size={11} /> {u.email}
</div>
</td>
<td className="px-4 py-3">
<Select
className="!py-1 text-xs"
value={u.role}
onChange={(e) => setRole(u, e.target.value as MemberRole)}
>
{Object.entries(ROLE_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleAdmin(u)}
disabled={isMe}
title={isMe ? 'Tu ne peux pas retirer ton propre statut' : 'Basculer admin'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs ${
u.is_admin
? 'bg-amber-100 text-amber-700'
: 'bg-panel2 text-muted hover:text-ink'
} ${isMe ? 'cursor-not-allowed opacity-60' : ''}`}
>
<Crown size={12} /> {u.is_admin ? 'Admin' : 'Membre'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => remove(u)}
disabled={isMe}
title={isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer'}
className={`text-muted hover:text-rose-600 ${
isMe ? 'cursor-not-allowed opacity-40' : ''
}`}
>
<Trash2 size={16} />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{creating && (
<CreateUserModal
onClose={() => setCreating(false)}
onCreated={async () => {
setCreating(false)
await load()
toast('Compte créé.', 'success')
}}
/>
)}
</div>
)
}
function CreateUserModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => Promise<void>
}): JSX.Element {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [role, setRole] = useState<MemberRole>('programmer')
const [isAdmin, setIsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const submit = async (): Promise<void> => {
setError('')
if (!email.trim() || password.length < 6) {
setError('Email valide et mot de passe (6+ caractères) requis.')
return
}
setBusy(true)
try {
await adminCreateUser({
email: email.trim(),
password,
full_name: fullName.trim(),
role,
is_admin: isAdmin
})
await onCreated()
} catch (e) {
setError((e as Error).message)
setBusy(false)
}
}
return (
<Modal title="Créer un compte" onClose={onClose}>
<label className="label">Nom complet</label>
<input
className="input mb-3"
placeholder="Alex Martin"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
placeholder="alex@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Mot de passe provisoire</label>
<input
className="input mb-3"
type="text"
placeholder="au moins 6 caractères"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label className="label">Rôle</label>
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}>
{Object.entries(ROLE_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
<label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
Donner les droits administrateur
</label>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Créer le compte
</button>
<p className="mt-3 text-center text-xs text-subtle">
Communique l'email et le mot de passe provisoire au membre.
</p>
</Modal>
)
}
+12 -12
View File
@@ -16,16 +16,16 @@ import { Avatar, Modal, Select, EmptyState, PriorityBadge } from '../components/
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
const SEVERITY_STYLES: Record<BugSeverity, string> = { const SEVERITY_STYLES: Record<BugSeverity, string> = {
low: 'bg-slate-500/20 text-slate-300', low: 'bg-slate-100 text-slate-600',
medium: 'bg-amber-500/20 text-amber-300', medium: 'bg-amber-100 text-amber-700',
high: 'bg-orange-500/20 text-orange-300', high: 'bg-orange-100 text-orange-700',
critical: 'bg-rose-500/20 text-rose-300' critical: 'bg-rose-100 text-rose-700'
} }
const STATUS_STYLES: Record<BugStatus, string> = { const STATUS_STYLES: Record<BugStatus, string> = {
open: 'bg-rose-500/20 text-rose-300', open: 'bg-rose-100 text-rose-700',
in_progress: 'bg-sky-500/20 text-sky-300', in_progress: 'bg-sky-100 text-sky-700',
resolved: 'bg-emerald-500/20 text-emerald-300', resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-slate-500/20 text-slate-400' closed: 'bg-slate-100 text-slate-500'
} }
export default function Bugs(): JSX.Element { export default function Bugs(): JSX.Element {
@@ -79,7 +79,7 @@ export default function Bugs(): JSX.Element {
<EmptyState icon={<BugIcon size={40} />} text="Aucun bug. 🎉" /> <EmptyState icon={<BugIcon size={40} />} text="Aucun bug. 🎉" />
) : ( ) : (
<table className="w-full border-separate border-spacing-y-2 text-sm"> <table className="w-full border-separate border-spacing-y-2 text-sm">
<thead className="text-left text-xs uppercase text-gray-500"> <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">Sévérité</th> <th className="px-3">Sévérité</th>
@@ -92,10 +92,10 @@ export default function Bugs(): JSX.Element {
{shown.map((b) => ( {shown.map((b) => (
<tr <tr
key={b.id} key={b.id}
className="cursor-pointer bg-panel hover:bg-panel2" className="cursor-pointer border border-border bg-panel shadow-sm hover:bg-panel2"
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
> >
<td className="rounded-l-lg px-3 py-3 font-medium text-white">{b.title}</td> <td className="rounded-l-lg px-3 py-3 font-medium text-ink">{b.title}</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]}
@@ -246,7 +246,7 @@ function BugModal({
</div> </div>
<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-400 hover:bg-rose-500/10" onClick={remove}> <button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer <Trash2 size={16} /> Supprimer
</button> </button>
) : ( ) : (
+12 -12
View File
@@ -65,9 +65,9 @@ export default function Dashboard(): JSX.Element {
icon={<CheckCircle2 />} icon={<CheckCircle2 />}
label="Terminées" label="Terminées"
value={`${doneTasks} (${pct}%)`} value={`${doneTasks} (${pct}%)`}
color="text-emerald-400" color="text-emerald-600"
/> />
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-400" /> <StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-600" />
<StatCard <StatCard
icon={<Target />} icon={<Target />}
label="Jalons" label="Jalons"
@@ -79,8 +79,8 @@ export default function Dashboard(): JSX.Element {
{/* Avancement global */} {/* Avancement global */}
<div className="card mt-4"> <div className="card mt-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-white">Avancement global</h3> <h3 className="font-medium text-ink">Avancement global</h3>
<span className="text-sm text-gray-400">{pct}%</span> <span className="text-sm text-muted">{pct}%</span>
</div> </div>
<div className="h-3 overflow-hidden rounded-full bg-panel2"> <div className="h-3 overflow-hidden rounded-full bg-panel2">
<motion.div <motion.div
@@ -95,14 +95,14 @@ export default function Dashboard(): JSX.Element {
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Répartition des tâches */} {/* Répartition des tâches */}
<div className="card"> <div className="card">
<h3 className="mb-3 font-medium text-white">Répartition des tâches</h3> <h3 className="mb-3 font-medium text-ink">Répartition des tâches</h3>
<div className="space-y-2"> <div className="space-y-2">
{TASK_STATUS_ORDER.map((s) => { {TASK_STATUS_ORDER.map((s) => {
const n = tasks.filter((t) => t.status === s).length const n = tasks.filter((t) => t.status === s).length
const w = tasks.length ? (n / tasks.length) * 100 : 0 const w = tasks.length ? (n / tasks.length) * 100 : 0
return ( return (
<div key={s}> <div key={s}>
<div className="mb-1 flex justify-between text-xs text-gray-400"> <div className="mb-1 flex justify-between text-xs text-muted">
<span>{TASK_STATUS_LABELS[s]}</span> <span>{TASK_STATUS_LABELS[s]}</span>
<span>{n}</span> <span>{n}</span>
</div> </div>
@@ -117,9 +117,9 @@ export default function Dashboard(): JSX.Element {
{/* Prochaines échéances */} {/* Prochaines échéances */}
<div className="card"> <div className="card">
<h3 className="mb-3 font-medium text-white">Prochaines échéances</h3> <h3 className="mb-3 font-medium text-ink">Prochaines échéances</h3>
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<p className="py-6 text-center text-sm text-gray-500">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((m) => {
@@ -130,13 +130,13 @@ export default function Dashboard(): JSX.Element {
key={m.id} key={m.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-white"> <span className="flex items-center gap-2 text-sm text-ink">
<Target size={14} className="text-accent2" /> <Target size={14} className="text-accent2" />
{m.title} {m.title}
</span> </span>
<span <span
className={`flex items-center gap-1 text-xs ${ className={`flex items-center gap-1 text-xs ${
overdue ? 'text-rose-400' : 'text-gray-400' overdue ? 'text-rose-600' : 'text-muted'
}`} }`}
> >
<CalendarClock size={12} /> <CalendarClock size={12} />
@@ -175,8 +175,8 @@ function StatCard({
> >
<div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div> <div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div>
<div> <div>
<div className="text-2xl font-semibold text-white">{value}</div> <div className="text-2xl font-semibold text-ink">{value}</div>
<div className="text-xs text-gray-400">{label}</div> <div className="text-xs text-muted">{label}</div>
</div> </div>
</motion.div> </motion.div>
) )
+144 -194
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, DragEvent } from 'react' import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Plus, Trash2, CalendarDays } 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'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
@@ -9,23 +9,28 @@ import { useMembers } from '../lib/useMembers'
import { import {
Task, Task,
TaskStatus, TaskStatus,
Priority, TimeLog,
Milestone,
TASK_STATUS_ORDER, TASK_STATUS_ORDER,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
PRIORITY_LABELS formatMinutes
} from '../lib/types' } from '../lib/types'
import { Avatar, Modal, Select, PriorityBadge } from '../components/ui' import { Avatar, PriorityBadge, Tag, Select } from '../components/ui'
import TaskModal from '../components/TaskModal'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
export default function Kanban(): JSX.Element { export default function Kanban(): JSX.Element {
const { currentProject, profile } = useApp() const { currentProject } = useApp()
const { members, byId } = useMembers() const { members, byId } = useMembers()
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [times, setTimes] = useState<Record<string, number>>({})
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)
// Filtres
const [fAssignee, setFAssignee] = useState('')
const [fTag, setFTag] = useState('')
const projectId = currentProject?.id const projectId = currentProject?.id
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -35,14 +40,32 @@ export default function Kanban(): JSX.Element {
.select('*') .select('*')
.eq('project_id', projectId) .eq('project_id', projectId)
.order('position') .order('position')
setTasks((data ?? []) as Task[]) const list = (data ?? []) as Task[]
setTasks(list)
// Totaux de temps par tâche
if (list.length) {
const { data: tl } = await supa()
.from('time_logs')
.select('task_id, minutes')
.in(
'task_id',
list.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]) }, [projectId])
useEffect(() => { useEffect(() => {
load() load()
}, [load]) }, [load])
// Synchro temps réel : recharge quand un membre modifie une tâche. // Synchro temps réel
useEffect(() => { useEffect(() => {
if (!projectId) return if (!projectId) return
const ch = supa() const ch = supa()
@@ -52,6 +75,7 @@ export default function Kanban(): JSX.Element {
{ event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` }, { event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
() => load() () => load()
) )
.on('postgres_changes', { event: '*', schema: 'public', table: 'time_logs' }, () => load())
.subscribe() .subscribe()
return () => { return () => {
supa().removeChannel(ch) supa().removeChannel(ch)
@@ -72,6 +96,25 @@ 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(
() =>
tasks.filter(
(t) =>
(!fAssignee || t.assignee_id === fAssignee) &&
(!fTag || (t.tags ?? []).includes(fTag))
),
[tasks, fAssignee, fTag]
)
const hasFilter = fAssignee || fTag
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader title="Tâches" subtitle={currentProject?.name}> <PageHeader title="Tâches" subtitle={currentProject?.name}>
@@ -80,52 +123,112 @@ export default function Kanban(): JSX.Element {
</button> </button>
</PageHeader> </PageHeader>
<div className="flex flex-1 gap-4 overflow-x-auto p-6"> {/* Barre de filtres */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-6 py-2.5">
<span className="flex items-center gap-1.5 text-xs text-subtle">
<Filter size={14} /> Filtrer
</span>
<Select
className="!w-auto !py-1 text-xs"
value={fAssignee}
onChange={(e) => setFAssignee(e.target.value)}
>
<option value="">Tous les membres</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
<Select
className="!w-auto !py-1 text-xs"
value={fTag}
onChange={(e) => setFTag(e.target.value)}
>
<option value="">Tous les tags</option>
{allTags.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
{hasFilter && (
<button
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
onClick={() => {
setFAssignee('')
setFTag('')
}}
>
<X size={12} /> Réinitialiser
</button>
)}
</div>
<div className="flex flex-1 gap-3 overflow-x-auto p-6">
{TASK_STATUS_ORDER.map((status) => { {TASK_STATUS_ORDER.map((status) => {
const colTasks = tasks.filter((t) => t.status === status) const colTasks = visible.filter((t) => t.status === status)
return ( return (
<div <div
key={status} key={status}
className="flex w-72 shrink-0 flex-col rounded-xl bg-panel/50" className="flex min-w-[240px] flex-1 flex-col rounded-xl border border-border bg-panel2/50"
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, status)} onDrop={(e) => onDrop(e, status)}
> >
<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-white"> <span className="text-sm font-semibold text-ink">
{TASK_STATUS_LABELS[status]} {TASK_STATUS_LABELS[status]}
</span> </span>
<span className="badge bg-panel2 text-gray-400">{colTasks.length}</span> <span className="badge bg-panel2 text-muted">{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) => {
<motion.div const mins = times[t.id] ?? 0
key={t.id} return (
layout <motion.div
initial={{ opacity: 0, y: 6 }} key={t.id}
animate={{ opacity: 1, y: 0 }} layout
whileHover={{ y: -2 }} initial={{ opacity: 0, y: 6 }}
draggable animate={{ opacity: 1, y: 0 }}
onDragStart={() => setDragId(t.id)} whileHover={{ y: -2 }}
onClick={() => setEditing(t)} draggable
className="card cursor-pointer p-3 hover:border-accent" onDragStart={() => setDragId(t.id)}
> onClick={() => setEditing(t)}
<div className="mb-2 flex items-start justify-between gap-2"> className="card cursor-pointer p-3 hover:border-accent"
<p className="text-sm font-medium text-white">{t.title}</p> >
<Avatar profile={byId(t.assignee_id)} size={24} /> <div className="mb-2 flex items-start justify-between gap-2">
</div> <p className="text-sm font-medium text-ink">{t.title}</p>
<div className="flex items-center justify-between"> <Avatar profile={byId(t.assignee_id)} size={24} />
<PriorityBadge priority={t.priority} /> </div>
{t.due_date && (
<span className="flex items-center gap-1 text-xs text-gray-400"> {t.tags?.length > 0 && (
<CalendarDays size={12} /> <div className="mb-2 flex flex-wrap gap-1">
{format(new Date(t.due_date), 'd MMM', { locale: fr })} {t.tags.map((tag) => (
</span> <Tag key={tag} label={tag} />
))}
</div>
)} )}
</div>
</motion.div> <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
))} <PriorityBadge priority={t.priority} />
{mins > 0 && (
<span className="flex items-center gap-1 text-xs text-accent2">
<Clock size={12} />
{formatMinutes(mins)}
</span>
)}
{t.due_date && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted">
<CalendarDays size={12} />
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
</span>
)}
</div>
</motion.div>
)
})}
<button <button
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-gray-500 hover:bg-panel2 hover:text-white" className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-subtle hover:bg-panel2 hover:text-ink"
onClick={() => setCreatingIn(status)} onClick={() => setCreatingIn(status)}
> >
<Plus size={14} /> Ajouter <Plus size={14} /> Ajouter
@@ -141,7 +244,6 @@ export default function Kanban(): JSX.Element {
task={editing} task={editing}
defaultStatus={creatingIn ?? 'todo'} defaultStatus={creatingIn ?? 'todo'}
projectId={projectId!} projectId={projectId!}
createdBy={profile?.id ?? null}
members={members} members={members}
onClose={() => { onClose={() => {
setEditing(null) setEditing(null)
@@ -153,155 +255,3 @@ export default function Kanban(): JSX.Element {
</div> </div>
) )
} }
function TaskModal({
task,
defaultStatus,
projectId,
createdBy,
members,
onClose,
onSaved
}: {
task: Task | null
defaultStatus: TaskStatus
projectId: string
createdBy: string | null
members: ReturnType<typeof useMembers>['members']
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [title, setTitle] = useState(task?.title ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [assignee, setAssignee] = useState(task?.assignee_id ?? '')
const [milestone, setMilestone] = useState(task?.milestone_id ?? '')
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [milestones, setMilestones] = useState<Milestone[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
supa()
.from('milestones')
.select('*')
.eq('project_id', projectId)
.then(({ data }) => setMilestones((data ?? []) as Milestone[]))
}, [projectId])
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
status,
priority,
assignee_id: assignee || null,
milestone_id: milestone || null,
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: createdBy })
}
await onSaved()
setBusy(false)
onClose()
}
const remove = async (): Promise<void> => {
if (!task) return
if (!confirm('Supprimer cette tâche ?')) return
await supa().from('tasks').delete().eq('id', task.id)
await onSaved()
onClose()
}
return (
<Modal title={task ? 'Modifier la tâche' : 'Nouvelle tâche'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Implémenter le saut du personnage"
/>
<label className="label">Description</label>
<textarea
className="input mb-3 h-24 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 TaskStatus)}>
{TASK_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{TASK_STATUS_LABELS[s]}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Assigné à</label>
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
<option value="">Personne</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
</div>
<div>
<label className="label">Jalon</label>
<Select value={milestone} onChange={(e) => setMilestone(e.target.value)}>
<option value="">Aucun</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className="label">Échéance</label>
<input
type="date"
className="input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
{task ? (
<button className="btn text-rose-400 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{task ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
+10 -63
View File
@@ -2,37 +2,19 @@ import { useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Gamepad2, Loader2 } from 'lucide-react' import { Gamepad2, Loader2 } from 'lucide-react'
import { supa } from '../lib/supabase' import { supa } from '../lib/supabase'
import { Select } from '../components/ui'
import { MemberRole, ROLE_LABELS } from '../lib/types'
export default function Login(): JSX.Element { export default function Login(): JSX.Element {
const [mode, setMode] = useState<'signin' | 'signup'>('signin')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [role, setRole] = useState<MemberRole>('programmer')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [info, setInfo] = useState('')
const submit = async (): Promise<void> => { const submit = async (): Promise<void> => {
setError('') setError('')
setInfo('')
setBusy(true) setBusy(true)
try { try {
if (mode === 'signup') { const { error } = await supa().auth.signInWithPassword({ email, password })
const { error } = await supa().auth.signUp({ if (error) throw error
email,
password,
options: { data: { full_name: fullName || email.split('@')[0], role } }
})
if (error) throw error
setInfo('Compte créé ! Tu peux maintenant te connecter.')
setMode('signin')
} else {
const { error } = await supa().auth.signInWithPassword({ email, password })
if (error) throw error
}
} catch (e) { } catch (e) {
setError(translate((e as Error).message)) setError(translate((e as Error).message))
} finally { } finally {
@@ -52,36 +34,10 @@ export default function Login(): JSX.Element {
<div className="rounded-xl bg-accent/20 p-3 text-accent"> <div className="rounded-xl bg-accent/20 p-3 text-accent">
<Gamepad2 size={28} /> <Gamepad2 size={28} />
</div> </div>
<h1 className="text-xl font-semibold text-white">GameDev Tracker</h1> <h1 className="text-xl font-semibold text-ink">GameDev Tracker</h1>
<p className="text-sm text-gray-400"> <p className="text-sm text-muted">Connecte-toi à ton équipe</p>
{mode === 'signin' ? 'Connecte-toi à ton équipe' : 'Crée ton compte membre'}
</p>
</div> </div>
{mode === 'signup' && (
<>
<label className="label">Nom complet</label>
<input
className="input mb-3"
placeholder="Alex Martin"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Rôle dans l'équipe</label>
<Select
className="mb-3"
value={role}
onChange={(e) => setRole(e.target.value as MemberRole)}
>
{Object.entries(ROLE_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</>
)}
<label className="label">Email</label> <label className="label">Email</label>
<input <input
className="input mb-3" className="input mb-3"
@@ -100,24 +56,16 @@ export default function Login(): JSX.Element {
onKeyDown={(e) => e.key === 'Enter' && submit()} onKeyDown={(e) => e.key === 'Enter' && submit()}
/> />
{error && <p className="mb-3 text-sm text-rose-400">{error}</p>} {error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
{info && <p className="mb-3 text-sm text-accent2">{info}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}> <button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />} {busy && <Loader2 className="animate-spin" size={16} />}
{mode === 'signin' ? 'Se connecter' : "Créer le compte"} Se connecter
</button> </button>
<button <p className="mt-4 text-center text-xs text-subtle">
className="mt-4 w-full text-center text-sm text-gray-400 hover:text-white" Pas de compte ? Demande à ton administrateur de t'en créer un.
onClick={() => { </p>
setMode(mode === 'signin' ? 'signup' : 'signin')
setError('')
setInfo('')
}}
>
{mode === 'signin' ? "Pas encore de compte ? S'inscrire" : 'Déjà un compte ? Se connecter'}
</button>
</motion.div> </motion.div>
</div> </div>
) )
@@ -125,7 +73,6 @@ export default function Login(): JSX.Element {
function translate(msg: string): string { function translate(msg: string): string {
if (msg.includes('Invalid login')) return 'Email ou mot de passe incorrect.' if (msg.includes('Invalid login')) return 'Email ou mot de passe incorrect.'
if (msg.includes('already registered')) return 'Cet email est déjà utilisé.' if (msg.includes('Email not confirmed')) return "L'email n'a pas encore é confirmé."
if (msg.includes('at least 6')) return 'Le mot de passe doit faire au moins 6 caractères.'
return msg return msg
} }
+3 -3
View File
@@ -38,11 +38,11 @@ export default function Members(): JSX.Element {
<div key={m.id} className="card flex items-center gap-3"> <div key={m.id} className="card flex items-center gap-3">
<Avatar profile={m} size={44} /> <Avatar profile={m} size={44} />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium text-white"> <div className="truncate font-medium text-ink">
{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-gray-400">{ROLE_LABELS[m.role]}</div> <div className="text-sm text-muted">{ROLE_LABELS[m.role]}</div>
</div> </div>
</div> </div>
))} ))}
@@ -107,7 +107,7 @@ function EditProfileModal({
<button <button
key={c} key={c}
onClick={() => setColor(c)} onClick={() => setColor(c)}
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-white' : ''}`} className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
style={{ background: c }} style={{ background: c }}
/> />
))} ))}
+77 -15
View File
@@ -1,28 +1,32 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { Plus, Target, Trash2, CalendarClock } from 'lucide-react' import { Plus, Target, Trash2, CalendarClock, Clock } 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'
import { useApp } from '../lib/AppContext' import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { import {
Milestone, Milestone,
MilestoneStatus, MilestoneStatus,
Task, Task,
MILESTONE_STATUS_LABELS TimeLog,
MILESTONE_STATUS_LABELS,
formatMinutes
} from '../lib/types' } from '../lib/types'
import { Modal, Select, EmptyState } from '../components/ui' import { Modal, Select, EmptyState, Tag } from '../components/ui'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
const STATUS_STYLES: Record<MilestoneStatus, string> = { const STATUS_STYLES: Record<MilestoneStatus, string> = {
planned: 'bg-slate-500/20 text-slate-300', planned: 'bg-slate-100 text-slate-600',
in_progress: 'bg-sky-500/20 text-sky-300', in_progress: 'bg-sky-100 text-sky-700',
done: 'bg-emerald-500/20 text-emerald-300' done: 'bg-emerald-100 text-emerald-700'
} }
export default function Milestones(): JSX.Element { export default function Milestones(): JSX.Element {
const { currentProject } = useApp() const { currentProject } = useApp()
const [milestones, setMilestones] = useState<Milestone[]>([]) const [milestones, setMilestones] = useState<Milestone[]>([])
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [times, setTimes] = useState<Record<string, number>>({})
const [editing, setEditing] = useState<Milestone | null>(null) const [editing, setEditing] = useState<Milestone | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const projectId = currentProject?.id const projectId = currentProject?.id
@@ -37,7 +41,25 @@ export default function Milestones(): JSX.Element {
(a.due_date ?? '9999').localeCompare(b.due_date ?? '9999') (a.due_date ?? '9999').localeCompare(b.due_date ?? '9999')
) )
setMilestones(sorted) setMilestones(sorted)
setTasks((ts ?? []) as Task[]) 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]) }, [projectId])
useEffect(() => { useEffect(() => {
@@ -51,6 +73,19 @@ export default function Milestones(): JSX.Element {
return { done, total: linked.length, pct } 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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader title="Jalons & planning" subtitle={currentProject?.name}> <PageHeader title="Jalons & planning" subtitle={currentProject?.name}>
@@ -69,6 +104,8 @@ export default function Milestones(): JSX.Element {
<div className="mx-auto max-w-3xl space-y-3"> <div className="mx-auto max-w-3xl space-y-3">
{milestones.map((m) => { {milestones.map((m) => {
const { done, total, pct } = progressOf(m) const { done, total, pct } = progressOf(m)
const mins = timeOf(m)
const mTags = tagsOf(m)
const overdue = const overdue =
m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59')) m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59'))
return ( return (
@@ -79,9 +116,9 @@ export default function Milestones(): JSX.Element {
> >
<div className="mb-2 flex items-start justify-between gap-3"> <div className="mb-2 flex items-start justify-between gap-3">
<div> <div>
<h3 className="font-medium text-white">{m.title}</h3> <h3 className="font-medium text-ink">{m.title}</h3>
{m.description && ( {m.description && (
<p className="mt-0.5 text-sm text-gray-400">{m.description}</p> <p className="mt-0.5 text-sm text-muted">{m.description}</p>
)} )}
</div> </div>
<span className={`badge ${STATUS_STYLES[m.status]}`}> <span className={`badge ${STATUS_STYLES[m.status]}`}>
@@ -89,6 +126,14 @@ export default function Milestones(): JSX.Element {
</span> </span>
</div> </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="mb-2 h-2 overflow-hidden rounded-full bg-panel2">
<div <div
className="h-full rounded-full bg-accent2 transition-all" className="h-full rounded-full bg-accent2 transition-all"
@@ -96,13 +141,20 @@ export default function Milestones(): JSX.Element {
/> />
</div> </div>
<div className="flex items-center justify-between text-xs text-gray-400"> <div className="flex items-center justify-between text-xs text-muted">
<span> <span className="flex items-center gap-3">
{done}/{total} tâche(s) {pct}% <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> </span>
{m.due_date && ( {m.due_date && (
<span <span
className={`flex items-center gap-1 ${overdue ? 'text-rose-400' : ''}`} className={`flex items-center gap-1 ${overdue ? 'text-rose-600' : ''}`}
> >
<CalendarClock size={12} /> <CalendarClock size={12} />
{format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })} {format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })}
@@ -155,6 +207,7 @@ function MilestoneModal({
const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned') const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned')
const [dueDate, setDueDate] = useState(milestone?.due_date ?? '') const [dueDate, setDueDate] = useState(milestone?.due_date ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const { toast, confirm } = useFeedback()
const save = async (): Promise<void> => { const save = async (): Promise<void> => {
if (!title.trim()) return if (!title.trim()) return
@@ -170,13 +223,22 @@ function MilestoneModal({
else await supa().from('milestones').insert(payload) else await supa().from('milestones').insert(payload)
await onSaved() await onSaved()
setBusy(false) setBusy(false)
toast(milestone ? 'Jalon enregistré.' : 'Jalon créé.', 'success')
onClose() onClose()
} }
const remove = async (): Promise<void> => { const remove = async (): Promise<void> => {
if (!milestone || !confirm('Supprimer ce jalon ?')) return 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 supa().from('milestones').delete().eq('id', milestone.id)
await onSaved() await onSaved()
toast('Jalon supprimé.', 'success')
onClose() onClose()
} }
@@ -219,7 +281,7 @@ function MilestoneModal({
</div> </div>
<div className="mt-5 flex items-center justify-between"> <div className="mt-5 flex items-center justify-between">
{milestone ? ( {milestone ? (
<button className="btn text-rose-400 hover:bg-rose-500/10" onClick={remove}> <button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer <Trash2 size={16} /> Supprimer
</button> </button>
) : ( ) : (
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { ChevronLeft, ChevronRight, Filter, X } from 'lucide-react'
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
addMonths,
addWeeks,
isSameMonth,
isSameDay,
isToday
} from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers'
import { Task, TaskStatus } from '../lib/types'
import { Select } from '../components/ui'
import TaskModal from '../components/TaskModal'
import PageHeader from '../components/PageHeader'
type ViewMode = 'month' | 'week'
const STATUS_DOT: Record<TaskStatus, string> = {
todo: 'bg-slate-400',
in_progress: 'bg-sky-400',
review: 'bg-amber-400',
done: 'bg-emerald-400'
}
export default function Planning(): JSX.Element {
const { currentProject } = useApp()
const { members } = useMembers()
const [tasks, setTasks] = useState<Task[]>([])
const [view, setView] = useState<ViewMode>('month')
const [cursor, setCursor] = useState<Date>(() => startOfMonth(new Date()))
const [editing, setEditing] = useState<Task | null>(null)
const [fAssignee, setFAssignee] = useState('')
const [fTag, setFTag] = useState('')
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const { data } = await supa().from('tasks').select('*').eq('project_id', projectId)
setTasks((data ?? []) as Task[])
}, [projectId])
useEffect(() => {
load()
}, [load])
const allTags = useMemo(() => {
const set = new Set<string>()
tasks.forEach((t) => (t.tags ?? []).forEach((tag) => set.add(tag)))
return Array.from(set).sort()
}, [tasks])
const visible = useMemo(
() =>
tasks.filter(
(t) =>
t.due_date &&
(!fAssignee || t.assignee_id === fAssignee) &&
(!fTag || (t.tags ?? []).includes(fTag))
),
[tasks, fAssignee, fTag]
)
const tasksOn = (day: Date): Task[] =>
visible.filter((t) => t.due_date && isSameDay(new Date(t.due_date), day))
// Jours affichés selon la vue
const days = useMemo(() => {
if (view === 'month') {
const start = startOfWeek(startOfMonth(cursor), { weekStartsOn: 1 })
const end = endOfWeek(endOfMonth(cursor), { weekStartsOn: 1 })
return eachDayOfInterval({ start, end })
}
const start = startOfWeek(cursor, { weekStartsOn: 1 })
const end = endOfWeek(cursor, { weekStartsOn: 1 })
return eachDayOfInterval({ start, end })
}, [view, cursor])
const move = (dir: number): void =>
setCursor((c) => (view === 'month' ? addMonths(c, dir) : addWeeks(c, dir)))
const title =
view === 'month'
? format(cursor, 'MMMM yyyy', { locale: fr })
: `Semaine du ${format(startOfWeek(cursor, { weekStartsOn: 1 }), 'd MMM', { locale: fr })}`
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const hasFilter = fAssignee || fTag
return (
<div className="flex h-full flex-col">
<PageHeader title="Planning" subtitle={currentProject?.name}>
<div className="flex rounded-lg border border-border p-0.5">
{(['month', 'week'] as ViewMode[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`rounded-md px-3 py-1 text-sm ${
view === v ? 'bg-accent text-ink' : 'text-muted hover:text-ink'
}`}
>
{v === 'month' ? 'Mois' : 'Semaine'}
</button>
))}
</div>
</PageHeader>
{/* Navigation + filtres */}
<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)}>
<ChevronLeft size={16} />
</button>
<button
className="rounded-lg px-2 py-1 text-sm text-gray-600 hover:bg-panel2"
onClick={() => setCursor(view === 'month' ? startOfMonth(new Date()) : new Date())}
>
Aujourd'hui
</button>
<button className="btn-ghost !px-2 !py-1" onClick={() => move(1)}>
<ChevronRight size={16} />
</button>
<span className="ml-1 text-sm font-medium capitalize text-ink">{title}</span>
<span className="ml-auto flex items-center gap-1.5 text-xs text-subtle">
<Filter size={14} /> Filtrer
</span>
<Select
className="!w-auto !py-1 text-xs"
value={fAssignee}
onChange={(e) => setFAssignee(e.target.value)}
>
<option value="">Tous les membres</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
<Select
className="!w-auto !py-1 text-xs"
value={fTag}
onChange={(e) => setFTag(e.target.value)}
>
<option value="">Tous les tags</option>
{allTags.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
{hasFilter && (
<button
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
onClick={() => {
setFAssignee('')
setFTag('')
}}
>
<X size={12} /> Réinitialiser
</button>
)}
</div>
{/* Grille calendrier */}
<div className="flex flex-1 flex-col overflow-hidden p-6">
<div className="grid grid-cols-7 border-b border-border pb-2 text-center text-xs font-medium uppercase text-subtle">
{weekDays.map((d) => (
<div key={d}>{d}</div>
))}
</div>
<div
className={`grid flex-1 grid-cols-7 gap-1 overflow-y-auto pt-1 ${
view === 'week' ? 'auto-rows-fr' : ''
}`}
style={view === 'month' ? { gridAutoRows: 'minmax(7rem, 1fr)' } : undefined}
>
{days.map((day) => {
const dayTasks = tasksOn(day)
const muted = view === 'month' && !isSameMonth(day, cursor)
return (
<div
key={day.toISOString()}
className={`flex flex-col gap-1 rounded-lg border border-border/60 p-1.5 ${
muted ? 'opacity-40' : ''
} ${isToday(day) ? 'ring-1 ring-accent' : ''}`}
>
<div className="flex justify-end">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
isToday(day) ? 'bg-accent font-semibold text-ink' : 'text-muted'
}`}
>
{format(day, 'd')}
</span>
</div>
<div className="flex flex-col gap-1 overflow-y-auto">
{dayTasks.map((t) => (
<button
key={t.id}
onClick={() => setEditing(t)}
className="flex items-center gap-1.5 truncate rounded-md bg-panel2 px-1.5 py-1 text-left text-xs text-ink hover:bg-border"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${STATUS_DOT[t.status]}`} />
<span className="truncate">{t.title}</span>
</button>
))}
</div>
</div>
)
})}
</div>
</div>
{editing && (
<TaskModal
task={editing}
defaultStatus={editing.status}
projectId={projectId!}
members={members}
onClose={() => setEditing(null)}
onSaved={load}
/>
)}
</div>
)
}
+7 -7
View File
@@ -38,14 +38,14 @@ export default function Setup(): JSX.Element {
<Database size={24} /> <Database size={24} />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold text-white">Connexion à la base de données</h1> <h1 className="text-xl font-semibold text-ink">Connexion à la base de données</h1>
<p className="text-sm text-gray-400">Configuration unique pour toute l'équipe</p> <p className="text-sm text-muted">Configuration unique pour toute l'équipe</p>
</div> </div>
</div> </div>
<div className="mb-4 rounded-lg border border-border bg-panel2 p-3 text-sm text-gray-300"> <div className="mb-4 rounded-lg border border-border bg-panel2 p-3 text-sm text-gray-600">
<p className="mb-2 font-medium text-white">Comment obtenir ces clés (gratuit) :</p> <p className="mb-2 font-medium text-ink">Comment obtenir ces clés (gratuit) :</p>
<ol className="list-inside list-decimal space-y-1 text-gray-400"> <ol className="list-inside list-decimal space-y-1 text-muted">
<li> <li>
Crée un compte sur{' '} Crée un compte sur{' '}
<a <a
@@ -76,13 +76,13 @@ export default function Setup(): JSX.Element {
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
/> />
{error && <p className="mb-3 text-sm text-rose-400">{error}</p>} {error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}> <button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />} {busy && <Loader2 className="animate-spin" size={16} />}
Connecter Connecter
</button> </button>
<p className="mt-3 text-center text-xs text-gray-500"> <p className="mt-3 text-center text-xs text-subtle">
Les clés sont stockées localement sur ton ordinateur uniquement. Les clés sont stockées localement sur ton ordinateur uniquement.
</p> </p>
</motion.div> </motion.div>
+144
View File
@@ -0,0 +1,144 @@
// =====================================================================
// Edge Function : admin-users
// Gère les comptes (lister / créer / supprimer / modifier rôle & admin).
// Tourne sur les serveurs Supabase et garde la clé service_role en
// sécurité — elle n'est JAMAIS exposée dans l'appli.
//
// Déploiement (voir le guide) :
// supabase functions deploy admin-users --no-verify-jwt
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous.)
// =====================================================================
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
}
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!
const ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!
const SERVICE_ROLE = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...cors, 'Content-Type': 'application/json' }
})
}
Deno.serve(async (req) => {
// Pré-vol CORS (envoyé automatiquement par le navigateur/webview).
if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
try {
// --- 1) Identifier l'appelant à partir de son token de connexion ---
const authHeader = req.headers.get('Authorization') ?? ''
const caller = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: authHeader } }
})
const {
data: { user },
error: userErr
} = await caller.auth.getUser()
if (userErr || !user) return json({ error: 'Non authentifié.' }, 401)
// --- 2) Client "admin" (service_role) pour les opérations sensibles ---
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false }
})
// --- 3) Vérifier que l'appelant est bien administrateur ---
const { data: me } = await admin
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single()
if (!me?.is_admin) return json({ error: 'Accès réservé aux administrateurs.' }, 403)
// --- 4) Exécuter l'action demandée ---
const { action, ...p } = await req.json()
switch (action) {
case 'list': {
// Emails dans auth.users + métadonnées dans profiles → on fusionne.
const { data: list, error } = await admin.auth.admin.listUsers({ perPage: 1000 })
if (error) throw error
const { data: profiles } = await admin.from('profiles').select('*')
const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr]))
const users = list.users.map((u) => {
const pr: any = byId.get(u.id) ?? {}
return {
id: u.id,
email: u.email ?? '',
full_name: pr.full_name ?? '',
role: pr.role ?? 'other',
is_admin: pr.is_admin ?? false,
created_at: pr.created_at ?? u.created_at
}
})
return json(users)
}
case 'create': {
const { email, password, full_name, role, is_admin } = p
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
if (String(password).length < 6)
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
// Crée le compte (email déjà confirmé : connexion immédiate possible).
const { data: created, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name || email.split('@')[0], role: role || 'other' }
})
if (error) return json({ error: error.message }, 400)
// Le trigger handle_new_user a créé le profil ; on cale les champs.
await admin
.from('profiles')
.update({
full_name: full_name || email.split('@')[0],
role: role || 'other',
is_admin: !!is_admin
})
.eq('id', created.user.id)
return json({ ok: true, id: created.user.id })
}
case 'delete': {
const { id } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id)
return json({ error: 'Tu ne peux pas supprimer ton propre compte.' }, 400)
const { error } = await admin.auth.admin.deleteUser(id) // profil supprimé en cascade
if (error) return json({ error: error.message }, 400)
return json({ ok: true })
}
case 'update': {
const { id, full_name, role, is_admin } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id && is_admin === false)
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name
if (role !== undefined) patch.role = role
if (is_admin !== undefined) patch.is_admin = !!is_admin
if (Object.keys(patch).length === 0) return json({ error: 'Rien à modifier.' }, 400)
const { error } = await admin.from('profiles').update(patch).eq('id', id)
if (error) return json({ error: error.message }, 400)
return json({ ok: true })
}
default:
return json({ error: 'Action inconnue.' }, 400)
}
} catch (e) {
return json({ error: (e as Error).message }, 500)
}
})
+90
View File
@@ -155,3 +155,93 @@ exception when duplicate_object then null; end $$;
do $$ begin do $$ begin
alter publication supabase_realtime add table public.milestones; alter publication supabase_realtime add table public.milestones;
exception when duplicate_object then null; end $$; exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.profiles;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- ADMINISTRATION : comptes & rôles gérés par un panel admin in-app
-- =====================================================================
-- ---------- Champ "administrateur" sur les profils -------------------
alter table public.profiles
add column if not exists is_admin boolean not null default false;
-- ---------- Sécurité fine sur les profils ----------------------------
-- On remplace la policy permissive "profiles_all" par des règles précises :
-- * tout le monde (connecté) PEUT LIRE les profils (affichage de l'équipe)
-- * chacun ne peut MODIFIER que SON propre profil
-- * la création/suppression de comptes passe uniquement par l'Edge
-- Function "admin-users" (clé service_role, côté serveur).
drop policy if exists profiles_all on public.profiles;
drop policy if exists profiles_select on public.profiles;
drop policy if exists profiles_update_self on public.profiles;
create policy profiles_select on public.profiles
for select to authenticated using (true);
create policy profiles_update_self on public.profiles
for update to authenticated
using (id = auth.uid()) with check (id = auth.uid());
-- ---------- Garde-fou : on ne se promeut pas admin soi-même ----------
-- Empêche un membre connecté (rôle "authenticated") de changer le champ
-- is_admin. Les opérations légitimes passent par l'Edge Function
-- (rôle "service_role") ou par le SQL Editor (rôle "postgres"), tous deux
-- autorisés ici. Un admin déjà en place peut aussi modifier le statut.
create or replace function public.guard_is_admin()
returns trigger language plpgsql security definer set search_path = public as $$
begin
if new.is_admin is distinct from old.is_admin
and current_user = 'authenticated'
and not exists (
select 1 from public.profiles p where p.id = auth.uid() and p.is_admin
) then
raise exception 'Seul un administrateur peut modifier le statut admin.';
end if;
return new;
end $$;
drop trigger if exists trg_profiles_guard_admin on public.profiles;
create trigger trg_profiles_guard_admin before update on public.profiles
for each row execute function public.guard_is_admin();
-- ---------- Amorçage du PREMIER admin (à faire UNE fois) -------------
-- Après ta première inscription dans l'appli, exécute cette ligne en
-- remplaçant l'email par le tien pour devenir administrateur :
--
-- update public.profiles set is_admin = true
-- where id = (select id from auth.users where email = 'TON_EMAIL@ exemple.com');
--
-- Ensuite, tu pourras créer/gérer tous les autres comptes depuis l'appli.
-- =====================================================================
-- TAGS (« jobs ») & SUIVI DU TEMPS
-- =====================================================================
-- ---------- Tags sur les tâches (ex: Programmation, Art, Audio) ------
alter table public.tasks
add column if not exists tags text[] not null default '{}';
-- ---------- Journal du temps passé (par tâche, par membre) -----------
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references public.tasks(id) on delete cascade,
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()
);
create index if not exists idx_time_logs_task on public.time_logs(task_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 $$;
+13 -6
View File
@@ -1,15 +1,22 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {
bg: '#0f1117', // Tokens pilotés par variables CSS (voir :root et .dark dans index.css).
panel: '#171a23', bg: 'rgb(var(--c-bg) / <alpha-value>)',
panel2: '#1e222e', panel: 'rgb(var(--c-panel) / <alpha-value>)',
border: '#2a2f3d', panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
accent: '#7c5cff', sidebar: 'rgb(var(--c-sidebar) / <alpha-value>)',
accent2: '#00d2b8' border: 'rgb(var(--c-border) / <alpha-value>)',
ink: 'rgb(var(--c-ink) / <alpha-value>)',
muted: 'rgb(var(--c-muted) / <alpha-value>)',
subtle: 'rgb(var(--c-subtle) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
accentfg: 'rgb(var(--c-accentfg) / <alpha-value>)',
accent2: 'rgb(var(--c-accent2) / <alpha-value>)'
} }
} }
}, },