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:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git check-ignore *)",
|
||||||
|
"Bash(ls src-tauri/.gitignore)",
|
||||||
|
"Bash(cat src-tauri/.gitignore)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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).
|
||||||
Generated
+10
-54
@@ -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
@@ -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
@@ -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)}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+11
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<FeedbackProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</FeedbackProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+123
-173
@@ -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,24 +123,68 @@ 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) => {
|
||||||
|
const mins = times[t.id] ?? 0
|
||||||
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
layout
|
layout
|
||||||
@@ -110,22 +197,38 @@ export default function Kanban(): JSX.Element {
|
|||||||
className="card cursor-pointer p-3 hover:border-accent"
|
className="card cursor-pointer p-3 hover:border-accent"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-white">{t.title}</p>
|
<p className="text-sm font-medium text-ink">{t.title}</p>
|
||||||
<Avatar profile={byId(t.assignee_id)} size={24} />
|
<Avatar profile={byId(t.assignee_id)} size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
|
{t.tags?.length > 0 && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{t.tags.map((tag) => (
|
||||||
|
<Tag key={tag} label={tag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<PriorityBadge priority={t.priority} />
|
<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 && (
|
{t.due_date && (
|
||||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
<span className="ml-auto flex items-center gap-1 text-xs text-muted">
|
||||||
<CalendarDays size={12} />
|
<CalendarDays size={12} />
|
||||||
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
|
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-61
@@ -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.signUp({
|
|
||||||
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 })
|
const { error } = await supa().auth.signInWithPassword({ email, password })
|
||||||
if (error) throw error
|
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 été confirmé."
|
||||||
if (msg.includes('at least 6')) return 'Le mot de passe doit faire au moins 6 caractères.'
|
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+75
-13
@@ -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 className="flex items-center gap-3">
|
||||||
<span>
|
<span>
|
||||||
{done}/{total} tâche(s) — {pct}%
|
{done}/{total} tâche(s) — {pct}%
|
||||||
</span>
|
</span>
|
||||||
|
{mins > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-accent2">
|
||||||
|
<Clock size={12} /> {formatMinutes(mins)}
|
||||||
|
</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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
@@ -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>)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user