V0.3
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
"PowerShell(npm run tauri:build)",
|
||||
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||
"PowerShell(npm run typecheck)",
|
||||
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)"
|
||||
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)",
|
||||
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)",
|
||||
"PowerShell($ErrorActionPreference='SilentlyContinue'; $v = supabase --version 2>$null; if \\($v\\) { \"CLI présent : $v\" } else { \"CLI Supabase NON installé\" }; \"scoop: $\\(\\(Get-Command scoop\\).Source\\)\"; \"npx: $\\(\\(Get-Command npx\\).Source\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,12 @@ dist/
|
||||
# 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).
|
||||
|
||||
# Fichiers de travail de la CLI Supabase
|
||||
supabase/.temp/
|
||||
|
||||
# Cles de signature des mises a jour (NE JAMAIS COMMITTER)
|
||||
.tauri-signing-key.local.pub
|
||||
|
||||
# Binaire distribuable produit par `npm run tauri:build`
|
||||
/release/
|
||||
|
||||
+24
-6
@@ -36,11 +36,14 @@ VITE_SUPABASE_ANON_KEY=eyJhbGci...
|
||||
|
||||
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**.
|
||||
3. Copie **tout** le contenu du fichier :
|
||||
- **base neuve** → `supabase/schema.sql`
|
||||
- **base déjà en service** → `supabase/update_all.sql`
|
||||
4. Colle-le et 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.
|
||||
> Ça met en place `is_admin`, les commentaires, les pièces jointes et les
|
||||
> sécurités du panel admin.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,9 +121,10 @@ Envoie ce fichier à tes collègues. Les clés sont dedans : ils n'ont **rien**
|
||||
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
|
||||
- **Créer un compte** : nom, email, mot de passe provisoire (bouton
|
||||
« Générer » si tu manques d'imagination), pôles, et droits admin
|
||||
- **Modifier** un membre : nom, email, mot de passe, photo, pôles
|
||||
- **Promouvoir / rétrograder** un admin (avec confirmation)
|
||||
- **Supprimer** un compte
|
||||
|
||||
Communique l'email + le mot de passe provisoire à chaque membre. Il se connecte,
|
||||
@@ -135,3 +139,17 @@ c'est tout. ✅
|
||||
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).
|
||||
- Depuis la v0.2, la **suppression d'un projet** est réservée à son créateur ou à
|
||||
un administrateur (avant, n'importe quel membre pouvait tout effacer).
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Après la mise à jour v0.2 — redéployer la fonction
|
||||
|
||||
La migration v10 supprime la colonne `profiles.role`, dans laquelle l'ancienne
|
||||
version de l'Edge Function écrivait. **Redéploie-la**, sinon la création de
|
||||
comptes échouera :
|
||||
|
||||
```bash
|
||||
npx supabase functions deploy admin-users --no-verify-jwt
|
||||
```
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# 🔄 Mises à jour automatiques
|
||||
|
||||
Plus besoin d'envoyer un `.exe` à chaque correctif. L'application va chercher
|
||||
elle-même la dernière version publiée.
|
||||
|
||||
```
|
||||
Toi Ton collègue
|
||||
─── ────────────
|
||||
npm run release -- 0.3.0 "…"
|
||||
│
|
||||
├─ construit + signe l'installeur
|
||||
├─ le dépose sur Supabase
|
||||
└─ écrit latest.json
|
||||
au lancement :
|
||||
lit latest.json → 0.3.0 > 0.2.0
|
||||
🔔 « Version 0.3.0 disponible »
|
||||
clic → installe → redémarre
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation (une seule fois)
|
||||
|
||||
### 1. Créer le bucket
|
||||
|
||||
Supabase → **SQL Editor** → **New query** → colle
|
||||
[`supabase/releases_bucket.sql`](supabase/releases_bucket.sql) → **Run**.
|
||||
|
||||
### 2. Donner au script la clé de publication
|
||||
|
||||
Supabase → **Settings → API** → copie la clé **`service_role`**.
|
||||
|
||||
Mets-la dans le fichier `.env.local` à la racine du projet :
|
||||
|
||||
```env
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
|
||||
```
|
||||
|
||||
> `.env.local` est ignoré par git (règle `*.local`). Cette clé sert **uniquement**
|
||||
> à téléverser depuis ta machine : elle n'entre jamais dans l'application
|
||||
> distribuée à l'équipe.
|
||||
|
||||
### 3. 🔑 Sauvegarder la clé de signature — **le point important**
|
||||
|
||||
Le fichier `.tauri-signing-key.local` à la racine du projet signe chaque mise à
|
||||
jour. Les applications déjà installées **n'acceptent que** les paquets signés
|
||||
avec cette clé.
|
||||
|
||||
> ⚠️ **Si tu la perds, aucune mise à jour ne pourra plus être installée.**
|
||||
> Il faudrait générer une nouvelle clé, reconstruire, et redistribuer un `.exe`
|
||||
> à la main à chaque membre de l'équipe.
|
||||
|
||||
Copie-la maintenant dans un endroit sûr — gestionnaire de mots de passe, clé USB,
|
||||
disque de sauvegarde. **Pas** dans le dépôt git.
|
||||
|
||||
### 4. La dernière distribution manuelle
|
||||
|
||||
Les versions déjà installées chez tes collègues ne connaissent pas encore le
|
||||
système de mise à jour. Il faut donc leur livrer **une dernière fois** un
|
||||
installeur à la main :
|
||||
|
||||
```bash
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
Envoie le fichier `src-tauri/target/release/bundle/nsis/GameDev Tracker_0.2.0_x64-setup.exe`.
|
||||
|
||||
C'est le dernier. À partir de cette version, tout est automatique.
|
||||
|
||||
---
|
||||
|
||||
## Publier une nouvelle version
|
||||
|
||||
```bash
|
||||
npm run release -- 0.3.0 "Filtres multiples sur les tâches, correction du Kanban"
|
||||
```
|
||||
|
||||
Le script :
|
||||
|
||||
1. écrit `0.3.0` dans `package.json` et `tauri.conf.json` ;
|
||||
2. construit et **signe** l'installeur (quelques minutes) ;
|
||||
3. le téléverse dans le bucket `releases` ;
|
||||
4. écrit `latest.json` — c'est ce fichier que les applications lisent.
|
||||
|
||||
Ensuite, pense à valider les deux fichiers de version :
|
||||
|
||||
```bash
|
||||
git add package.json src-tauri/tauri.conf.json && git commit -m "v0.3.0"
|
||||
```
|
||||
|
||||
### Quel numéro de version ?
|
||||
|
||||
Format `X.Y.Z`, toujours croissant.
|
||||
|
||||
| Ce que tu as fait | Numéro |
|
||||
|---|---|
|
||||
| Correction de bug | `0.2.0` → `0.2.1` |
|
||||
| Nouvelle fonctionnalité | `0.2.1` → `0.3.0` |
|
||||
| Refonte, changement cassant | `0.3.0` → `1.0.0` |
|
||||
|
||||
Le texte entre guillemets s'affiche dans le bandeau chez tes collègues : écris
|
||||
ce qui **change pour eux**, pas le détail technique.
|
||||
|
||||
---
|
||||
|
||||
## Côté équipe : ce qu'ils voient
|
||||
|
||||
Une bande fine en haut de l'application :
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ ✨ Version 0.3.0 disponible. Filtres multiples sur les tâches │
|
||||
│ [⬇ Installer et redémarrer] [✕ plus tard]│
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Jamais de fenêtre bloquante : on peut ignorer et continuer à travailler.
|
||||
- « Plus tard » masque le bandeau ; la proposition revient au lancement suivant.
|
||||
- Vérification au démarrage, puis toutes les 2 heures pour les sessions longues.
|
||||
- Le menu **Aide** (touche `?`) affiche la version installée et un bouton
|
||||
« Rechercher une mise à jour ».
|
||||
|
||||
---
|
||||
|
||||
## En cas de problème
|
||||
|
||||
| Symptôme | Cause probable | Solution |
|
||||
|---|---|---|
|
||||
| Aucun bandeau n'apparaît | `latest.json` absent ou version identique | Ouvre l'URL du manifeste dans un navigateur, vérifie le champ `version` |
|
||||
| « Signature invalide » | L'installeur a été signé avec une autre clé | Republie avec la bonne `.tauri-signing-key.local` |
|
||||
| Le script dit « bucket not found » | Étape 1 non faite | Exécute `supabase/releases_bucket.sql` |
|
||||
| Le script dit « clé introuvable » | `.tauri-signing-key.local` absent | Restaure ta sauvegarde (étape 3) |
|
||||
| Le téléchargement échoue chez un membre | Bucket non public | Rejoue `releases_bucket.sql` |
|
||||
|
||||
URL du manifeste (à ouvrir dans un navigateur pour vérifier) :
|
||||
|
||||
```
|
||||
https://<ton-projet>.supabase.co/storage/v1/object/public/releases/latest.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coût
|
||||
|
||||
Un installeur pèse ~8 Mo. L'offre gratuite Supabase donne 1 Go de stockage et
|
||||
5 Go de transfert par mois : à 6 personnes, c'est environ **100 mises à jour par
|
||||
mois** avant de s'en approcher. Tu peux supprimer les vieux installeurs du
|
||||
bucket de temps en temps (garde le dernier).
|
||||
@@ -1,90 +1,194 @@
|
||||
# 🎮 GameDev Tracker
|
||||
|
||||
Application **desktop** de gestion de projet de jeu vidéo, pensée pour une **petite équipe (5-6 personnes)**.
|
||||
Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base de données **gratuite Supabase**.
|
||||
**Tauri + React + TypeScript + Tailwind**, base de données **Supabase** (offre gratuite suffisante).
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
---
|
||||
|
||||
- 🔐 **Connexion / inscription** par email + mot de passe
|
||||
- 👥 **Équipe, rôles & pôles** — le rôle d'un membre (programmeur, artiste, game designer…) définit son **pôle**
|
||||
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*, tâche confiée à un **pôle** avec une ou **plusieurs personnes** assignées
|
||||
- ⏱️ **Feuille de temps** — vue par semaine (7 jours en colonnes) : consigne les heures faites chaque jour sur une tâche **ou** une activité libre (réunion, veille…)
|
||||
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, pôle, assignation
|
||||
- 🔔 **Notifications** — quand une tâche/un bug est confié à un pôle, ses membres reçoivent une notif (cloche 🔔 dans la barre latérale)
|
||||
- 📊 **Tableau de bord** — avancement global, répartition des tâches, temps total, échéances
|
||||
- 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour
|
||||
## ✨ Ce que fait l'application
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 📊 **Tableau de bord** | Ce que **tu** as à faire, avancement, bugs par sévérité, heures de l'équipe, dernières discussions |
|
||||
| 📋 **Tâches (Kanban)** | 4 colonnes, glisser-déposer **et déplacement au clavier**, checklists, étiquettes, priorité, échéance |
|
||||
| 🐛 **Bugs** | Sévérité expliquée, tri et filtres, **captures d'écran collables (Ctrl+V)**, temps passé |
|
||||
| ⏱️ **Feuille de temps** | Semaine en 7 colonnes, saisie sur une tâche / un bug / une activité libre, **export CSV** |
|
||||
| 📚 **Documentation** | Fiches en Markdown : GDD, conventions, notes de réunion — créées dans l'app ou **importées depuis un `.md` / `.docx`** |
|
||||
| 💬 **Discussions** | Fil de commentaires sur chaque tâche et chaque bug, avec notification des personnes concernées |
|
||||
| 📎 **Pièces jointes** | Fichiers et captures rattachés aux tâches et aux bugs |
|
||||
| 👥 **Équipe & pôles** | Un membre appartient à un ou plusieurs pôles (programmeur, artiste, sound designer…) |
|
||||
| 🔔 **Notifications** | Quand une tâche/un bug t'est confié — par ton **pôle** ou **nommément** — et à chaque message |
|
||||
| 🔎 **Recherche globale** | `Ctrl + K` : tâches, bugs, personnes, actions |
|
||||
| 🔄 **Temps réel** | Tout se synchronise entre les membres, sans recharger |
|
||||
| 🌗 **Thème** | Clair, sombre, ou suivant le système |
|
||||
| 🔄 **Mises à jour auto** | L'app se met à jour toute seule — plus de `.exe` à renvoyer ([détails](MISE_A_JOUR.md)) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Mise en route
|
||||
|
||||
### 1. Créer la base de données (gratuit, une seule fois pour toute l'équipe)
|
||||
### 1. Créer la base de données (une seule fois, pour toute l'équipe)
|
||||
|
||||
1. Va sur **[supabase.com](https://supabase.com)** et crée un compte.
|
||||
2. Clique **New project** (choisis un mot de passe de base de données, garde-le quelque part).
|
||||
3. Attends ~2 min que le projet se crée.
|
||||
4. Dans le menu de gauche : **SQL Editor → New query**.
|
||||
5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**.
|
||||
> Cela crée les tables (profils, projets, tâches, bugs, suivi du temps, notifications), la sécurité et la synchro temps réel.
|
||||
>
|
||||
> 🆙 **Tu avais déjà installé une version précédente (avec les jalons) ?** Exécute plutôt, dans l'ordre, [`supabase/migration_v2.sql`](supabase/migration_v2.sql) (supprime les jalons, ajoute pôles + assignation multiple + notifications), [`supabase/migration_v3.sql`](supabase/migration_v3.sql) (feuille de temps : entrées libres type réunion) puis [`supabase/migration_v4.sql`](supabase/migration_v4.sql) (plusieurs pôles par membre) — **sans perdre tes données**.
|
||||
>
|
||||
> 🔁 Après la migration v4, **redéploie l'Edge Function** pour que l'admin puisse gérer les pôles multiples : `supabase functions deploy admin-users --no-verify-jwt`.
|
||||
6. Menu **Settings (⚙️) → API**. Note deux valeurs :
|
||||
- **Project URL** → ressemble à `https://xxxxx.supabase.co`
|
||||
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...`
|
||||
7. **Recommandé pour une petite équipe** : désactive la confirmation d'email pour pouvoir se connecter tout de suite après l'inscription.
|
||||
- Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »** et enregistre.
|
||||
- (Sinon, chaque membre devra cliquer sur un lien reçu par email avant de pouvoir se connecter.)
|
||||
1. Crée un compte sur **[supabase.com](https://supabase.com)** puis un **New project** (offre gratuite).
|
||||
2. Menu **SQL Editor → New query**.
|
||||
3. Colle le contenu de **[`supabase/schema.sql`](supabase/schema.sql)** et clique **Run**.
|
||||
|
||||
> ℹ️ La clé *anon public* est faite pour être distribuée à l'app cliente : pas de secret sensible exposé.
|
||||
> 🆙 **Tu avais déjà une version précédente installée ?**
|
||||
> Exécute plutôt **[`supabase/update_all.sql`](supabase/update_all.sql)** : il met la base à jour
|
||||
> sans rien perdre (voir « Mise à jour v0.2 » plus bas).
|
||||
|
||||
### 2. Lancer l'application
|
||||
4. Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »**
|
||||
(sinon chaque membre doit cliquer un lien reçu par mail avant de pouvoir se connecter).
|
||||
5. Menu **Settings → API** : note l'**URL du projet** et la clé **anon public**.
|
||||
|
||||
Prérequis (déjà installés sur cette machine) : **Node.js**, **Rust**.
|
||||
### 2. Configurer l'application
|
||||
|
||||
Crée un fichier `.env` à la racine :
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=https://xxxxx.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOi...
|
||||
```
|
||||
|
||||
> La clé *anon* est **publique par nature** : c'est la sécurité au niveau des lignes (RLS)
|
||||
> de Supabase qui protège les données, pas le secret de cette clé.
|
||||
|
||||
### 3. Lancer
|
||||
|
||||
Prérequis : **Node.js** et **Rust**.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
Au premier lancement, l'app affiche un écran de **configuration** : colle l'**URL** et la **clé anon** obtenues à l'étape 1, puis **Connecter**.
|
||||
Ensuite : crée ton compte (avec ton rôle), crée un **projet**, et c'est parti. 🎉
|
||||
### 4. Devenir administrateur (une seule fois)
|
||||
|
||||
### 3. Distribuer à l'équipe
|
||||
Crée ton compte, puis dans le **SQL Editor** de Supabase :
|
||||
|
||||
Pour générer un installeur (`.exe`/`.msi` sur Windows) :
|
||||
```sql
|
||||
update public.profiles set is_admin = true
|
||||
where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
|
||||
```
|
||||
|
||||
Déploie ensuite l'espace d'administration (voir **[GUIDE_ADMIN.md](GUIDE_ADMIN.md)**) :
|
||||
|
||||
```bash
|
||||
supabase functions deploy admin-users --no-verify-jwt
|
||||
```
|
||||
|
||||
Tu peux alors créer les comptes de toute l'équipe depuis l'onglet **Administration**.
|
||||
|
||||
### 5. Distribuer — une seule fois
|
||||
|
||||
```bash
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
Le fichier d'installation se trouve dans `src-tauri/target/release/bundle/`.
|
||||
Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la même URL + clé anon** que toi — ils rejoignent ainsi le même projet partagé.
|
||||
Génère un installeur dans `src-tauri/target/release/bundle/nsis/`. Envoie-le à
|
||||
ton équipe : **c'est la dernière fois que tu le fais à la main.**
|
||||
|
||||
### 6. Activer les mises à jour automatiques
|
||||
|
||||
Ensuite, chaque nouvelle version se publie en une commande :
|
||||
|
||||
```bash
|
||||
npm run release -- 0.3.0 "Ce qui change pour l'équipe"
|
||||
```
|
||||
|
||||
Tes collègues voient un bandeau « Version disponible » au lancement, cliquent, et
|
||||
l'application se met à jour toute seule. Mise en place (une fois) et mode
|
||||
d'emploi complet : **[MISE_A_JOUR.md](MISE_A_JOUR.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Scripts utiles
|
||||
## ⌨️ Raccourcis clavier
|
||||
|
||||
| Commande | Effet |
|
||||
| --- | --- |
|
||||
| `npm run tauri:dev` | Lance l'app en mode développement (rechargement à chaud) |
|
||||
| `npm run tauri:build` | Génère l'installeur de l'app |
|
||||
| `npm run dev` | Lance uniquement le front-end dans le navigateur (sans la fenêtre desktop) |
|
||||
| `npm run typecheck` | Vérifie les types TypeScript |
|
||||
| Raccourci | Action |
|
||||
|---|---|
|
||||
| `Ctrl + K` ou `/` | Recherche globale / palette de commandes |
|
||||
| `Ctrl + 1…6` | Changer d'écran |
|
||||
| `Ctrl + B` | Replier / déployer le menu latéral |
|
||||
| `?` | Afficher l'aide |
|
||||
| `Échap` | Fermer une fenêtre |
|
||||
| `Espace` puis `← → ↑ ↓` | Déplacer une carte du Kanban **au clavier** |
|
||||
| `Ctrl + Entrée` | Envoyer un commentaire |
|
||||
| `Ctrl + S` | Enregistrer une fiche de documentation |
|
||||
| `Alt + ← / →` | Semaine précédente / suivante (feuille de temps) |
|
||||
|
||||
## 🏗️ Architecture
|
||||
---
|
||||
|
||||
```text
|
||||
├─ index.html # point d'entrée du front
|
||||
├─ src/ # application React (front-end)
|
||||
│ ├─ pages/ # Dashboard, Kanban, Planning (feuille de temps), Bugs, Members, Admin, Login, Setup
|
||||
│ ├─ components/ # Layout, UI, animations
|
||||
│ └─ lib/ # client Supabase, types, contexte global, config
|
||||
├─ src-tauri/ # enveloppe desktop Tauri (Rust)
|
||||
└─ supabase/schema.sql # schéma de la base à exécuter dans Supabase
|
||||
## 🧱 Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
supabase.ts Client unique, clés lues depuis .env
|
||||
db.ts run/rows/mutate + useQuery + useRealtime ← toute la fiabilité est ici
|
||||
AppContext.tsx Session, profil, membres, projets, écran courant
|
||||
types.ts Types métier + libellés + helpers de formatage
|
||||
tones.ts Couleurs sémantiques (badges, pastilles) — clair ET sombre
|
||||
notify.ts Qui prévenir, et quand
|
||||
storage.ts Avatars et pièces jointes
|
||||
markdown.ts Rendu Markdown assaini + import .md/.docx
|
||||
feedback.tsx Toasts et confirmations (le SEUL système de retour utilisateur)
|
||||
theme.ts Clair / sombre / système
|
||||
components/
|
||||
ui.tsx Primitives : Button, IconButton, Field, états vide/erreur/chargement
|
||||
Modal.tsx Modale : en-tête et pied fixes, piège à focus, garde anti-perte de saisie
|
||||
Select.tsx Menu déroulant entièrement pilotable au clavier
|
||||
people.tsx Avatars, pôles, assignation, étiquettes
|
||||
EntityExtras.tsx Discussion, pièces jointes, temps passé (tâches ET bugs)
|
||||
Layout.tsx Barre latérale + UNE barre supérieure + raccourcis
|
||||
pages/ Un fichier par écran
|
||||
supabase/
|
||||
schema.sql Installation neuve
|
||||
update_all.sql Mise à jour d'une base existante
|
||||
migration_v10.sql La refonte v0.2 seule
|
||||
migration_v11.sql La documentation seule
|
||||
functions/admin-users/ Edge Function de gestion des comptes
|
||||
```
|
||||
|
||||
## 🔒 Note de sécurité
|
||||
### Trois règles à respecter en contribuant
|
||||
|
||||
L'app suppose une **équipe de confiance** : tout membre connecté peut lire et modifier les données du projet (politique RLS « accès complet pour les utilisateurs authentifiés »). C'est volontaire pour rester simple sur une petite équipe. Si un jour tu veux des permissions plus fines (ex. seul le lead supprime un projet), il faudra ajuster les *policies* dans `supabase/schema.sql`.
|
||||
1. **Aucune requête sans gestion d'erreur.** On passe par `run` / `rows` / `mutate` de
|
||||
`lib/db.ts`, jamais par `const { data } = await supa()...`.
|
||||
2. **Aucune couleur en dur.** Pas de `bg-amber-100` ni de `text-gray-500` : on utilise les
|
||||
tokens (`bg-panel`, `text-muted`…) et le système de tons de `lib/tones.ts`.
|
||||
3. **Aucun bouton écrit à la main.** `Button` / `IconButton` de `components/ui.tsx`.
|
||||
Un bouton-icône a toujours un `label` (infobulle **et** lecteur d'écran).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Vérifications
|
||||
|
||||
```bash
|
||||
npm run check # types + lint
|
||||
npm run build # typecheck puis build de production
|
||||
npm run release -- X.Y.Z "notes" # publie une version pour toute l'équipe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆙 Mise à jour v0.2
|
||||
|
||||
La migration `supabase/update_all.sql` :
|
||||
|
||||
- ajoute les tables **`comments`** et **`attachments`** (+ bucket de stockage) ;
|
||||
- ajoute **`bugs.poles`** (plusieurs pôles par bug) et **`time_logs.bug_id`** (temps sur un bug) ;
|
||||
- **supprime les colonnes de compatibilité** `profiles.role`, `tasks.pole`, `bugs.assignee_id`
|
||||
après avoir recopié leur contenu dans les tableaux correspondants ;
|
||||
- ajoute la fonction **`reorder_tasks`** (réordonnancement du Kanban en une transaction) ;
|
||||
- réserve la **suppression d'un projet** à son créateur ou à un administrateur.
|
||||
|
||||
> ⚠️ **Redéploie l'Edge Function après la migration** — elle écrivait dans `profiles.role`,
|
||||
> qui n'existe plus :
|
||||
> ```bash
|
||||
> supabase functions deploy admin-users --no-verify-jwt
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
Sur ton autre PC (fixe/portable)
|
||||
Première fois (si le projet n'y est pas encore) :
|
||||
|
||||
npm install
|
||||
npm run tauri:dev
|
||||
@@ -0,0 +1,48 @@
|
||||
// Configuration ESLint (format « flat », ESLint 9).
|
||||
// L'application avait des commentaires `eslint-disable` un peu partout…
|
||||
// sans qu'ESLint ne soit installé. Il l'est maintenant.
|
||||
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules', 'src-tauri', 'supabase/functions'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': 'off',
|
||||
|
||||
// Les dépendances de hooks manquantes ont causé de vrais bugs de
|
||||
// rafraîchissement : c'est une erreur, pas un avertissement.
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
|
||||
// Avertissement, pas erreur : réagir à un changement de contexte externe
|
||||
// (notification qui demande d'ouvrir une fiche, ouverture d'un menu)
|
||||
// passe légitimement par un setState dans un effet.
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
eqeqeq: ['error', 'smart'],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'prefer-const': 'error'
|
||||
}
|
||||
}
|
||||
)
|
||||
+34
@@ -5,6 +5,40 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<title>GameDev Tracker</title>
|
||||
|
||||
<!--
|
||||
Couleur de fond appliquée AVANT le CSS et le JS.
|
||||
|
||||
Sans ça, la toute première image peinte par le webview est blanche —
|
||||
un éclair visible au démarrage, particulièrement en thème sombre.
|
||||
Les valeurs reprennent --c-bg de src/index.css : toute modification
|
||||
là-bas doit être répercutée ici.
|
||||
-->
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #fafaf9;
|
||||
}
|
||||
html.dark,
|
||||
html.dark body {
|
||||
background: #0f0f13;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Choix du thème le plus tôt possible, avant le premier rendu.
|
||||
// Doit rester aligné sur src/lib/theme.ts (défaut = clair).
|
||||
try {
|
||||
var t = localStorage.getItem('gamedev-tracker-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
} catch (e) {
|
||||
/* localStorage indisponible : on reste en clair */
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1479
-3
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -1,38 +1,53 @@
|
||||
{
|
||||
"name": "gamedev-tracker",
|
||||
"version": "0.1.0",
|
||||
"description": "Application de gestion de projet de jeu vidéo pour petite équipe",
|
||||
"version": "0.2.7",
|
||||
"description": "Gestion de projet de jeu video pour petite equipe (Tauri + React + Supabase)",
|
||||
"type": "module",
|
||||
"author": "Mathew",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
"tauri:build": "node scripts/tauri-build.mjs",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"release": "node scripts/release.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "^0.441.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"marked": "^18.0.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.9.0",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Génère les deux images de l'installeur NSIS à partir du logo de l'app.
|
||||
|
||||
python scripts/installer-art.py
|
||||
|
||||
NSIS impose du BMP à des tailles fixes :
|
||||
· sidebar.bmp 164 x 314 — panneau de gauche (accueil et fin)
|
||||
· header.bmp 150 x 57 — bandeau des pages intermédiaires
|
||||
|
||||
Les images sont versionnées dans src-tauri/installer/ : ce script n'est à
|
||||
relancer que si le logo ou les couleurs de marque changent (il demande
|
||||
Pillow, qui n'est PAS une dépendance du projet).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "src-tauri" / "installer"
|
||||
ICON = ROOT / "src-tauri" / "icons" / "128x128@2x.png"
|
||||
|
||||
# Dégradé du logo (public/logo.svg)
|
||||
INDIGO = (99, 102, 241)
|
||||
TEAL = (20, 184, 166)
|
||||
INK = (16, 16, 24)
|
||||
INK_SOFT = (30, 29, 48)
|
||||
|
||||
|
||||
def font(size: int, bold: bool = False):
|
||||
"""Segoe UI si disponible, repli sur la police par défaut de Pillow."""
|
||||
for name in (["segoeuib.ttf", "seguisb.ttf"] if bold else ["segoeui.ttf"]):
|
||||
try:
|
||||
return ImageFont.truetype(f"C:/Windows/Fonts/{name}", size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
d = ImageDraw.Draw(img)
|
||||
for y in range(h):
|
||||
d.line([(0, y), (w, y)], fill=lerp(top, bottom, y / max(h - 1, 1)))
|
||||
return img
|
||||
|
||||
|
||||
def horizontal_gradient(size, left, right):
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
d = ImageDraw.Draw(img)
|
||||
for x in range(w):
|
||||
d.line([(x, 0), (x, h)], fill=lerp(left, right, x / max(w - 1, 1)))
|
||||
return img
|
||||
|
||||
|
||||
def logo(px: int) -> Image.Image:
|
||||
return Image.open(ICON).convert("RGBA").resize((px, px), Image.LANCZOS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Panneau latéral 164 x 314 — fond sombre, logo, nom, liseré de marque
|
||||
# ---------------------------------------------------------------------
|
||||
def sidebar() -> Image.Image:
|
||||
W, H = 164, 314
|
||||
img = vertical_gradient((W, H), INK_SOFT, INK)
|
||||
d = ImageDraw.Draw(img, "RGBA")
|
||||
|
||||
# Halo indigo diffus derrière le logo, pour éviter le fond plat.
|
||||
cx, cy = W // 2, 96
|
||||
for r in range(92, 0, -4):
|
||||
alpha = int(30 * (1 - r / 92) ** 2)
|
||||
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(*INDIGO, alpha))
|
||||
|
||||
mark = logo(76)
|
||||
img.paste(mark, (cx - 38, cy - 38), mark)
|
||||
|
||||
title = font(15, bold=True)
|
||||
sub = font(11)
|
||||
d.text((cx, 168), "GameDev", font=title, fill=(255, 255, 255), anchor="mm")
|
||||
d.text((cx, 186), "Tracker", font=title, fill=(255, 255, 255), anchor="mm")
|
||||
d.text((cx, 210), "Gestion de projet", font=sub, fill=(150, 148, 170), anchor="mm")
|
||||
d.text((cx, 225), "de jeu vidéo", font=sub, fill=(150, 148, 170), anchor="mm")
|
||||
|
||||
# Liseré dégradé en pied de panneau : la signature visuelle de la marque.
|
||||
bar = horizontal_gradient((W, 4), INDIGO, TEAL)
|
||||
img.paste(bar, (0, H - 4))
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Bandeau 150 x 57 — fond SOMBRE, accordé à MUI_BGCOLOR du template
|
||||
# ---------------------------------------------------------------------
|
||||
def header() -> Image.Image:
|
||||
W, H = 150, 57
|
||||
img = Image.new("RGB", (W, H), INK)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
mark = logo(34)
|
||||
img.paste(mark, (W - 34 - 12, (H - 34) // 2), mark)
|
||||
|
||||
name = font(11, bold=True)
|
||||
d.text((W - 56, H // 2 - 7), "GameDev", font=name, fill=(255, 255, 255), anchor="rm")
|
||||
d.text((W - 56, H // 2 + 7), "Tracker", font=name, fill=(255, 255, 255), anchor="rm")
|
||||
|
||||
bar = horizontal_gradient((W, 3), INDIGO, TEAL)
|
||||
img.paste(bar, (0, H - 3))
|
||||
return img
|
||||
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for name, image in (("sidebar", sidebar()), ("header", header())):
|
||||
path = OUT / f"{name}.bmp"
|
||||
# BMP 24 bits sans alpha : le seul format que NSIS accepte partout.
|
||||
image.convert("RGB").save(path, "BMP")
|
||||
print(f" {path.relative_to(ROOT)} {image.width}x{image.height}")
|
||||
|
||||
print("\nImages générées.")
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================================
|
||||
// Publication d'une nouvelle version.
|
||||
//
|
||||
// npm run release -- 0.3.0 "Ce qui change pour l'équipe"
|
||||
//
|
||||
// Ce que fait le script, dans l'ordre :
|
||||
// 1. écrit le numéro de version dans package.json et tauri.conf.json
|
||||
// 2. construit et SIGNE l'installeur Windows
|
||||
// 3. téléverse l'installeur dans le bucket public « releases »
|
||||
// 4. écrit latest.json — c'est ce fichier que les applications lisent
|
||||
//
|
||||
// À partir de là, chaque membre voit un bandeau « Version X disponible »
|
||||
// au lancement, clique, et c'est fait.
|
||||
// =====================================================================
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join, basename } from 'node:path'
|
||||
import { ROOT, KEY_PATH, signingEnv, tauriBin } from './signing.mjs'
|
||||
|
||||
const BUCKET = 'releases'
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Petits utilitaires
|
||||
// ---------------------------------------------------------------------
|
||||
const c = {
|
||||
ok: (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`),
|
||||
step: (m) => console.log(`\n\x1b[1m\x1b[36m▸ ${m}\x1b[0m`),
|
||||
info: (m) => console.log(` ${m}`),
|
||||
warn: (m) => console.log(`\x1b[33m!\x1b[0m ${m}`)
|
||||
}
|
||||
|
||||
function fail(message, hint) {
|
||||
console.error(`\n\x1b[31m✗ ${message}\x1b[0m`)
|
||||
if (hint) console.error(`\n${hint}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Lit .env puis .env.local (le second gagne). */
|
||||
function loadEnv() {
|
||||
const env = {}
|
||||
for (const file of ['.env', '.env.local']) {
|
||||
const path = join(ROOT, file)
|
||||
if (!existsSync(path)) continue
|
||||
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/i)
|
||||
if (m) env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
function writeJson(path, data) {
|
||||
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1) Arguments et vérifications
|
||||
// ---------------------------------------------------------------------
|
||||
const [version, ...noteParts] = process.argv.slice(2)
|
||||
const notes = noteParts.join(' ').trim()
|
||||
|
||||
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
fail(
|
||||
'Numéro de version manquant ou invalide.',
|
||||
'Utilisation :\n' +
|
||||
' npm run release -- 1.2.3 "Ce qui change pour l\'équipe"\n\n' +
|
||||
'Le numéro doit être au format X.Y.Z.'
|
||||
)
|
||||
}
|
||||
|
||||
const env = loadEnv()
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL
|
||||
const SERVICE_KEY = env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!SUPABASE_URL) fail('VITE_SUPABASE_URL absent du fichier .env')
|
||||
if (!SERVICE_KEY) {
|
||||
fail(
|
||||
'SUPABASE_SERVICE_ROLE_KEY absent du fichier .env.local',
|
||||
'Cette clé sert UNIQUEMENT à téléverser depuis ta machine — elle ne part\n' +
|
||||
"jamais dans l'application distribuée.\n\n" +
|
||||
'Récupère-la dans Supabase → Settings → API → « service_role », puis\n' +
|
||||
'crée un fichier .env.local à la racine avec :\n\n' +
|
||||
' SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...\n\n' +
|
||||
'(.env.local est ignoré par git : la clé ne sera jamais publiée.)'
|
||||
)
|
||||
}
|
||||
|
||||
const currentPkg = readJson(join(ROOT, 'package.json'))
|
||||
if (currentPkg.version === version) {
|
||||
c.warn(`package.json est déjà en ${version} — on republie par-dessus.`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2) Numéro de version
|
||||
// ---------------------------------------------------------------------
|
||||
c.step(`Version ${version}`)
|
||||
|
||||
const pkgPath = join(ROOT, 'package.json')
|
||||
const pkg = readJson(pkgPath)
|
||||
pkg.version = version
|
||||
writeJson(pkgPath, pkg)
|
||||
c.ok('package.json')
|
||||
|
||||
const confPath = join(ROOT, 'src-tauri', 'tauri.conf.json')
|
||||
const conf = readJson(confPath)
|
||||
conf.version = version
|
||||
writeJson(confPath, conf)
|
||||
c.ok('tauri.conf.json')
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3) Construction signée
|
||||
// ---------------------------------------------------------------------
|
||||
c.step('Construction de l\'installeur (plusieurs minutes)')
|
||||
|
||||
try {
|
||||
execSync(`"${tauriBin()}" build`, { cwd: ROOT, stdio: 'inherit', env: signingEnv() })
|
||||
} catch {
|
||||
fail('La construction a échoué. Corrige l\'erreur ci-dessus puis relance.')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4) Retrouver l'installeur et sa signature
|
||||
// ---------------------------------------------------------------------
|
||||
c.step('Recherche des fichiers produits')
|
||||
|
||||
const bundleDir = join(ROOT, 'src-tauri', 'target', 'release', 'bundle')
|
||||
|
||||
/** Cherche le premier fichier correspondant, dans l'ordre de préférence. */
|
||||
function findBundle() {
|
||||
// NSIS d'abord : c'est le seul format que l'updater Windows sait installer
|
||||
// sans intervention (le .msi exige des droits administrateur à chaque fois).
|
||||
for (const [dir, ext] of [
|
||||
['nsis', '-setup.exe'],
|
||||
['msi', '.msi']
|
||||
]) {
|
||||
const path = join(bundleDir, dir)
|
||||
if (!existsSync(path)) continue
|
||||
const file = readdirSync(path).find((f) => f.endsWith(ext) && f.includes(version))
|
||||
if (file) return join(path, file)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const installer = findBundle()
|
||||
if (!installer) {
|
||||
fail(
|
||||
`Aucun installeur trouvé pour la version ${version}.`,
|
||||
`Regarde dans ${bundleDir}.`
|
||||
)
|
||||
}
|
||||
|
||||
const sigPath = `${installer}.sig`
|
||||
if (!existsSync(sigPath)) {
|
||||
fail(
|
||||
'Signature absente : l\'installeur n\'a pas été signé.',
|
||||
`Vérifie que ${KEY_PATH} existe et que la section "plugins.updater"\n` +
|
||||
'de tauri.conf.json contient bien "pubkey".'
|
||||
)
|
||||
}
|
||||
|
||||
const signature = readFileSync(sigPath, 'utf8').trim()
|
||||
const installerName = basename(installer)
|
||||
const installerBytes = readFileSync(installer)
|
||||
|
||||
c.ok(`${installerName} (${(installerBytes.length / 1048576).toFixed(1)} Mo)`)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5) Téléversement
|
||||
// ---------------------------------------------------------------------
|
||||
c.step('Téléversement vers Supabase')
|
||||
|
||||
async function upload(name, body, contentType) {
|
||||
const res = await fetch(
|
||||
`${SUPABASE_URL}/storage/v1/object/${BUCKET}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${SERVICE_KEY}`,
|
||||
'Content-Type': contentType,
|
||||
'x-upsert': 'true', // republier une version écrase l'ancienne
|
||||
'cache-control': 'max-age=60'
|
||||
},
|
||||
body
|
||||
}
|
||||
)
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
if (/bucket not found/i.test(text)) {
|
||||
fail(
|
||||
`Le bucket « ${BUCKET} » n'existe pas.`,
|
||||
'Exécute une seule fois, dans le SQL Editor de Supabase :\n\n' +
|
||||
" insert into storage.buckets (id, name, public)\n" +
|
||||
` values ('${BUCKET}', '${BUCKET}', true) on conflict (id) do nothing;\n\n` +
|
||||
" create policy releases_read on storage.objects\n" +
|
||||
` for select using (bucket_id = '${BUCKET}');`
|
||||
)
|
||||
}
|
||||
fail(`Téléversement de ${name} refusé (HTTP ${res.status})`, text)
|
||||
}
|
||||
}
|
||||
|
||||
await upload(installerName, installerBytes, 'application/octet-stream')
|
||||
c.ok(installerName)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 6) Le manifeste — c'est lui que les applications interrogent
|
||||
// ---------------------------------------------------------------------
|
||||
const manifest = {
|
||||
version,
|
||||
notes: notes || `Version ${version}`,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
'windows-x86_64': {
|
||||
signature,
|
||||
url: `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${encodeURIComponent(installerName)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await upload('latest.json', JSON.stringify(manifest, null, 2), 'application/json')
|
||||
c.ok('latest.json')
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
c.step(`Version ${version} publiée 🎉`)
|
||||
c.info('Chaque membre verra le bandeau « Mise à jour disponible » au prochain')
|
||||
c.info('lancement (ou dans les 2 heures si son application reste ouverte).')
|
||||
c.info('')
|
||||
c.info('Pense à valider les fichiers modifiés :')
|
||||
c.info(' git add package.json src-tauri/tauri.conf.json && git commit -m "v' + version + '"')
|
||||
console.log()
|
||||
@@ -0,0 +1,43 @@
|
||||
// =====================================================================
|
||||
// Clé de signature des mises à jour — lecture commune aux scripts.
|
||||
//
|
||||
// Le CLI Tauri 2.11 n'honore pas TAURI_SIGNING_PRIVATE_KEY_PATH : il ne
|
||||
// lit que TAURI_SIGNING_PRIVATE_KEY, qui accepte soit un chemin, soit la
|
||||
// clé elle-même. On lui passe donc directement le contenu du fichier.
|
||||
// =====================================================================
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, join } from 'node:path'
|
||||
|
||||
export const ROOT = resolve(import.meta.dirname, '..')
|
||||
export const KEY_PATH = join(ROOT, '.tauri-signing-key.local')
|
||||
|
||||
/**
|
||||
* Renvoie l'environnement à passer à `tauri build`, clé de signature
|
||||
* incluse. Sort du programme avec un message clair si la clé manque —
|
||||
* sans elle, aucune mise à jour ne serait acceptée par les installations
|
||||
* existantes.
|
||||
*/
|
||||
export function signingEnv() {
|
||||
if (!existsSync(KEY_PATH)) {
|
||||
console.error(
|
||||
'\n\x1b[31m✗ Clé de signature introuvable : .tauri-signing-key.local\x1b[0m\n\n' +
|
||||
'Elle signe chaque mise à jour. Restaure ta sauvegarde (MISE_A_JOUR.md, étape 3).\n\n' +
|
||||
"Si tu n'as PAS encore de clé — attention, les applications déjà installées\n" +
|
||||
'avec une autre clé ne pourront plus jamais se mettre à jour :\n\n' +
|
||||
' npx tauri signer generate --ci -p "" -w .tauri-signing-key.local\n'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
TAURI_SIGNING_PRIVATE_KEY: readFileSync(KEY_PATH, 'utf8').trim(),
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Chemin du binaire Tauri local (npx inutile, et pas de shell à deviner). */
|
||||
export function tauriBin() {
|
||||
return join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri')
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================================
|
||||
// `npm run tauri:build`
|
||||
//
|
||||
// 1. construit et SIGNE l'application
|
||||
// 2. dépose LE fichier à distribuer dans `release/`, sous son vrai nom
|
||||
//
|
||||
// Le setup NSIS produit dans `src-tauri/target/release/bundle/nsis/` ne
|
||||
// sert QU'À l'updater (installation silencieuse des mises à jour). Il
|
||||
// reste enfoui volontairement : c'est `release/GameDev Tracker.exe` qu'on
|
||||
// envoie à l'équipe, et lui seul.
|
||||
// =====================================================================
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ROOT, signingEnv, tauriBin } from './signing.mjs'
|
||||
|
||||
const extra = process.argv.slice(2).join(' ')
|
||||
|
||||
execSync(`"${tauriBin()}" build ${extra}`.trim(), {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
env: signingEnv()
|
||||
})
|
||||
|
||||
// --- Le fichier à distribuer -----------------------------------------
|
||||
const built = join(ROOT, 'src-tauri', 'target', 'release', 'app.exe')
|
||||
const outDir = join(ROOT, 'release')
|
||||
const out = join(outDir, 'GameDev Tracker.exe')
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
copyFileSync(built, out)
|
||||
|
||||
const mb = (statSync(out).size / 1048576).toFixed(1)
|
||||
console.log(
|
||||
`
|
||||
[1m[32m✓ À DISTRIBUER[0m ${out} (${mb} Mo)
|
||||
` +
|
||||
` C'est CE fichier qu'on envoie à l'équipe : il contient l'application
|
||||
` +
|
||||
` ET son écran d'installation.
|
||||
|
||||
` +
|
||||
` [2m(le setup NSIS enfoui dans src-tauri/target/… ne sert qu'aux
|
||||
` +
|
||||
` mises à jour automatiques — ne le distribue pas)[0m
|
||||
`
|
||||
)
|
||||
Generated
+611
-13
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,15 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.3", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
|
||||
# Installeur intégré (src/installer.rs) : dossiers standards + registre Windows.
|
||||
dirs = "5"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
# Mises a jour automatiques (uniquement sur les plateformes de bureau).
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"description": "Permissions de base + fenetre de l'installeur integre.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,310 @@
|
||||
//! Installeur intégré à l'application (« self-installing portable »).
|
||||
//!
|
||||
//! Le `.exe` distribué EST l'application. Lancé depuis un dossier autre que
|
||||
//! le dossier d'installation, il affiche son propre écran d'installation —
|
||||
//! écrit en React, avec le design system de l'app — qui se recopie dans
|
||||
//! `%LOCALAPPDATA%\GameDev Tracker`, crée les raccourcis, s'enregistre
|
||||
//! auprès de Windows, puis se relance depuis l'emplacement installé.
|
||||
//!
|
||||
//! Pas de fenêtre NSIS pour la première installation.
|
||||
//!
|
||||
//! # Cohabitation avec les mises à jour automatiques
|
||||
//!
|
||||
//! Les mises à jour, elles, continuent de passer par l'installeur NSIS
|
||||
//! silencieux (invisible pour l'utilisateur). Pour qu'il écrase la même
|
||||
//! installation au lieu d'en créer une seconde, on reproduit ICI très
|
||||
//! exactement ce que le template NSIS de Tauri écrit et relit :
|
||||
//!
|
||||
//! * dossier par défaut : `%LOCALAPPDATA%\{PRODUCT_NAME}`
|
||||
//! (cf. `StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}"`)
|
||||
//! * clé de localisation : `HKCU\Software\{PUBLISHER}\{PRODUCT_NAME}`, valeur
|
||||
//! par défaut (cf. `RestorePreviousInstallLocation`)
|
||||
//! * clé de désinstallation : `HKCU\…\Uninstall\{PRODUCT_NAME}`
|
||||
//!
|
||||
//! Toute modification de ces trois constantes doit rester alignée sur
|
||||
//! `src-tauri/installer/installer.nsi`, sinon une mise à jour installerait
|
||||
//! une deuxième copie à côté de la première.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Doit correspondre à `productName` de tauri.conf.json (= `${PRODUCTNAME}`).
|
||||
pub const PRODUCT_NAME: &str = "GameDev Tracker";
|
||||
/// Doit correspondre à `bundle.publisher` (= `${MANUFACTURER}`).
|
||||
pub const PUBLISHER: &str = "Mathew";
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Nom du binaire une fois installé (= `${MAINBINARYNAME}.exe`).
|
||||
const BINARY_NAME: &str = "app.exe";
|
||||
|
||||
fn uninstall_key() -> String {
|
||||
format!(
|
||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}",
|
||||
PRODUCT_NAME
|
||||
)
|
||||
}
|
||||
|
||||
/// `Software\{PUBLISHER}\{PRODUCT_NAME}` — c'est là que NSIS va relire le
|
||||
/// dossier d'installation existant.
|
||||
fn manu_product_key() -> String {
|
||||
format!("Software\\{}\\{}", PUBLISHER, PRODUCT_NAME)
|
||||
}
|
||||
|
||||
/// `%LOCALAPPDATA%\GameDev Tracker` — le même défaut que NSIS en mode
|
||||
/// « currentUser ».
|
||||
pub fn default_install_dir() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join(PRODUCT_NAME))
|
||||
}
|
||||
|
||||
fn installed_exe_in(dir: &Path) -> PathBuf {
|
||||
dir.join(BINARY_NAME)
|
||||
}
|
||||
|
||||
fn canon(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Dossier d'installation enregistré, s'il existe.
|
||||
pub fn registered_install_dir() -> Option<PathBuf> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
use winreg::RegKey;
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
// 1) La clé que NSIS relit en priorité.
|
||||
if let Ok(key) = hkcu.open_subkey(manu_product_key()) {
|
||||
if let Ok(dir) = key.get_value::<String, _>("") {
|
||||
if !dir.is_empty() {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Repli : l'entrée « Applications installées » de Windows.
|
||||
// NSIS y écrit le chemin ENTRE GUILLEMETS — sans ce nettoyage, on
|
||||
// obtiendrait un dossier littéralement nommé `"C:\...\GameDev Tracker"`.
|
||||
let key = hkcu.open_subkey(uninstall_key()).ok()?;
|
||||
let dir: String = key.get_value("InstallLocation").ok()?;
|
||||
let dir = dir.trim().trim_matches('"').to_string();
|
||||
(!dir.is_empty()).then(|| PathBuf::from(dir))
|
||||
}
|
||||
|
||||
/// Le binaire en cours d'exécution est-il celui qui est installé ?
|
||||
///
|
||||
/// C'est ce test qui décide, au démarrage, entre « afficher l'installeur »
|
||||
/// et « démarrer l'application ».
|
||||
pub fn is_running_installed() -> bool {
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
return false;
|
||||
};
|
||||
let Some(dir) = exe.parent() else {
|
||||
return false;
|
||||
};
|
||||
let dir = canon(dir);
|
||||
|
||||
if let Some(registered) = registered_install_dir() {
|
||||
if canon(®istered) == dir {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Même sans registre : si on tourne depuis le dossier d'installation
|
||||
// par défaut, c'est qu'on est installé (cas d'une mise à jour NSIS qui
|
||||
// aurait réécrit les clés autrement).
|
||||
default_install_dir().map(|d| canon(&d) == dir).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Recopie le binaire courant dans le dossier d'installation.
|
||||
fn copy_exe(dst_dir: &Path) -> Result<PathBuf, String> {
|
||||
let src = std::env::current_exe().map_err(|e| format!("Binaire courant introuvable : {e}"))?;
|
||||
let dst = installed_exe_in(dst_dir);
|
||||
|
||||
std::fs::create_dir_all(dst_dir)
|
||||
.map_err(|e| format!("Création de « {} » impossible : {e}", dst_dir.display()))?;
|
||||
|
||||
// Réinstallation par-dessus : on écrase.
|
||||
if dst.exists() {
|
||||
std::fs::remove_file(&dst).ok();
|
||||
}
|
||||
std::fs::copy(&src, &dst).map_err(|e| format!("Copie impossible : {e}"))?;
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// Écrit un raccourci `.lnk` via WScript.Shell — évite d'embarquer une
|
||||
/// bibliothèque COM entière pour cette seule opération.
|
||||
fn write_lnk(lnk: &Path, target: &Path) -> Result<(), String> {
|
||||
let script = format!(
|
||||
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{}');\
|
||||
$s.TargetPath='{}';$s.WorkingDirectory='{}';$s.Description='{}';$s.Save()",
|
||||
lnk.display(),
|
||||
target.display(),
|
||||
target.parent().map(|p| p.display().to_string()).unwrap_or_default(),
|
||||
PRODUCT_NAME
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let out = cmd.output().map_err(|e| format!("PowerShell indisponible : {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"Raccourci « {} » non créé : {}",
|
||||
lnk.display(),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_start_menu_shortcut(target: &Path) -> Result<(), String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or("APPDATA introuvable")?
|
||||
.join("Microsoft/Windows/Start Menu/Programs");
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
|
||||
}
|
||||
|
||||
fn create_desktop_shortcut(target: &Path) -> Result<(), String> {
|
||||
let dir = dirs::desktop_dir().ok_or("Bureau introuvable")?;
|
||||
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
|
||||
}
|
||||
|
||||
fn dir_size_kb(dir: &Path) -> u32 {
|
||||
fn walk(p: &Path) -> u64 {
|
||||
let mut total = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(p) {
|
||||
for e in entries.flatten() {
|
||||
match e.metadata() {
|
||||
Ok(m) if m.is_dir() => total += walk(&e.path()),
|
||||
Ok(m) => total += m.len(),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
((walk(dir) / 1024) as u32).max(1)
|
||||
}
|
||||
|
||||
/// Enregistre l'application auprès de Windows : elle apparaît dans
|
||||
/// « Paramètres → Applications », avec un bouton de désinstallation.
|
||||
fn write_registry(exe: &Path, install_dir: &Path) -> Result<(), String> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
use winreg::RegKey;
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let exe_s = exe.display().to_string();
|
||||
let dir_s = install_dir.display().to_string();
|
||||
|
||||
// 1) Localisation — c'est CETTE clé que l'updater NSIS relit pour
|
||||
// réinstaller au même endroit.
|
||||
let (loc, _) = hkcu
|
||||
.create_subkey(manu_product_key())
|
||||
.map_err(|e| format!("Registre (localisation) : {e}"))?;
|
||||
loc.set_value("", &dir_s).map_err(|e| format!("Registre : {e}"))?;
|
||||
|
||||
// 2) Entrée « Applications installées ».
|
||||
let (key, _) = hkcu
|
||||
.create_subkey(uninstall_key())
|
||||
.map_err(|e| format!("Registre (désinstallation) : {e}"))?;
|
||||
|
||||
let set = |name: &str, value: &str| key.set_value(name, &value.to_string());
|
||||
set("DisplayName", PRODUCT_NAME).ok();
|
||||
set("DisplayVersion", VERSION).ok();
|
||||
set("Publisher", PUBLISHER).ok();
|
||||
set("InstallLocation", &dir_s).ok();
|
||||
set("DisplayIcon", &exe_s).ok();
|
||||
set("UninstallString", &format!("\"{exe_s}\" --uninstall")).ok();
|
||||
set("QuietUninstallString", &format!("\"{exe_s}\" --uninstall --silent")).ok();
|
||||
// MainBinaryName : lu par l'updater NSIS pour nettoyer l'ancien binaire.
|
||||
set("MainBinaryName", BINARY_NAME).ok();
|
||||
key.set_value("NoModify", &1u32).ok();
|
||||
key.set_value("NoRepair", &1u32).ok();
|
||||
key.set_value("EstimatedSize", &dir_size_kb(install_dir)).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Commandes exposées au front
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct InstallerStatus {
|
||||
/// `true` = on tourne depuis le dossier installé → démarrer l'app.
|
||||
pub running_installed: bool,
|
||||
/// Une installation existe déjà (réinstallation / mise à jour manuelle).
|
||||
pub already_installed: bool,
|
||||
pub install_dir: String,
|
||||
pub version: String,
|
||||
pub product_name: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn installer_status() -> InstallerStatus {
|
||||
let dir = registered_install_dir()
|
||||
.or_else(default_install_dir)
|
||||
.unwrap_or_default();
|
||||
InstallerStatus {
|
||||
running_installed: is_running_installed(),
|
||||
already_installed: registered_install_dir()
|
||||
.map(|d| installed_exe_in(&d).exists())
|
||||
.unwrap_or(false),
|
||||
install_dir: dir.display().to_string(),
|
||||
version: VERSION.to_string(),
|
||||
product_name: PRODUCT_NAME.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Installe : copie, raccourcis, registre. Renvoie le dossier d'installation.
|
||||
#[tauri::command]
|
||||
pub fn installer_install(desktop_shortcut: bool) -> Result<String, String> {
|
||||
let dir = default_install_dir().ok_or("LOCALAPPDATA introuvable")?;
|
||||
let exe = copy_exe(&dir)?;
|
||||
|
||||
// Les raccourcis ne sont pas critiques : on n'annule pas une
|
||||
// installation réussie parce qu'un .lnk n'a pas pu être écrit.
|
||||
if let Err(e) = create_start_menu_shortcut(&exe) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
if desktop_shortcut {
|
||||
if let Err(e) = create_desktop_shortcut(&exe) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
write_registry(&exe, &dir)?;
|
||||
Ok(dir.display().to_string())
|
||||
}
|
||||
|
||||
/// Démarre la version installée puis quitte le processus courant.
|
||||
#[tauri::command]
|
||||
pub fn installer_launch(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = registered_install_dir()
|
||||
.or_else(default_install_dir)
|
||||
.ok_or("Aucune installation enregistrée")?;
|
||||
let target = installed_exe_in(&dir);
|
||||
|
||||
let mut cmd = Command::new(&target);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0000_0008); // DETACHED_PROCESS
|
||||
}
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Démarrage de « {} » impossible : {e}", target.display()))?;
|
||||
|
||||
// On laisse au nouveau processus le temps de prendre la main : sans ce
|
||||
// délai, la fenêtre disparaît avant que la suivante n'apparaisse.
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
handle.exit(0);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
+77
-1
@@ -1,6 +1,46 @@
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
#[cfg(windows)]
|
||||
mod installer;
|
||||
|
||||
/// L'application tourne-t-elle depuis son dossier d'installation ?
|
||||
/// (Sur les autres plateformes il n'y a pas d'installeur intégré.)
|
||||
fn running_installed() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
installer::is_running_installed()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
// Mises à jour automatiques : l'application va chercher elle-même la
|
||||
// dernière version publiée. Plus besoin de renvoyer un .exe à l'équipe.
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init());
|
||||
}
|
||||
|
||||
// Installeur intégré : le front affiche l'écran d'installation ou
|
||||
// l'application selon `installer_status()`.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
builder = builder.invoke_handler(tauri::generate_handler![
|
||||
installer::installer_status,
|
||||
installer::installer_install,
|
||||
installer::installer_launch
|
||||
]);
|
||||
}
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -9,6 +49,42 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// La fenêtre est créée ICI, directement à sa forme définitive.
|
||||
//
|
||||
// Elle n'est volontairement PAS déclarée dans tauri.conf.json : la
|
||||
// déclarer là-bas imposait de la corriger ensuite — la masquer, la
|
||||
// redimensionner, la maximiser, puis l'afficher. Chacune de ces
|
||||
// étapes était visible, d'où l'impression que « plusieurs petites
|
||||
// fenêtres » s'ouvraient avant l'application.
|
||||
//
|
||||
// Créée d'un coup aux bonnes dimensions, il n'y a plus rien à
|
||||
// rattraper après coup : une seule apparition, la bonne.
|
||||
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
// Le glisser-déposer doit rester géré par le webview (Kanban,
|
||||
// dépôt de pièces jointes) et non capté par Tauri.
|
||||
.disable_drag_drop_handler();
|
||||
|
||||
if running_installed() {
|
||||
window
|
||||
.title("GameDev Tracker")
|
||||
.inner_size(1360.0, 860.0)
|
||||
.min_inner_size(900.0, 620.0)
|
||||
.resizable(true)
|
||||
.maximized(true)
|
||||
.build()?;
|
||||
} else {
|
||||
// Écran d'installation : petite fenêtre sans bordure, centrée.
|
||||
window
|
||||
.title("Installation de GameDev Tracker")
|
||||
.inner_size(520.0, 560.0)
|
||||
.resizable(false)
|
||||
.maximizable(false)
|
||||
.decorations(false)
|
||||
.center()
|
||||
.build()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
+37
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "GameDev Tracker",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.7",
|
||||
"identifier": "com.gamedevtracker.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -10,33 +10,52 @@
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "GameDev Tracker",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 960,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true,
|
||||
"backgroundColor": "#0f1117",
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; script-src 'self'"
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"createUpdaterArtifacts": true,
|
||||
"publisher": "Mathew",
|
||||
"copyright": "© 2026 Mathew",
|
||||
"shortDescription": "Gestion de projet de jeu vidéo",
|
||||
"longDescription": "Tâches, bugs, feuille de temps et discussions pour une petite équipe de développement de jeu vidéo.",
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": [
|
||||
"French"
|
||||
],
|
||||
"displayLanguageSelector": false,
|
||||
"headerImage": "installer/header.bmp",
|
||||
"sidebarImage": "installer/sidebar.bmp",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"template": "installer/installer.nsi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY3OUVGOUREMUExQjA4OTUKUldTVkNCc2EzZm1lOTlMMVVCUHRVMnFtRTArN3cyQkhsQ3RPSmlEdjlKdGR3WWRYTnpmcjlYU3QK",
|
||||
"endpoints": [
|
||||
"https://cesjrlfqeqftayhnqjkb.supabase.co/storage/v1/object/public/releases/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "quiet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+155
-26
@@ -1,69 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Component, ErrorInfo, ReactNode, Suspense, lazy, useEffect, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, Database, RefreshCw } from 'lucide-react'
|
||||
import { AppProvider, useApp } from './lib/AppContext'
|
||||
import { Page } from './lib/types'
|
||||
import { PageFade } from './components/motion'
|
||||
import Setup from './pages/Setup'
|
||||
import { Button, CenteredSpinner } from './components/ui'
|
||||
import { InstallerStatus, resolveStartupMode } from './lib/installer'
|
||||
import Login from './pages/Login'
|
||||
import Onboarding from './pages/Onboarding'
|
||||
import Layout, { Page } from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Kanban from './pages/Kanban'
|
||||
import Planning from './pages/Planning'
|
||||
import Bugs from './pages/Bugs'
|
||||
import Members from './pages/Members'
|
||||
import Admin from './pages/Admin'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
const PAGES: Record<Page, () => JSX.Element> = {
|
||||
// Chargement paresseux : le bundle initial ne contient que le shell
|
||||
// (authentification + mise en page). Chaque écran arrive à la demande.
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Kanban = lazy(() => import('./pages/Kanban'))
|
||||
const Planning = lazy(() => import('./pages/Planning'))
|
||||
const Bugs = lazy(() => import('./pages/Bugs'))
|
||||
const Docs = lazy(() => import('./pages/Docs'))
|
||||
const Members = lazy(() => import('./pages/Members'))
|
||||
const Admin = lazy(() => import('./pages/Admin'))
|
||||
const Installer = lazy(() => import('./pages/Installer'))
|
||||
|
||||
const PAGES: Record<Page, React.LazyExoticComponent<() => JSX.Element>> = {
|
||||
dashboard: Dashboard,
|
||||
kanban: Kanban,
|
||||
planning: Planning,
|
||||
bugs: Bugs,
|
||||
docs: Docs,
|
||||
members: Members,
|
||||
admin: Admin
|
||||
}
|
||||
|
||||
function Shell(): JSX.Element {
|
||||
const { stage, profile } = useApp()
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
// ---------------------------------------------------------------------
|
||||
// Filet de sécurité : une erreur de rendu ne doit pas laisser un écran
|
||||
// blanc sans explication ni moyen de repartir.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
if (stage === 'loading') {
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): { error: Error } {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Erreur de rendu :', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted">
|
||||
<Loader2 className="animate-spin" size={28} />
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="card max-w-md text-center">
|
||||
<AlertTriangle size={32} className="mx-auto mb-3 text-rose-500" />
|
||||
<h1 className="mb-1 text-base font-semibold text-ink">Quelque chose s'est mal passé</h1>
|
||||
<p className="mb-4 text-sm text-muted">{this.state.error.message}</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<RefreshCw size={15} />}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Relancer l'application
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (stage === 'setup') return <Setup />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Clés Supabase absentes du build : on explique quoi faire. */
|
||||
function Unconfigured(): JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="card max-w-lg">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-accent/12 p-2 text-accent">
|
||||
<Database size={22} />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-ink">Base de données non configurée</h1>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
L'application a été construite sans clés Supabase. Crée un fichier <code>.env</code> à la
|
||||
racine du projet avec :
|
||||
</p>
|
||||
<pre className="mb-3 overflow-x-auto rounded-lg border border-border bg-panel2 p-3 text-xs text-ink">
|
||||
{'VITE_SUPABASE_URL=https://xxxxx.supabase.co\nVITE_SUPABASE_ANON_KEY=eyJhbGciOi...'}
|
||||
</pre>
|
||||
<p className="text-sm text-muted">
|
||||
Ces deux valeurs se trouvent dans Supabase → <strong>Settings → API</strong>. Reconstruis
|
||||
ensuite l'application (<code>npm run tauri:dev</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMissing({ message }: { message: string }): JSX.Element {
|
||||
const { signOut } = useApp()
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="card max-w-md text-center">
|
||||
<AlertTriangle size={30} className="mx-auto mb-3 text-amber-500" />
|
||||
<h1 className="mb-1 text-base font-semibold text-ink">Profil inaccessible</h1>
|
||||
<p className="mb-4 text-sm text-muted">{message}</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button icon={<RefreshCw size={15} />} onClick={() => window.location.reload()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={signOut}>
|
||||
Se déconnecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function Shell(): JSX.Element {
|
||||
const { stage, page, profile, profileError } = useApp()
|
||||
|
||||
if (stage === 'unconfigured') return <Unconfigured />
|
||||
if (stage === 'loading') return <CenteredSpinner />
|
||||
if (stage === 'login') return <Login />
|
||||
|
||||
// Session ouverte mais profil pas encore chargé.
|
||||
// Session ouverte, profil pas encore là.
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted">
|
||||
<Loader2 className="animate-spin" size={28} />
|
||||
</div>
|
||||
)
|
||||
if (profileError) return <ProfileMissing message={profileError} />
|
||||
return <CenteredSpinner />
|
||||
}
|
||||
// Première connexion : accueil (nom, avatar, pôles) avant d'entrer dans l'app.
|
||||
|
||||
// Première connexion : accueil (nom, photo, pôles) avant d'entrer dans l'app.
|
||||
if (!profile.onboarded) return <Onboarding />
|
||||
|
||||
const ActivePage = PAGES[page]
|
||||
|
||||
return (
|
||||
<Layout page={page} setPage={setPage}>
|
||||
<Layout>
|
||||
<AnimatePresence mode="wait">
|
||||
<PageFade key={page}>
|
||||
<Suspense fallback={<CenteredSpinner />}>
|
||||
<ActivePage />
|
||||
</Suspense>
|
||||
</PageFade>
|
||||
</AnimatePresence>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Le .exe distribué est à la fois l'application ET son installeur.
|
||||
* Lancé hors de son dossier d'installation, il affiche l'écran
|
||||
* d'installation ; sinon, l'application normale.
|
||||
*
|
||||
* La forme de la fenêtre (taille, bordure) est décidée côté Rust au
|
||||
* moment de sa création : ici on ne fait que choisir quoi afficher dedans.
|
||||
*/
|
||||
export default function App(): JSX.Element {
|
||||
const [boot, setBoot] = useState<{
|
||||
mode: 'checking' | 'installer' | 'app'
|
||||
status: InstallerStatus | null
|
||||
}>({ mode: 'checking', status: null })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
const { mode, status } = await resolveStartupMode()
|
||||
if (alive) setBoot({ mode, status })
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (boot.mode === 'checking') return <div className="h-full bg-bg" />
|
||||
|
||||
if (boot.mode === 'installer') {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="h-full bg-bg" />}>
|
||||
<Installer status={boot.status} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppProvider>
|
||||
<Shell />
|
||||
</AppProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate, run } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { notifyBug } from '../lib/notify'
|
||||
import {
|
||||
Bug,
|
||||
BugSeverity,
|
||||
BugStatus,
|
||||
BUG_STATUS_LABELS,
|
||||
BUG_STATUS_ORDER,
|
||||
Pole,
|
||||
SEVERITY_HINTS,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_ORDER,
|
||||
severityBadge
|
||||
} from '../lib/types'
|
||||
import { Badge, Button, Field, Input } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
import { Select } from './Select'
|
||||
import { AssigneePicker, PoleChips } from './people'
|
||||
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
|
||||
|
||||
interface Draft {
|
||||
title: string
|
||||
description: string
|
||||
severity: BugSeverity
|
||||
status: BugStatus
|
||||
poles: Pole[]
|
||||
assignees: string[]
|
||||
}
|
||||
|
||||
function draftOf(bug: Bug | null): Draft {
|
||||
return {
|
||||
title: bug?.title ?? '',
|
||||
description: bug?.description ?? '',
|
||||
severity: bug?.severity ?? 'medium',
|
||||
status: bug?.status ?? 'open',
|
||||
poles: bug?.poles ?? [],
|
||||
assignees: bug?.assignee_ids ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export default function BugModal({
|
||||
bug,
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
bug: Bug | null
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}): JSX.Element {
|
||||
const { profile, members, membersLoading, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
|
||||
const initial = useMemo(() => draftOf(bug), [bug])
|
||||
const [d, setD] = useState<Draft>(initial)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
|
||||
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!d.title.trim()) {
|
||||
toast('Donne un titre au bug.', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
title: d.title.trim(),
|
||||
description: d.description.trim() || null,
|
||||
severity: d.severity,
|
||||
status: d.status,
|
||||
poles: d.poles,
|
||||
assignee_ids: d.assignees
|
||||
}
|
||||
|
||||
try {
|
||||
let entityId = bug?.id ?? null
|
||||
if (bug) {
|
||||
await mutate(supa().from('bugs').update(payload).eq('id', bug.id))
|
||||
} else {
|
||||
const created = await run<{ id: string }>(
|
||||
supa()
|
||||
.from('bugs')
|
||||
.insert({ ...payload, reporter_id: profile?.id ?? null })
|
||||
.select('id')
|
||||
.single()
|
||||
)
|
||||
entityId = created?.id ?? null
|
||||
}
|
||||
|
||||
if (entityId) {
|
||||
const newPoles = d.poles.filter((p) => !(bug?.poles ?? []).includes(p))
|
||||
const newAssignees = d.assignees.filter((a) => !(bug?.assignee_ids ?? []).includes(a))
|
||||
if (newPoles.length || newAssignees.length) {
|
||||
await notifyBug({
|
||||
projectId,
|
||||
bugId: entityId,
|
||||
title: d.title.trim(),
|
||||
members,
|
||||
poles: newPoles,
|
||||
assigneeIds: newAssignees,
|
||||
actorId: profile?.id ?? null,
|
||||
isNew: !bug
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
toast(bug ? 'Bug enregistré.' : 'Bug signalé.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!bug) return
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer ce bug ?',
|
||||
message: `« ${bug.title} » et tout ce qui y est rattaché (temps, discussion, captures) seront supprimés.`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await mutate(supa().from('bugs').delete().eq('id', bug.id))
|
||||
await onSaved()
|
||||
toast('Bug supprimé.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Passe le bug en résolu en un clic : le geste le plus fréquent. */
|
||||
const resolve = async (): Promise<void> => {
|
||||
if (!bug) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await mutate(supa().from('bugs').update({ status: 'resolved' }).eq('id', bug.id))
|
||||
await onSaved()
|
||||
toast('Bug marqué comme résolu.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reporter = memberById(bug?.reporter_id)
|
||||
const subtitle = bug ? (
|
||||
<span>
|
||||
Signalé {reporter ? `par ${reporter.full_name} ` : ''}
|
||||
le {format(new Date(bug.created_at), 'd MMM yyyy', { locale: fr })}
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={bug ? 'Bug' : 'Signaler un bug'}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
dirty={dirty && !busy}
|
||||
footer={
|
||||
<ModalActions onDelete={bug ? remove : undefined} onCancel={onClose}>
|
||||
{bug && bug.status !== 'resolved' && bug.status !== 'closed' && (
|
||||
<Button onClick={resolve} disabled={busy}>
|
||||
Marquer résolu
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
|
||||
{bug ? 'Enregistrer' : 'Signaler'}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Que se passe-t-il ?" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
value={d.title}
|
||||
maxLength={160}
|
||||
placeholder="Le joueur traverse le mur en sautant contre la porte"
|
||||
onChange={(e) => patch({ title: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Étapes pour reproduire"
|
||||
hint="Ce que tu faisais, ce qui devait arriver, ce qui est arrivé. Plus c'est précis, plus c'est vite corrigé."
|
||||
>
|
||||
{({ id }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="input h-28 resize-none"
|
||||
value={d.description}
|
||||
placeholder={'1. Aller au niveau 2\n2. Sauter contre la porte de droite\n→ Le personnage passe à travers'}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ---- Sévérité : un seul axe d'importance, expliqué ---- */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Sévérité" hint={SEVERITY_HINTS[d.severity]}>
|
||||
<Select
|
||||
value={d.severity}
|
||||
onChange={(severity) => patch({ severity })}
|
||||
ariaLabel="Sévérité du bug"
|
||||
options={SEVERITY_ORDER.map((s) => ({
|
||||
value: s,
|
||||
label: SEVERITY_LABELS[s],
|
||||
hint: SEVERITY_HINTS[s]
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Statut">
|
||||
<Select
|
||||
value={d.status}
|
||||
onChange={(status) => patch({ status })}
|
||||
ariaLabel="Statut du bug"
|
||||
options={BUG_STATUS_ORDER.map((s) => ({ value: s, label: BUG_STATUS_LABELS[s] }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={severityBadge(d.severity)}>{SEVERITY_LABELS[d.severity]}</Badge>
|
||||
<span className="text-xs text-subtle">{SEVERITY_HINTS[d.severity]}</span>
|
||||
</div>
|
||||
|
||||
{/* ---- Qui ? ---- */}
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<Field
|
||||
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
|
||||
hint="Tous les membres du pôle reçoivent une notification."
|
||||
>
|
||||
<PoleChips
|
||||
selected={d.poles}
|
||||
onToggle={(p) =>
|
||||
patch({
|
||||
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
|
||||
<AssigneePicker
|
||||
members={members}
|
||||
loading={membersLoading}
|
||||
selected={d.assignees}
|
||||
onChange={(assignees) => patch({ assignees })}
|
||||
poles={d.poles}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{bug ? (
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<AttachmentPanel
|
||||
entity={{
|
||||
kind: 'bug',
|
||||
id: bug.id,
|
||||
projectId,
|
||||
title: bug.title,
|
||||
involvedIds: bug.assignee_ids
|
||||
}}
|
||||
/>
|
||||
<TimeSection target={{ kind: 'bug', id: bug.id }} projectId={projectId} />
|
||||
<CommentThread
|
||||
entity={{
|
||||
kind: 'bug',
|
||||
id: bug.id,
|
||||
projectId,
|
||||
title: bug.title,
|
||||
involvedIds: [...bug.assignee_ids, ...(bug.reporter_id ? [bug.reporter_id] : [])]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
|
||||
Une fois le bug créé, tu pourras y coller des captures d'écran (Ctrl+V), suivre le temps
|
||||
passé et en discuter avec l'équipe.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// =====================================================================
|
||||
// Palette de commandes (Ctrl+K) — la recherche qui manquait totalement.
|
||||
// Cherche dans les tâches, les bugs et l'équipe du projet courant, et
|
||||
// expose les actions fréquentes (naviguer, créer, changer de thème…).
|
||||
// =====================================================================
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Bug as BugIcon,
|
||||
CornerDownLeft,
|
||||
KanbanSquare,
|
||||
Loader2,
|
||||
FileText,
|
||||
Search,
|
||||
Sparkles,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { rows } from '../lib/db'
|
||||
import { excerpt } from '../lib/markdown'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { getTheme, setTheme } from '../lib/theme'
|
||||
import {
|
||||
Bug,
|
||||
BUG_STATUS_LABELS,
|
||||
Doc,
|
||||
Task,
|
||||
TASK_STATUS_LABELS,
|
||||
normalizeBugs,
|
||||
normalizeDocs,
|
||||
normalizeTasks,
|
||||
polesLabel,
|
||||
memberPoles
|
||||
} from '../lib/types'
|
||||
import { NAV } from './Layout'
|
||||
import { Avatar } from './people'
|
||||
import { Kbd } from './ui'
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint?: string
|
||||
icon: JSX.Element
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/** Score simple : les correspondances en début de mot passent devant. */
|
||||
function match(text: string, q: string): number {
|
||||
const t = text.toLowerCase()
|
||||
const i = t.indexOf(q)
|
||||
if (i < 0) return -1
|
||||
if (i === 0) return 0
|
||||
return t[i - 1] === ' ' ? 1 : 2
|
||||
}
|
||||
|
||||
export default function CommandPalette({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const { profile, members, currentProject, projects, selectProject, setFocusEntity, setPage } =
|
||||
useApp()
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [bugs, setBugs] = useState<Bug[]>([])
|
||||
const [docs, setDocs] = useState<Doc[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Contenu du projet courant, chargé une fois à l'ouverture.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const projectId = currentProject?.id
|
||||
if (!projectId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
Promise.all([
|
||||
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
|
||||
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
|
||||
rows<Doc>(supa().from('documents').select('*').eq('project_id', projectId)).then(normalizeDocs)
|
||||
])
|
||||
.then(([t, b, d]) => {
|
||||
if (!alive) return
|
||||
setTasks(t)
|
||||
setBugs(b)
|
||||
setDocs(d)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => alive && setLoading(false))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [currentProject?.id])
|
||||
|
||||
const open = (kind: 'task' | 'bug', id: string): void => {
|
||||
setFocusEntity({ kind, id })
|
||||
setPage(kind === 'bug' ? 'bugs' : 'kanban')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const out: Item[] = []
|
||||
|
||||
// --- Actions ---
|
||||
const nav = NAV.filter((n) => !n.adminOnly || profile?.is_admin)
|
||||
for (const n of nav) {
|
||||
out.push({
|
||||
id: `nav-${n.id}`,
|
||||
group: 'Aller à',
|
||||
label: n.label,
|
||||
icon: <n.icon size={16} />,
|
||||
run: () => {
|
||||
setPage(n.id)
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
out.push({
|
||||
id: 'theme',
|
||||
group: 'Actions',
|
||||
label: 'Changer de thème (clair / sombre / système)',
|
||||
icon: <Sparkles size={16} />,
|
||||
run: () => {
|
||||
const cur = getTheme()
|
||||
setTheme(cur === 'light' ? 'dark' : cur === 'dark' ? 'system' : 'light')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
for (const p of projects) {
|
||||
if (p.id === currentProject?.id) continue
|
||||
out.push({
|
||||
id: `proj-${p.id}`,
|
||||
group: 'Projets',
|
||||
label: `Ouvrir « ${p.name} »`,
|
||||
icon: <KanbanSquare size={16} />,
|
||||
run: () => {
|
||||
selectProject(p)
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Contenu (uniquement quand on tape) ---
|
||||
if (q) {
|
||||
for (const t of tasks) {
|
||||
if (match(t.title, q) < 0 && match(t.description ?? '', q) < 0) continue
|
||||
out.push({
|
||||
id: `task-${t.id}`,
|
||||
group: 'Tâches',
|
||||
label: t.title,
|
||||
hint: `${TASK_STATUS_LABELS[t.status]}${t.tags.length ? ' · ' + t.tags.join(', ') : ''}`,
|
||||
icon: <KanbanSquare size={16} className="text-accent" />,
|
||||
run: () => open('task', t.id)
|
||||
})
|
||||
}
|
||||
for (const b of bugs) {
|
||||
if (match(b.title, q) < 0 && match(b.description ?? '', q) < 0) continue
|
||||
out.push({
|
||||
id: `bug-${b.id}`,
|
||||
group: 'Bugs',
|
||||
label: b.title,
|
||||
hint: BUG_STATUS_LABELS[b.status],
|
||||
icon: <BugIcon size={16} className="text-rose-500" />,
|
||||
run: () => open('bug', b.id)
|
||||
})
|
||||
}
|
||||
for (const d of docs) {
|
||||
if (match(d.title, q) < 0 && match(d.content, q) < 0) continue
|
||||
out.push({
|
||||
id: `doc-${d.id}`,
|
||||
group: 'Documentation',
|
||||
label: d.title,
|
||||
hint: excerpt(d.content, 70),
|
||||
icon: <FileText size={16} className="text-accent2" />,
|
||||
run: () => {
|
||||
setPage('docs')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
for (const m of members) {
|
||||
if (match(m.full_name, q) < 0) continue
|
||||
out.push({
|
||||
id: `member-${m.id}`,
|
||||
group: 'Équipe',
|
||||
label: m.full_name,
|
||||
hint: polesLabel(memberPoles(m)),
|
||||
icon: <Avatar profile={m} size={18} />,
|
||||
run: () => {
|
||||
setPage('members')
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!q) return out.filter((i) => !['Tâches', 'Bugs', 'Documentation'].includes(i.group))
|
||||
|
||||
return out
|
||||
.map((i) => ({ i, s: Math.max(match(i.label, q), match(i.group, q) >= 0 ? 3 : -1) }))
|
||||
.filter((r) => r.s >= 0)
|
||||
.sort((a, b) => a.s - b.s)
|
||||
.map((r) => r.i)
|
||||
.slice(0, 40)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, tasks, bugs, docs, members, projects, currentProject?.id, profile?.is_admin])
|
||||
|
||||
useEffect(() => setActive(0), [query])
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active])
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.min(i + 1, items.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
items[active]?.run()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// En-têtes de groupe : affichés au premier élément de chaque groupe.
|
||||
let lastGroup = ''
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
data-overlay
|
||||
className="fixed inset-0 z-[75] flex items-start justify-center bg-black/50 p-4 pt-[12vh] backdrop-blur-[2px]"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Rechercher"
|
||||
initial={{ opacity: 0, scale: 0.97, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[60vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-pop"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-border px-4">
|
||||
<Search size={17} className="shrink-0 text-subtle" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Chercher une tâche, un bug, une fiche, une personne…"
|
||||
aria-label="Rechercher"
|
||||
className="flex-1 bg-transparent py-3.5 text-sm text-ink outline-none placeholder:text-subtle"
|
||||
/>
|
||||
{loading && <Loader2 size={15} className="animate-spin text-subtle" />}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-8 text-center text-sm text-subtle">
|
||||
Aucun résultat pour « {query} ».
|
||||
</p>
|
||||
) : (
|
||||
items.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null
|
||||
lastGroup = item.group
|
||||
const on = i === active
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{header && (
|
||||
<p className="px-2.5 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wide text-subtle">
|
||||
{header}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-active={on}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={item.run}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
on ? 'bg-accent/12' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-muted">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-ink">{item.label}</span>
|
||||
{item.hint && (
|
||||
<span className="block truncate text-xs text-subtle">{item.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
{on && <CornerDownLeft size={14} className="shrink-0 text-subtle" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2 text-[11px] text-subtle">
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd> naviguer
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>⏎</Kbd> ouvrir
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>Échap</Kbd> fermer
|
||||
</span>
|
||||
{!currentProject && (
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<User size={11} /> Aucun projet ouvert
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
</motion.div>
|
||||
</motion.div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Pole, Profile, memberPoles } from '../lib/types'
|
||||
import { Button, Field, Input } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
import { AvatarUploader, ColorPicker, PoleChips } from './people'
|
||||
|
||||
/**
|
||||
* Édition de SON propre profil : nom, email, mot de passe, photo, pôles.
|
||||
* Accessible depuis la barre latérale et depuis la page Équipe.
|
||||
*/
|
||||
export default function EditProfileModal({
|
||||
profile,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
profile: Profile
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { session } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
const currentEmail = session?.user?.email ?? ''
|
||||
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
name: profile.full_name,
|
||||
email: currentEmail,
|
||||
roles: memberPoles(profile),
|
||||
color: profile.avatar_color,
|
||||
avatarUrl: profile.avatar_url ?? null
|
||||
}),
|
||||
[profile, currentEmail]
|
||||
)
|
||||
|
||||
const [name, setName] = useState(initial.name)
|
||||
const [email, setEmail] = useState(initial.email)
|
||||
const [password, setPassword] = useState('')
|
||||
const [roles, setRoles] = useState<Pole[]>(initial.roles)
|
||||
const [color, setColor] = useState(initial.color)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial.avatarUrl)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const dirty =
|
||||
name !== initial.name ||
|
||||
email !== initial.email ||
|
||||
password.length > 0 ||
|
||||
color !== initial.color ||
|
||||
avatarUrl !== initial.avatarUrl ||
|
||||
JSON.stringify(roles) !== JSON.stringify(initial.roles)
|
||||
|
||||
const toggleRole = (p: Pole): void =>
|
||||
setRoles((cur) => (cur.includes(p) ? cur.filter((r) => r !== p) : [...cur, p]))
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!name.trim()) return toast('Le nom ne peut pas être vide.', 'error')
|
||||
if (password && password.length < 6) {
|
||||
return toast('Le mot de passe doit faire au moins 6 caractères.', 'error')
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
// 1) Profil (nom, pôles, avatar) — modifiable par soi-même via RLS.
|
||||
await mutate(
|
||||
supa()
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: name.trim(),
|
||||
roles,
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl
|
||||
})
|
||||
.eq('id', profile.id)
|
||||
)
|
||||
|
||||
// 2) Compte (email / mot de passe).
|
||||
const authPatch: { email?: string; password?: string } = {}
|
||||
if (email.trim() && email.trim() !== currentEmail) authPatch.email = email.trim()
|
||||
if (password) authPatch.password = password
|
||||
if (Object.keys(authPatch).length > 0) {
|
||||
const { error } = await supa().auth.updateUser(authPatch)
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
toast(
|
||||
authPatch.email
|
||||
? 'Profil enregistré. Confirme la nouvelle adresse via l’email reçu.'
|
||||
: 'Profil enregistré.',
|
||||
'success'
|
||||
)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Mon profil"
|
||||
subtitle={currentEmail}
|
||||
onClose={onClose}
|
||||
dirty={dirty && !busy}
|
||||
footer={
|
||||
<ModalActions onCancel={onClose}>
|
||||
<Button variant="primary" loading={busy} disabled={!dirty} onClick={save}>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Photo de profil">
|
||||
<AvatarUploader
|
||||
userId={profile.id}
|
||||
name={name}
|
||||
color={color}
|
||||
avatarUrl={avatarUrl}
|
||||
onChange={setAvatarUrl}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!avatarUrl && (
|
||||
<Field label="Couleur de tes initiales">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Nom complet" required>
|
||||
{({ id }) => (
|
||||
<Input id={id} value={name} autoComplete="name" onChange={(e) => setName(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Email" hint="Changer d'adresse demande une confirmation par email.">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Nouveau mot de passe" hint="Laisse vide pour ne pas le changer.">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Mes pôles"
|
||||
hint="Tu es notifié quand une tâche ou un bug est confié à l'un de tes pôles."
|
||||
>
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
// =====================================================================
|
||||
// Discussion et pièces jointes — partagés à l'identique par les tâches
|
||||
// et les bugs (mêmes tables, même comportement, un seul composant).
|
||||
// =====================================================================
|
||||
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2,
|
||||
UploadCloud
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, rows, run, useRealtime } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { notifyComment } from '../lib/notify'
|
||||
import {
|
||||
attachmentUrl,
|
||||
deleteAttachment,
|
||||
listAttachments,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
uploadAttachment
|
||||
} from '../lib/storage'
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
EntityKind,
|
||||
TimeLog,
|
||||
formatBytes,
|
||||
formatMinutes,
|
||||
isImage
|
||||
} from '../lib/types'
|
||||
import { Avatar } from './people'
|
||||
import { Button, ErrorState, Field, Input, Skeleton } from './ui'
|
||||
|
||||
interface EntityRef {
|
||||
kind: EntityKind
|
||||
id: string
|
||||
projectId: string
|
||||
/** Titre de la tâche / du bug, repris dans les notifications. */
|
||||
title: string
|
||||
/** Personnes concernées (assignés, rapporteur…) à prévenir d'un commentaire. */
|
||||
involvedIds: string[]
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Discussion
|
||||
// =====================================================================
|
||||
|
||||
export function CommentThread({ entity }: { entity: EntityRef }): JSX.Element {
|
||||
const { profile, members, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
const [items, setItems] = useState<Comment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems(
|
||||
await rows<Comment>(
|
||||
supa()
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.eq('entity_kind', entity.kind)
|
||||
.eq('entity_id', entity.id)
|
||||
.order('created_at')
|
||||
)
|
||||
)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(humanize(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [entity.kind, entity.id])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useRealtime(
|
||||
`comments-${entity.kind}-${entity.id}`,
|
||||
[{ table: 'comments', filter: `entity_id=eq.${entity.id}` }],
|
||||
load
|
||||
)
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const body = draft.trim()
|
||||
if (!body || !profile) return
|
||||
setSending(true)
|
||||
try {
|
||||
const created = await run<Comment>(
|
||||
supa()
|
||||
.from('comments')
|
||||
.insert({
|
||||
project_id: entity.projectId,
|
||||
entity_kind: entity.kind,
|
||||
entity_id: entity.id,
|
||||
author_id: profile.id,
|
||||
body
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
)
|
||||
setDraft('')
|
||||
if (created) setItems((list) => [...list, created])
|
||||
requestAnimationFrame(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }))
|
||||
await notifyComment({
|
||||
projectId: entity.projectId,
|
||||
entityKind: entity.kind,
|
||||
entityId: entity.id,
|
||||
entityTitle: entity.title,
|
||||
body,
|
||||
members,
|
||||
involvedIds: entity.involvedIds,
|
||||
actorId: profile.id,
|
||||
actorName: profile.full_name
|
||||
})
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (c: Comment): Promise<void> => {
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer ce message ?',
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await run(supa().from('comments').delete().eq('id', c.id))
|
||||
setItems((list) => list.filter((x) => x.id !== c.id))
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="well p-3">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<MessageSquare size={16} className="text-muted" />
|
||||
Discussion
|
||||
{items.length > 0 && <span className="text-xs font-normal text-subtle">· {items.length}</span>}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-3/4" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState compact message={error} onRetry={load} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-2 text-xs text-subtle">
|
||||
Aucun message. Pose une question ou note une décision : tout le monde la retrouvera ici.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-2.5 overflow-y-auto pr-1">
|
||||
{items.map((c) => {
|
||||
const author = memberById(c.author_id)
|
||||
const mine = c.author_id === profile?.id
|
||||
return (
|
||||
<li key={c.id} className="group flex gap-2.5">
|
||||
<Avatar profile={author} size={26} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-ink">
|
||||
{author?.full_name ?? 'Membre supprimé'}
|
||||
</span>
|
||||
<time
|
||||
className="text-[11px] text-subtle"
|
||||
dateTime={c.created_at}
|
||||
title={format(new Date(c.created_at), 'PPPp', { locale: fr })}
|
||||
>
|
||||
{formatDistanceToNow(new Date(c.created_at), { addSuffix: true, locale: fr })}
|
||||
</time>
|
||||
{mine && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(c)}
|
||||
aria-label="Supprimer mon message"
|
||||
className="ml-auto text-subtle opacity-0 transition hover:text-rose-600 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-snug text-muted">
|
||||
{c.body}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<textarea
|
||||
className="input min-h-[38px] resize-none py-2"
|
||||
rows={draft.includes('\n') || draft.length > 60 ? 3 : 1}
|
||||
placeholder="Écrire un message… (Ctrl + Entrée pour envoyer)"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
void send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Send size={15} />}
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={send}
|
||||
>
|
||||
Envoyer
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pièces jointes
|
||||
// =====================================================================
|
||||
|
||||
export function AttachmentPanel({ entity }: { entity: EntityRef }): JSX.Element {
|
||||
const { profile, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
const [items, setItems] = useState<Attachment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems(await listAttachments(entity.kind, entity.id))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(humanize(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [entity.kind, entity.id])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const upload = useCallback(
|
||||
async (files: File[]): Promise<void> => {
|
||||
if (!profile || files.length === 0) return
|
||||
setBusy(true)
|
||||
try {
|
||||
for (const file of files) {
|
||||
const created = await uploadAttachment({
|
||||
file,
|
||||
projectId: entity.projectId,
|
||||
entityKind: entity.kind,
|
||||
entityId: entity.id,
|
||||
uploaderId: profile.id
|
||||
})
|
||||
setItems((list) => [...list, created])
|
||||
}
|
||||
toast(files.length > 1 ? `${files.length} fichiers ajoutés.` : 'Fichier ajouté.', 'success')
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[profile, entity.projectId, entity.kind, entity.id, toast]
|
||||
)
|
||||
|
||||
// Coller une capture d'écran directement (Impr. écran → Ctrl+V) :
|
||||
// c'est LE geste attendu quand on signale un bug.
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent): void => {
|
||||
const files = Array.from(e.clipboardData?.files ?? []).filter((f) =>
|
||||
f.type.startsWith('image/')
|
||||
)
|
||||
if (files.length === 0) return
|
||||
e.preventDefault()
|
||||
void upload(files)
|
||||
}
|
||||
document.addEventListener('paste', onPaste)
|
||||
return () => document.removeEventListener('paste', onPaste)
|
||||
}, [upload])
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>): void => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
void upload(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
const remove = async (a: Attachment): Promise<void> => {
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer ce fichier ?',
|
||||
message: a.name,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await deleteAttachment(a)
|
||||
setItems((list) => list.filter((x) => x.id !== a.id))
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const images = items.filter(isImage)
|
||||
const files = items.filter((a) => !isImage(a))
|
||||
|
||||
return (
|
||||
<section className="well p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<Paperclip size={16} className="text-muted" />
|
||||
Pièces jointes
|
||||
{items.length > 0 && <span className="text-xs font-normal text-subtle">· {items.length}</span>}
|
||||
</h3>
|
||||
<Button size="sm" loading={busy} onClick={() => fileRef.current?.click()}>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void upload(Array.from(e.target.files ?? []))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : error ? (
|
||||
<ErrorState compact message={error} onRetry={load} />
|
||||
) : (
|
||||
<>
|
||||
{images.length > 0 && (
|
||||
<div className="mb-2 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{images.map((a) => (
|
||||
<figure key={a.id} className="group relative overflow-hidden rounded-lg border border-border">
|
||||
<a href={attachmentUrl(a.path)} target="_blank" rel="noreferrer" title={a.name}>
|
||||
<img
|
||||
src={attachmentUrl(a.path)}
|
||||
alt={a.name}
|
||||
loading="lazy"
|
||||
className="h-20 w-full bg-panel2 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Supprimer ${a.name}`}
|
||||
onClick={() => remove(a)}
|
||||
className="absolute right-1 top-1 rounded-md bg-black/60 p-1 text-white opacity-0 transition group-hover:opacity-100 hover:bg-rose-600"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<ul className="mb-2 space-y-1">
|
||||
{files.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="group flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<FileText size={15} className="shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-ink">{a.name}</span>
|
||||
<span className="shrink-0 text-xs text-subtle">{formatBytes(a.size)}</span>
|
||||
<a
|
||||
href={attachmentUrl(a.path)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={a.name}
|
||||
aria-label={`Télécharger ${a.name}`}
|
||||
className="shrink-0 text-subtle hover:text-accent"
|
||||
>
|
||||
<Download size={14} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Supprimer ${a.name}`}
|
||||
onClick={() => remove(a)}
|
||||
className="shrink-0 text-subtle opacity-0 transition hover:text-rose-600 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Zone de dépôt : glisser-déposer OU coller une capture d'écran. */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border border-dashed px-3 py-4 text-center transition-colors ${
|
||||
dragging ? 'border-accent bg-accent/8' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
) : (
|
||||
<UploadCloud size={20} className="text-subtle" />
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
Glisse un fichier ici, ou fais <span className="kbd">Ctrl</span>{' '}
|
||||
<span className="kbd">V</span> pour coller une capture d'écran
|
||||
</p>
|
||||
<p className="text-[11px] text-subtle">{formatBytes(MAX_ATTACHMENT_BYTES)} maximum par fichier</p>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<p className="mt-2 flex items-center gap-1 text-[11px] text-subtle">
|
||||
<ImageIcon size={11} />
|
||||
Ajouté par {memberById(items[items.length - 1].uploader_id)?.full_name ?? 'un membre'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Temps passé — fonctionne à l'identique sur une tâche ou sur un bug.
|
||||
// Les bugs n'avaient aucun suivi du temps : incohérence corrigée.
|
||||
// =====================================================================
|
||||
|
||||
export function TimeSection({
|
||||
target,
|
||||
projectId
|
||||
}: {
|
||||
target: { kind: EntityKind; id: string }
|
||||
projectId: string
|
||||
}): JSX.Element {
|
||||
const { profile, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
const [logs, setLogs] = useState<TimeLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hours, setHours] = useState('')
|
||||
const [minutes, setMinutes] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const column = target.kind === 'bug' ? 'bug_id' : 'task_id'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLogs(
|
||||
await rows<TimeLog>(
|
||||
supa()
|
||||
.from('time_logs')
|
||||
.select('*')
|
||||
.eq(column, target.id)
|
||||
.order('logged_at', { ascending: false })
|
||||
)
|
||||
)
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [column, target.id, toast])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
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 (heures et/ou minutes).', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await run(
|
||||
supa()
|
||||
.from('time_logs')
|
||||
.insert({
|
||||
[column]: target.id,
|
||||
project_id: projectId,
|
||||
user_id: profile?.id ?? null,
|
||||
minutes: mins,
|
||||
note: note.trim() || null
|
||||
})
|
||||
)
|
||||
setHours('')
|
||||
setMinutes('')
|
||||
setNote('')
|
||||
await load()
|
||||
toast('Temps ajouté.', 'success')
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
await run(supa().from('time_logs').delete().eq('id', id))
|
||||
setLogs((list) => list.filter((l) => l.id !== id))
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="well p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<Clock size={16} className="text-muted" /> Temps passé
|
||||
</h3>
|
||||
<span className="text-sm font-semibold text-accent2 tabular">{formatMinutes(total)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-end gap-2">
|
||||
<Field label="Heures" className="w-[4.5rem]">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
placeholder="0"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Minutes" className="w-[4.5rem]">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
placeholder="0"
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Note" className="min-w-[8rem] flex-1">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
placeholder="Ce que tu as fait (optionnel)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button icon={<Plus size={15} />} loading={busy} onClick={add}>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : logs.length === 0 ? (
|
||||
<p className="py-2 text-center text-xs text-subtle">
|
||||
Aucun temps enregistré. Ces heures alimentent la feuille de temps de l'équipe.
|
||||
</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={memberById(l.user_id)} size={22} />
|
||||
<span className="shrink-0 font-medium tabular text-ink">{formatMinutes(l.minutes)}</span>
|
||||
{l.note && <span className="min-w-0 truncate text-muted">— {l.note}</span>}
|
||||
<span className="ml-auto shrink-0 text-xs text-subtle">
|
||||
{format(new Date(l.logged_at), 'd MMM', { locale: fr })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Supprimer cette entrée"
|
||||
onClick={() => del(l.id)}
|
||||
className="shrink-0 text-subtle opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+532
-176
@@ -1,227 +1,583 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
KanbanSquare,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Bug,
|
||||
Users,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Folder,
|
||||
KanbanSquare,
|
||||
Keyboard,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Settings,
|
||||
Monitor,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
Moon
|
||||
Users,
|
||||
WifiOff
|
||||
} from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { ROLE_LABELS } from '../lib/types'
|
||||
import { Theme, getTheme, setTheme } from '../lib/theme'
|
||||
import { Avatar, Modal } from './ui'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { humanize } from '../lib/db'
|
||||
import { Theme, ResolvedTheme, getTheme, resolveTheme, setTheme, watchSystemTheme } from '../lib/theme'
|
||||
import { Page, plural } from '../lib/types'
|
||||
import { currentVersion } from '../lib/updater'
|
||||
import { Avatar } from './people'
|
||||
import { Button, IconButton, Kbd } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
import { Select } from './Select'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import UpdateBanner, { UpdateCheckRow } from './UpdateBanner'
|
||||
import CommandPalette from './CommandPalette'
|
||||
import ProjectModal from './ProjectModal'
|
||||
import Logo from './Logo'
|
||||
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'kanban'
|
||||
| 'planning'
|
||||
| 'bugs'
|
||||
| 'members'
|
||||
| 'admin'
|
||||
export type { Page }
|
||||
|
||||
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
|
||||
{ id: 'planning', label: 'Feuille de temps', icon: Clock },
|
||||
{ id: 'bugs', label: 'Bugs', icon: Bug },
|
||||
{ id: 'members', label: 'Équipe', icon: Users },
|
||||
{ id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true }
|
||||
export const NAV: {
|
||||
id: Page
|
||||
label: string
|
||||
short: string
|
||||
icon: typeof LayoutDashboard
|
||||
adminOnly?: boolean
|
||||
}[] = [
|
||||
{ id: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: LayoutDashboard },
|
||||
{ id: 'kanban', label: 'Tâches', short: 'Tâches', icon: KanbanSquare },
|
||||
{ id: 'planning', label: 'Feuille de temps', short: 'Temps', icon: Clock },
|
||||
{ id: 'bugs', label: 'Bugs', short: 'Bugs', icon: Bug },
|
||||
{ id: 'docs', label: 'Documentation', short: 'Doc', icon: BookOpen },
|
||||
{ id: 'members', label: 'Équipe', short: 'Équipe', icon: Users },
|
||||
{ id: 'admin', label: 'Administration', short: 'Admin', icon: ShieldCheck, adminOnly: true }
|
||||
]
|
||||
|
||||
export default function Layout({
|
||||
page,
|
||||
setPage,
|
||||
children
|
||||
}: {
|
||||
page: Page
|
||||
setPage: (p: Page) => void
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme())
|
||||
// ---------------------------------------------------------------------
|
||||
// Barre supérieure partagée
|
||||
//
|
||||
// Avant, l'application empilait DEUX barres : un bandeau global de 48 px
|
||||
// quasi vide, plus l'en-tête propre à chaque page. Désormais il n'y en a
|
||||
// qu'une : le titre vient de la navigation, et chaque page y injecte ses
|
||||
// actions via <PageActions> (un portal) et son sous-titre via
|
||||
// usePageSubtitle().
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const toggleTheme = (): void => {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
setThemeState(next)
|
||||
interface PageChrome {
|
||||
slot: HTMLElement | null
|
||||
setSubtitle: (s: string | null) => void
|
||||
}
|
||||
|
||||
const ChromeCtx = createContext<PageChrome>({ slot: null, setSubtitle: () => undefined })
|
||||
|
||||
/** Boutons d'action de la page courante, rendus dans la barre supérieure. */
|
||||
export function PageActions({ children }: { children: ReactNode }): JSX.Element | null {
|
||||
const { slot } = useContext(ChromeCtx)
|
||||
if (!slot) return null
|
||||
return createPortal(<>{children}</>, slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sous-titre contextuel de la page (« 3 bugs actifs », la semaine en cours…).
|
||||
*
|
||||
* ⚠️ Passe une CHAÎNE (ou une valeur mémoïsée) : la valeur est stockée dans un
|
||||
* état, donc un élément JSX recréé à chaque rendu ferait boucler la page.
|
||||
*/
|
||||
export function usePageSubtitle(subtitle: string | null): void {
|
||||
const { setSubtitle } = useContext(ChromeCtx)
|
||||
useEffect(() => {
|
||||
setSubtitle(subtitle)
|
||||
return () => setSubtitle(null)
|
||||
}, [subtitle, setSubtitle])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const SIDEBAR_KEY = 'gamedev-tracker-sidebar'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }): JSX.Element {
|
||||
const { page, setPage, profile, projects, currentProject, online, signOut } = useApp()
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_KEY) === '1')
|
||||
const [showNewProject, setShowNewProject] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showPalette, setShowPalette] = useState(false)
|
||||
const [editProfile, setEditProfile] = useState(false)
|
||||
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme())
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(getTheme()))
|
||||
|
||||
const [slot, setSlot] = useState<HTMLElement | null>(null)
|
||||
const [subtitle, setSubtitle] = useState<string | null>(null)
|
||||
|
||||
const nav = useMemo(() => NAV.filter((n) => !n.adminOnly || profile?.is_admin), [profile?.is_admin])
|
||||
const active = nav.find((n) => n.id === page) ?? nav[0]
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setCollapsed((c) => {
|
||||
localStorage.setItem(SIDEBAR_KEY, c ? '0' : '1')
|
||||
return !c
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** clair → sombre → système → clair */
|
||||
const cycleTheme = useCallback(() => {
|
||||
setThemeState((cur) => {
|
||||
const next: Theme = cur === 'light' ? 'dark' : cur === 'dark' ? 'system' : 'light'
|
||||
setResolved(setTheme(next))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => watchSystemTheme(setResolved), [])
|
||||
|
||||
// Raccourcis globaux. Tous avec Ctrl/Cmd : une touche seule changeait le
|
||||
// thème ou la page par simple frappe involontaire.
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
const el = document.activeElement as HTMLElement | null
|
||||
const typing =
|
||||
!!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
|
||||
|
||||
if (mod && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setShowPalette(true)
|
||||
return
|
||||
}
|
||||
if (!typing && e.key === '/') {
|
||||
e.preventDefault()
|
||||
setShowPalette(true)
|
||||
return
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault()
|
||||
toggleSidebar()
|
||||
return
|
||||
}
|
||||
if (!typing && e.key === '?') {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
}
|
||||
if (mod && !e.shiftKey && /^[1-9]$/.test(e.key)) {
|
||||
const target = nav[Number(e.key) - 1]
|
||||
if (target) {
|
||||
e.preventDefault()
|
||||
setPage(target.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [nav, setPage, toggleSidebar])
|
||||
|
||||
const ThemeIcon = theme === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun
|
||||
const themeLabel =
|
||||
theme === 'system' ? 'Thème : système' : theme === 'dark' ? 'Thème : sombre' : 'Thème : clair'
|
||||
|
||||
const chrome = useMemo<PageChrome>(() => ({ slot, setSubtitle }), [slot])
|
||||
|
||||
return (
|
||||
<ChromeCtx.Provider value={chrome}>
|
||||
<div className="flex h-full">
|
||||
{/* Barre latérale */}
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-sidebar">
|
||||
<div className="flex items-center gap-2.5 px-4 py-4">
|
||||
<Logo size={28} />
|
||||
<span className="font-semibold text-ink">GameDev Tracker</span>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de projet */}
|
||||
<div className="relative px-3">
|
||||
<button
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border bg-panel2 px-3 py-2 text-left text-sm hover:border-accent"
|
||||
onClick={() => setPicker((v) => !v)}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="block text-[10px] uppercase text-subtle">Projet</span>
|
||||
<span className="font-medium text-ink">{currentProject?.name ?? 'Aucun projet'}</span>
|
||||
</span>
|
||||
<ChevronDown size={16} className="text-muted" />
|
||||
</button>
|
||||
{picker && (
|
||||
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-border"
|
||||
onClick={() => {
|
||||
setCurrentProject(p)
|
||||
setPicker(false)
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-accent2 hover:bg-border"
|
||||
onClick={() => {
|
||||
setShowNew(true)
|
||||
setPicker(false)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Nouveau projet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="mt-4 flex-1 space-y-1 px-3">
|
||||
{NAV.filter((n) => !n.adminOnly || profile?.is_admin).map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => setPage(n.id)}
|
||||
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
page === n.id
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:bg-accent/10 hover:text-accent'
|
||||
{/* ------------------------------ Barre latérale ------------------------------ */}
|
||||
<aside
|
||||
className={`flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width] duration-200 ease-smooth ${
|
||||
collapsed ? 'w-[3.75rem]' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
{page === n.id && (
|
||||
<div className={`flex h-14 items-center gap-2.5 ${collapsed ? 'justify-center px-2' : 'px-4'}`}>
|
||||
<Logo size={26} />
|
||||
{!collapsed && <span className="truncate font-semibold text-ink">GameDev Tracker</span>}
|
||||
</div>
|
||||
|
||||
<ProjectSwitcher collapsed={collapsed} onCreate={() => setShowNewProject(true)} />
|
||||
|
||||
<nav className="mt-3 flex-1 space-y-0.5 px-2" aria-label="Navigation principale">
|
||||
{nav.map((n) => {
|
||||
const on = page === n.id
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => setPage(n.id)}
|
||||
title={collapsed ? n.label : undefined}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
className={`relative flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
} ${on ? 'text-accent' : 'text-muted hover:bg-hover hover:text-ink'}`}
|
||||
>
|
||||
{on && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
className="absolute inset-0 rounded-lg bg-accent/15 ring-1 ring-inset ring-accent/25"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
className="absolute inset-0 rounded-lg bg-accent/12 ring-1 ring-inset ring-accent/25"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
||||
/>
|
||||
)}
|
||||
<n.icon size={18} className="relative z-10" />
|
||||
<span className="relative z-10">{n.label}</span>
|
||||
<n.icon size={18} className="relative z-10 shrink-0" />
|
||||
{!collapsed && <span className="relative z-10 truncate">{n.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Pied : utilisateur */}
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar profile={profile} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-ink">{profile?.full_name}</div>
|
||||
<div className="truncate text-xs text-subtle">
|
||||
{profile ? ROLE_LABELS[profile.role] : ''}
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell setPage={setPage} />
|
||||
<div className="border-t border-border p-2">
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
|
||||
className="text-muted hover:text-ink"
|
||||
type="button"
|
||||
onClick={() => setEditProfile(true)}
|
||||
title={`${profile?.full_name ?? ''} — modifier mon profil`}
|
||||
className="rounded-full ring-offset-2 ring-offset-sidebar hover:ring-2 hover:ring-accent"
|
||||
>
|
||||
{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} />
|
||||
<Avatar profile={profile} size={28} />
|
||||
</button>
|
||||
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={16} />} onClick={cycleTheme} />
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="danger"
|
||||
label="Se déconnecter"
|
||||
icon={<LogOut size={16} />}
|
||||
onClick={signOut}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditProfile(true)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg p-1 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<Avatar profile={profile} size={30} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink">
|
||||
{profile?.full_name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-subtle">Modifier mon profil</span>
|
||||
</span>
|
||||
<Pencil size={14} className="shrink-0 text-subtle" />
|
||||
</button>
|
||||
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={17} />} onClick={cycleTheme} />
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="danger"
|
||||
label="Se déconnecter"
|
||||
icon={<LogOut size={17} />}
|
||||
onClick={signOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Contenu */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{currentProject || page === 'admin' ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted">
|
||||
<Settings size={40} className="text-gray-600" />
|
||||
<p>Aucun projet pour le moment.</p>
|
||||
<button className="btn-primary" onClick={() => setShowNew(true)}>
|
||||
<Plus size={16} /> Créer le premier projet
|
||||
</button>
|
||||
{/* ------------------------------ Contenu ------------------------------ */}
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<UpdateBanner />
|
||||
|
||||
{!online && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex shrink-0 items-center justify-center gap-2 bg-amber-500/15 py-1.5 text-xs font-medium text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<WifiOff size={13} />
|
||||
Hors ligne — tes modifications ne seront pas enregistrées tant que la connexion n'est pas revenue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UNE seule barre supérieure pour toute l'application */}
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-panel px-4">
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={collapsed ? 'Déployer le menu (Ctrl+B)' : 'Replier le menu (Ctrl+B)'}
|
||||
icon={collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink">
|
||||
{active?.label}
|
||||
</h1>
|
||||
<p className="truncate text-xs leading-tight text-subtle">
|
||||
{subtitle ?? currentProject?.name ?? 'Aucun projet'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Actions injectées par la page courante */}
|
||||
<div ref={setSlot} className="flex items-center gap-2" />
|
||||
|
||||
<span className="mx-1 h-6 w-px bg-border" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPalette(true)}
|
||||
className="hidden items-center gap-2 rounded-lg border border-border bg-panel2 py-1.5 pl-2.5 pr-2 text-xs text-subtle transition-colors hover:border-border-strong hover:text-muted lg:flex"
|
||||
>
|
||||
<Search size={14} />
|
||||
Rechercher
|
||||
<Kbd>Ctrl K</Kbd>
|
||||
</button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Rechercher (Ctrl+K)"
|
||||
icon={<Search size={17} />}
|
||||
onClick={() => setShowPalette(true)}
|
||||
className="lg:hidden"
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Raccourcis clavier (?)"
|
||||
icon={<Keyboard size={17} />}
|
||||
onClick={() => setShowHelp(true)}
|
||||
/>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{currentProject || page === 'admin' || page === 'members' ? (
|
||||
children
|
||||
) : (
|
||||
<NoProject
|
||||
loading={projects.length === 0}
|
||||
onCreate={() => setShowNewProject(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={refreshProjects} />}
|
||||
{showNewProject && <ProjectModal mode="create" onClose={() => setShowNewProject(false)} />}
|
||||
{editProfile && profile && <ProfileModalLazy onClose={() => setEditProfile(false)} />}
|
||||
{showHelp && <ShortcutsModal nav={nav} onClose={() => setShowHelp(false)} />}
|
||||
{showPalette && <CommandPalette onClose={() => setShowPalette(false)} />}
|
||||
</div>
|
||||
</ChromeCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Sélecteur de projet
|
||||
// (l'ancien menu maison ne se fermait ni au clic extérieur ni à Échap)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function ProjectSwitcher({
|
||||
collapsed,
|
||||
onCreate
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onCreate: () => void
|
||||
}): JSX.Element | null {
|
||||
const { projects, currentProject, selectProject, projectsLoading } = useApp()
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex justify-center px-2">
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={currentProject?.name ?? 'Aucun projet'}
|
||||
icon={<Folder size={17} />}
|
||||
onClick={onCreate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (projectsLoading) return <div className="mx-3 h-[42px] skeleton" />
|
||||
|
||||
const NEW = '__new__'
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<Select
|
||||
ariaLabel="Changer de projet"
|
||||
placeholder="Aucun projet"
|
||||
className="!bg-panel"
|
||||
value={currentProject?.id ?? ''}
|
||||
onChange={(id) => {
|
||||
if (id === NEW) {
|
||||
onCreate()
|
||||
return
|
||||
}
|
||||
const p = projects.find((x) => x.id === id)
|
||||
if (p) selectProject(p)
|
||||
}}
|
||||
options={[
|
||||
...projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
hint: p.description ?? undefined,
|
||||
group: 'Projets'
|
||||
})),
|
||||
{ value: NEW, label: '+ Nouveau projet', group: 'Gérer' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewProjectModal({
|
||||
onClose,
|
||||
onCreated
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { profile, setCurrentProject } = useApp()
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const create = async (): Promise<void> => {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
const { data } = await supa()
|
||||
.from('projects')
|
||||
.insert({ name: name.trim(), description: desc.trim() || null, created_by: profile?.id })
|
||||
.select()
|
||||
.single()
|
||||
await onCreated()
|
||||
if (data) setCurrentProject(data)
|
||||
setBusy(false)
|
||||
onClose()
|
||||
function NoProject({ loading, onCreate }: { loading: boolean; onCreate: () => void }): JSX.Element {
|
||||
const { projectsLoading, projectsError, refreshProjects } = useApp()
|
||||
|
||||
// Avant, « Aucun projet » clignotait pendant le chargement.
|
||||
if (projectsLoading || (loading && !projectsError)) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="w-72 space-y-3">
|
||||
<div className="skeleton h-6 w-1/2" />
|
||||
<div className="skeleton h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (projectsError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="mb-3 text-sm text-muted">{projectsError}</p>
|
||||
<Button onClick={refreshProjects}>Réessayer</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Nouveau projet" onClose={onClose}>
|
||||
<label className="label">Nom du jeu / projet</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Mon super jeu"
|
||||
/>
|
||||
<label className="label">Description (optionnel)</label>
|
||||
<textarea
|
||||
className="input mb-4 h-24 resize-none"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="Genre, plateforme, pitch..."
|
||||
/>
|
||||
<button className="btn-primary w-full" onClick={create} disabled={busy || !name.trim()}>
|
||||
Créer
|
||||
</button>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<Folder size={40} className="text-subtle" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-ink">Aucun projet pour le moment</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted">
|
||||
Un projet regroupe les tâches, les bugs et le temps passé de ton jeu.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={onCreate}>
|
||||
Créer le premier projet
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function ShortcutsModal({
|
||||
nav,
|
||||
onClose
|
||||
}: {
|
||||
nav: { id: Page; label: string }[]
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [version, setVersion] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
currentVersion().then(setVersion)
|
||||
}, [])
|
||||
|
||||
const groups: { title: string; rows: [string, string][] }[] = [
|
||||
{
|
||||
title: 'Général',
|
||||
rows: [
|
||||
['Ctrl K', 'Rechercher / palette de commandes'],
|
||||
['/', 'Rechercher'],
|
||||
['Ctrl B', 'Replier ou déployer le menu'],
|
||||
['?', 'Afficher cette aide'],
|
||||
['Échap', 'Fermer une fenêtre']
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Navigation',
|
||||
rows: nav.map((n, i): [string, string] => [`Ctrl ${i + 1}`, n.label])
|
||||
},
|
||||
{
|
||||
title: 'Kanban',
|
||||
rows: [
|
||||
['Espace', 'Prendre / déposer une carte au clavier'],
|
||||
['← →', 'Changer de colonne (carte prise)'],
|
||||
['↑ ↓', 'Monter / descendre (carte prise)']
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Écriture',
|
||||
rows: [['Ctrl ⏎', 'Envoyer un commentaire']]
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal title="Raccourcis clavier" onClose={onClose} footer={
|
||||
<ModalActions>
|
||||
<Button variant="primary" onClick={onClose}>Compris</Button>
|
||||
</ModalActions>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{groups.map((g) => (
|
||||
<section key={g.title}>
|
||||
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-subtle">
|
||||
{g.title}
|
||||
</h3>
|
||||
<ul className="divide-y divide-border">
|
||||
{g.rows.map(([key, label]) => (
|
||||
<li key={key + label} className="flex items-center justify-between gap-4 py-1.5 text-sm">
|
||||
<span className="text-ink">{label}</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
{key.split(' ').map((k) => (
|
||||
<Kbd key={k}>{k}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-subtle">
|
||||
Les raccourcis de navigation sont inactifs pendant la saisie de texte.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<UpdateCheckRow version={version} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Édition du profil — importée à la demande pour ne pas alourdir le shell.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function ProfileModalLazy({ onClose }: { onClose: () => void }): JSX.Element | null {
|
||||
const { profile, refreshProfile } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
const [Comp, setComp] = useState<null | React.ComponentType<{
|
||||
profile: NonNullable<typeof profile>
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
import('./EditProfileModal')
|
||||
.then((m) => setComp(() => m.default))
|
||||
.catch((e) => {
|
||||
toast(humanize(e), 'error')
|
||||
onClose()
|
||||
})
|
||||
}, [onClose, toast])
|
||||
|
||||
if (!Comp || !profile) return null
|
||||
return <Comp profile={profile} onClose={onClose} onSaved={refreshProfile} />
|
||||
}
|
||||
|
||||
/** Petit utilitaire de libellé réexporté pour les pages. */
|
||||
export { plural }
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// =====================================================================
|
||||
// Affichage d'une fiche Markdown.
|
||||
//
|
||||
// Le projet n'utilise pas @tailwindcss/typography : les styles sont
|
||||
// écrits ici, avec les tokens de l'application, pour que le rendu suive
|
||||
// le thème clair comme sombre. Un plugin générique aurait ramené ses
|
||||
// propres gris, invisibles en mode sombre.
|
||||
// =====================================================================
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
|
||||
/** Un titre du document, pour construire le plan latéral. */
|
||||
export interface Heading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
/** Identifiant d'ancre lisible et stable à partir du texte d'un titre. */
|
||||
function slug(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'section'
|
||||
)
|
||||
}
|
||||
|
||||
const STYLES = [
|
||||
// Titres
|
||||
'[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:text-ink first:[&_h1]:mt-0',
|
||||
'[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-1.5 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-ink',
|
||||
'[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-ink',
|
||||
'[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-ink',
|
||||
// Texte
|
||||
'[&_p]:my-3 [&_p]:leading-relaxed [&_p]:text-muted',
|
||||
'[&_strong]:font-semibold [&_strong]:text-ink',
|
||||
'[&_em]:italic',
|
||||
'[&_del]:text-subtle [&_del]:line-through',
|
||||
// Listes
|
||||
'[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:text-muted',
|
||||
'[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:text-muted',
|
||||
'[&_li]:my-1 [&_li]:leading-relaxed',
|
||||
'[&_li>ul]:my-1 [&_li>ol]:my-1',
|
||||
// Cases à cocher des listes de tâches GFM
|
||||
'[&_input[type=checkbox]]:mr-1.5 [&_input[type=checkbox]]:align-middle',
|
||||
// Liens
|
||||
'[&_a]:text-accent [&_a]:underline-offset-2 hover:[&_a]:underline [&_a]:break-words',
|
||||
// Code
|
||||
"[&_code]:rounded [&_code]:bg-panel2 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-ink",
|
||||
'[&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-panel2 [&_pre]:p-3',
|
||||
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[13px]',
|
||||
// Citations
|
||||
'[&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-accent [&_blockquote]:pl-4 [&_blockquote]:text-muted [&_blockquote]:italic',
|
||||
// Tableaux — le conteneur défile plutôt que la page
|
||||
'[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm',
|
||||
'[&_th]:border [&_th]:border-border [&_th]:bg-panel2 [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-medium [&_th]:text-ink',
|
||||
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-1.5 [&_td]:text-muted',
|
||||
// Divers
|
||||
'[&_h1]:scroll-mt-4 [&_h2]:scroll-mt-4 [&_h3]:scroll-mt-4',
|
||||
'[&_hr]:my-6 [&_hr]:border-border',
|
||||
'[&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:border [&_img]:border-border'
|
||||
].join(' ')
|
||||
|
||||
export default function Markdown({
|
||||
content,
|
||||
className = '',
|
||||
onHeadings
|
||||
}: {
|
||||
content: string
|
||||
className?: string
|
||||
/** Reçoit les titres du document, pour afficher un plan latéral. */
|
||||
onHeadings?: (headings: Heading[]) => void
|
||||
}): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const html = useMemo(() => renderMarkdown(content), [content])
|
||||
|
||||
// Les ancres sont posées sur le DOM plutôt que par un moteur de rendu
|
||||
// Markdown : le texte affiché fait foi, et on reste indépendant de
|
||||
// l'API de `marked`, qui a déjà changé plusieurs fois de signature.
|
||||
const lastSent = useRef('')
|
||||
useEffect(() => {
|
||||
const root = ref.current
|
||||
if (!root) return
|
||||
|
||||
const seen = new Map<string, number>()
|
||||
const found: Heading[] = []
|
||||
|
||||
root.querySelectorAll<HTMLElement>('h1, h2, h3').forEach((el) => {
|
||||
const text = el.textContent?.trim() ?? ''
|
||||
if (!text) return
|
||||
// Deux sections peuvent porter le même nom : on suffixe pour que
|
||||
// chaque ancre reste unique et que le clic vise la bonne.
|
||||
const base = slug(text)
|
||||
const n = seen.get(base) ?? 0
|
||||
seen.set(base, n + 1)
|
||||
const id = n === 0 ? base : `${base}-${n}`
|
||||
el.id = id
|
||||
found.push({ id, text, level: Number(el.tagName[1]) })
|
||||
})
|
||||
|
||||
// Ne remonter que si le plan a réellement changé : sinon le parent
|
||||
// re-rendrait en boucle.
|
||||
const key = found.map((h) => `${h.level}:${h.id}`).join('|')
|
||||
if (key !== lastSent.current) {
|
||||
lastSent.current = key
|
||||
onHeadings?.(found)
|
||||
}
|
||||
}, [html, onHeadings])
|
||||
|
||||
// Les liens externes doivent s'ouvrir dans le NAVIGATEUR, pas remplacer
|
||||
// l'application par une page web — ce qui la rendrait inutilisable, la
|
||||
// fenêtre n'ayant ni barre d'adresse ni bouton retour.
|
||||
useEffect(() => {
|
||||
const root = ref.current
|
||||
if (!root) return
|
||||
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
const href = link?.getAttribute('href')
|
||||
if (!href) return
|
||||
e.preventDefault()
|
||||
if (href.startsWith('#')) {
|
||||
root.querySelector(href)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
if (/^https?:\/\//i.test(href)) window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
root.addEventListener('click', onClick)
|
||||
return () => root.removeEventListener('click', onClick)
|
||||
}, [html])
|
||||
|
||||
if (!content.trim()) {
|
||||
return <p className={`text-sm italic text-subtle ${className}`}>Cette fiche est vide.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`overflow-x-auto text-sm ${STYLES} ${className}`}
|
||||
// Contenu déjà assaini par DOMPurify (voir lib/markdown.ts).
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// =====================================================================
|
||||
// Modale — une seule implémentation pour toute l'application.
|
||||
//
|
||||
// Corrige trois défauts qui coûtaient cher à l'usage :
|
||||
// 1. Le contenu long faisait défiler le TITRE et le bouton « Enregistrer »
|
||||
// hors de l'écran → en-tête et pied sont désormais fixes.
|
||||
// 2. Fermer par Échap ou par un clic à côté effaçait une saisie en cours
|
||||
// sans un mot → `dirty` déclenche une demande de confirmation.
|
||||
// 3. Le focus s'échappait derrière la modale → piège à focus + restitution
|
||||
// du focus à l'élément d'origine à la fermeture.
|
||||
// =====================================================================
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Trash2, X } from 'lucide-react'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Button, IconButton } from './ui'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const WIDTH: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl'
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
size = 'md',
|
||||
/** Y a-t-il une saisie non enregistrée ? Si oui, on confirme la fermeture. */
|
||||
dirty = false
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
onClose: () => void
|
||||
size?: ModalSize
|
||||
dirty?: boolean
|
||||
}): JSX.Element {
|
||||
const { confirm } = useFeedback()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const returnFocus = useRef<HTMLElement | null>(null)
|
||||
|
||||
const requestClose = useCallback(async (): Promise<void> => {
|
||||
if (!dirty) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Abandonner les modifications ?',
|
||||
message: "Ce que tu viens de saisir sera perdu.",
|
||||
confirmLabel: 'Abandonner',
|
||||
cancelLabel: 'Continuer l’édition',
|
||||
danger: true
|
||||
})
|
||||
if (ok) onClose()
|
||||
}, [dirty, onClose, confirm])
|
||||
|
||||
// Focus initial + restitution à la fermeture.
|
||||
useEffect(() => {
|
||||
returnFocus.current = document.activeElement as HTMLElement | null
|
||||
const first = panelRef.current?.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled])'
|
||||
)
|
||||
// Un champ si la modale en contient un, sinon le panneau lui-même.
|
||||
;(first ?? panelRef.current)?.focus({ preventScroll: true })
|
||||
return () => returnFocus.current?.focus?.({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
// Échap (sauf si un menu déroulant est ouvert : il ferme en premier)
|
||||
// et piège à focus sur Tab.
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
if (document.querySelector('[data-select-open]')) return
|
||||
e.preventDefault()
|
||||
void requestClose()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !panelRef.current) return
|
||||
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetParent !== null
|
||||
)
|
||||
if (items.length === 0) return
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
if (e.shiftKey && (active === first || active === panelRef.current)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [requestClose])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
data-overlay
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
|
||||
onMouseDown={() => void requestClose()}
|
||||
>
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0, scale: 0.97, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[88vh] w-full flex-col overflow-hidden rounded-xl border border-border
|
||||
bg-panel shadow-pop outline-none ${WIDTH[size]}`}
|
||||
>
|
||||
{/* En-tête fixe */}
|
||||
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-5 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
|
||||
{subtitle && <div className="mt-0.5 text-xs text-muted">{subtitle}</div>}
|
||||
</div>
|
||||
<IconButton label="Fermer" size="sm" icon={<X size={18} />} onClick={() => void requestClose()} />
|
||||
</header>
|
||||
|
||||
{/* Corps défilant */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
|
||||
{/* Pied fixe : le bouton principal reste TOUJOURS visible */}
|
||||
{footer && (
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-panel px-5 py-3">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposition normalisée du pied de modale : action destructrice à gauche,
|
||||
* Annuler + action principale à droite.
|
||||
*/
|
||||
export function ModalActions({
|
||||
onDelete,
|
||||
deleteLabel = 'Supprimer',
|
||||
onCancel,
|
||||
cancelLabel = 'Annuler',
|
||||
children
|
||||
}: {
|
||||
onDelete?: () => void
|
||||
deleteLabel?: string
|
||||
onCancel?: () => void
|
||||
cancelLabel?: string
|
||||
children: ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{onDelete ? (
|
||||
<Button
|
||||
variant="quiet"
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={onDelete}
|
||||
className="text-rose-600 hover:bg-rose-500/10 hover:text-rose-600"
|
||||
>
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button variant="quiet" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// =====================================================================
|
||||
// Sélecteur MULTIPLE — pour filtrer sur plusieurs valeurs à la fois
|
||||
// (« montre-moi les tâches d'Alex ET de Sam », « les pôles Art ET Audio »).
|
||||
//
|
||||
// Même comportement que <Select> : rendu en portal, retournement vers le
|
||||
// haut si le bas manque de place, pilotage complet au clavier. La
|
||||
// différence : on coche plusieurs entrées, et le menu reste ouvert.
|
||||
// =====================================================================
|
||||
|
||||
import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronDown, Search } from 'lucide-react'
|
||||
|
||||
export interface MultiOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Petit visuel à gauche (avatar, pastille de couleur…). */
|
||||
leading?: ReactNode
|
||||
hint?: string
|
||||
/** Placé en tête, séparé du reste (ex : « Non assigné »). */
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
const ROW_H = 34
|
||||
const MAX_H = 320
|
||||
|
||||
export function MultiSelect({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
searchable,
|
||||
emptyLabel
|
||||
}: {
|
||||
/** Nom du filtre, affiché quand rien n'est sélectionné (« Membres »). */
|
||||
label: string
|
||||
options: MultiOption[]
|
||||
selected: string[]
|
||||
onChange: (values: string[]) => void
|
||||
className?: string
|
||||
searchable?: boolean
|
||||
/** Message quand la liste est vide. */
|
||||
emptyLabel?: string
|
||||
}): JSX.Element {
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const [pos, setPos] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
|
||||
const showSearch = searchable ?? options.length > 8
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const list = q
|
||||
? options.filter(
|
||||
(o) => o.label.toLowerCase().includes(q) || (o.hint ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
: options
|
||||
// Les entrées « épinglées » restent en tête.
|
||||
return [...list].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)))
|
||||
}, [options, query])
|
||||
|
||||
/** Résumé affiché sur le bouton : « Membres », « Alex », « 3 membres ». */
|
||||
const summary = useMemo(() => {
|
||||
if (selected.length === 0) return label
|
||||
if (selected.length === 1) {
|
||||
return options.find((o) => o.value === selected[0])?.label ?? label
|
||||
}
|
||||
return `${selected.length} · ${label.toLowerCase()}`
|
||||
}, [selected, options, label])
|
||||
|
||||
const place = (): void => {
|
||||
const r = btnRef.current?.getBoundingClientRect()
|
||||
if (!r) return
|
||||
const estimated = Math.min(visible.length * ROW_H + (showSearch ? 42 : 0) + 44, MAX_H)
|
||||
const below = window.innerHeight - r.bottom
|
||||
const flip = below < estimated + 8 && r.top > below
|
||||
const width = Math.max(r.width, 220)
|
||||
setPos({
|
||||
left: Math.max(8, Math.min(r.left, window.innerWidth - width - 8)),
|
||||
top: flip ? Math.max(8, r.top - Math.min(estimated, r.top - 8) - 4) : r.bottom + 4,
|
||||
width
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
place()
|
||||
const onMove = (): void => place()
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
const t = e.target as Node
|
||||
if (btnRef.current?.contains(t) || panelRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
}
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, visible.length, showSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
setActive(0)
|
||||
if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
panelRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active, open])
|
||||
|
||||
const toggle = (value: string): void =>
|
||||
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value])
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
btnRef.current?.focus()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.min(i + 1, visible.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, 0))
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setActive(0)
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
setActive(visible.length - 1)
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
// On coche sans refermer : c'est tout l'intérêt d'un choix multiple.
|
||||
if (visible[active]) {
|
||||
e.preventDefault()
|
||||
toggle(visible[active].value)
|
||||
}
|
||||
break
|
||||
case 'Tab':
|
||||
setOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const on = selected.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${label}${on ? ` — ${selected.length} sélectionné(s)` : ''}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs
|
||||
outline-none transition-colors
|
||||
${on ? 'border-accent bg-accent/10 text-accent' : 'border-border bg-panel2 text-ink hover:border-border-strong'}
|
||||
${open ? '!border-accent ring-2 ring-accent/20' : ''} ${className}`}
|
||||
>
|
||||
<span className="max-w-[10rem] truncate font-medium">{summary}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''} ${on ? '' : 'text-muted'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
pos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-select-open
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ left: pos.left, top: pos.top, width: pos.width }}
|
||||
className="fixed z-[80] flex max-h-80 flex-col overflow-hidden rounded-lg border border-border bg-panel shadow-pop"
|
||||
>
|
||||
{showSearch && (
|
||||
<div className="relative shrink-0 border-b border-border">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle"
|
||||
/>
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
placeholder="Filtrer…"
|
||||
aria-label={`Filtrer ${label}`}
|
||||
className="w-full bg-transparent py-2 pl-8 pr-2 text-sm text-ink outline-none placeholder:text-subtle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul role="listbox" aria-multiselectable aria-label={label} className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{visible.length === 0 && (
|
||||
<li className="px-2.5 py-3 text-center text-xs text-subtle">
|
||||
{emptyLabel ?? 'Aucun résultat.'}
|
||||
</li>
|
||||
)}
|
||||
{visible.map((o, i) => {
|
||||
const checked = selected.includes(o.value)
|
||||
const isActive = i === active
|
||||
const separated = o.pinned && visible[i + 1] && !visible[i + 1].pinned
|
||||
return (
|
||||
<li key={o.value} className={separated ? 'mb-1 border-b border-border pb-1' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
data-active={isActive}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => toggle(o.value)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
isActive ? 'bg-accent/12' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
checked ? 'border-accent bg-accent text-accentfg' : 'border-border-strong'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
{o.leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-ink">{o.label}</span>
|
||||
{o.hint && <span className="block truncate text-[11px] text-subtle">{o.hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between border-t border-border px-2 py-1.5">
|
||||
<span className="text-[11px] text-subtle">
|
||||
{selected.length === 0
|
||||
? 'Tout est affiché'
|
||||
: `${selected.length} sélectionné${selected.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(visible.map((o) => o.value))}
|
||||
className="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-ink"
|
||||
>
|
||||
Tout cocher
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0}
|
||||
onClick={() => onChange([])}
|
||||
className="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-ink disabled:opacity-40"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,33 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Bell, Check, KanbanSquare, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||
import { Bell, Bug as BugIcon, CheckCheck, KanbanSquare, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate, rows } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { AppNotification } from '../lib/types'
|
||||
import type { Page } from './Layout'
|
||||
import { AppNotification, EntityKind, NotificationKind } from '../lib/types'
|
||||
import { ErrorState, IconButton, Skeleton } from './ui'
|
||||
|
||||
export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element {
|
||||
const { profile, setFocusEntity } = useApp()
|
||||
const ICONS: Record<NotificationKind, JSX.Element> = {
|
||||
task: <KanbanSquare size={14} />,
|
||||
bug: <BugIcon size={14} />,
|
||||
comment: <MessageSquare size={14} />
|
||||
}
|
||||
|
||||
const ICON_STYLES: Record<NotificationKind, string> = {
|
||||
task: 'bg-accent/12 text-accent',
|
||||
bug: 'bg-rose-500/12 text-rose-600 dark:text-rose-300',
|
||||
comment: 'bg-teal-500/12 text-teal-600 dark:text-teal-300'
|
||||
}
|
||||
|
||||
export default function NotificationBell(): JSX.Element {
|
||||
const { profile, projects, currentProject, selectProject, setFocusEntity, setPage } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
const [items, setItems] = useState<AppNotification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -20,157 +35,223 @@ export default function NotificationBell({ setPage }: { setPage: (p: Page) => vo
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!uid) return
|
||||
const { data } = await supa()
|
||||
try {
|
||||
setItems(
|
||||
await rows<AppNotification>(
|
||||
supa()
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', uid)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
setItems((data ?? []) as AppNotification[])
|
||||
.limit(40)
|
||||
)
|
||||
)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(humanize(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [uid])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Synchro temps réel : nouvelles notifications qui me sont destinées.
|
||||
// Temps réel : une notification qui m'est destinée déclenche un toast.
|
||||
const seen = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
if (!uid) return
|
||||
const ch = supa()
|
||||
.channel(`notifications-${uid}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
|
||||
{ event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
|
||||
(payload) => {
|
||||
// Toast à la réception d'une nouvelle notification.
|
||||
if (payload.eventType === 'INSERT') {
|
||||
const n = payload.new as AppNotification
|
||||
toast(n.body ? `${n.title} : ${n.body}` : n.title, 'info')
|
||||
}
|
||||
load()
|
||||
if (seen.current.has(n.id)) return
|
||||
seen.current.add(n.id)
|
||||
toast(n.body ? `${n.title} — ${n.body}` : n.title, 'info')
|
||||
setItems((list) => [n, ...list.filter((x) => x.id !== n.id)].slice(0, 40))
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
|
||||
() => load()
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [uid, load, toast])
|
||||
|
||||
// Fermeture au clic extérieur.
|
||||
// Fermeture au clic extérieur / Échap.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [])
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const unread = items.filter((n) => !n.read).length
|
||||
|
||||
const markAllRead = async (): Promise<void> => {
|
||||
if (!uid || unread === 0) return
|
||||
const before = items
|
||||
setItems((list) => list.map((n) => ({ ...n, read: true })))
|
||||
await supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
|
||||
try {
|
||||
await mutate(
|
||||
supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
|
||||
)
|
||||
} catch (e) {
|
||||
setItems(before) // on ne ment pas : si l'écriture échoue, on revient en arrière
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = async (): Promise<void> => {
|
||||
if (!uid || items.length === 0) return
|
||||
const before = items
|
||||
setItems([])
|
||||
try {
|
||||
await mutate(supa().from('notifications').delete().eq('user_id', uid))
|
||||
} catch (e) {
|
||||
setItems(before)
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const openItem = async (n: AppNotification): Promise<void> => {
|
||||
setOpen(false)
|
||||
if (!n.read) {
|
||||
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
|
||||
await supa().from('notifications').update({ read: true }).eq('id', n.id)
|
||||
try {
|
||||
await mutate(supa().from('notifications').update({ read: true }).eq('id', n.id))
|
||||
} catch {
|
||||
/* la lecture n'est pas critique */
|
||||
}
|
||||
// Ouvre directement la tâche / le bug concerné(e) sur la page cible.
|
||||
if (n.entity_id) setFocusEntity({ kind: n.kind, id: n.entity_id })
|
||||
setPage(n.kind === 'bug' ? 'bugs' : 'kanban')
|
||||
}
|
||||
// La notification peut concerner un AUTRE projet : on bascule dessus
|
||||
// avant d'ouvrir l'élément, sinon la page ne le trouverait jamais.
|
||||
if (n.project_id && n.project_id !== currentProject?.id) {
|
||||
const target = projects.find((p) => p.id === n.project_id)
|
||||
if (target) selectProject(target)
|
||||
}
|
||||
// Repli sur `kind` : les notifications créées avant la migration v10
|
||||
// n'ont pas de colonne `entity_kind`.
|
||||
const target: EntityKind = n.entity_kind ?? (n.kind === 'bug' ? 'bug' : 'task')
|
||||
if (n.entity_id) setFocusEntity({ kind: target, id: n.entity_id })
|
||||
setPage(target === 'bug' ? 'bugs' : 'kanban')
|
||||
}
|
||||
|
||||
const clearAll = async (): Promise<void> => {
|
||||
if (!uid || items.length === 0) return
|
||||
setItems([])
|
||||
await supa().from('notifications').delete().eq('user_id', uid)
|
||||
}
|
||||
const projectName = (id: string | null): string | null =>
|
||||
id && id !== currentProject?.id ? (projects.find((p) => p.id === id)?.name ?? null) : null
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={unread > 0 ? `Notifications (${unread} non lues)` : 'Notifications'}
|
||||
icon={<Bell size={17} />}
|
||||
active={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Notifications"
|
||||
className="relative text-muted hover:text-ink"
|
||||
>
|
||||
<Bell size={18} />
|
||||
/>
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg">
|
||||
<span className="pointer-events-none absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg ring-2 ring-panel">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 26 }}
|
||||
className="absolute bottom-8 left-0 z-40 w-80 overflow-hidden rounded-xl border border-border bg-panel shadow-2xl"
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
className="absolute right-0 top-11 z-40 w-[22rem] overflow-hidden rounded-xl border border-border bg-panel shadow-pop"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-semibold text-ink">Notifications</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<header className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
Notifications
|
||||
{unread > 0 && <span className="ml-1.5 text-xs font-normal text-accent">{unread}</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{unread > 0 && (
|
||||
<button
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Tout marquer comme lu"
|
||||
icon={<CheckCheck size={15} />}
|
||||
onClick={markAllRead}
|
||||
title="Tout marquer comme lu"
|
||||
className="rounded p-1 text-muted hover:bg-panel2 hover:text-ink"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="danger"
|
||||
label="Tout effacer"
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={clearAll}
|
||||
title="Tout effacer"
|
||||
className="rounded p-1 text-muted hover:bg-panel2 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-subtle">Aucune notification.</p>
|
||||
<div className="max-h-[26rem] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3">
|
||||
<ErrorState compact message={error} onRetry={load} />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-subtle">
|
||||
Rien de neuf. Tu seras prévenu quand une tâche ou un bug te concerne.
|
||||
</p>
|
||||
) : (
|
||||
items.map((n) => (
|
||||
items.map((n) => {
|
||||
const other = projectName(n.project_id)
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => openItem(n)}
|
||||
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left last:border-0 hover:bg-panel2 ${
|
||||
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors last:border-0 hover:bg-hover ${
|
||||
n.read ? '' : 'bg-accent/5'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${
|
||||
n.kind === 'bug'
|
||||
? 'bg-rose-100 text-rose-600'
|
||||
: 'bg-accent/15 text-accent'
|
||||
}`}
|
||||
>
|
||||
{n.kind === 'bug' ? <BugIcon size={14} /> : <KanbanSquare size={14} />}
|
||||
<span className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${ICON_STYLES[n.kind] ?? ICON_STYLES.task}`}>
|
||||
{ICONS[n.kind] ?? ICONS.task}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-ink">{n.title}</span>
|
||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||
<span className="truncate text-sm font-medium text-ink">{n.title}</span>
|
||||
{!n.read && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
|
||||
</span>
|
||||
{n.body && <span className="block truncate text-xs text-muted">{n.body}</span>}
|
||||
<span className="block text-[11px] text-subtle">
|
||||
<span className="mt-0.5 block text-[11px] text-subtle">
|
||||
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, locale: fr })}
|
||||
{other && <> · projet {other}</>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children?: ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-ink">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Création, renommage et suppression d'un projet.
|
||||
// La suppression manquait complètement : un projet créé par erreur restait
|
||||
// là pour toujours. Elle est réservée à son créateur ou à un admin (RLS).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { humanize } from '../lib/db'
|
||||
import { Project } from '../lib/types'
|
||||
import { Button, Field, Input } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
|
||||
export default function ProjectModal({
|
||||
mode,
|
||||
project,
|
||||
onClose
|
||||
}: {
|
||||
mode: 'create' | 'edit'
|
||||
project?: Project
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { profile, createProject, renameProject, deleteProject } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
|
||||
const [name, setName] = useState(project?.name ?? '')
|
||||
const [description, setDescription] = useState(project?.description ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const dirty =
|
||||
name !== (project?.name ?? '') || description !== (project?.description ?? '')
|
||||
const canSubmit = name.trim().length > 0 && (mode === 'create' || dirty)
|
||||
|
||||
const canDelete =
|
||||
mode === 'edit' && !!project && (profile?.is_admin || project.created_by === profile?.id)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!canSubmit) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await createProject(name, description)
|
||||
toast('Projet créé.', 'success')
|
||||
} else if (project) {
|
||||
await renameProject(project.id, name, description)
|
||||
toast('Projet mis à jour.', 'success')
|
||||
}
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!project) return
|
||||
const ok = await confirm({
|
||||
title: `Supprimer « ${project.name} » ?`,
|
||||
message:
|
||||
'Toutes les tâches, bugs, entrées de temps, discussions et pièces jointes de ce projet ' +
|
||||
'seront définitivement supprimés. Cette action est irréversible.',
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer définitivement'
|
||||
})
|
||||
if (!ok) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await deleteProject(project.id)
|
||||
toast('Projet supprimé.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === 'create' ? 'Nouveau projet' : 'Paramètres du projet'}
|
||||
onClose={onClose}
|
||||
dirty={dirty && !busy}
|
||||
footer={
|
||||
<ModalActions
|
||||
onDelete={canDelete ? remove : undefined}
|
||||
deleteLabel="Supprimer le projet"
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Button variant="primary" loading={busy} disabled={!canSubmit} onClick={submit}>
|
||||
{mode === 'create' ? 'Créer le projet' : 'Enregistrer'}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Field label="Nom du jeu / projet" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
value={name}
|
||||
maxLength={80}
|
||||
placeholder="Mon super jeu"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Description" hint="Genre, plateforme, pitch… (optionnel)">
|
||||
{({ id }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="input h-24 resize-none"
|
||||
value={description}
|
||||
maxLength={500}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Permet de valider avec Entrée sans bouton visible en double. */}
|
||||
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// =====================================================================
|
||||
// Menu déroulant — LA seule façon de faire un « select » dans l'app.
|
||||
// (Le <select> natif n'est pas stylable et est particulièrement laid
|
||||
// sous Windows / WebView2.)
|
||||
//
|
||||
// Rendu via un portal pour ne jamais être coupé par le défilement d'une
|
||||
// modale, s'ouvre vers le haut quand le bas manque de place, et se pilote
|
||||
// ENTIÈREMENT au clavier : ↑ ↓ Début Fin Entrée Échap + saisie directe.
|
||||
// Au-delà de 8 entrées, un champ de recherche apparaît automatiquement.
|
||||
// =====================================================================
|
||||
|
||||
import { Fragment, ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronDown, Search } from 'lucide-react'
|
||||
|
||||
export interface SelectOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
/** En-tête de groupe : affiché avant le premier élément du groupe. */
|
||||
group?: string
|
||||
/** Ligne secondaire (ex : le pôle d'un membre). */
|
||||
hint?: string
|
||||
/** Visuel à gauche : pastille de statut, avatar… */
|
||||
leading?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ROW_H = 34
|
||||
const MAX_H = 288
|
||||
|
||||
export function Select<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Choisir…',
|
||||
className = '',
|
||||
size = 'md',
|
||||
disabled,
|
||||
ariaLabel,
|
||||
searchable
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: SelectOption<T>[]
|
||||
placeholder?: string
|
||||
className?: string
|
||||
size?: 'sm' | 'md'
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
/** Forcer l'affichage (ou non) du champ de recherche. */
|
||||
searchable?: boolean
|
||||
}): JSX.Element {
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const [pos, setPos] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
|
||||
const showSearch = searchable ?? options.length > 8
|
||||
const current = options.find((o) => o.value === value)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!query.trim()) return options
|
||||
const q = query.trim().toLowerCase()
|
||||
return options.filter(
|
||||
(o) => o.label.toLowerCase().includes(q) || (o.hint ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
}, [options, query])
|
||||
|
||||
const selectable = visible.filter((o) => !o.disabled)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Positionnement (avec retournement vers le haut si nécessaire)
|
||||
// ------------------------------------------------------------------
|
||||
const place = (): void => {
|
||||
const r = btnRef.current?.getBoundingClientRect()
|
||||
if (!r) return
|
||||
const estimated = Math.min(visible.length * ROW_H + (showSearch ? 42 : 0) + 8, MAX_H)
|
||||
const below = window.innerHeight - r.bottom
|
||||
const flip = below < estimated + 8 && r.top > below
|
||||
setPos({
|
||||
left: Math.max(8, Math.min(r.left, window.innerWidth - r.width - 8)),
|
||||
top: flip ? Math.max(8, r.top - Math.min(estimated, r.top - 8) - 4) : r.bottom + 4,
|
||||
width: r.width
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
place()
|
||||
const onMove = (): void => place()
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
const t = e.target as Node
|
||||
if (btnRef.current?.contains(t) || listRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
}
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, visible.length, showSearch])
|
||||
|
||||
// À l'ouverture : positionne le curseur sur la valeur courante.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
const idx = selectable.findIndex((o) => o.value === value)
|
||||
setActive(idx >= 0 ? idx : 0)
|
||||
if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// Garde l'élément actif visible dans la liste.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active, open])
|
||||
|
||||
const commit = (o: SelectOption<T>): void => {
|
||||
if (o.disabled) return
|
||||
onChange(o.value)
|
||||
setOpen(false)
|
||||
btnRef.current?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // ne ferme pas la modale par-dessous
|
||||
setOpen(false)
|
||||
btnRef.current?.focus()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.min(i + 1, selectable.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActive((i) => Math.max(i - 1, 0))
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setActive(0)
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
setActive(selectable.length - 1)
|
||||
break
|
||||
case 'Tab':
|
||||
setOpen(false)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectable[active]) commit(selectable[active])
|
||||
break
|
||||
default:
|
||||
// Saisie directe quand il n'y a pas de champ de recherche.
|
||||
if (!showSearch && e.key.length === 1 && /\S/.test(e.key)) {
|
||||
const hit = selectable.findIndex((o) => o.label.toLowerCase().startsWith(e.key.toLowerCase()))
|
||||
if (hit >= 0) setActive(hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pad = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-2 text-sm'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg border
|
||||
border-border bg-panel2 text-left text-ink outline-none transition-colors
|
||||
hover:border-border-strong disabled:cursor-not-allowed disabled:opacity-60
|
||||
${pad} ${open ? '!border-accent ring-2 ring-accent/20' : ''} ${className}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{current?.leading}
|
||||
<span className={`truncate ${current ? 'text-ink' : 'text-subtle'}`}>
|
||||
{current?.label ?? placeholder}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={size === 'sm' ? 14 : 16}
|
||||
className={`shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
pos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
data-select-open
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ left: pos.left, top: pos.top, minWidth: pos.width }}
|
||||
className="fixed z-[80] overflow-hidden rounded-lg border border-border bg-panel shadow-pop"
|
||||
>
|
||||
{showSearch && (
|
||||
<div className="relative border-b border-border">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
placeholder="Filtrer…"
|
||||
aria-label="Filtrer les options"
|
||||
className="w-full bg-transparent py-2 pl-8 pr-2 text-sm text-ink outline-none placeholder:text-subtle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul role="listbox" aria-label={ariaLabel} className="max-h-64 overflow-y-auto p-1">
|
||||
{visible.length === 0 && (
|
||||
<li className="px-2.5 py-3 text-center text-xs text-subtle">Aucun résultat.</li>
|
||||
)}
|
||||
{visible.map((o, i) => {
|
||||
const selected = o.value === value
|
||||
const isActive = selectable[active]?.value === o.value
|
||||
const newGroup = o.group && o.group !== visible[i - 1]?.group
|
||||
return (
|
||||
<Fragment key={o.value}>
|
||||
{newGroup && (
|
||||
<li className="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-subtle first:pt-1">
|
||||
{o.group}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
disabled={o.disabled}
|
||||
data-active={isActive}
|
||||
onMouseEnter={() => {
|
||||
const idx = selectable.findIndex((s) => s.value === o.value)
|
||||
if (idx >= 0) setActive(idx)
|
||||
}}
|
||||
onClick={() => commit(o)}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5
|
||||
text-left text-sm transition-colors disabled:opacity-40
|
||||
${selected ? 'font-medium text-accent' : 'text-ink'}
|
||||
${isActive ? 'bg-accent/12' : ''}`}
|
||||
>
|
||||
{o.leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{o.label}</span>
|
||||
{o.hint && <span className="block truncate text-[11px] text-subtle">{o.hint}</span>}
|
||||
</span>
|
||||
{selected && <Check size={15} className="shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+332
-284
@@ -1,373 +1,421 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Trash2, Clock, Plus, Loader2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { ListChecks, Plus, X } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate, rows, run } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { notifyPole } from '../lib/notify'
|
||||
import { notifyTask } from '../lib/notify'
|
||||
import {
|
||||
ChecklistItem,
|
||||
Pole,
|
||||
Priority,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_ORDER,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Priority,
|
||||
Pole,
|
||||
TimeLog,
|
||||
Profile,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
memberPoles,
|
||||
taskPoles,
|
||||
formatMinutes
|
||||
TASK_STATUS_ORDER,
|
||||
checklistProgress
|
||||
} from '../lib/types'
|
||||
import { Modal, Select, Avatar, PoleChips } from './ui'
|
||||
import { Button, Checkbox, Field, IconButton, Input, ProgressBar } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
import { Select } from './Select'
|
||||
import { AssigneePicker, PoleChips, TagInput } from './people'
|
||||
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
|
||||
|
||||
/** Instantané des champs éditables : sert à détecter une saisie non enregistrée. */
|
||||
interface Draft {
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
priority: Priority
|
||||
poles: Pole[]
|
||||
assignees: string[]
|
||||
checklist: ChecklistItem[]
|
||||
tags: string[]
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
function draftOf(task: Task | null, defaultStatus: TaskStatus): Draft {
|
||||
return {
|
||||
title: task?.title ?? '',
|
||||
description: task?.description ?? '',
|
||||
status: task?.status ?? defaultStatus,
|
||||
priority: task?.priority ?? 'medium',
|
||||
poles: task?.poles ?? [],
|
||||
assignees: task?.assignee_ids ?? [],
|
||||
checklist: task?.checklist ?? [],
|
||||
tags: task?.tags ?? [],
|
||||
dueDate: task?.due_date ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
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 { profile, members, membersLoading } = 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 [poles, setPoles] = useState<Pole[]>(task ? taskPoles(task) : [])
|
||||
const [assignees, setAssignees] = useState<string[]>(task?.assignee_ids ?? [])
|
||||
|
||||
const togglePole = (p: Pole): void =>
|
||||
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
||||
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
|
||||
const initial = useMemo(() => draftOf(task, defaultStatus), [task, defaultStatus])
|
||||
const [d, setD] = useState<Draft>(initial)
|
||||
const [newItem, setNewItem] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggleAssignee = (id: string): void =>
|
||||
setAssignees((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
|
||||
const progress = checklistProgress(d.checklist)
|
||||
|
||||
// -------------------------------------------------------------- checklist
|
||||
const addChecklistItem = (): void => {
|
||||
const text = newItem.trim()
|
||||
if (!text) return
|
||||
patch({ checklist: [...d.checklist, { id: crypto.randomUUID(), text, done: false }] })
|
||||
setNewItem('')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- save
|
||||
const save = async (): Promise<void> => {
|
||||
if (!title.trim()) return
|
||||
if (!d.title.trim()) {
|
||||
toast('Donne un titre à la tâche.', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
status,
|
||||
priority,
|
||||
pole: poles[0] ?? null, // pôle principal (compat)
|
||||
poles,
|
||||
assignee_ids: assignees,
|
||||
due_date: dueDate || null
|
||||
title: d.title.trim(),
|
||||
description: d.description.trim() || null,
|
||||
status: d.status,
|
||||
priority: d.priority,
|
||||
poles: d.poles,
|
||||
assignee_ids: d.assignees,
|
||||
checklist: d.checklist,
|
||||
tags: d.tags,
|
||||
due_date: d.dueDate || null
|
||||
}
|
||||
|
||||
// Notifier chaque pôle nouvellement confié (création, ou pôle ajouté).
|
||||
const before = task ? taskPoles(task) : []
|
||||
const newPoles = poles.filter((p) => !before.includes(p))
|
||||
try {
|
||||
let entityId = task?.id ?? null
|
||||
|
||||
if (task) {
|
||||
await supa().from('tasks').update(payload).eq('id', task.id)
|
||||
await mutate(supa().from('tasks').update(payload).eq('id', task.id))
|
||||
} else {
|
||||
const { data } = await supa()
|
||||
// Position = fin de la colonne. On lit le maximum existant plutôt
|
||||
// que d'utiliser Date.now() : ça mélangeait deux échelles de valeurs.
|
||||
const last = await rows<{ position: number }>(
|
||||
supa()
|
||||
.from('tasks')
|
||||
.insert({ ...payload, created_by: profile?.id ?? null })
|
||||
.select('position')
|
||||
.eq('project_id', projectId)
|
||||
.eq('status', d.status)
|
||||
.order('position', { ascending: false })
|
||||
.limit(1)
|
||||
)
|
||||
const created = await run<{ id: string }>(
|
||||
supa()
|
||||
.from('tasks')
|
||||
.insert({
|
||||
...payload,
|
||||
position: (last[0]?.position ?? -1) + 1,
|
||||
created_by: profile?.id ?? null
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
entityId = (data as { id: string } | null)?.id ?? null
|
||||
)
|
||||
entityId = created?.id ?? null
|
||||
}
|
||||
|
||||
// On ne notifie que ce qui est NOUVEAU (pôle ajouté, personne ajoutée),
|
||||
// en une seule requête et sans doublon.
|
||||
if (entityId) {
|
||||
for (const pole of newPoles) {
|
||||
await notifyPole({
|
||||
const beforePoles = task?.poles ?? []
|
||||
const beforeAssignees = task?.assignee_ids ?? []
|
||||
const newPoles = d.poles.filter((p) => !beforePoles.includes(p))
|
||||
const newAssignees = d.assignees.filter((a) => !beforeAssignees.includes(a))
|
||||
if (newPoles.length || newAssignees.length) {
|
||||
await notifyTask({
|
||||
projectId,
|
||||
pole,
|
||||
kind: 'task',
|
||||
title: 'Nouvelle tâche',
|
||||
body: title.trim(),
|
||||
entityId,
|
||||
taskId: entityId,
|
||||
title: d.title.trim(),
|
||||
members,
|
||||
poles: newPoles,
|
||||
assigneeIds: newAssignees,
|
||||
actorId: profile?.id ?? null,
|
||||
members
|
||||
isNew: !task
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
message: `« ${task.title} » et tout ce qui y est rattaché (temps, discussion, fichiers) seront supprimés.`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
await supa().from('tasks').delete().eq('id', task.id)
|
||||
setBusy(true)
|
||||
try {
|
||||
await mutate(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)}
|
||||
/>
|
||||
|
||||
{/* Pôles — sélection multiple par pastilles (clique pour ajouter/retirer) */}
|
||||
<label className="label">
|
||||
Pôle(s) {poles.length > 0 && <span className="text-subtle">· {poles.length}</span>}
|
||||
</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips selected={poles} onToggle={togglePole} />
|
||||
</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 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>
|
||||
|
||||
{/* Personnes assignées (une ou plusieurs) */}
|
||||
<div className="mt-3">
|
||||
<label className="label">
|
||||
Assigné(s) {assignees.length > 0 && <span className="text-subtle">· {assignees.length}</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.length === 0 && (
|
||||
<span className="text-xs text-subtle">Aucun membre dans l'équipe.</span>
|
||||
)}
|
||||
{members.map((m) => {
|
||||
const on = assignees.includes(m.id)
|
||||
const inPole = poles.some((p) => memberPoles(m).includes(p))
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleAssignee(m.id)}
|
||||
title={inPole ? `${m.full_name} · ce pôle` : m.full_name}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-sm transition-colors ${
|
||||
on
|
||||
? 'border-accent bg-accent/15 text-ink'
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<Avatar profile={m} size={22} />
|
||||
<span>{m.full_name}</span>
|
||||
{inPole && <span className="h-1.5 w-1.5 rounded-full bg-accent2" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suivi du temps — disponible une fois la tâche créée */}
|
||||
{task && <TimeSection taskId={task.id} projectId={projectId} 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,
|
||||
projectId,
|
||||
members
|
||||
}: {
|
||||
taskId: string
|
||||
projectId: 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,
|
||||
project_id: projectId,
|
||||
user_id: profile?.id ?? null,
|
||||
minutes: mins,
|
||||
note: note.trim() || null
|
||||
})
|
||||
setHours('')
|
||||
setMinutes('')
|
||||
setNote('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
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()
|
||||
}
|
||||
const subtitle = task ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>Créée le {format(new Date(task.created_at), 'd MMM yyyy', { locale: fr })}</span>
|
||||
{task.updated_at !== task.created_at && (
|
||||
<span className="text-subtle">
|
||||
· modifiée {format(new Date(task.updated_at), 'd MMM à HH:mm', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null
|
||||
|
||||
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é
|
||||
<Modal
|
||||
title={task ? 'Modifier la tâche' : 'Nouvelle tâche'}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
dirty={dirty && !busy}
|
||||
footer={
|
||||
<ModalActions onDelete={task ? remove : undefined} onCancel={onClose}>
|
||||
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
|
||||
{task ? 'Enregistrer' : 'Créer la tâche'}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Titre" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
value={d.title}
|
||||
maxLength={160}
|
||||
placeholder="Implémenter le saut du personnage"
|
||||
onChange={(e) => patch({ title: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Description">
|
||||
{({ id }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="input h-24 resize-none"
|
||||
value={d.description}
|
||||
placeholder="Contexte, contraintes, critères d'acceptation…"
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ---------------------------- Checklist ---------------------------- */}
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="label mb-0 flex items-center gap-1.5">
|
||||
<ListChecks size={15} className="text-muted" /> Checklist
|
||||
{progress.total > 0 && (
|
||||
<span className="text-subtle">
|
||||
· {progress.done}/{progress.total}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-accent2">{formatMinutes(total)}</span>
|
||||
)}
|
||||
</span>
|
||||
{progress.total > 0 && (
|
||||
<span className="text-xs font-semibold text-accent2 tabular">{progress.pct} %</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>
|
||||
{progress.total > 0 && <ProgressBar pct={progress.pct} barClassName="bg-accent2" className="mb-2" />}
|
||||
|
||||
{/* 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) => (
|
||||
<ul className="space-y-0.5">
|
||||
{d.checklist.map((item) => (
|
||||
<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"
|
||||
key={item.id}
|
||||
className="group flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-panel2/70"
|
||||
>
|
||||
<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 })}
|
||||
<Checkbox
|
||||
checked={item.done}
|
||||
onChange={() =>
|
||||
patch({
|
||||
checklist: d.checklist.map((i) =>
|
||||
i.id === item.id ? { ...i, done: !i.done } : i
|
||||
)
|
||||
})
|
||||
}
|
||||
label={
|
||||
<span className={item.done ? 'text-subtle line-through' : 'text-ink'}>
|
||||
{item.text}
|
||||
</span>
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => del(l.id)}
|
||||
className="text-gray-600 opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
|
||||
type="button"
|
||||
aria-label={`Retirer « ${item.text} »`}
|
||||
onClick={() => patch({ checklist: d.checklist.filter((i) => i.id !== item.id) })}
|
||||
className="shrink-0 text-subtle opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input
|
||||
value={newItem}
|
||||
placeholder="Ajouter un élément…"
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addChecklistItem()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button icon={<Plus size={15} />} onClick={addChecklistItem} disabled={!newItem.trim()}>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------- Planning ---------------------------- */}
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-border pt-4">
|
||||
<Field label="Statut">
|
||||
<Select
|
||||
value={d.status}
|
||||
onChange={(status) => patch({ status })}
|
||||
ariaLabel="Statut de la tâche"
|
||||
options={TASK_STATUS_ORDER.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Priorité">
|
||||
<Select
|
||||
value={d.priority}
|
||||
onChange={(priority) => patch({ priority })}
|
||||
ariaLabel="Priorité de la tâche"
|
||||
options={PRIORITY_ORDER.map((p) => ({ value: p, label: PRIORITY_LABELS[p] }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Échéance"
|
||||
className="col-span-2"
|
||||
hint={d.dueDate ? undefined : 'Optionnel — la tâche apparaîtra dans « à venir » sur le tableau de bord.'}
|
||||
>
|
||||
{({ id }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
value={d.dueDate}
|
||||
onChange={(e) => patch({ dueDate: e.target.value })}
|
||||
/>
|
||||
{d.dueDate && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Retirer l'échéance"
|
||||
icon={<X size={15} />}
|
||||
onClick={() => patch({ dueDate: '' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Étiquettes" hint="Pour regrouper et retrouver les tâches (Ctrl+K).">
|
||||
<TagInput value={d.tags} onChange={(tags) => patch({ tags })} />
|
||||
</Field>
|
||||
|
||||
{/* ---------------------------- Qui ? ---------------------------- */}
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<Field
|
||||
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
|
||||
hint="Tous les membres du pôle reçoivent une notification."
|
||||
>
|
||||
<PoleChips
|
||||
selected={d.poles}
|
||||
onToggle={(p) =>
|
||||
patch({
|
||||
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
|
||||
<AssigneePicker
|
||||
members={members}
|
||||
loading={membersLoading}
|
||||
selected={d.assignees}
|
||||
onChange={(assignees) => patch({ assignees })}
|
||||
poles={d.poles}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ------------- Disponible une fois la tâche enregistrée ------------- */}
|
||||
{task ? (
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<TimeSection target={{ kind: 'task', id: task.id }} projectId={projectId} />
|
||||
<AttachmentPanel
|
||||
entity={{
|
||||
kind: 'task',
|
||||
id: task.id,
|
||||
projectId,
|
||||
title: task.title,
|
||||
involvedIds: task.assignee_ids
|
||||
}}
|
||||
/>
|
||||
<CommentThread
|
||||
entity={{
|
||||
kind: 'task',
|
||||
id: task.id,
|
||||
projectId,
|
||||
title: task.title,
|
||||
involvedIds: [...task.assignee_ids, ...(task.created_by ? [task.created_by] : [])]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
|
||||
Le suivi du temps, les pièces jointes et la discussion seront disponibles dès que la
|
||||
tâche sera créée.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// =====================================================================
|
||||
// Bandeau « une mise à jour est disponible ».
|
||||
//
|
||||
// Discret : une bande fine en haut de l'application, jamais une modale
|
||||
// qui interrompt le travail. On peut reporter — la proposition revient
|
||||
// au prochain lancement.
|
||||
// =====================================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Download, RefreshCw, Sparkles, X } from 'lucide-react'
|
||||
import { AvailableUpdate, checkForUpdate } from '../lib/updater'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Button, IconButton } from './ui'
|
||||
|
||||
/** Nouvelle vérification toutes les 2 heures pour les sessions qui durent. */
|
||||
const RECHECK_MS = 2 * 60 * 60 * 1000
|
||||
|
||||
export default function UpdateBanner(): JSX.Element | null {
|
||||
const { toast } = useFeedback()
|
||||
const [update, setUpdate] = useState<AvailableUpdate | null>(null)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [progress, setProgress] = useState<number | null>(null)
|
||||
|
||||
const look = useCallback(async () => {
|
||||
const found = await checkForUpdate()
|
||||
if (found) setUpdate(found)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Au démarrage, mais sans retarder l'affichage de l'application.
|
||||
const first = setTimeout(look, 4000)
|
||||
const timer = setInterval(look, RECHECK_MS)
|
||||
return () => {
|
||||
clearTimeout(first)
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [look])
|
||||
|
||||
if (!update || dismissed) return null
|
||||
|
||||
const install = async (): Promise<void> => {
|
||||
setInstalling(true)
|
||||
setProgress(0)
|
||||
try {
|
||||
// Au succès l'application redémarre : rien à faire de plus ici.
|
||||
await update.install((pct) => setProgress(pct))
|
||||
} catch (e) {
|
||||
setInstalling(false)
|
||||
setProgress(null)
|
||||
toast(
|
||||
`La mise à jour a échoué : ${(e as Error).message}. Réessaie plus tard ou préviens l'administrateur.`,
|
||||
'error'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="shrink-0 overflow-hidden border-b border-accent/25 bg-accent/10"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<Sparkles size={15} className="shrink-0 text-accent" />
|
||||
|
||||
<span className="min-w-0 flex-1 text-xs text-ink">
|
||||
<span className="font-medium">Version {update.version} disponible.</span>{' '}
|
||||
{installing ? (
|
||||
<span className="text-muted">
|
||||
{progress === null || progress === 0
|
||||
? 'Téléchargement…'
|
||||
: progress < 100
|
||||
? `Téléchargement ${progress} %…`
|
||||
: 'Installation, l’application va redémarrer…'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">
|
||||
{update.notes?.split('\n')[0] || "L'application se relancera toute seule."}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{installing ? (
|
||||
<div className="h-1 w-32 shrink-0 overflow-hidden rounded-full bg-accent/20">
|
||||
<div
|
||||
className={`h-full rounded-full bg-accent transition-[width] duration-300 ${
|
||||
progress === null ? 'w-1/3 animate-pulse' : ''
|
||||
}`}
|
||||
style={progress === null ? undefined : { width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="primary" icon={<Download size={13} />} onClick={install}>
|
||||
Installer et redémarrer
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Plus tard"
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setDismissed(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ligne « Version X · Rechercher une mise à jour », pour l'écran d'aide. */
|
||||
export function UpdateCheckRow({ version }: { version: string | null }): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const look = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
const found = await checkForUpdate()
|
||||
setBusy(false)
|
||||
toast(
|
||||
found
|
||||
? `Version ${found.version} disponible — le bandeau apparaît en haut de l'écran.`
|
||||
: 'Tu es déjà à jour.',
|
||||
found ? 'info' : 'success'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border pt-3 text-sm">
|
||||
<span className="text-muted">
|
||||
Version installée <span className="font-medium text-ink">{version ?? '—'}</span>
|
||||
</span>
|
||||
<Button size="sm" icon={<RefreshCw size={13} />} loading={busy} onClick={look}>
|
||||
Rechercher une mise à jour
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// =====================================================================
|
||||
// Tout ce qui représente une personne, un pôle ou une étiquette.
|
||||
// =====================================================================
|
||||
|
||||
import { ChangeEvent, KeyboardEvent, useRef, useState } from 'react'
|
||||
import { Camera, Plus, X } from 'lucide-react'
|
||||
import {
|
||||
Pole,
|
||||
POLE_LABELS,
|
||||
POLE_ORDER,
|
||||
Profile,
|
||||
TAG_SUGGESTIONS,
|
||||
memberPoles,
|
||||
poleBadge,
|
||||
polesLabel,
|
||||
tagBadge
|
||||
} from '../lib/types'
|
||||
import { uploadAvatar } from '../lib/storage'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Badge, Button } from './ui'
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Avatar
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((s) => s[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
profile,
|
||||
size = 32,
|
||||
className = ''
|
||||
}: {
|
||||
profile?: Partial<Profile> | null
|
||||
size?: number
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
// Si l'image ne charge pas (URL périmée, bucket nettoyé), on retombe sur
|
||||
// les initiales au lieu d'afficher une vignette cassée.
|
||||
const [broken, setBroken] = useState(false)
|
||||
const name = profile?.full_name ?? ''
|
||||
const title = name || 'Non assigné'
|
||||
|
||||
if (profile?.avatar_url && !broken) {
|
||||
return (
|
||||
<img
|
||||
src={profile.avatar_url}
|
||||
alt={name}
|
||||
title={title}
|
||||
draggable={false}
|
||||
onError={() => setBroken(true)}
|
||||
className={`shrink-0 rounded-full bg-panel2 object-cover ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className={`flex shrink-0 select-none items-center justify-center rounded-full font-semibold text-white ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.round(size * 0.4),
|
||||
background: profile?.avatar_color ?? 'rgb(var(--c-border-strong))'
|
||||
}}
|
||||
>
|
||||
{name ? initialsOf(name) : '–'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pile d'avatars superposés (personnes assignées à une tâche / un bug). */
|
||||
export function AvatarStack({
|
||||
ids,
|
||||
resolve,
|
||||
size = 24,
|
||||
max = 3
|
||||
}: {
|
||||
ids: string[]
|
||||
resolve: (id: string) => Profile | undefined
|
||||
size?: number
|
||||
max?: number
|
||||
}): JSX.Element | null {
|
||||
if (!ids.length) return null
|
||||
const shown = ids.slice(0, max)
|
||||
const extra = ids.length - shown.length
|
||||
const names = ids.map((id) => resolve(id)?.full_name ?? 'Inconnu').join(', ')
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 -space-x-1.5" title={names} aria-label={`Assigné à ${names}`}>
|
||||
{shown.map((id) => (
|
||||
<span key={id} className="rounded-full ring-2 ring-panel">
|
||||
<Avatar profile={resolve(id)} size={size} />
|
||||
</span>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-panel2 text-[10px] font-semibold text-muted ring-2 ring-panel"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
+{extra}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Avatar + nom + pôles, utilisé dans les listes de membres. */
|
||||
export function MemberLine({
|
||||
member,
|
||||
size = 36,
|
||||
isMe
|
||||
}: {
|
||||
member: Profile
|
||||
size?: number
|
||||
isMe?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar profile={member} size={size} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-ink">
|
||||
{member.full_name}
|
||||
{isMe && <span className="ml-1.5 text-xs font-normal text-accent">(moi)</span>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-subtle">{polesLabel(memberPoles(member))}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pôles
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function PoleBadge({ pole }: { pole: Pole }): JSX.Element {
|
||||
return <Badge className={poleBadge(pole)}>{POLE_LABELS[pole]}</Badge>
|
||||
}
|
||||
|
||||
/** Sélection de pôle(s) par pastilles cliquables. */
|
||||
export function PoleChips({
|
||||
selected,
|
||||
onToggle,
|
||||
choices = POLE_ORDER
|
||||
}: {
|
||||
selected: Pole[] | null | undefined
|
||||
onToggle: (pole: Pole) => void
|
||||
choices?: Pole[]
|
||||
}): JSX.Element {
|
||||
const sel = selected ?? []
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{choices.map((p) => {
|
||||
const on = sel.includes(p)
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onToggle(p)}
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
on
|
||||
? `${poleBadge(p)} border-transparent ring-2 ring-accent/40`
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{POLE_LABELS[p]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Affichage seul d'une liste de pôles (lignes de tableau, cartes). */
|
||||
export function PoleList({ poles }: { poles: Pole[] }): JSX.Element {
|
||||
if (poles.length === 0) return <span className="text-xs text-subtle">—</span>
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{poles.map((p) => (
|
||||
<PoleBadge key={p} pole={p} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Assignation de personnes
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sélection des personnes assignées — LA seule façon d'assigner (tâches
|
||||
* comme bugs). Si `poles` est renseigné, on ne montre d'abord que les
|
||||
* membres de ces pôles ; les personnes déjà choisies restent toujours
|
||||
* visibles, et un lien permet d'ouvrir à toute l'équipe.
|
||||
*/
|
||||
export function AssigneePicker({
|
||||
members,
|
||||
selected,
|
||||
onChange,
|
||||
poles = [],
|
||||
loading
|
||||
}: {
|
||||
members: Profile[]
|
||||
selected: string[]
|
||||
onChange: (ids: string[]) => void
|
||||
poles?: Pole[]
|
||||
loading?: boolean
|
||||
}): JSX.Element {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const toggle = (id: string): void =>
|
||||
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id])
|
||||
|
||||
// Pendant le chargement on ne ment pas en disant « aucun membre ».
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="skeleton h-8 w-28 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (members.length === 0) {
|
||||
return <p className="text-xs text-subtle">Aucun membre dans l'équipe pour l'instant.</p>
|
||||
}
|
||||
|
||||
const inPole = (m: Profile): boolean => poles.some((p) => memberPoles(m).includes(p))
|
||||
const filtering = poles.length > 0 && !showAll
|
||||
const visible = filtering ? members.filter((m) => inPole(m) || selected.includes(m.id)) : members
|
||||
const hidden = members.length - visible.length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visible.map((m) => {
|
||||
const on = selected.includes(m.id)
|
||||
const outside = poles.length > 0 && !inPole(m)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => toggle(m.id)}
|
||||
title={outside ? `${m.full_name} · hors pôle` : m.full_name}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-sm transition-colors ${
|
||||
on
|
||||
? 'border-accent bg-accent/12 text-ink'
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<Avatar profile={m} size={22} />
|
||||
<span>{m.full_name}</span>
|
||||
{outside && <span className="text-[10px] text-subtle">hors pôle</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filtering && visible.length === 0 && (
|
||||
<p className="text-xs text-subtle">Personne dans ce pôle pour l'instant.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{poles.length > 0 && (hidden > 0 || showAll) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
className="mt-2 text-xs font-medium text-accent hover:underline"
|
||||
>
|
||||
{showAll ? 'Réduire aux membres du pôle' : `Afficher toute l'équipe (+${hidden})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Étiquettes
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function Tag({ label }: { label: string }): JSX.Element {
|
||||
return <Badge className={tagBadge(label)}>{label}</Badge>
|
||||
}
|
||||
|
||||
/** Champ d'édition d'étiquettes libres, avec suggestions. */
|
||||
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().slice(0, 24)
|
||||
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 = TAG_SUGGESTIONS.filter(
|
||||
(s) => !value.some((v) => v.toLowerCase() === s.toLowerCase())
|
||||
).slice(0, 6)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-panel2 px-2 py-1.5 focus-within:border-accent">
|
||||
{value.map((t) => (
|
||||
<span key={t} className={`badge ${tagBadge(t)}`}>
|
||||
{t}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Retirer l'étiquette ${t}`}
|
||||
onClick={() => remove(t)}
|
||||
className="ml-0.5 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="min-w-[120px] flex-1 bg-transparent py-1 text-sm text-ink outline-none placeholder:text-subtle"
|
||||
placeholder={value.length ? 'Ajouter…' : 'Étiquette 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}
|
||||
type="button"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Photo de profil
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Palette d'avatars partagée. */
|
||||
export const AVATAR_COLORS = [
|
||||
'#7c5cff',
|
||||
'#00d2b8',
|
||||
'#ff5c8a',
|
||||
'#ffa53c',
|
||||
'#3ca8ff',
|
||||
'#9bdb4d',
|
||||
'#e15cff',
|
||||
'#ff6b6b'
|
||||
]
|
||||
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
value: string
|
||||
onChange: (c: string) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Couleur d'avatar">
|
||||
{AVATAR_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === c}
|
||||
aria-label={`Couleur ${c}`}
|
||||
onClick={() => onChange(c)}
|
||||
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
|
||||
value === c ? 'ring-2 ring-accent ring-offset-2 ring-offset-panel' : ''
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bloc « photo de profil » — LA seule façon d'envoyer un avatar. */
|
||||
export function AvatarUploader({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
avatarUrl,
|
||||
onChange,
|
||||
size = 56
|
||||
}: {
|
||||
userId: string
|
||||
name: string
|
||||
color: string
|
||||
avatarUrl: string | null
|
||||
onChange: (url: string | null) => void
|
||||
size?: number
|
||||
}): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const pick = async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast('Image trop lourde (5 Mo maximum).', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
onChange(await uploadAvatar(file, userId))
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const preview = { full_name: name || '', avatar_color: color, avatar_url: avatarUrl }
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar profile={preview} size={size} />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pick} />
|
||||
<Button icon={<Camera size={16} />} onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
{avatarUrl ? 'Changer' : 'Ajouter une photo'}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+443
-191
@@ -1,218 +1,470 @@
|
||||
import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import {
|
||||
Profile,
|
||||
Priority,
|
||||
PRIORITY_LABELS,
|
||||
JOB_TAGS,
|
||||
tagColor,
|
||||
Pole,
|
||||
POLE_ORDER,
|
||||
POLE_LABELS,
|
||||
poleColor
|
||||
} from '../lib/types'
|
||||
// =====================================================================
|
||||
// Primitives d'interface — LA source unique des boutons, champs, états.
|
||||
// Aucune page ne doit réécrire un bouton ou un badge à la main.
|
||||
// =====================================================================
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
import {
|
||||
ReactNode,
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
forwardRef,
|
||||
useId
|
||||
} from 'react'
|
||||
import { AlertTriangle, Loader2, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { Tone, TONE_DOT } from '../lib/tones'
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Bouton
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type ButtonVariant = 'primary' | 'ghost' | 'quiet' | 'danger'
|
||||
type ButtonSize = 'sm' | 'md'
|
||||
|
||||
const VARIANT: Record<ButtonVariant, string> = {
|
||||
primary: 'btn-primary',
|
||||
ghost: 'btn-ghost',
|
||||
quiet: 'btn-quiet',
|
||||
danger: 'btn-danger'
|
||||
}
|
||||
|
||||
const SIZE: Record<ButtonSize, string> = {
|
||||
sm: 'px-2.5 py-1 text-xs',
|
||||
md: ''
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'ghost',
|
||||
size = 'md',
|
||||
icon,
|
||||
loading,
|
||||
full,
|
||||
children,
|
||||
onClose,
|
||||
wide
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
wide?: boolean
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
icon?: ReactNode
|
||||
loading?: boolean
|
||||
full?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onMouseDown={onClose}
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={`${VARIANT[variant]} ${SIZE[size]} ${full ? 'w-full' : ''} ${className}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
|
||||
className={`card max-h-[90vh] w-full overflow-y-auto ${wide ? 'max-w-2xl' : 'max-w-md'}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
<button onClick={onClose} className="text-muted hover:text-ink">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : icon}
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?: number }): JSX.Element {
|
||||
const initials = (profile?.full_name ?? '?')
|
||||
.split(' ')
|
||||
.map((s) => s[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
if (profile?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={profile.avatar_url}
|
||||
alt={profile.full_name}
|
||||
title={profile.full_name}
|
||||
draggable={false}
|
||||
className="shrink-0 rounded-full object-cover"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
title={profile?.full_name ?? 'Non assigné'}
|
||||
className="flex shrink-0 items-center justify-center rounded-full font-semibold text-ink"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: size * 0.4,
|
||||
background: profile?.avatar_color ?? '#3a3f4d'
|
||||
}}
|
||||
>
|
||||
{profile ? initials : '–'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES: Record<Priority, string> = {
|
||||
low: 'bg-slate-100 text-slate-600',
|
||||
medium: 'bg-sky-100 text-sky-700',
|
||||
high: 'bg-amber-100 text-amber-700',
|
||||
urgent: 'bg-rose-100 text-rose-700'
|
||||
}
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element {
|
||||
return <span className={`badge ${PRIORITY_STYLES[priority]}`}>{PRIORITY_LABELS[priority]}</span>
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>): JSX.Element {
|
||||
return <select {...props} className={`input ${props.className ?? ''}`} />
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-subtle">
|
||||
{icon}
|
||||
<p className="text-sm">{text}</p>
|
||||
</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>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélection de pôle(s) par pastilles cliquables (comme les tags).
|
||||
* Utilisé pour une tâche/bug (un pôle) comme pour un membre (plusieurs pôles).
|
||||
* Bouton contenant UNIQUEMENT une icône.
|
||||
*
|
||||
* Existe pour une raison précise : l'application était truffée de
|
||||
* `<button className="text-muted hover:text-ink"><Icon/></button>` — des
|
||||
* cibles de 18 px, sans libellé accessible et sans état de survol lisible.
|
||||
* Ici la zone cliquable fait 32 px (36 en « md ») et `label` est obligatoire :
|
||||
* il alimente à la fois l'infobulle et le lecteur d'écran.
|
||||
*/
|
||||
export function PoleChips({
|
||||
selected,
|
||||
onToggle
|
||||
}: {
|
||||
selected: Pole[] | null | undefined
|
||||
onToggle: (pole: Pole) => void
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
size = 'md',
|
||||
tone = 'default',
|
||||
active,
|
||||
className = '',
|
||||
...rest
|
||||
}: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
size?: 'sm' | 'md'
|
||||
tone?: 'default' | 'danger' | 'accent'
|
||||
active?: boolean
|
||||
}): JSX.Element {
|
||||
const sel = selected ?? []
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{POLE_ORDER.map((p) => {
|
||||
const on = sel.includes(p)
|
||||
const box = size === 'sm' ? 'h-8 w-8' : 'h-9 w-9'
|
||||
const tones = {
|
||||
default: 'text-muted hover:bg-hover hover:text-ink',
|
||||
accent: 'text-accent hover:bg-accent/10',
|
||||
danger: 'text-muted hover:bg-rose-500/10 hover:text-rose-600'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onToggle(p)}
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
on
|
||||
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
{...rest}
|
||||
className={`inline-flex ${box} shrink-0 items-center justify-center rounded-lg
|
||||
transition-colors disabled:cursor-not-allowed disabled:opacity-40
|
||||
${active ? 'bg-accent/12 text-accent' : tones[tone]} ${className}`}
|
||||
>
|
||||
{POLE_LABELS[p]}
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Champ d'édition de tags avec suggestions de « jobs ». */
|
||||
export function TagInput({
|
||||
value,
|
||||
onChange
|
||||
// ---------------------------------------------------------------------
|
||||
// Champs de formulaire
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Libellé + champ + aide, correctement reliés (`htmlFor` / `aria-describedby`). */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className = '',
|
||||
children
|
||||
}: {
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string | null
|
||||
required?: boolean
|
||||
className?: string
|
||||
children: ReactNode | ((props: { id: string; describedBy?: string }) => ReactNode)
|
||||
}): 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()))
|
||||
|
||||
const id = useId()
|
||||
const hintId = hint || error ? `${id}-hint` : undefined
|
||||
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 className={className}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="label">
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-rose-600">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{typeof children === 'function' ? children({ id, describedBy: hintId }) : children}
|
||||
{error ? (
|
||||
<p id={hintId} className="mt-1 text-xs text-rose-600">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
hint && (
|
||||
<p id={hintId} className="mt-1 text-xs text-subtle">
|
||||
{hint}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
function Input({ className = '', ...rest }, ref) {
|
||||
return <input ref={ref} {...rest} className={`input ${className}`} />
|
||||
}
|
||||
)
|
||||
|
||||
/** Champ de recherche avec icône et bouton d'effacement. */
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Rechercher…',
|
||||
className = '',
|
||||
autoFocus
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
autoFocus?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Search size={15} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle" />
|
||||
<input
|
||||
type="search"
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="input py-1.5 pl-8 pr-8 [&::-webkit-search-cancel-button]:hidden"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Effacer la recherche"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-subtle hover:text-ink"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bascule segmentée (ex : Tâche / Autre activité). */
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
ariaLabel
|
||||
}: {
|
||||
options: readonly (readonly [T, string])[]
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={`flex rounded-lg border border-border bg-panel2 p-0.5 ${className}`}
|
||||
>
|
||||
{options.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === v}
|
||||
onClick={() => onChange(v)}
|
||||
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
value === v ? 'bg-accent text-accentfg shadow-card' : 'text-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className = ''
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
label: ReactNode
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className={`flex cursor-pointer items-center gap-2 text-sm text-ink ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 shrink-0 rounded border-border accent-[rgb(var(--c-accent))]"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Affichage
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function Badge({
|
||||
className = '',
|
||||
children
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}): JSX.Element {
|
||||
return <span className={`badge ${className}`}>{children}</span>
|
||||
}
|
||||
|
||||
/** Pastille pleine de statut (rond coloré). */
|
||||
export function Dot({ tone, className = '' }: { tone: Tone; className?: string }): JSX.Element {
|
||||
return <span className={`h-2 w-2 shrink-0 rounded-full ${TONE_DOT[tone]} ${className}`} />
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
pct,
|
||||
className = '',
|
||||
barClassName = 'bg-accent',
|
||||
height = 'h-1.5',
|
||||
label
|
||||
}: {
|
||||
pct: number
|
||||
className?: string
|
||||
barClassName?: string
|
||||
height?: string
|
||||
label?: string
|
||||
}): JSX.Element {
|
||||
const safe = Math.max(0, Math.min(100, Math.round(pct)))
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={safe}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
className={`${height} w-full overflow-hidden rounded-full bg-panel2 ${className}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-smooth ${barClassName}`}
|
||||
style={{ width: `${safe}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Kbd({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <kbd className="kbd">{children}</kbd>
|
||||
}
|
||||
|
||||
/** Pastille d'un filtre actif, retirable d'un clic. */
|
||||
export function FilterChip({
|
||||
children,
|
||||
onRemove
|
||||
}: {
|
||||
children: ReactNode
|
||||
onRemove: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-xs font-medium text-accent">
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Retirer ce filtre"
|
||||
onClick={onRemove}
|
||||
className="rounded-full p-0.5 transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// États d'écran : chargement / erreur / vide
|
||||
// Les trois sont distincts. Afficher « Aucun bug 🎉 » pendant un
|
||||
// chargement ou après une panne réseau était le pire défaut de l'app.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function Skeleton({ className = '' }: { className?: string }): JSX.Element {
|
||||
return <div className={`skeleton ${className}`} />
|
||||
}
|
||||
|
||||
/** Squelette générique de liste, pendant le premier chargement. */
|
||||
export function SkeletonList({ rows = 5, className = '' }: { rows?: number; className?: string }): JSX.Element {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`} aria-hidden>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCards({ count = 4, className = '' }: { count?: number; className?: string }): JSX.Element {
|
||||
return (
|
||||
<div className={className} aria-hidden>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[76px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
action
|
||||
}: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
text?: string
|
||||
action?: ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<div className="text-subtle">{icon}</div>
|
||||
<p className="text-sm font-medium text-ink">{title}</p>
|
||||
{text && <p className="max-w-sm text-sm text-muted">{text}</p>}
|
||||
{action && <div className="mt-1">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Panne : on dit ce qui s'est passé ET on propose de réessayer. */
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
compact
|
||||
}: {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
compact?: boolean
|
||||
}): JSX.Element {
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/8 px-3 py-2 text-sm text-rose-600 dark:text-rose-300"
|
||||
>
|
||||
<AlertTriangle size={16} className="shrink-0" />
|
||||
<span className="flex-1">{message}</span>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="shrink-0 font-medium underline-offset-2 hover:underline">
|
||||
Réessayer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div role="alert" className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<AlertTriangle size={36} className="text-rose-500" />
|
||||
<p className="text-sm font-medium text-ink">Impossible de charger ces données</p>
|
||||
<p className="max-w-md text-sm text-muted">{message}</p>
|
||||
{onRetry && (
|
||||
<Button icon={<RefreshCw size={15} />} onClick={onRetry} className="mt-1">
|
||||
Réessayer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Spinner({ size = 24, className = '' }: { size?: number; className?: string }): JSX.Element {
|
||||
return (
|
||||
<span role="status" aria-label="Chargement">
|
||||
<Loader2 size={size} className={`animate-spin text-muted ${className}`} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CenteredSpinner(): JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner size={28} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendu conditionnel standard d'une page : chargement → erreur → contenu.
|
||||
* Garantit qu'aucun écran n'affiche plus un faux « état vide ».
|
||||
*/
|
||||
export function AsyncContent({
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: ReactNode
|
||||
}): JSX.Element {
|
||||
if (loading) return <>{skeleton ?? <CenteredSpinner />}</>
|
||||
if (error) return <ErrorState message={error} onRetry={onRetry} />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
Vendored
+10
@@ -10,3 +10,13 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
// mammoth publie ses types pour l'entrée Node, pas pour la construction
|
||||
// navigateur — qui est pourtant la seule utilisable ici (elle n'embarque
|
||||
// pas les modules `fs`). On déclare donc la seule fonction employée.
|
||||
declare module 'mammoth/mammoth.browser' {
|
||||
export function convertToMarkdown(input: { arrayBuffer: ArrayBuffer }): Promise<{
|
||||
value: string
|
||||
messages: { type: string; message: string }[]
|
||||
}>
|
||||
}
|
||||
|
||||
+151
-48
@@ -3,37 +3,73 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Thèmes — base épurée (Notion) réchauffée par un accent coloré
|
||||
accent = indigo (actions), accent2 = teal (temps / progression)
|
||||
THÈME — une seule échelle de surfaces, deux couleurs d'accent.
|
||||
|
||||
Surfaces (du fond vers l'avant) : bg < sidebar < panel < panel2
|
||||
Texte (du plus fort au plus faible) : ink > muted > subtle
|
||||
|
||||
accent = bleu ciel — TOUT ce qui est action, navigation, sélection
|
||||
accent2 = teal — UNIQUEMENT le temps (heures, feuille de temps)
|
||||
Rien d'autre : un token = un sens.
|
||||
============================================================ */
|
||||
:root {
|
||||
color-scheme: light; /* aligne selects/inputs/scrollbars natifs sur le thème */
|
||||
--c-bg: 251 251 250;
|
||||
color-scheme: light; /* aligne inputs date/scrollbars natifs sur le thème */
|
||||
|
||||
--c-bg: 250 250 249;
|
||||
--c-sidebar: 246 246 248;
|
||||
--c-panel: 255 255 255;
|
||||
--c-panel2: 244 243 246;
|
||||
--c-sidebar: 248 247 250;
|
||||
--c-border: 232 231 235;
|
||||
--c-ink: 45 43 56;
|
||||
--c-muted: 112 110 122;
|
||||
--c-subtle: 152 150 162;
|
||||
--c-accent: 99 90 232; /* indigo/violet */
|
||||
--c-panel2: 243 243 246;
|
||||
--c-hover: 236 236 241;
|
||||
|
||||
--c-border: 228 228 234;
|
||||
--c-border-strong: 208 208 217;
|
||||
|
||||
--c-ink: 32 31 42;
|
||||
--c-muted: 104 102 118;
|
||||
--c-subtle: 146 144 160;
|
||||
|
||||
/* Bleu ciel. On prend le ton « 600 » et non « 500 » : le texte blanc des
|
||||
boutons principaux doit rester lisible dessus (contraste 4,6:1). */
|
||||
--c-accent: 2 132 199;
|
||||
--c-accent-soft: 224 242 254;
|
||||
--c-accentfg: 255 255 255;
|
||||
--c-accent2: 13 148 136; /* teal */
|
||||
--c-accent2: 13 148 136;
|
||||
|
||||
/* Élévation : en clair, une vraie ombre portée suffit. */
|
||||
--shadow-card: 0 1px 2px rgb(16 15 30 / 0.05);
|
||||
--shadow-raised: 0 2px 6px rgb(16 15 30 / 0.07), 0 1px 2px rgb(16 15 30 / 0.05);
|
||||
--shadow-pop: 0 12px 32px -8px rgb(16 15 30 / 0.18), 0 2px 8px rgb(16 15 30 / 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--c-bg: 23 23 27;
|
||||
--c-panel: 31 31 37;
|
||||
--c-panel2: 40 40 48;
|
||||
|
||||
--c-bg: 15 15 19;
|
||||
--c-sidebar: 19 19 24;
|
||||
--c-border: 51 51 60;
|
||||
--c-ink: 234 234 238;
|
||||
--c-muted: 160 158 172;
|
||||
--c-subtle: 124 122 136;
|
||||
--c-accent: 129 121 248; /* indigo clair sur fond sombre */
|
||||
--c-panel: 26 26 32;
|
||||
--c-panel2: 36 36 44;
|
||||
--c-hover: 46 46 56;
|
||||
|
||||
/* Bordures nettement plus contrastées : en sombre, ce sont ELLES qui
|
||||
dessinent la hiérarchie — les ombres portées y sont invisibles. */
|
||||
--c-border: 48 48 58;
|
||||
--c-border-strong: 68 68 82;
|
||||
|
||||
--c-ink: 235 235 240;
|
||||
--c-muted: 163 161 176;
|
||||
--c-subtle: 126 124 140;
|
||||
|
||||
/* Sur fond sombre, un bleu ciel plus clair : c'est lui qui doit ressortir. */
|
||||
--c-accent: 56 189 248;
|
||||
--c-accent-soft: 12 74 110;
|
||||
--c-accentfg: 255 255 255;
|
||||
--c-accent2: 45 212 191; /* teal clair */
|
||||
--c-accent2: 45 212 191;
|
||||
|
||||
/* En sombre on combine une ombre profonde ET un liseré clair interne,
|
||||
sinon les cartes « flottent » sans se détacher du fond. */
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.03);
|
||||
--shadow-raised: 0 4px 12px -2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
--shadow-pop: 0 16px 40px -8px rgb(0 0 0 / 0.7), inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -42,66 +78,133 @@
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-ink antialiased;
|
||||
font-family: ui-sans-serif, 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-family: 'Inter var', 'Inter', ui-sans-serif, 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: -0.011em;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'tnum' 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.021em;
|
||||
}
|
||||
|
||||
/* Les chiffres alignés verticalement dans les tableaux et compteurs. */
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--c-border)) transparent;
|
||||
scrollbar-color: rgb(var(--c-border-strong)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--c-border));
|
||||
background: rgb(var(--c-border-strong));
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--c-subtle));
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Accessibilité clavier : anneau cohérent sur tout élément interactif,
|
||||
sans polluer la navigation à la souris. */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--c-accent));
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Respect du réglage système « réduire les animations ». */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------- Boutons ---------- */
|
||||
.btn {
|
||||
@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;
|
||||
@apply inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-3 py-1.5
|
||||
text-sm font-medium transition-all duration-150
|
||||
disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-accent text-accentfg shadow-sm hover:opacity-90 active:scale-[0.98];
|
||||
@apply btn bg-accent text-accentfg shadow-raised hover:brightness-110 active:scale-[0.98];
|
||||
}
|
||||
/* Bouton secondaire : bordé, donc visible sur TOUTES les surfaces —
|
||||
l'ancien `bg-panel2` disparaissait dans les barres de filtres. */
|
||||
.btn-ghost {
|
||||
@apply btn bg-panel2 text-ink hover:bg-border;
|
||||
@apply btn border border-border bg-panel text-ink shadow-card
|
||||
hover:border-border-strong hover:bg-hover active:scale-[0.98];
|
||||
}
|
||||
.btn-quiet {
|
||||
@apply btn text-muted hover:bg-hover hover:text-ink;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-rose-600 text-white shadow-raised hover:bg-rose-700 active:scale-[0.98];
|
||||
}
|
||||
|
||||
/* ---------- Champs ---------- */
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/15 placeholder:text-subtle;
|
||||
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink
|
||||
outline-none transition-colors placeholder:text-subtle
|
||||
focus:border-accent focus:ring-2 focus:ring-accent/20
|
||||
disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
/* Selects : flèche maison + options aux couleurs de l'appli (au lieu du style natif brut) */
|
||||
select.input {
|
||||
@apply cursor-pointer pr-8;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
background-size: 1.05rem;
|
||||
}
|
||||
select.input option {
|
||||
background: rgb(var(--c-panel));
|
||||
color: rgb(var(--c-ink));
|
||||
.input-sm {
|
||||
@apply rounded-md px-2 py-1 text-xs;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium text-muted;
|
||||
@apply mb-1.5 block text-xs font-medium text-muted;
|
||||
}
|
||||
|
||||
/* ---------- Surfaces ---------- */
|
||||
.card {
|
||||
@apply rounded-xl border border-border bg-panel p-4 shadow-sm transition-colors;
|
||||
@apply rounded-xl border border-border bg-panel p-4 shadow-card;
|
||||
}
|
||||
.surface {
|
||||
@apply rounded-xl border border-border bg-panel;
|
||||
}
|
||||
/* Zone de travail encastrée (colonnes Kanban, encadrés dans les modales). */
|
||||
.well {
|
||||
@apply rounded-xl border border-border bg-panel2/60;
|
||||
}
|
||||
|
||||
/* ---------- Divers ---------- */
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium;
|
||||
@apply inline-flex items-center gap-1 whitespace-nowrap rounded-md px-2 py-0.5
|
||||
text-xs font-medium;
|
||||
}
|
||||
.kbd {
|
||||
@apply inline-flex h-5 min-w-5 items-center justify-center rounded border border-border
|
||||
bg-panel2 px-1.5 font-mono text-[11px] font-medium text-muted;
|
||||
}
|
||||
.toolbar {
|
||||
@apply flex flex-wrap items-center gap-2 border-b border-border bg-panel px-6 py-2.5;
|
||||
}
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded-md bg-panel2;
|
||||
}
|
||||
.link {
|
||||
@apply text-accent underline-offset-2 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
+283
-74
@@ -1,28 +1,60 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef
|
||||
} from 'react'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
import { initSupabase, supa, hasClient } from './supabase'
|
||||
import { getConfig, setConfig } from './config'
|
||||
import { Profile, Project } from './types'
|
||||
import { supa, isConfigured } from './supabase'
|
||||
import { rows, run, mutate, humanize, useRealtime } from './db'
|
||||
import { Profile, Project, EntityKind, Page } from './types'
|
||||
|
||||
type Stage = 'loading' | 'setup' | 'login' | 'ready'
|
||||
type Stage = 'loading' | 'unconfigured' | 'login' | 'ready'
|
||||
|
||||
/** Élément à ouvrir après navigation (clic sur une notification, palette…). */
|
||||
export interface FocusEntity {
|
||||
kind: 'task' | 'bug'
|
||||
kind: EntityKind
|
||||
id: string
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
stage: Stage
|
||||
session: Session | null
|
||||
|
||||
/** Écran affiché. Vit ici pour que n'importe quelle page puisse naviguer
|
||||
* (une notification, la palette, une carte du tableau de bord). */
|
||||
page: Page
|
||||
setPage: (p: Page) => void
|
||||
|
||||
profile: Profile | null
|
||||
projects: Project[]
|
||||
currentProject: Project | null
|
||||
setCurrentProject: (p: Project) => void
|
||||
saveConfig: (url: string, key: string) => Promise<void>
|
||||
refreshProjects: () => Promise<void>
|
||||
profileError: string | null
|
||||
refreshProfile: () => Promise<void>
|
||||
|
||||
/** Tous les membres de l'équipe — chargés UNE fois ici, plus dans chaque page. */
|
||||
members: Profile[]
|
||||
membersLoading: boolean
|
||||
memberById: (id: string | null | undefined) => Profile | undefined
|
||||
refreshMembers: () => Promise<void>
|
||||
|
||||
projects: Project[]
|
||||
projectsLoading: boolean
|
||||
projectsError: string | null
|
||||
currentProject: Project | null
|
||||
selectProject: (p: Project) => void
|
||||
refreshProjects: () => Promise<void>
|
||||
createProject: (name: string, description: string) => Promise<Project>
|
||||
renameProject: (id: string, name: string, description: string) => Promise<void>
|
||||
deleteProject: (id: string) => Promise<void>
|
||||
|
||||
/** Connexion internet (bandeau « hors ligne »). */
|
||||
online: boolean
|
||||
|
||||
signOut: () => Promise<void>
|
||||
// Élément à ouvrir (depuis une notification) : la page cible s'en saisit.
|
||||
|
||||
focusEntity: FocusEntity | null
|
||||
setFocusEntity: (f: FocusEntity | null) => void
|
||||
}
|
||||
@@ -35,95 +67,272 @@ export function useApp(): AppState {
|
||||
return v
|
||||
}
|
||||
|
||||
const LAST_PROJECT_KEY = 'gamedev-tracker-last-project'
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [stage, setStage] = useState<Stage>('loading')
|
||||
const [stage, setStage] = useState<Stage>(isConfigured ? 'loading' : 'unconfigured')
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
|
||||
const [profile, setProfile] = useState<Profile | null>(null)
|
||||
const [profileError, setProfileError] = useState<string | null>(null)
|
||||
|
||||
const [members, setMembers] = useState<Profile[]>([])
|
||||
const [membersLoading, setMembersLoading] = useState(true)
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
||||
const [projectsLoading, setProjectsLoading] = useState(true)
|
||||
const [projectsError, setProjectsError] = useState<string | null>(null)
|
||||
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
|
||||
() => localStorage.getItem(LAST_PROJECT_KEY)
|
||||
)
|
||||
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
const [focusEntity, setFocusEntity] = useState<FocusEntity | null>(null)
|
||||
const [online, setOnline] = useState(() => navigator.onLine)
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
const uid = (await supa().auth.getUser()).data.user?.id
|
||||
if (!uid) return
|
||||
const { data } = await supa().from('profiles').select('*').eq('id', uid).single()
|
||||
if (data) setProfile(data as Profile)
|
||||
}, [])
|
||||
const userId = session?.user?.id ?? null
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
const { data } = await supa().from('projects').select('*').order('created_at')
|
||||
const list = (data ?? []) as Project[]
|
||||
setProjects(list)
|
||||
setCurrentProject((prev) => {
|
||||
if (prev) return list.find((p) => p.id === prev.id) ?? list[0] ?? null
|
||||
return list[0] ?? null
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Démarrage : charge la config locale puis tente de restaurer la session.
|
||||
// ------------------------------------------------------------------
|
||||
// Connexion internet
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
|
||||
setStage('setup')
|
||||
return
|
||||
const up = (): void => setOnline(true)
|
||||
const down = (): void => setOnline(false)
|
||||
window.addEventListener('online', up)
|
||||
window.addEventListener('offline', down)
|
||||
return () => {
|
||||
window.removeEventListener('online', up)
|
||||
window.removeEventListener('offline', down)
|
||||
}
|
||||
initSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey)
|
||||
const { data } = await supa().auth.getSession()
|
||||
setSession(data.session)
|
||||
supa().auth.onAuthStateChange((_e, s) => setSession(s))
|
||||
setStage(data.session ? 'ready' : 'login')
|
||||
})()
|
||||
}, [])
|
||||
|
||||
// Quand la session change, on (re)charge profil + projets.
|
||||
// ------------------------------------------------------------------
|
||||
// Session — un seul abonnement, correctement libéré
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!hasClient()) return
|
||||
if (session) {
|
||||
setStage('ready')
|
||||
refreshProfile()
|
||||
refreshProjects()
|
||||
} else if (stage !== 'setup' && stage !== 'loading') {
|
||||
setStage('login')
|
||||
if (!isConfigured) return
|
||||
let alive = true
|
||||
|
||||
supa()
|
||||
.auth.getSession()
|
||||
.then(({ data }) => {
|
||||
if (!alive) return
|
||||
setSession(data.session)
|
||||
setStage(data.session ? 'ready' : 'login')
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setStage('login')
|
||||
})
|
||||
|
||||
const { data: sub } = supa().auth.onAuthStateChange((_event, s) => {
|
||||
if (!alive) return
|
||||
setSession(s)
|
||||
setStage(s ? 'ready' : 'login')
|
||||
if (!s) {
|
||||
setProfile(null)
|
||||
setProjects([])
|
||||
setCurrentProject(null)
|
||||
setMembers([])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
})
|
||||
|
||||
const saveConfig = async (url: string, key: string): Promise<void> => {
|
||||
setConfig({ supabaseUrl: url.trim(), supabaseAnonKey: key.trim() })
|
||||
initSupabase(url.trim(), key.trim())
|
||||
const { data } = await supa().auth.getSession()
|
||||
setSession(data.session)
|
||||
supa().auth.onAuthStateChange((_e, s) => setSession(s))
|
||||
setStage(data.session ? 'ready' : 'login')
|
||||
return () => {
|
||||
alive = false
|
||||
sub.subscription.unsubscribe() // manquait avant : listeners empilés
|
||||
}
|
||||
}, [])
|
||||
|
||||
const signOut = async (): Promise<void> => {
|
||||
// ------------------------------------------------------------------
|
||||
// Profil de l'utilisateur connecté
|
||||
// ------------------------------------------------------------------
|
||||
const refreshProfile = useCallback(async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
// L'id vient de la session déjà en mémoire : plus d'appel réseau
|
||||
// `auth.getUser()` inutile à chaque rafraîchissement.
|
||||
const data = await run<Profile>(
|
||||
supa().from('profiles').select('*').eq('id', userId).maybeSingle()
|
||||
)
|
||||
setProfile(data)
|
||||
setProfileError(data ? null : "Ton profil est introuvable. Contacte un administrateur.")
|
||||
} catch (e) {
|
||||
setProfileError(humanize(e))
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Membres de l'équipe (partagés par toute l'application)
|
||||
// ------------------------------------------------------------------
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
setMembers(await rows<Profile>(supa().from('profiles').select('*').order('full_name')))
|
||||
} catch {
|
||||
// Non bloquant : les pages affichent les avatars en repli « ? ».
|
||||
} finally {
|
||||
setMembersLoading(false)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Projets
|
||||
// ------------------------------------------------------------------
|
||||
const refreshProjects = useCallback(async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
const list = await rows<Project>(supa().from('projects').select('*').order('created_at'))
|
||||
setProjects(list)
|
||||
setProjectsError(null)
|
||||
} catch (e) {
|
||||
setProjectsError(humanize(e))
|
||||
} finally {
|
||||
setProjectsLoading(false)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
refreshProfile()
|
||||
refreshMembers()
|
||||
refreshProjects()
|
||||
}, [userId, refreshProfile, refreshMembers, refreshProjects])
|
||||
|
||||
// Synchro temps réel de l'équipe et des projets : un collègue crée un
|
||||
// projet ou change d'avatar → tout le monde le voit sans recharger.
|
||||
useRealtime(userId ? `team-${userId}` : null, [{ table: 'profiles' }], () => {
|
||||
refreshMembers()
|
||||
refreshProfile()
|
||||
})
|
||||
useRealtime(userId ? `projects-${userId}` : null, [{ table: 'projects' }], refreshProjects)
|
||||
|
||||
// Le projet courant est dérivé de la liste : il ne peut jamais pointer
|
||||
// vers un projet supprimé (c'était possible avant).
|
||||
const currentProject = useMemo(() => {
|
||||
if (projects.length === 0) return null
|
||||
return projects.find((p) => p.id === currentProjectId) ?? projects[0]
|
||||
}, [projects, currentProjectId])
|
||||
|
||||
// Mémorise le dernier projet ouvert pour le retrouver au prochain lancement.
|
||||
const lastSaved = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (currentProject && currentProject.id !== lastSaved.current) {
|
||||
lastSaved.current = currentProject.id
|
||||
localStorage.setItem(LAST_PROJECT_KEY, currentProject.id)
|
||||
}
|
||||
}, [currentProject])
|
||||
|
||||
const selectProject = useCallback((p: Project) => setCurrentProjectId(p.id), [])
|
||||
|
||||
// Si l'utilisateur n'est plus administrateur, on le sort de l'écran d'admin.
|
||||
useEffect(() => {
|
||||
if (page === 'admin' && profile && !profile.is_admin) setPage('dashboard')
|
||||
}, [page, profile])
|
||||
|
||||
const createProject = useCallback(
|
||||
async (name: string, description: string): Promise<Project> => {
|
||||
const created = await run<Project>(
|
||||
supa()
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
created_by: userId
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
)
|
||||
if (!created) throw new Error("Le projet n'a pas pu être créé.")
|
||||
setProjects((list) => [...list, created])
|
||||
setCurrentProjectId(created.id)
|
||||
return created
|
||||
},
|
||||
[userId]
|
||||
)
|
||||
|
||||
const renameProject = useCallback(
|
||||
async (id: string, name: string, description: string): Promise<void> => {
|
||||
await mutate(
|
||||
supa()
|
||||
.from('projects')
|
||||
.update({ name: name.trim(), description: description.trim() || null })
|
||||
.eq('id', id)
|
||||
)
|
||||
await refreshProjects()
|
||||
},
|
||||
[refreshProjects]
|
||||
)
|
||||
|
||||
const deleteProject = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await mutate(supa().from('projects').delete().eq('id', id))
|
||||
setProjects((list) => list.filter((p) => p.id !== id))
|
||||
setCurrentProjectId((cur) => (cur === id ? null : cur))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const memberById = useCallback(
|
||||
(id: string | null | undefined): Profile | undefined =>
|
||||
id ? members.find((m) => m.id === id) : undefined,
|
||||
[members]
|
||||
)
|
||||
|
||||
const signOut = useCallback(async (): Promise<void> => {
|
||||
await supa().auth.signOut()
|
||||
setSession(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Ctx.Provider
|
||||
value={{
|
||||
const value = useMemo<AppState>(
|
||||
() => ({
|
||||
stage,
|
||||
session,
|
||||
page,
|
||||
setPage,
|
||||
profile,
|
||||
projects,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
saveConfig,
|
||||
refreshProjects,
|
||||
profileError,
|
||||
refreshProfile,
|
||||
members,
|
||||
membersLoading,
|
||||
memberById,
|
||||
refreshMembers,
|
||||
projects,
|
||||
projectsLoading,
|
||||
projectsError,
|
||||
currentProject,
|
||||
selectProject,
|
||||
refreshProjects,
|
||||
createProject,
|
||||
renameProject,
|
||||
deleteProject,
|
||||
online,
|
||||
signOut,
|
||||
focusEntity,
|
||||
setFocusEntity
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
}),
|
||||
[
|
||||
stage,
|
||||
session,
|
||||
page,
|
||||
profile,
|
||||
profileError,
|
||||
refreshProfile,
|
||||
members,
|
||||
membersLoading,
|
||||
memberById,
|
||||
refreshMembers,
|
||||
projects,
|
||||
projectsLoading,
|
||||
projectsError,
|
||||
currentProject,
|
||||
selectProject,
|
||||
refreshProjects,
|
||||
createProject,
|
||||
renameProject,
|
||||
deleteProject,
|
||||
online,
|
||||
signOut,
|
||||
focusEntity
|
||||
]
|
||||
)
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
+17
-10
@@ -1,39 +1,46 @@
|
||||
// Client de l'espace administration : appelle l'Edge Function "admin-users".
|
||||
// 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'
|
||||
import { Pole } from './types'
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
role: MemberRole
|
||||
roles: MemberRole[]
|
||||
roles: Pole[]
|
||||
is_admin: boolean
|
||||
avatar_color: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Appel générique de l'Edge Function avec remontée d'erreur lisible. */
|
||||
/** 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
|
||||
// Supabase enveloppe la réponse HTTP : on va y chercher le vrai 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 */
|
||||
/* on garde le message d'origine */
|
||||
}
|
||||
}
|
||||
if (/failed to send|fetch/i.test(msg)) {
|
||||
msg =
|
||||
"L'espace d'administration est injoignable. Vérifie que l'Edge Function « admin-users » " +
|
||||
'est bien déployée (voir GUIDE_ADMIN.md).'
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'error' in data) {
|
||||
throw new Error((data as { error: string }).error)
|
||||
}
|
||||
@@ -46,7 +53,7 @@ export const adminCreateUser = (input: {
|
||||
email: string
|
||||
password: string
|
||||
full_name: string
|
||||
roles: MemberRole[]
|
||||
roles: Pole[]
|
||||
is_admin: boolean
|
||||
}): Promise<{ ok: true; id: string }> => call('create', input)
|
||||
|
||||
@@ -58,7 +65,7 @@ export const adminUpdateUser = (
|
||||
full_name?: string
|
||||
email?: string
|
||||
password?: string
|
||||
roles?: MemberRole[]
|
||||
roles?: Pole[]
|
||||
is_admin?: boolean
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { supa } from './supabase'
|
||||
|
||||
/**
|
||||
* Envoie une image de profil dans le bucket "avatars" et renvoie son URL
|
||||
* publique. Le chemin est préfixé par l'id du membre.
|
||||
*/
|
||||
export async function uploadAvatar(file: File, userId: string): Promise<string> {
|
||||
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
|
||||
const path = `${userId}/${Date.now()}.${ext}`
|
||||
const { error } = await supa()
|
||||
.storage.from('avatars')
|
||||
.upload(path, file, { upsert: true, cacheControl: '3600' })
|
||||
if (error) throw error
|
||||
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Stockage local des clés Supabase (la clé "anon" est publique par nature,
|
||||
// localStorage convient parfaitement et évite tout code natif).
|
||||
|
||||
export interface AppConfig {
|
||||
supabaseUrl?: string
|
||||
supabaseAnonKey?: string
|
||||
}
|
||||
|
||||
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 {
|
||||
const baked = getBakedConfig()
|
||||
if (baked) return baked
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function setConfig(config: AppConfig): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(config))
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// =====================================================================
|
||||
// Couche d'accès aux données — UNE seule façon de parler à Supabase.
|
||||
//
|
||||
// Règle absolue : aucune erreur n'est avalée. Avant, le code faisait
|
||||
// `const { data } = await supa()...` et ignorait `error` : une panne
|
||||
// réseau ou une policy RLS se traduisait par une liste vide et un
|
||||
// « Aucun bug 🎉 » mensonger. Ici, toute erreur lève une DbError avec
|
||||
// un message en français, que l'appelant affiche (ErrorState / toast).
|
||||
// =====================================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PostgrestError } from '@supabase/supabase-js'
|
||||
import { supa } from './supabase'
|
||||
|
||||
export class DbError extends Error {
|
||||
readonly code: string | undefined
|
||||
readonly details: string | undefined
|
||||
|
||||
constructor(message: string, code?: string, details?: string) {
|
||||
super(message)
|
||||
this.name = 'DbError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/** Traduit une erreur Postgrest/réseau en phrase compréhensible. */
|
||||
export function humanize(err: unknown): string {
|
||||
if (err instanceof DbError) return err.message
|
||||
if (!err) return 'Une erreur inconnue est survenue.'
|
||||
|
||||
const e = err as Partial<PostgrestError> & { message?: string; status?: number }
|
||||
const raw = e.message ?? String(err)
|
||||
const code = e.code ?? ''
|
||||
|
||||
// Réseau / serveur injoignable
|
||||
if (/failed to fetch|network|load failed|ECONNREFUSED/i.test(raw)) {
|
||||
return 'Impossible de joindre le serveur. Vérifie ta connexion internet.'
|
||||
}
|
||||
// Session expirée
|
||||
if (/jwt|token|not authenticated|invalid claim/i.test(raw) || e.status === 401) {
|
||||
return 'Ta session a expiré. Reconnecte-toi.'
|
||||
}
|
||||
// RLS
|
||||
if (code === '42501' || /row-level security|permission denied/i.test(raw)) {
|
||||
return "Tu n'as pas les droits pour cette action."
|
||||
}
|
||||
// Contraintes
|
||||
if (code === '23505') return 'Cet élément existe déjà.'
|
||||
if (code === '23503') return "L'élément lié n'existe plus. Actualise la page."
|
||||
if (code === '23514') return 'Une valeur saisie est invalide.'
|
||||
// Table / colonne absente, ou cache de schéma périmé côté PostgREST.
|
||||
// Le message brut (« Could not find the 'tags' column of 'tasks' in the
|
||||
// schema cache ») ne veut rien dire pour l'utilisateur : on dit quoi faire.
|
||||
if (
|
||||
code === '42P01' ||
|
||||
code === '42703' ||
|
||||
code === 'PGRST204' ||
|
||||
code === 'PGRST205' ||
|
||||
/schema cache/i.test(raw) ||
|
||||
/does not exist/i.test(raw)
|
||||
) {
|
||||
const missing = raw.match(/'([^']+)' column of '([^']+)'/)
|
||||
const what = missing ? ` (colonne « ${missing[1]} » de « ${missing[2]} »)` : ''
|
||||
return (
|
||||
`La base de données n'est pas à jour${what} : exécute « supabase/update_all.sql » ` +
|
||||
"dans le SQL Editor de Supabase, puis la ligne « notify pgrst, 'reload schema'; », " +
|
||||
'puis relance l\'application.'
|
||||
)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
interface Result<T> {
|
||||
data: T | null
|
||||
error: PostgrestError | null
|
||||
}
|
||||
|
||||
/** Exécute une requête et lève si elle échoue. Renvoie `data` tel quel. */
|
||||
export async function run<T>(query: PromiseLike<Result<T>>): Promise<T | null> {
|
||||
const { data, error } = await query
|
||||
if (error) throw new DbError(humanize(error), error.code, error.details)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Exécute une requête de liste : renvoie toujours un tableau (jamais null). */
|
||||
export async function rows<T>(query: PromiseLike<Result<T[]>>): Promise<T[]> {
|
||||
return (await run<T[]>(query)) ?? []
|
||||
}
|
||||
|
||||
/** Exécute une requête d'écriture dont on n'exploite pas le retour. */
|
||||
export async function mutate(query: PromiseLike<Result<unknown>>): Promise<void> {
|
||||
await run(query)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// useQuery — chargement de données avec états explicites
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface QueryState<T> {
|
||||
data: T
|
||||
loading: boolean
|
||||
/** Rechargement en arrière-plan (les données affichées restent visibles). */
|
||||
refreshing: boolean
|
||||
error: string | null
|
||||
reload: () => Promise<void>
|
||||
/** Mise à jour optimiste locale, sans aller-retour serveur. */
|
||||
set: (updater: T | ((prev: T) => T)) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge des données de façon sûre : trois états distincts (chargement /
|
||||
* erreur / données), pas de « faux état vide » pendant le fetch.
|
||||
*
|
||||
* `enabled = false` (ex : pas encore de projet sélectionné) laisse l'état
|
||||
* en chargement sans déclencher de requête.
|
||||
*/
|
||||
export function useQuery<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
initial: T,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
): QueryState<T> {
|
||||
const [data, setData] = useState<T>(initial)
|
||||
const [loading, setLoading] = useState(enabled)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Évite un setState après démontage, et ignore les réponses obsolètes
|
||||
// quand deux chargements se chevauchent (changement de projet rapide).
|
||||
const alive = useRef(true)
|
||||
const seq = useRef(0)
|
||||
const loaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
return () => {
|
||||
alive.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
const id = ++seq.current
|
||||
if (loaded.current) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
try {
|
||||
const result = await fetcher()
|
||||
if (!alive.current || id !== seq.current) return
|
||||
setData(result)
|
||||
setError(null)
|
||||
loaded.current = true
|
||||
} catch (e) {
|
||||
if (!alive.current || id !== seq.current) return
|
||||
setError(humanize(e))
|
||||
} finally {
|
||||
if (alive.current && id === seq.current) {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [enabled, fetcher])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
void reload()
|
||||
}, [enabled, reload])
|
||||
|
||||
const set = useCallback((updater: T | ((prev: T) => T)) => {
|
||||
setData((prev) => (typeof updater === 'function' ? (updater as (p: T) => T)(prev) : updater))
|
||||
}, [])
|
||||
|
||||
return { data, loading, refreshing, error, reload, set }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// useRealtime — abonnement Postgres changes, groupé et débouncé
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface RealtimeTable {
|
||||
table: string
|
||||
/** Filtre côté serveur, ex : `project_id=eq.${id}`. Toujours le préciser
|
||||
* quand c'est possible : sans lui, TOUTES les écritures de la table
|
||||
* (tous projets confondus) réveillent la page. */
|
||||
filter?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* S'abonne à plusieurs tables sur un seul canal et appelle `onChange` de
|
||||
* façon débouncée (une rafale d'UPDATE = un seul rechargement).
|
||||
*/
|
||||
export function useRealtime(
|
||||
channel: string | null,
|
||||
tables: RealtimeTable[],
|
||||
onChange: () => void
|
||||
): void {
|
||||
// La ref évite de re-souscrire le canal à chaque rendu quand l'appelant
|
||||
// passe une fonction anonyme ; elle est synchronisée dans un effet, jamais
|
||||
// pendant le rendu.
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Sérialisé pour servir de dépendance stable sans re-souscrire à chaque rendu.
|
||||
const key = JSON.stringify(tables)
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel) return
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const ping = (): void => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => onChangeRef.current(), 120)
|
||||
}
|
||||
|
||||
const list = JSON.parse(key) as RealtimeTable[]
|
||||
let ch = supa().channel(channel)
|
||||
for (const t of list) {
|
||||
ch = ch.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: t.table, ...(t.filter ? { filter: t.filter } : {}) },
|
||||
ping
|
||||
)
|
||||
}
|
||||
ch.subscribe()
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [channel, key])
|
||||
}
|
||||
+114
-32
@@ -1,17 +1,30 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
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
|
||||
// Notifications (toasts) + confirmations — remplace alert()/confirm().
|
||||
// UN seul système de retour utilisateur dans toute l'application : les
|
||||
// bandeaux d'erreur locaux (<p class="text-rose-600">) ont disparu.
|
||||
// =====================================================================
|
||||
|
||||
type ToastKind = 'success' | 'error' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string
|
||||
message?: string
|
||||
@@ -19,6 +32,7 @@ interface ConfirmOptions {
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
resolve: (ok: boolean) => void
|
||||
}
|
||||
@@ -36,16 +50,47 @@ export function useFeedback(): FeedbackApi {
|
||||
return v
|
||||
}
|
||||
|
||||
const ICONS: Record<ToastKind, JSX.Element> = {
|
||||
success: <CheckCircle2 size={18} className="text-emerald-600 dark:text-emerald-400" />,
|
||||
error: <AlertTriangle size={18} className="text-rose-600 dark:text-rose-400" />,
|
||||
info: <Info size={18} className="text-accent" />
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const t = timers.current.get(id)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
timers.current.delete(id)
|
||||
}
|
||||
setToasts((list) => list.filter((x) => x.id !== id))
|
||||
}, [])
|
||||
|
||||
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)
|
||||
setToasts((list) => {
|
||||
// Pas deux fois le même message à l'écran (rafales temps réel).
|
||||
const deduped = list.filter((t) => t.message !== message)
|
||||
// Au plus 4 : au-delà, ça masque l'interface.
|
||||
return [...deduped, { id, kind, message }].slice(-4)
|
||||
})
|
||||
// Une erreur reste plus longtemps : on doit avoir le temps de la lire.
|
||||
const delay = kind === 'error' ? 6500 : 3800
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), delay)
|
||||
)
|
||||
}, [dismiss])
|
||||
|
||||
useEffect(() => {
|
||||
const map = timers.current
|
||||
return () => map.forEach(clearTimeout)
|
||||
}, [])
|
||||
|
||||
const confirm = useCallback(
|
||||
@@ -54,37 +99,60 @@ export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Ele
|
||||
[]
|
||||
)
|
||||
|
||||
const closeConfirm = (ok: boolean): void => {
|
||||
confirmState?.resolve(ok)
|
||||
setConfirmState(null)
|
||||
const closeConfirm = useCallback((ok: boolean): void => {
|
||||
setConfirmState((cur) => {
|
||||
cur?.resolve(ok)
|
||||
return null
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Échap = annuler. Entrée = confirmer, SAUF pour une action destructrice :
|
||||
// valider une suppression d'une frappe involontaire serait impardonnable.
|
||||
useEffect(() => {
|
||||
if (!confirmState) return
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
closeConfirm(false)
|
||||
} else if (e.key === 'Enter' && !confirmState.danger) {
|
||||
closeConfirm(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [confirmState, closeConfirm])
|
||||
|
||||
const api = useMemo(() => ({ toast, confirm }), [toast, confirm])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ toast, confirm }}>
|
||||
<Ctx.Provider value={api}>
|
||||
{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 — annoncés aux lecteurs d'écran (ils étaient muets avant). */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 40, scale: 0.95 }}
|
||||
initial={{ opacity: 0, x: 32, scale: 0.96 }}
|
||||
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"
|
||||
exit={{ opacity: 0, x: 32, scale: 0.96 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
|
||||
className="pointer-events-auto flex items-start gap-2.5 rounded-xl border border-border bg-panel px-3.5 py-3 shadow-pop"
|
||||
>
|
||||
<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>
|
||||
<span className="mt-px shrink-0">{ICONS[t.kind]}</span>
|
||||
<p className="flex-1 text-sm leading-snug text-ink">{t.message}</p>
|
||||
<button
|
||||
onClick={() => setToasts((list) => list.filter((x) => x.id !== t.id))}
|
||||
className="text-subtle hover:text-ink"
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="shrink-0 text-subtle transition-colors hover:text-ink"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -100,30 +168,44 @@ export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Ele
|
||||
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"
|
||||
data-overlay
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
|
||||
onMouseDown={() => closeConfirm(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={confirmState.title}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
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"
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
|
||||
className="w-full max-w-sm rounded-xl border border-border bg-panel p-5 shadow-pop"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{confirmState.danger && <AlertTriangle size={20} className="text-rose-600" />}
|
||||
{confirmState.danger && (
|
||||
<AlertTriangle size={19} className="shrink-0 text-rose-600 dark:text-rose-400" />
|
||||
)}
|
||||
<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>
|
||||
<p className="mb-1 text-sm leading-relaxed text-muted">{confirmState.message}</p>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<button className="btn-ghost" onClick={() => closeConfirm(false)}>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
// Sur une action destructrice, le focus part sur « Annuler ».
|
||||
autoFocus={confirmState.danger}
|
||||
onClick={() => closeConfirm(false)}
|
||||
>
|
||||
{confirmState.cancelLabel ?? 'Annuler'}
|
||||
</button>
|
||||
<button
|
||||
className={confirmState.danger ? 'btn bg-rose-500 text-ink hover:bg-rose-600' : 'btn-primary'}
|
||||
type="button"
|
||||
className={confirmState.danger ? 'btn-danger' : 'btn-primary'}
|
||||
autoFocus={!confirmState.danger}
|
||||
onClick={() => closeConfirm(true)}
|
||||
>
|
||||
{confirmState.confirmLabel ?? 'Confirmer'}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// =====================================================================
|
||||
// Filtres multi-valeurs — sémantique partagée par le Kanban et les bugs.
|
||||
// =====================================================================
|
||||
|
||||
/** Valeur spéciale : « non assigné » / « sans pôle ». */
|
||||
export const NONE = '__none__'
|
||||
|
||||
/**
|
||||
* Un élément passe le filtre s'il correspond à AU MOINS UNE des valeurs
|
||||
* cochées (OU logique).
|
||||
*
|
||||
* C'est ce qu'on attend en cochant « Alex » + « Sam » : les tâches d'Alex
|
||||
* **et** celles de Sam — pas seulement celles où les deux sont assignés.
|
||||
* Aucune case cochée = aucun filtre, tout passe.
|
||||
*/
|
||||
export function matchesAny(selected: string[], values: readonly string[]): boolean {
|
||||
if (selected.length === 0) return true
|
||||
if (values.length === 0) return selected.includes(NONE)
|
||||
return values.some((v) => selected.includes(v))
|
||||
}
|
||||
|
||||
/** Idem pour une valeur unique (priorité, sévérité, statut…). */
|
||||
export function matchesOne(selected: string[], value: string): boolean {
|
||||
return selected.length === 0 || selected.includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit des filtres enregistrés en tolérant l'ancien format à valeur
|
||||
* unique (`assignee: "id"` → `assignees: ["id"]`), pour ne pas perdre les
|
||||
* préférences de ceux qui utilisaient déjà l'application.
|
||||
*/
|
||||
export function readList(value: unknown, legacy?: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
|
||||
if (typeof legacy === 'string' && legacy) return [legacy]
|
||||
return []
|
||||
}
|
||||
|
||||
export function readText(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// =====================================================================
|
||||
// Installeur intégré — côté front.
|
||||
//
|
||||
// Le .exe distribué EST l'application. Lancé depuis ailleurs que son
|
||||
// dossier d'installation, il affiche son propre écran d'installation
|
||||
// (src/pages/Installer.tsx) au lieu de démarrer l'app.
|
||||
//
|
||||
// Voir src-tauri/src/installer.rs pour la partie système.
|
||||
// =====================================================================
|
||||
|
||||
import { isDesktop } from './updater'
|
||||
|
||||
export interface InstallerStatus {
|
||||
running_installed: boolean
|
||||
already_installed: boolean
|
||||
install_dir: string
|
||||
version: string
|
||||
product_name: string
|
||||
}
|
||||
|
||||
export type StartupMode = 'installer' | 'app'
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Décide, au démarrage, entre l'écran d'installation et l'application.
|
||||
*
|
||||
* En cas de doute on démarre l'APPLICATION : mieux vaut lancer l'app depuis
|
||||
* un dossier temporaire que bloquer quelqu'un sur un installeur inutile.
|
||||
*/
|
||||
export async function resolveStartupMode(): Promise<{
|
||||
mode: StartupMode
|
||||
status: InstallerStatus | null
|
||||
}> {
|
||||
if (!isDesktop()) return { mode: 'app', status: null }
|
||||
try {
|
||||
const status = await invoke<InstallerStatus>('installer_status')
|
||||
return { mode: status.running_installed ? 'app' : 'installer', status }
|
||||
} catch (e) {
|
||||
console.warn('Statut d’installation indisponible :', e)
|
||||
return { mode: 'app', status: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Copie l'application, crée les raccourcis, l'enregistre auprès de Windows. */
|
||||
export function install(desktopShortcut: boolean): Promise<string> {
|
||||
return invoke<string>('installer_install', { desktopShortcut })
|
||||
}
|
||||
|
||||
/** Démarre la version installée et ferme le processus courant. */
|
||||
export function launchInstalled(): Promise<void> {
|
||||
return invoke<void>('installer_launch')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Fenêtre
|
||||
//
|
||||
// Rien ici ne redimensionne ni n'affiche la fenêtre : elle est créée
|
||||
// directement à la bonne forme côté Rust (src-tauri/src/lib.rs). Ne
|
||||
// restent que les commandes de la barre de titre maison de l'installeur,
|
||||
// puisque la fenêtre y est sans décoration Windows.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export async function closeWindow(): Promise<void> {
|
||||
if (!isDesktop()) return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
|
||||
export async function minimizeWindow(): Promise<void> {
|
||||
if (!isDesktop()) return
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().minimize()
|
||||
}
|
||||
|
||||
/** Permet de déplacer la fenêtre sans barre de titre, en tirant sur l'en-tête. */
|
||||
export async function startDragging(): Promise<void> {
|
||||
if (!isDesktop()) return
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
await getCurrentWindow().startDragging()
|
||||
} catch {
|
||||
/* sans conséquence */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// =====================================================================
|
||||
// Markdown — rendu et import de fichiers.
|
||||
//
|
||||
// Le Markdown est le format pivot de la documentation : un .docx importé
|
||||
// y est converti immédiatement. Une seule représentation en base, donc
|
||||
// des fiches lisibles, cherchables et modifiables dans l'application.
|
||||
// =====================================================================
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
/**
|
||||
* Convertit du Markdown en HTML **assaini**.
|
||||
*
|
||||
* DOMPurify n'est pas une précaution de principe : le contenu vient d'un
|
||||
* fichier importé, potentiellement produit par un outil tiers. Sans lui,
|
||||
* un `<img onerror=…>` glissé dans un .docx s'exécuterait dans l'app.
|
||||
*/
|
||||
export function renderMarkdown(md: string): string {
|
||||
const html = marked.parse(md ?? '', { async: false })
|
||||
return DOMPurify.sanitize(html, {
|
||||
// Les liens s'ouvrent dans le navigateur (voir Markdown.tsx) ; on
|
||||
// interdit tout ce qui pourrait exécuter du code ou charger un cadre.
|
||||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'style']
|
||||
})
|
||||
}
|
||||
|
||||
/** Texte brut d'une fiche : sert à la recherche et aux extraits. */
|
||||
export function markdownToText(md: string): string {
|
||||
return (md ?? '')
|
||||
.replace(/```[\s\S]*?```/g, ' ') // blocs de code
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // liens → libellé
|
||||
.replace(/[#>*_`~|-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Extrait de N caractères, coupé sur un mot. */
|
||||
export function excerpt(md: string, max = 160): string {
|
||||
const text = markdownToText(md)
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, text.lastIndexOf(' ', max) || max)}…`
|
||||
}
|
||||
|
||||
/** Premier titre de niveau 1, s'il y en a un — sert de titre à l'import. */
|
||||
export function firstHeading(md: string): string | null {
|
||||
const m = (md ?? '').match(/^\s*#\s+(.+)$/m)
|
||||
return m ? m[1].trim() : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Import de fichiers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Extensions acceptées à l'import, pour l'attribut `accept` du champ. */
|
||||
export const IMPORT_ACCEPT = '.md,.markdown,.txt,.docx'
|
||||
|
||||
export interface ImportedDoc {
|
||||
title: string
|
||||
content: string
|
||||
sourceName: string
|
||||
}
|
||||
|
||||
const MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Nom du fichier sans son extension — titre par défaut. */
|
||||
function baseName(name: string): string {
|
||||
return name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un fichier et le ramène à du Markdown.
|
||||
*
|
||||
* `.docx` passe par mammoth ; le paquet est lourd (~200 Ko) et n'est donc
|
||||
* chargé qu'au moment où quelqu'un importe réellement un fichier Word.
|
||||
*/
|
||||
export async function importFile(file: File): Promise<ImportedDoc> {
|
||||
if (file.size > MAX_IMPORT_BYTES) {
|
||||
throw new Error('Fichier trop lourd : 5 Mo maximum.')
|
||||
}
|
||||
|
||||
const ext = (file.name.split('.').pop() ?? '').toLowerCase()
|
||||
let content: string
|
||||
|
||||
if (ext === 'docx') {
|
||||
const mammoth = await import('mammoth/mammoth.browser')
|
||||
const buffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
|
||||
content = result.value
|
||||
if (!content.trim()) {
|
||||
throw new Error("Ce document Word semble vide, ou son contenu n'a pas pu être lu.")
|
||||
}
|
||||
} else if (ext === 'md' || ext === 'markdown' || ext === 'txt' || ext === '') {
|
||||
content = await file.text()
|
||||
} else if (ext === 'doc') {
|
||||
throw new Error(
|
||||
"L'ancien format Word (.doc) n'est pas lisible. Ouvre le fichier dans Word " +
|
||||
'et enregistre-le en .docx, puis réessaie.'
|
||||
)
|
||||
} else if (ext === 'pdf') {
|
||||
throw new Error(
|
||||
"Le PDF n'est pas pris en charge : son texte n'est pas structuré et la fiche " +
|
||||
'serait illisible. Copie-colle le contenu dans une nouvelle fiche.'
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Format « .${ext} » non pris en charge. Formats acceptés : ${IMPORT_ACCEPT}.`)
|
||||
}
|
||||
|
||||
return {
|
||||
// Le titre du document prime sur le nom du fichier : plus fiable.
|
||||
title: firstHeading(content) ?? baseName(file.name) ?? 'Sans titre',
|
||||
content: content.trim(),
|
||||
sourceName: file.name
|
||||
}
|
||||
}
|
||||
+139
-25
@@ -1,35 +1,149 @@
|
||||
import { supa } from './supabase'
|
||||
import { Pole, Profile, NotificationKind, memberPoles } from './types'
|
||||
// =====================================================================
|
||||
// Notifications — qui prévenir, et quand.
|
||||
//
|
||||
// Avant, seuls les membres d'un PÔLE étaient notifiés : assigner
|
||||
// nommément quelqu'un hors pôle ne le prévenait jamais. Et un membre
|
||||
// appartenant à deux pôles d'une même tâche recevait deux fois la même
|
||||
// notification, via autant de requêtes séquentielles.
|
||||
//
|
||||
// Ici : on calcule l'ensemble des destinataires (pôles ∪ assignés),
|
||||
// on retire l'auteur de l'action, on dédoublonne, et on insère TOUT
|
||||
// en une seule requête.
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Crée une notification pour chaque membre du pôle destinataire
|
||||
* (sauf l'auteur de l'action). Utilisé lorsqu'une tâche ou un bug
|
||||
* est confié à un pôle.
|
||||
*/
|
||||
export async function notifyPole(opts: {
|
||||
projectId: string
|
||||
pole: Pole
|
||||
kind: NotificationKind
|
||||
title: string // ex : « Nouvelle tâche »
|
||||
body: string // titre de la tâche / du bug
|
||||
entityId: string
|
||||
actorId: string | null
|
||||
import { supa } from './supabase'
|
||||
import { EntityKind, NotificationKind, Pole, Profile, memberPoles } from './types'
|
||||
|
||||
interface Recipients {
|
||||
members: Profile[]
|
||||
}): Promise<void> {
|
||||
const recipients = opts.members.filter(
|
||||
(m) => memberPoles(m).includes(opts.pole) && m.id !== opts.actorId
|
||||
)
|
||||
/** Pôles destinataires (tous leurs membres seront prévenus). */
|
||||
poles?: Pole[]
|
||||
/** Personnes nommément assignées (prévenues même hors pôle). */
|
||||
assigneeIds?: string[]
|
||||
/** Auteur de l'action : jamais notifié de sa propre action. */
|
||||
actorId: string | null
|
||||
}
|
||||
|
||||
/** Ensemble dédoublonné des identifiants à prévenir. */
|
||||
function resolveRecipients({ members, poles = [], assigneeIds = [], actorId }: Recipients): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const m of members) {
|
||||
if (poles.some((p) => memberPoles(m).includes(p))) ids.add(m.id)
|
||||
}
|
||||
for (const id of assigneeIds) ids.add(id)
|
||||
if (actorId) ids.delete(actorId)
|
||||
// On ne notifie que des membres réellement existants.
|
||||
const known = new Set(members.map((m) => m.id))
|
||||
return [...ids].filter((id) => known.has(id))
|
||||
}
|
||||
|
||||
interface NotifyOptions extends Recipients {
|
||||
projectId: string
|
||||
kind: NotificationKind
|
||||
entityKind: EntityKind
|
||||
entityId: string
|
||||
title: string
|
||||
body: string
|
||||
/** Pôle mis en avant dans la notification (informatif). */
|
||||
pole?: Pole | null
|
||||
}
|
||||
|
||||
/** Envoie une notification à tous les destinataires — en UNE requête. */
|
||||
export async function notify(opts: NotifyOptions): Promise<void> {
|
||||
const recipients = resolveRecipients(opts)
|
||||
if (recipients.length === 0) return
|
||||
|
||||
const rows = recipients.map((m) => ({
|
||||
user_id: m.id,
|
||||
const rows = recipients.map((user_id) => ({
|
||||
user_id,
|
||||
project_id: opts.projectId,
|
||||
kind: opts.kind,
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
entity_kind: opts.entityKind,
|
||||
entity_id: opts.entityId,
|
||||
pole: opts.pole
|
||||
title: opts.title,
|
||||
body: opts.body.slice(0, 160),
|
||||
pole: opts.pole ?? null
|
||||
}))
|
||||
|
||||
await supa().from('notifications').insert(rows)
|
||||
// Une notification qui échoue ne doit jamais faire échouer l'action
|
||||
// métier (la tâche est déjà enregistrée) : on avale silencieusement.
|
||||
const { error } = await supa().from('notifications').insert(rows)
|
||||
if (error) console.warn('Notification non envoyée :', error.message)
|
||||
}
|
||||
|
||||
/** Nouvelle tâche / nouveau pôle ou assigné sur une tâche. */
|
||||
export function notifyTask(opts: {
|
||||
projectId: string
|
||||
taskId: string
|
||||
title: string
|
||||
members: Profile[]
|
||||
poles: Pole[]
|
||||
assigneeIds: string[]
|
||||
actorId: string | null
|
||||
isNew: boolean
|
||||
}): Promise<void> {
|
||||
return notify({
|
||||
projectId: opts.projectId,
|
||||
kind: 'task',
|
||||
entityKind: 'task',
|
||||
entityId: opts.taskId,
|
||||
title: opts.isNew ? 'Nouvelle tâche' : 'Tâche qui te concerne',
|
||||
body: opts.title,
|
||||
members: opts.members,
|
||||
poles: opts.poles,
|
||||
assigneeIds: opts.assigneeIds,
|
||||
actorId: opts.actorId,
|
||||
pole: opts.poles[0] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
/** Nouveau bug / nouveau pôle ou assigné sur un bug. */
|
||||
export function notifyBug(opts: {
|
||||
projectId: string
|
||||
bugId: string
|
||||
title: string
|
||||
members: Profile[]
|
||||
poles: Pole[]
|
||||
assigneeIds: string[]
|
||||
actorId: string | null
|
||||
isNew: boolean
|
||||
}): Promise<void> {
|
||||
return notify({
|
||||
projectId: opts.projectId,
|
||||
kind: 'bug',
|
||||
entityKind: 'bug',
|
||||
entityId: opts.bugId,
|
||||
title: opts.isNew ? 'Nouveau bug' : 'Bug qui te concerne',
|
||||
body: opts.title,
|
||||
members: opts.members,
|
||||
poles: opts.poles,
|
||||
assigneeIds: opts.assigneeIds,
|
||||
actorId: opts.actorId,
|
||||
pole: opts.poles[0] ?? null
|
||||
})
|
||||
}
|
||||
|
||||
/** Nouveau commentaire : on prévient les gens impliqués sur l'élément. */
|
||||
export function notifyComment(opts: {
|
||||
projectId: string
|
||||
entityKind: EntityKind
|
||||
entityId: string
|
||||
entityTitle: string
|
||||
body: string
|
||||
members: Profile[]
|
||||
/** Assignés + auteur/rapporteur de l'élément commenté. */
|
||||
involvedIds: string[]
|
||||
actorId: string | null
|
||||
actorName: string
|
||||
}): Promise<void> {
|
||||
return notify({
|
||||
projectId: opts.projectId,
|
||||
kind: 'comment',
|
||||
entityKind: opts.entityKind,
|
||||
entityId: opts.entityId,
|
||||
title: `${opts.actorName} a commenté « ${opts.entityTitle} »`,
|
||||
body: opts.body,
|
||||
members: opts.members,
|
||||
assigneeIds: opts.involvedIds,
|
||||
actorId: opts.actorId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// =====================================================================
|
||||
// Fichiers : photos de profil (bucket "avatars") et pièces jointes
|
||||
// (bucket "attachments").
|
||||
// =====================================================================
|
||||
|
||||
import { supa } from './supabase'
|
||||
import { Attachment, EntityKind } from './types'
|
||||
import { rows, run } from './db'
|
||||
|
||||
/** 10 Mo — largement suffisant pour une capture d'écran ou un log. */
|
||||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
function storageError(err: { message: string }, what: string): Error {
|
||||
const m = err.message.toLowerCase()
|
||||
if (
|
||||
m.includes('bucket') ||
|
||||
m.includes('not found') ||
|
||||
m.includes('row-level') ||
|
||||
m.includes('violates') ||
|
||||
m.includes('permission')
|
||||
) {
|
||||
return new Error(
|
||||
`Le stockage ${what} n'est pas configuré sur Supabase : exécute ` +
|
||||
'« supabase/update_all.sql » dans le SQL Editor, puis réessaie.'
|
||||
)
|
||||
}
|
||||
return new Error(err.message)
|
||||
}
|
||||
|
||||
/** Nom de fichier sûr : pas d'accent ni d'espace dans un chemin de bucket. */
|
||||
function slug(name: string): string {
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '') // accents décomposés par NFD
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(-80)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Avatars
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Envoie une photo de profil et renvoie son URL publique. Les anciennes
|
||||
* photos du même membre sont supprimées : sans ça, le bucket accumulait
|
||||
* indéfiniment tous les avatars jamais choisis.
|
||||
*/
|
||||
export async function uploadAvatar(file: File, userId: string): Promise<string> {
|
||||
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
|
||||
const path = `${userId}/${Date.now()}.${ext}`
|
||||
|
||||
const { error } = await supa()
|
||||
.storage.from('avatars')
|
||||
.upload(path, file, { cacheControl: '3600' })
|
||||
if (error) throw storageError(error, 'des photos de profil')
|
||||
|
||||
// Ménage : on retire les versions précédentes (best effort).
|
||||
try {
|
||||
const { data: old } = await supa().storage.from('avatars').list(userId)
|
||||
const stale = (old ?? []).map((f) => `${userId}/${f.name}`).filter((p) => p !== path)
|
||||
if (stale.length) await supa().storage.from('avatars').remove(stale)
|
||||
} catch {
|
||||
/* sans conséquence */
|
||||
}
|
||||
|
||||
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pièces jointes
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function attachmentUrl(path: string): string {
|
||||
return supa().storage.from('attachments').getPublicUrl(path).data.publicUrl
|
||||
}
|
||||
|
||||
export function listAttachments(kind: EntityKind, entityId: string): Promise<Attachment[]> {
|
||||
return rows<Attachment>(
|
||||
supa()
|
||||
.from('attachments')
|
||||
.select('*')
|
||||
.eq('entity_kind', kind)
|
||||
.eq('entity_id', entityId)
|
||||
.order('created_at')
|
||||
)
|
||||
}
|
||||
|
||||
export async function uploadAttachment(opts: {
|
||||
file: File
|
||||
projectId: string
|
||||
entityKind: EntityKind
|
||||
entityId: string
|
||||
uploaderId: string
|
||||
}): Promise<Attachment> {
|
||||
const { file, projectId, entityKind, entityId, uploaderId } = opts
|
||||
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error('Fichier trop lourd : 10 Mo maximum.')
|
||||
}
|
||||
|
||||
const path = `${projectId}/${entityKind}/${entityId}/${crypto.randomUUID()}-${slug(file.name)}`
|
||||
const { error } = await supa()
|
||||
.storage.from('attachments')
|
||||
.upload(path, file, { cacheControl: '3600', contentType: file.type || undefined })
|
||||
if (error) throw storageError(error, 'des pièces jointes')
|
||||
|
||||
const created = await run<Attachment>(
|
||||
supa()
|
||||
.from('attachments')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
entity_kind: entityKind,
|
||||
entity_id: entityId,
|
||||
uploader_id: uploaderId,
|
||||
name: file.name,
|
||||
path,
|
||||
size: file.size,
|
||||
mime: file.type || null
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
)
|
||||
if (!created) throw new Error("La pièce jointe n'a pas pu être enregistrée.")
|
||||
return created
|
||||
}
|
||||
|
||||
export async function deleteAttachment(a: Attachment): Promise<void> {
|
||||
await run(supa().from('attachments').delete().eq('id', a.id))
|
||||
// Le fichier physique ensuite : si ça échoue, la ligne est déjà partie
|
||||
// et l'utilisateur ne voit plus rien d'incohérent.
|
||||
try {
|
||||
await supa().storage.from('attachments').remove([a.path])
|
||||
} catch {
|
||||
/* sans conséquence */
|
||||
}
|
||||
}
|
||||
+21
-13
@@ -1,25 +1,33 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
let client: SupabaseClient | null = null
|
||||
// Les clés sont intégrées au build depuis le fichier `.env`, versionné avec
|
||||
// le projet. L'URL et la clé « anon » sont publiques par nature :
|
||||
// c'est la RLS de Supabase qui protège les données, pas le secret de la clé.
|
||||
const URL = import.meta.env.VITE_SUPABASE_URL
|
||||
const ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
/** Crée (ou recrée) le client Supabase à partir des clés fournies. */
|
||||
export function initSupabase(url: string, anonKey: string): SupabaseClient {
|
||||
client = createClient(url, anonKey, {
|
||||
/** L'application a-t-elle été construite avec des clés Supabase ? */
|
||||
export const isConfigured = Boolean(URL && ANON_KEY)
|
||||
|
||||
const client: SupabaseClient | null = URL && ANON_KEY
|
||||
? createClient(URL, ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
storageKey: 'gamedev-tracker-auth'
|
||||
}
|
||||
},
|
||||
// Une équipe de 5-6 personnes n'a pas besoin de plus : évite de saturer
|
||||
// le quota temps réel du plan gratuit lors des rafales d'écriture.
|
||||
realtime: { params: { eventsPerSecond: 5 } }
|
||||
})
|
||||
return client
|
||||
}
|
||||
: null
|
||||
|
||||
/** Récupère le client courant (lève une erreur si non initialisé). */
|
||||
export function supa(): SupabaseClient {
|
||||
if (!client) throw new Error('Supabase non initialisé. Configure les clés dans Réglages.')
|
||||
if (!client) {
|
||||
throw new Error(
|
||||
"L'application n'a pas été configurée : renseigne VITE_SUPABASE_URL et " +
|
||||
'VITE_SUPABASE_ANON_KEY dans le fichier .env, puis reconstruis.'
|
||||
)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export function hasClient(): boolean {
|
||||
return client !== null
|
||||
}
|
||||
|
||||
+40
-7
@@ -1,17 +1,50 @@
|
||||
// Gestion du thème clair / sombre (persisté localement).
|
||||
export type Theme = 'light' | 'dark'
|
||||
// Thème clair / sombre / système.
|
||||
//
|
||||
// Le défaut est le mode CLAIR : c'est l'apparence de référence de
|
||||
// l'application. Le mode « système » reste disponible dans le sélecteur
|
||||
// (bouton de la barre latérale, ou palette Ctrl+K) pour qui préfère
|
||||
// suivre le réglage de Windows.
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
const KEY = 'gamedev-tracker-theme'
|
||||
|
||||
const media = (): MediaQueryList | null =>
|
||||
typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return localStorage.getItem(KEY) === 'dark' ? 'dark' : 'light'
|
||||
const v = localStorage.getItem(KEY)
|
||||
// Rien de choisi = clair. On ne suit le système que si on l'a demandé.
|
||||
return v === 'dark' || v === 'light' || v === 'system' ? v : 'light'
|
||||
}
|
||||
|
||||
export function applyTheme(t: Theme): void {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark')
|
||||
export function resolveTheme(t: Theme): ResolvedTheme {
|
||||
if (t !== 'system') return t
|
||||
return media()?.matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function setTheme(t: Theme): void {
|
||||
export function applyTheme(t: Theme): ResolvedTheme {
|
||||
const resolved = resolveTheme(t)
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark')
|
||||
// Aligne aussi la couleur de fond native de la fenêtre (évite un flash
|
||||
// blanc au démarrage en thème sombre).
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function setTheme(t: Theme): ResolvedTheme {
|
||||
localStorage.setItem(KEY, t)
|
||||
applyTheme(t)
|
||||
return applyTheme(t)
|
||||
}
|
||||
|
||||
/** Réagit au changement de thème du système quand on est en mode « système ». */
|
||||
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
|
||||
const mq = media()
|
||||
if (!mq) return () => undefined
|
||||
const handler = (): void => {
|
||||
if (getTheme() === 'system') onChange(applyTheme('system'))
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// =====================================================================
|
||||
// Système de « tons » — source unique de vérité pour la couleur
|
||||
// sémantique (badges, pastilles, barres, textes d'accentuation).
|
||||
//
|
||||
// Chaque ton s'adapte AU THÈME : classes claires par défaut, variantes
|
||||
// `dark:` pour le mode sombre. Aucune page ne doit écrire de
|
||||
// `bg-amber-100` ou `text-gray-500` en dur : tout passe par ici.
|
||||
// =====================================================================
|
||||
|
||||
export type Tone =
|
||||
| 'slate'
|
||||
| 'sky'
|
||||
| 'amber'
|
||||
| 'rose'
|
||||
| 'emerald'
|
||||
| 'violet'
|
||||
| 'fuchsia'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'lime'
|
||||
| 'indigo'
|
||||
| 'teal'
|
||||
|
||||
/** Badge « doux » : fond teinté + texte lisible, en clair comme en sombre. */
|
||||
export const TONE_BADGE: Record<Tone, string> = {
|
||||
slate: 'bg-slate-500/10 text-slate-600 dark:bg-slate-400/15 dark:text-slate-300',
|
||||
sky: 'bg-sky-500/10 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300',
|
||||
amber: 'bg-amber-500/12 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300',
|
||||
rose: 'bg-rose-500/10 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300',
|
||||
emerald: 'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300',
|
||||
violet: 'bg-violet-500/10 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300',
|
||||
fuchsia: 'bg-fuchsia-500/10 text-fuchsia-700 dark:bg-fuchsia-400/15 dark:text-fuchsia-300',
|
||||
cyan: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-400/15 dark:text-cyan-300',
|
||||
orange: 'bg-orange-500/12 text-orange-700 dark:bg-orange-400/15 dark:text-orange-300',
|
||||
lime: 'bg-lime-500/12 text-lime-700 dark:bg-lime-400/15 dark:text-lime-300',
|
||||
indigo: 'bg-indigo-500/10 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300',
|
||||
teal: 'bg-teal-500/10 text-teal-700 dark:bg-teal-400/15 dark:text-teal-300'
|
||||
}
|
||||
|
||||
/** Aplat plein : pastille de statut, barre de progression, liseré. */
|
||||
export const TONE_DOT: Record<Tone, string> = {
|
||||
slate: 'bg-slate-400',
|
||||
sky: 'bg-sky-500',
|
||||
amber: 'bg-amber-500',
|
||||
rose: 'bg-rose-500',
|
||||
emerald: 'bg-emerald-500',
|
||||
violet: 'bg-violet-500',
|
||||
fuchsia: 'bg-fuchsia-500',
|
||||
cyan: 'bg-cyan-500',
|
||||
orange: 'bg-orange-500',
|
||||
lime: 'bg-lime-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
teal: 'bg-teal-500'
|
||||
}
|
||||
|
||||
/** Texte seul coloré (chiffres clés, libellés d'accentuation). */
|
||||
export const TONE_TEXT: Record<Tone, string> = {
|
||||
slate: 'text-slate-600 dark:text-slate-300',
|
||||
sky: 'text-sky-600 dark:text-sky-300',
|
||||
amber: 'text-amber-600 dark:text-amber-300',
|
||||
rose: 'text-rose-600 dark:text-rose-300',
|
||||
emerald: 'text-emerald-600 dark:text-emerald-300',
|
||||
violet: 'text-violet-600 dark:text-violet-300',
|
||||
fuchsia: 'text-fuchsia-600 dark:text-fuchsia-300',
|
||||
cyan: 'text-cyan-600 dark:text-cyan-300',
|
||||
orange: 'text-orange-600 dark:text-orange-300',
|
||||
lime: 'text-lime-600 dark:text-lime-300',
|
||||
indigo: 'text-indigo-600 dark:text-indigo-300',
|
||||
teal: 'text-teal-600 dark:text-teal-300'
|
||||
}
|
||||
+342
-94
@@ -1,6 +1,23 @@
|
||||
// Types métier alignés sur le schéma SQL Supabase (voir supabase/schema.sql).
|
||||
//
|
||||
// v10 : les colonnes « de compatibilité » (profiles.role, tasks.pole,
|
||||
// bugs.assignee_id) ont disparu. Il n'y a plus qu'une seule source de
|
||||
// vérité par notion : `roles[]`, `poles[]`, `assignee_ids[]`.
|
||||
|
||||
export type MemberRole =
|
||||
import { Tone, TONE_BADGE, TONE_DOT } from './tones'
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Les écrans de l'application (l'onglet « admin » est réservé aux admins). */
|
||||
export type Page = 'dashboard' | 'kanban' | 'planning' | 'bugs' | 'docs' | 'members' | 'admin'
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pôles (métiers)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export type Pole =
|
||||
| 'lead'
|
||||
| 'programmer'
|
||||
| 'artist'
|
||||
@@ -10,7 +27,7 @@ export type MemberRole =
|
||||
| 'qa'
|
||||
| 'other'
|
||||
|
||||
export const ROLE_LABELS: Record<MemberRole, string> = {
|
||||
export const POLE_LABELS: Record<Pole, string> = {
|
||||
lead: 'Chef de projet',
|
||||
programmer: 'Programmeur',
|
||||
artist: 'Artiste',
|
||||
@@ -21,14 +38,7 @@ export const ROLE_LABELS: Record<MemberRole, string> = {
|
||||
other: 'Autre'
|
||||
}
|
||||
|
||||
// Un « pôle » = un métier/équipe. On réutilise les rôles comme pôles :
|
||||
// une tâche/bug est confiée à un pôle, et la notification part à tous les
|
||||
// membres de ce pôle. Les libellés sont ceux des rôles.
|
||||
export type Pole = MemberRole
|
||||
|
||||
export const POLE_LABELS: Record<Pole, string> = ROLE_LABELS
|
||||
|
||||
// Ordre d'affichage des pôles dans les listes déroulantes.
|
||||
/** Ordre d'affichage des pôles dans les listes et sélecteurs. */
|
||||
export const POLE_ORDER: Pole[] = [
|
||||
'programmer',
|
||||
'artist',
|
||||
@@ -40,60 +50,79 @@ export const POLE_ORDER: Pole[] = [
|
||||
'other'
|
||||
]
|
||||
|
||||
// Une couleur DISTINCTE par pôle (pas de collision entre deux rôles).
|
||||
const POLE_COLORS: Record<Pole, string> = {
|
||||
lead: 'bg-amber-100 text-amber-700',
|
||||
programmer: 'bg-sky-100 text-sky-700',
|
||||
artist: 'bg-fuchsia-100 text-fuchsia-700',
|
||||
game_designer: 'bg-violet-100 text-violet-700',
|
||||
sound: 'bg-emerald-100 text-emerald-700',
|
||||
writer: 'bg-orange-100 text-orange-700',
|
||||
qa: 'bg-cyan-100 text-cyan-700',
|
||||
other: 'bg-slate-100 text-slate-600'
|
||||
/** Un TON DISTINCT par pôle (aucune collision entre deux métiers). */
|
||||
export const POLE_TONE: Record<Pole, Tone> = {
|
||||
lead: 'amber',
|
||||
programmer: 'sky',
|
||||
artist: 'fuchsia',
|
||||
game_designer: 'violet',
|
||||
sound: 'emerald',
|
||||
writer: 'orange',
|
||||
qa: 'cyan',
|
||||
other: 'slate'
|
||||
}
|
||||
|
||||
export function poleColor(pole: Pole): string {
|
||||
return POLE_COLORS[pole] ?? POLE_COLORS.other
|
||||
}
|
||||
export const poleBadge = (pole: Pole): string => TONE_BADGE[POLE_TONE[pole] ?? 'slate']
|
||||
export const poleDot = (pole: Pole): string => TONE_DOT[POLE_TONE[pole] ?? 'slate']
|
||||
|
||||
// Pôles « critiques » : donnent un accès élargi, attribués par un admin
|
||||
// uniquement (jamais auto-assignables lors de l'onboarding).
|
||||
/** Pôles « critiques » : attribués par un admin uniquement. */
|
||||
export const CRITICAL_POLES: Pole[] = ['lead']
|
||||
|
||||
// Pôles qu'un membre peut se donner lui-même à l'inscription.
|
||||
export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter(
|
||||
(p) => !CRITICAL_POLES.includes(p)
|
||||
)
|
||||
/** Pôles qu'un membre peut se donner lui-même à l'inscription. */
|
||||
export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter((p) => !CRITICAL_POLES.includes(p))
|
||||
|
||||
// Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres).
|
||||
export const JOB_TAGS: string[] = [
|
||||
'Programmation',
|
||||
'Art',
|
||||
'Game Design',
|
||||
'Audio',
|
||||
'Écriture',
|
||||
'QA',
|
||||
// ---------------------------------------------------------------------
|
||||
// Étiquettes libres sur les tâches
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Étiquettes suggérées (l'utilisateur peut en taper d'autres). */
|
||||
export const TAG_SUGGESTIONS: string[] = [
|
||||
'Gameplay',
|
||||
'UI/UX',
|
||||
'Level Design',
|
||||
'UI/UX'
|
||||
'Audio',
|
||||
'Narration',
|
||||
'Optimisation',
|
||||
'Build',
|
||||
'Marketing'
|
||||
]
|
||||
|
||||
// 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 {
|
||||
const TAG_TONES: Tone[] = ['violet', 'sky', 'emerald', 'amber', 'rose', 'cyan', 'fuchsia', 'lime']
|
||||
|
||||
/** Ton stable d'une étiquette, dérivé de son texte (même tag = même couleur). */
|
||||
export function tagBadge(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]
|
||||
return TONE_BADGE[TAG_TONES[h % TAG_TONES.length]]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Checklists
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface ChecklistItem {
|
||||
id: string
|
||||
text: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
done: number
|
||||
total: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
export function checklistProgress(items: ChecklistItem[] | null | undefined): Progress {
|
||||
const list = items ?? []
|
||||
const total = list.length
|
||||
const done = list.filter((i) => i.done).length
|
||||
return { done, total, pct: total ? Math.round((done / total) * 100) : 0 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tâches
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
@@ -105,6 +134,15 @@ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
|
||||
export const TASK_STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'review', 'done']
|
||||
|
||||
export const TASK_STATUS_TONE: Record<TaskStatus, Tone> = {
|
||||
todo: 'slate',
|
||||
in_progress: 'sky',
|
||||
review: 'amber',
|
||||
done: 'emerald'
|
||||
}
|
||||
export const taskStatusBadge = (s: TaskStatus): string => TONE_BADGE[TASK_STATUS_TONE[s]]
|
||||
export const taskStatusDot = (s: TaskStatus): string => TONE_DOT[TASK_STATUS_TONE[s]]
|
||||
|
||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
||||
|
||||
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
@@ -114,15 +152,67 @@ export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
urgent: 'Urgente'
|
||||
}
|
||||
|
||||
export const PRIORITY_ORDER: Priority[] = ['urgent', 'high', 'medium', 'low']
|
||||
|
||||
/** Poids pour trier : plus grand = plus prioritaire. */
|
||||
export const PRIORITY_WEIGHT: Record<Priority, number> = {
|
||||
urgent: 3,
|
||||
high: 2,
|
||||
medium: 1,
|
||||
low: 0
|
||||
}
|
||||
|
||||
export const PRIORITY_TONE: Record<Priority, Tone> = {
|
||||
low: 'slate',
|
||||
medium: 'sky',
|
||||
high: 'amber',
|
||||
urgent: 'rose'
|
||||
}
|
||||
export const priorityBadge = (p: Priority): string => TONE_BADGE[PRIORITY_TONE[p]]
|
||||
export const priorityDot = (p: Priority): string => TONE_DOT[PRIORITY_TONE[p]]
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Bugs
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Un bug n'a QU'UN axe d'importance : la sévérité (l'impact constaté).
|
||||
// L'ancien champ « priorité » faisait doublon et personne ne savait quoi
|
||||
// y mettre — il n'est plus exposé dans l'interface.
|
||||
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
|
||||
low: 'Mineur',
|
||||
medium: 'Moyen',
|
||||
high: 'Majeur',
|
||||
critical: 'Critique'
|
||||
critical: 'Bloquant'
|
||||
}
|
||||
|
||||
/** Aide affichée sous le sélecteur : dit quoi choisir sans réfléchir. */
|
||||
export const SEVERITY_HINTS: Record<BugSeverity, string> = {
|
||||
low: 'Détail cosmétique, on peut vivre avec.',
|
||||
medium: 'Gênant, mais il existe un contournement.',
|
||||
high: 'Casse une fonctionnalité importante.',
|
||||
critical: 'Le jeu plante ou devient injouable.'
|
||||
}
|
||||
|
||||
export const SEVERITY_ORDER: BugSeverity[] = ['critical', 'high', 'medium', 'low']
|
||||
|
||||
export const SEVERITY_WEIGHT: Record<BugSeverity, number> = {
|
||||
critical: 3,
|
||||
high: 2,
|
||||
medium: 1,
|
||||
low: 0
|
||||
}
|
||||
|
||||
export const SEVERITY_TONE: Record<BugSeverity, Tone> = {
|
||||
low: 'slate',
|
||||
medium: 'amber',
|
||||
high: 'orange',
|
||||
critical: 'rose'
|
||||
}
|
||||
export const severityBadge = (s: BugSeverity): string => TONE_BADGE[SEVERITY_TONE[s]]
|
||||
export const severityDot = (s: BugSeverity): string => TONE_DOT[SEVERITY_TONE[s]]
|
||||
|
||||
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
|
||||
|
||||
export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
|
||||
@@ -132,28 +222,44 @@ export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
|
||||
closed: 'Fermé'
|
||||
}
|
||||
|
||||
export const BUG_STATUS_ORDER: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed']
|
||||
|
||||
/** Un bug « actif » demande encore du travail. */
|
||||
export const ACTIVE_BUG_STATUSES: BugStatus[] = ['open', 'in_progress']
|
||||
|
||||
export const BUG_STATUS_TONE: Record<BugStatus, Tone> = {
|
||||
open: 'rose',
|
||||
in_progress: 'sky',
|
||||
resolved: 'emerald',
|
||||
closed: 'slate'
|
||||
}
|
||||
export const bugStatusBadge = (s: BugStatus): string => TONE_BADGE[BUG_STATUS_TONE[s]]
|
||||
export const bugStatusDot = (s: BugStatus): string => TONE_DOT[BUG_STATUS_TONE[s]]
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entités
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
full_name: string
|
||||
role: MemberRole // rôle principal (= roles[0]) conservé pour l'affichage simple
|
||||
roles: MemberRole[] // tous les pôles auxquels le membre appartient
|
||||
roles: Pole[]
|
||||
avatar_color: string
|
||||
avatar_url: string | null // photo de profil (sinon initiales + couleur)
|
||||
avatar_url: string | null
|
||||
is_admin: boolean
|
||||
onboarded: boolean // a terminé l'accueil (nom, avatar, pôles)
|
||||
onboarded: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Liste des pôles d'un membre (avec repli sur le rôle principal). */
|
||||
export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pole[] {
|
||||
if (p.roles && p.roles.length) return p.roles
|
||||
return p.role ? [p.role] : []
|
||||
/** Pôles d'un membre (tableau toujours défini, même pour un profil partiel). */
|
||||
export function memberPoles(p: { roles?: Pole[] | null }): Pole[] {
|
||||
return p.roles ?? []
|
||||
}
|
||||
|
||||
/** Liste des pôles d'une tâche (avec repli sur l'ancien pôle unique). */
|
||||
export function taskPoles(t: { pole?: Pole | null; poles?: Pole[] }): Pole[] {
|
||||
if (t.poles && t.poles.length) return t.poles
|
||||
return t.pole ? [t.pole] : []
|
||||
/** Libellé lisible des pôles d'un membre, pour les sous-titres. */
|
||||
export function polesLabel(poles: Pole[]): string {
|
||||
if (poles.length === 0) return 'Aucun pôle'
|
||||
return poles.map((p) => POLE_LABELS[p]).join(' · ')
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@@ -171,39 +277,17 @@ export interface Task {
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
priority: Priority
|
||||
pole: Pole | null // pôle principal (= poles[0]) conservé pour compat
|
||||
poles: Pole[] // tous les pôles concernés par la tâche
|
||||
poles: Pole[]
|
||||
assignee_ids: string[]
|
||||
checklist: ChecklistItem[]
|
||||
tags: string[]
|
||||
due_date: string | null
|
||||
position: number
|
||||
tags: string[]
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TimeLog {
|
||||
id: string
|
||||
project_id: string | null
|
||||
task_id: string | null // null = activité libre (réunion, etc.)
|
||||
label: string | null // libellé quand il n'y a pas de tâche liée
|
||||
user_id: string | null
|
||||
minutes: number
|
||||
note: string | null
|
||||
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 {
|
||||
id: string
|
||||
project_id: string
|
||||
@@ -211,25 +295,189 @@ export interface Bug {
|
||||
description: string | null
|
||||
severity: BugSeverity
|
||||
status: BugStatus
|
||||
priority: Priority
|
||||
pole: Pole | null
|
||||
assignee_id: string | null
|
||||
poles: Pole[]
|
||||
assignee_ids: string[]
|
||||
reporter_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type NotificationKind = 'task' | 'bug'
|
||||
/** Une entrée de temps porte sur une tâche, OU un bug, OU rien (activité libre). */
|
||||
export type TimeTarget = 'task' | 'bug' | 'other'
|
||||
|
||||
export const TIME_TARGET_LABELS: Record<TimeTarget, string> = {
|
||||
task: 'Tâche',
|
||||
bug: 'Bug',
|
||||
other: 'Autre'
|
||||
}
|
||||
|
||||
export interface TimeLog {
|
||||
id: string
|
||||
project_id: string | null
|
||||
task_id: string | null
|
||||
bug_id: string | null
|
||||
label: string | null // libellé quand ni tâche ni bug
|
||||
user_id: string | null
|
||||
minutes: number
|
||||
note: string | null
|
||||
logged_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function timeTargetOf(l: Pick<TimeLog, 'task_id' | 'bug_id'>): TimeTarget {
|
||||
if (l.task_id) return 'task'
|
||||
if (l.bug_id) return 'bug'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Normalisation des lignes venues de la base
|
||||
//
|
||||
// Les types promettent des tableaux ; la base, elle, peut renvoyer un
|
||||
// champ absent (colonne pas encore migrée) ou NULL (donnée héritée). Un
|
||||
// seul `undefined` faisait planter tout l'écran sur un `.map()`.
|
||||
// On tient la promesse ICI, une fois au chargement, plutôt que de semer
|
||||
// des `?? []` défensifs dans chaque composant.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function normalizeTask(row: Task): Task {
|
||||
return {
|
||||
...row,
|
||||
poles: row.poles ?? [],
|
||||
assignee_ids: row.assignee_ids ?? [],
|
||||
checklist: row.checklist ?? [],
|
||||
tags: row.tags ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBug(row: Bug): Bug {
|
||||
return {
|
||||
...row,
|
||||
poles: row.poles ?? [],
|
||||
assignee_ids: row.assignee_ids ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeTasks = (list: Task[]): Task[] => list.map(normalizeTask)
|
||||
export const normalizeBugs = (list: Bug[]): Bug[] => list.map(normalizeBug)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Documentation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Une fiche de documentation : GDD, conventions, notes de réunion…
|
||||
*
|
||||
* `content` est du Markdown, toujours. Un fichier Word importé est
|
||||
* converti à l'import (voir lib/markdown.ts) : une seule représentation
|
||||
* en base, donc des fiches cherchables et modifiables dans l'app.
|
||||
*/
|
||||
export interface Doc {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
/** Nom du fichier d'origine si la fiche vient d'un import. */
|
||||
source_name: string | null
|
||||
created_by: string | null
|
||||
updated_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export function normalizeDoc(row: Doc): Doc {
|
||||
return { ...row, tags: row.tags ?? [], content: row.content ?? '' }
|
||||
}
|
||||
|
||||
export const normalizeDocs = (list: Doc[]): Doc[] => list.map(normalizeDoc)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Commentaires & pièces jointes (partagés entre tâches et bugs)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Ce à quoi un commentaire / un fichier peut être rattaché. */
|
||||
export type EntityKind = 'task' | 'bug'
|
||||
|
||||
export const ENTITY_LABELS: Record<EntityKind, string> = {
|
||||
task: 'tâche',
|
||||
bug: 'bug'
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
project_id: string
|
||||
entity_kind: EntityKind
|
||||
entity_id: string
|
||||
author_id: string | null
|
||||
body: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
project_id: string
|
||||
entity_kind: EntityKind
|
||||
entity_id: string
|
||||
uploader_id: string | null
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
mime: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function isImage(a: Attachment): boolean {
|
||||
return (a.mime ?? '').startsWith('image/')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export type NotificationKind = 'task' | 'bug' | 'comment'
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
kind: NotificationKind
|
||||
/** Sur quoi pointe la notification (une notif de commentaire ouvre la
|
||||
* tâche ou le bug commenté). */
|
||||
entity_kind: EntityKind
|
||||
entity_id: string | null
|
||||
title: string
|
||||
body: string | null
|
||||
entity_id: string | null
|
||||
pole: Pole | null
|
||||
read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Formatage
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** 90 → « 1 h 30 » · 45 → « 45 min » · 0 → « 0 min ». */
|
||||
export function formatMinutes(min: number): string {
|
||||
if (!min) return '0 min'
|
||||
const sign = min < 0 ? '-' : ''
|
||||
const abs = Math.abs(min)
|
||||
const h = Math.floor(abs / 60)
|
||||
const m = abs % 60
|
||||
if (h === 0) return `${sign}${m} min`
|
||||
if (m === 0) return `${sign}${h} h`
|
||||
return `${sign}${h} h ${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 1536 → « 1,5 Mo ». Pour la taille des pièces jointes. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`
|
||||
}
|
||||
|
||||
/** Accord singulier/pluriel : plural(1,'membre') → « 1 membre ». */
|
||||
export function plural(n: number, singular: string, plural?: string): string {
|
||||
return `${n} ${n > 1 ? (plural ?? `${singular}s`) : singular}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// =====================================================================
|
||||
// Mises à jour automatiques.
|
||||
//
|
||||
// L'application va chercher elle-même la dernière version publiée dans
|
||||
// le bucket public « releases » de Supabase. Plus besoin d'envoyer un
|
||||
// .exe à chaque correctif : le membre clique sur « Installer », l'app
|
||||
// se met à jour et redémarre.
|
||||
//
|
||||
// Le paquet est signé : Tauri refuse toute mise à jour qui ne vient pas
|
||||
// de la clé privée du projet. Un fichier déposé par erreur — ou par
|
||||
// quelqu'un d'autre — sur le bucket ne sera jamais installé.
|
||||
// =====================================================================
|
||||
|
||||
/** Tourne-t-on dans l'application desktop (et pas dans un navigateur) ? */
|
||||
export function isDesktop(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
}
|
||||
|
||||
export interface AvailableUpdate {
|
||||
version: string
|
||||
/** Notes de version saisies au moment de la publication. */
|
||||
notes: string | null
|
||||
date: string | null
|
||||
/** Lance le téléchargement puis l'installation. `onProgress` : 0 → 100. */
|
||||
install: (onProgress?: (pct: number) => void) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche une mise à jour. Renvoie `null` s'il n'y en a pas, si on n'est
|
||||
* pas dans l'application desktop, ou si le serveur est injoignable —
|
||||
* une panne de mise à jour ne doit jamais gêner le travail en cours.
|
||||
*/
|
||||
export async function checkForUpdate(): Promise<AvailableUpdate | null> {
|
||||
if (!isDesktop()) return null
|
||||
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return null
|
||||
|
||||
return {
|
||||
version: update.version,
|
||||
notes: update.body ?? null,
|
||||
date: update.date ?? null,
|
||||
install: async (onProgress) => {
|
||||
let downloaded = 0
|
||||
let total = 0
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
total = event.data.contentLength ?? 0
|
||||
onProgress?.(0)
|
||||
break
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength
|
||||
// Sans taille annoncée, on ne peut pas calculer de pourcentage :
|
||||
// on laisse la barre en indéterminé plutôt que d'inventer.
|
||||
if (total > 0) onProgress?.(Math.min(99, Math.round((downloaded / total) * 100)))
|
||||
break
|
||||
case 'Finished':
|
||||
onProgress?.(100)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
await relaunch()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Hors ligne, bucket vide, manifeste malformé… : on reste silencieux.
|
||||
console.warn('Vérification de mise à jour impossible :', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Version actuellement installée, pour l'afficher dans l'interface. */
|
||||
export async function currentVersion(): Promise<string | null> {
|
||||
if (!isDesktop()) return null
|
||||
try {
|
||||
const { getVersion } = await import('@tauri-apps/api/app')
|
||||
return await getVersion()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supa } from './supabase'
|
||||
import { Profile } from './types'
|
||||
|
||||
/** Charge la liste des membres (profils) pour les listes d'assignation. */
|
||||
export function useMembers(): { members: Profile[]; byId: (id: string | null) => Profile | undefined } {
|
||||
const [members, setMembers] = useState<Profile[]>([])
|
||||
useEffect(() => {
|
||||
supa()
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.order('full_name')
|
||||
.then(({ data }) => setMembers((data ?? []) as Profile[]))
|
||||
}, [])
|
||||
const byId = (id: string | null): Profile | undefined =>
|
||||
id ? members.find((m) => m.id === id) : undefined
|
||||
return { members, byId }
|
||||
}
|
||||
+5
-1
@@ -5,7 +5,7 @@ import { FeedbackProvider } from './lib/feedback'
|
||||
import { applyTheme, getTheme } from './lib/theme'
|
||||
import './index.css'
|
||||
|
||||
// Applique le thème enregistré avant le premier rendu (évite le flash).
|
||||
// Applique le thème AVANT le premier rendu (évite le flash de couleur).
|
||||
applyTheme(getTheme())
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
@@ -15,3 +15,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</FeedbackProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
// L'affichage de la fenêtre est piloté par App.tsx : il doit attendre de
|
||||
// savoir si l'on démarre en mode installeur (fenêtre sans bordure, plus
|
||||
// petite) ou en mode application.
|
||||
|
||||
+282
-293
@@ -1,167 +1,203 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail, Pencil, Camera } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Crown, Mail, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { MemberRole, Profile, memberPoles } from '../lib/types'
|
||||
import { useQuery } from '../lib/db'
|
||||
import { Pole, POLE_ORDER, POLE_LABELS, memberPoles, plural } from '../lib/types'
|
||||
import {
|
||||
AdminUser,
|
||||
adminListUsers,
|
||||
adminCreateUser,
|
||||
adminDeleteUser,
|
||||
adminListUsers,
|
||||
adminUpdateUser
|
||||
} from '../lib/admin'
|
||||
import { uploadAvatar } from '../lib/avatar'
|
||||
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { PageActions, usePageSubtitle } from '../components/Layout'
|
||||
import { Avatar, AvatarUploader, PoleChips, PoleList } from '../components/people'
|
||||
import {
|
||||
AsyncContent,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Field,
|
||||
IconButton,
|
||||
Input,
|
||||
SearchInput,
|
||||
SkeletonList
|
||||
} from '../components/ui'
|
||||
import { Modal, ModalActions } from '../components/Modal'
|
||||
|
||||
export default function Admin(): JSX.Element {
|
||||
const { profile } = useApp()
|
||||
const { profile, refreshMembers } = 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 [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
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 load = useCallback(() => adminListUsers(), [])
|
||||
const users = useQuery<AdminUser[]>(load, [])
|
||||
|
||||
const toggleRole = async (u: AdminUser, pole: MemberRole): Promise<void> => {
|
||||
const current = memberPoles(u)
|
||||
const roles = current.includes(pole)
|
||||
? current.filter((r) => r !== pole)
|
||||
: [...current, pole]
|
||||
setUsers((list) =>
|
||||
list.map((x) => (x.id === u.id ? { ...x, roles, role: roles[0] ?? 'other' } : x))
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return users.data
|
||||
return users.data.filter(
|
||||
(u) => u.full_name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)
|
||||
)
|
||||
try {
|
||||
await adminUpdateUser(u.id, { roles })
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
load()
|
||||
}
|
||||
}, [users.data, q])
|
||||
|
||||
const admins = users.data.filter((u) => u.is_admin).length
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
await users.reload()
|
||||
await refreshMembers()
|
||||
}
|
||||
|
||||
const toggleAdmin = async (u: AdminUser): Promise<void> => {
|
||||
const ok = await confirm({
|
||||
title: u.is_admin
|
||||
? `Retirer les droits admin à ${u.full_name} ?`
|
||||
: `Donner les droits admin à ${u.full_name} ?`,
|
||||
message: u.is_admin
|
||||
? "Cette personne ne pourra plus créer ni supprimer de comptes."
|
||||
: 'Cette personne pourra créer, modifier et supprimer les comptes de toute l’équipe.',
|
||||
confirmLabel: u.is_admin ? 'Retirer' : 'Donner les droits',
|
||||
danger: u.is_admin
|
||||
})
|
||||
if (!ok) return
|
||||
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)))
|
||||
await refresh()
|
||||
toast(u.is_admin ? 'Droits admin retirés.' : 'Droits admin accordés.', 'success')
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
toast((e as Error).message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
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é.`,
|
||||
message: `${u.full_name} (${u.email}) sera définitivement supprimé. Son travail (tâches, temps, messages) reste dans les projets, mais n'est plus rattaché à personne.`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
confirmLabel: 'Supprimer le compte'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await adminDeleteUser(u.id)
|
||||
setUsers((list) => list.filter((x) => x.id !== u.id))
|
||||
await refresh()
|
||||
toast('Compte supprimé.', 'success')
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
toast((e as Error).message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
usePageSubtitle(`${plural(users.data.length, 'compte')} · ${plural(admins, 'administrateur')}`)
|
||||
|
||||
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>
|
||||
<PageActions>
|
||||
<SearchInput
|
||||
className="hidden w-52 md:block"
|
||||
value={q}
|
||||
onChange={setQ}
|
||||
placeholder="Nom ou email…"
|
||||
/>
|
||||
<Button variant="primary" icon={<UserPlus size={16} />} onClick={() => setCreating(true)}>
|
||||
Créer un compte
|
||||
</Button>
|
||||
</PageActions>
|
||||
|
||||
<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="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<AsyncContent
|
||||
loading={users.loading}
|
||||
error={users.error}
|
||||
onRetry={users.reload}
|
||||
skeleton={<SkeletonList rows={5} />}
|
||||
>
|
||||
{shown.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ShieldCheck size={40} />}
|
||||
title={users.data.length === 0 ? 'Aucun compte' : 'Aucun résultat'}
|
||||
text={
|
||||
users.data.length === 0
|
||||
? "Crée le premier compte pour inviter quelqu'un dans l'équipe."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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">Pôles</th>
|
||||
<th className="px-4 py-3">Admin</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
<thead className="bg-panel2">
|
||||
<tr className="text-left text-xs font-medium uppercase tracking-wide text-subtle">
|
||||
<th scope="col" className="px-4 py-2.5">Membre</th>
|
||||
<th scope="col" className="px-4 py-2.5">Pôles</th>
|
||||
<th scope="col" className="px-4 py-2.5">Rôle</th>
|
||||
<th scope="col" className="px-4 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
{shown.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>}
|
||||
<tr key={u.id} className="border-t border-border hover:bg-hover">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar profile={u} size={32} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-ink">
|
||||
{u.full_name || '—'}
|
||||
{isMe && <span className="ml-1.5 text-xs text-accent">(moi)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 truncate text-xs text-subtle">
|
||||
<Mail size={11} className="shrink-0" /> {u.email}
|
||||
</div>
|
||||
</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">
|
||||
<PoleChips selected={memberPoles(u)} onToggle={(pole) => toggleRole(u, pole)} />
|
||||
{/* Affichage seul : l'édition se fait dans la fiche.
|
||||
Avant, 8 pastilles cliquables PAR LIGNE rendaient
|
||||
le tableau illisible. */}
|
||||
<td className="max-w-xs px-4 py-2.5">
|
||||
<PoleList poles={memberPoles(u)} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-2.5">
|
||||
<button
|
||||
type="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 ${
|
||||
title={
|
||||
isMe
|
||||
? 'Tu ne peux pas retirer ton propre statut'
|
||||
: u.is_admin
|
||||
? 'Retirer les droits admin'
|
||||
: 'Donner les droits admin'
|
||||
}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
u.is_admin
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
? 'bg-amber-500/12 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-panel2 text-muted hover:text-ink'
|
||||
} ${isMe ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
>
|
||||
<Crown size={12} /> {u.is_admin ? 'Admin' : 'Membre'}
|
||||
<Crown size={12} /> {u.is_admin ? 'Administrateur' : 'Membre'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={`Modifier le compte de ${u.full_name}`}
|
||||
icon={<Pencil size={16} />}
|
||||
onClick={() => setEditing(u)}
|
||||
title="Modifier le compte"
|
||||
className="text-muted hover:text-accent"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(u)}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="danger"
|
||||
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>
|
||||
label={
|
||||
isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer le compte'
|
||||
}
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={() => remove(u)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -171,27 +207,25 @@ export default function Admin(): JSX.Element {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AsyncContent>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<CreateUserModal
|
||||
<UserModal
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={async () => {
|
||||
onSaved={async () => {
|
||||
setCreating(false)
|
||||
await load()
|
||||
toast('Compte créé.', 'success')
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditUserModal
|
||||
<UserModal
|
||||
user={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
setEditing(null)
|
||||
await load()
|
||||
toast('Compte mis à jour.', 'success')
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -199,32 +233,61 @@ export default function Admin(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateUserModal({
|
||||
onClose,
|
||||
onCreated
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [roles, setRoles] = useState<MemberRole[]>(['programmer'])
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
// =====================================================================
|
||||
// Création / modification d'un compte — une seule modale pour les deux.
|
||||
// =====================================================================
|
||||
|
||||
const toggleRole = (pole: MemberRole): void =>
|
||||
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
|
||||
function UserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
user?: AdminUser
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const editMode = Boolean(user)
|
||||
|
||||
const [fullName, setFullName] = useState(user?.full_name ?? '')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [roles, setRoles] = useState<Pole[]>(user ? memberPoles(user) : ['programmer'])
|
||||
const [isAdmin, setIsAdmin] = useState(user?.is_admin ?? false)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user?.avatar_url ?? null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggleRole = (p: Pole): void =>
|
||||
setRoles((cur) => (cur.includes(p) ? cur.filter((r) => r !== p) : [...cur, p]))
|
||||
|
||||
/** Mot de passe provisoire lisible, à communiquer au nouveau membre. */
|
||||
const suggestPassword = (): void => {
|
||||
const words = ['pixel', 'sprite', 'shader', 'niveau', 'combo', 'boss', 'quete', 'arcade']
|
||||
const w = words[Math.floor(Math.random() * words.length)]
|
||||
setPassword(`${w}-${Math.floor(1000 + Math.random() * 9000)}`)
|
||||
}
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!email.trim() || password.length < 6) {
|
||||
setError('Email valide et mot de passe (6+ caractères) requis.')
|
||||
return
|
||||
if (!fullName.trim()) return toast('Le nom ne peut pas être vide.', 'error')
|
||||
if (!email.trim() || !email.includes('@')) return toast('Adresse email invalide.', 'error')
|
||||
if (!editMode && password.length < 6) {
|
||||
return toast('Le mot de passe provisoire doit faire au moins 6 caractères.', 'error')
|
||||
}
|
||||
if (editMode && password && password.length < 6) {
|
||||
return toast('Le nouveau mot de passe doit faire au moins 6 caractères.', 'error')
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
if (editMode && user) {
|
||||
const patch: Parameters<typeof adminUpdateUser>[1] = { roles }
|
||||
if (fullName.trim() !== user.full_name) patch.full_name = fullName.trim()
|
||||
if (email.trim() !== user.email) patch.email = email.trim()
|
||||
if (password) patch.password = password
|
||||
if ((avatarUrl ?? null) !== (user.avatar_url ?? null)) patch.avatar_url = avatarUrl
|
||||
await adminUpdateUser(user.id, patch)
|
||||
toast('Compte mis à jour.', 'success')
|
||||
} else {
|
||||
await adminCreateUser({
|
||||
email: email.trim(),
|
||||
password,
|
||||
@@ -232,195 +295,121 @@ function CreateUserModal({
|
||||
roles,
|
||||
is_admin: isAdmin
|
||||
})
|
||||
await onCreated()
|
||||
toast('Compte créé.', 'success')
|
||||
}
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
toast((e as Error).message, 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Créer un compte" onClose={onClose}>
|
||||
<label className="label">Nom complet</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
<Modal
|
||||
title={editMode ? 'Modifier le compte' : 'Créer un compte'}
|
||||
subtitle={editMode ? user?.email : "Le membre pourra se connecter tout de suite."}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<ModalActions onCancel={onClose}>
|
||||
<Button variant="primary" loading={busy} onClick={submit}>
|
||||
{editMode ? 'Enregistrer' : 'Créer le compte'}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{editMode && user && (
|
||||
<Field label="Photo de profil">
|
||||
<AvatarUploader
|
||||
userId={user.id}
|
||||
name={fullName}
|
||||
color={user.avatar_color}
|
||||
avatarUrl={avatarUrl}
|
||||
onChange={setAvatarUrl}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Nom complet" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus={!editMode}
|
||||
placeholder="Alex Martin"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Email" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
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">Pôles (un ou plusieurs)</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
user: AdminUser
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const [fullName, setFullName] = useState(user.full_name)
|
||||
const [email, setEmail] = useState(user.email)
|
||||
const [password, setPassword] = useState('')
|
||||
const [roles, setRoles] = useState<MemberRole[]>(memberPoles(user))
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleRole = (pole: MemberRole): void =>
|
||||
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
|
||||
|
||||
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
setAvatarUrl(await uploadAvatar(file, user.id))
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!fullName.trim()) {
|
||||
setError('Le nom ne peut pas être vide.')
|
||||
return
|
||||
}
|
||||
if (!email.trim()) {
|
||||
setError('Un email valide est requis.')
|
||||
return
|
||||
}
|
||||
if (password && password.length < 6) {
|
||||
setError('Le mot de passe doit faire au moins 6 caractères.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const patch: Parameters<typeof adminUpdateUser>[1] = { roles }
|
||||
if (fullName.trim() !== user.full_name) patch.full_name = fullName.trim()
|
||||
if (email.trim() !== user.email) patch.email = email.trim()
|
||||
if (password) patch.password = password
|
||||
if ((avatarUrl ?? null) !== (user.avatar_url ?? null)) patch.avatar_url = avatarUrl
|
||||
await adminUpdateUser(user.id, patch)
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Modifier le compte" onClose={onClose}>
|
||||
<label className="label">Photo de profil</label>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
profile={{ full_name: fullName || '?', avatar_color: user.avatar_color, avatar_url: avatarUrl } as Profile}
|
||||
size={56}
|
||||
/>
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<Loader2 className="animate-spin text-white" size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
|
||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
className="text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={editMode ? 'Nouveau mot de passe' : 'Mot de passe provisoire'}
|
||||
required={!editMode}
|
||||
hint={
|
||||
editMode
|
||||
? 'Laisse vide pour ne pas le changer.'
|
||||
: 'À communiquer au membre : il pourra le modifier depuis « Mon profil ».'
|
||||
}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="label">Nom complet</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label className="label">Nouveau mot de passe</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
{({ id }) => (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
placeholder="laisser vide pour ne pas changer"
|
||||
autoComplete="new-password"
|
||||
placeholder={editMode ? 'inchangé' : 'au moins 6 caractères'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<label className="label">Pôles (un ou plusieurs)</label>
|
||||
<div className="mb-4">
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
<Button onClick={suggestPassword}>Générer</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||
<Field
|
||||
label="Pôles"
|
||||
hint="Détermine les notifications reçues et les filtres. Un membre peut en avoir plusieurs."
|
||||
>
|
||||
<PoleChips selected={roles} onToggle={toggleRole} choices={POLE_ORDER} />
|
||||
</Field>
|
||||
|
||||
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
|
||||
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
{!editMode && (
|
||||
<Checkbox
|
||||
checked={isAdmin}
|
||||
onChange={setIsAdmin}
|
||||
label={
|
||||
<span>
|
||||
Donner les droits administrateur
|
||||
<span className="block text-xs text-subtle">
|
||||
Peut créer, modifier et supprimer les comptes de l'équipe.
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roles.length === 0 && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Sans pôle, ce membre ne recevra aucune notification d'équipe.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-subtle">
|
||||
Pôles disponibles : {POLE_ORDER.map((p) => POLE_LABELS[p]).join(', ')}.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
+486
-244
@@ -1,61 +1,151 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useMembers } from '../lib/useMembers'
|
||||
import { notifyPole } from '../lib/notify'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bug as BugIcon,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
UserCheck,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { rows, useQuery, useRealtime } from '../lib/db'
|
||||
import { NONE, matchesAny, matchesOne, readList, readText } from '../lib/filters'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { usePageSubtitle, PageActions } from '../components/Layout'
|
||||
import {
|
||||
ACTIVE_BUG_STATUSES,
|
||||
Bug,
|
||||
BugSeverity,
|
||||
BugStatus,
|
||||
Priority,
|
||||
Pole,
|
||||
SEVERITY_LABELS,
|
||||
BUG_STATUS_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
BUG_STATUS_ORDER,
|
||||
BUG_STATUS_TONE,
|
||||
POLE_LABELS,
|
||||
poleColor
|
||||
POLE_ORDER,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_ORDER,
|
||||
SEVERITY_WEIGHT,
|
||||
TimeLog,
|
||||
bugStatusBadge,
|
||||
formatMinutes,
|
||||
memberPoles,
|
||||
normalizeBugs,
|
||||
plural,
|
||||
poleDot,
|
||||
polesLabel,
|
||||
severityBadge,
|
||||
severityDot
|
||||
} from '../lib/types'
|
||||
import { Avatar, Modal, Select, EmptyState, PriorityBadge, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { TONE_DOT } from '../lib/tones'
|
||||
import {
|
||||
AsyncContent,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
FilterChip,
|
||||
IconButton,
|
||||
SearchInput,
|
||||
SkeletonList
|
||||
} from '../components/ui'
|
||||
import { MultiOption, MultiSelect } from '../components/MultiSelect'
|
||||
import { Avatar, AvatarStack, PoleList } from '../components/people'
|
||||
import BugModal from '../components/BugModal'
|
||||
|
||||
const SEVERITY_STYLES: Record<BugSeverity, string> = {
|
||||
low: 'bg-slate-100 text-slate-600',
|
||||
medium: 'bg-amber-100 text-amber-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-rose-100 text-rose-700'
|
||||
type SortKey = 'severity' | 'status' | 'created_at' | 'title'
|
||||
|
||||
const FILTER_KEY = 'gamedev-tracker-bug-filters'
|
||||
|
||||
/** Chaque filtre accepte plusieurs valeurs (cf. Kanban). */
|
||||
interface Filters {
|
||||
q: string
|
||||
/** Vide = bugs actifs uniquement (le réglage par défaut, le plus utile). */
|
||||
statuses: string[]
|
||||
severities: string[]
|
||||
poles: string[]
|
||||
assignees: string[]
|
||||
sort: SortKey
|
||||
desc: boolean
|
||||
}
|
||||
const STATUS_STYLES: Record<BugStatus, string> = {
|
||||
open: 'bg-rose-100 text-rose-700',
|
||||
in_progress: 'bg-sky-100 text-sky-700',
|
||||
resolved: 'bg-emerald-100 text-emerald-700',
|
||||
closed: 'bg-slate-100 text-slate-500'
|
||||
|
||||
const EMPTY: Filters = {
|
||||
q: '',
|
||||
statuses: [],
|
||||
severities: [],
|
||||
poles: [],
|
||||
assignees: [],
|
||||
sort: 'severity',
|
||||
desc: true
|
||||
}
|
||||
|
||||
const SORTS: SortKey[] = ['severity', 'status', 'created_at', 'title']
|
||||
|
||||
function loadFilters(): Filters {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(FILTER_KEY) ?? '{}') as Record<string, unknown>
|
||||
const legacyStatus = raw.status === 'active' ? undefined : raw.status
|
||||
return {
|
||||
q: readText(raw.q),
|
||||
statuses: readList(raw.statuses, legacyStatus),
|
||||
severities: readList(raw.severities, raw.severity),
|
||||
poles: readList(raw.poles, raw.pole),
|
||||
assignees: readList(raw.assignees, raw.assignee),
|
||||
sort: SORTS.includes(raw.sort as SortKey) ? (raw.sort as SortKey) : 'severity',
|
||||
desc: raw.desc !== false
|
||||
}
|
||||
} catch {
|
||||
return EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
function countFilters(f: Filters): number {
|
||||
return f.statuses.length + f.severities.length + f.poles.length + f.assignees.length
|
||||
}
|
||||
|
||||
export default function Bugs(): JSX.Element {
|
||||
const { currentProject, profile, focusEntity, setFocusEntity } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
const [bugs, setBugs] = useState<Bug[]>([])
|
||||
const [filter, setFilter] = useState<BugStatus | 'all'>('all')
|
||||
const [editing, setEditing] = useState<Bug | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const { currentProject, profile, members, memberById, focusEntity, setFocusEntity } = useApp()
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const { data } = await supa()
|
||||
.from('bugs')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
setBugs((data ?? []) as Bug[])
|
||||
}, [projectId])
|
||||
const [editing, setEditing] = useState<Bug | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [filters, setFilters] = useState<Filters>(loadFilters)
|
||||
const [showFilters, setShowFilters] = useState(() => countFilters(loadFilters()) > 0)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
localStorage.setItem(FILTER_KEY, JSON.stringify(filters))
|
||||
}, [filters])
|
||||
|
||||
// ------------------------------------------------------------------ data
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (!projectId) return { bugs: [] as Bug[], times: {} as Record<string, number> }
|
||||
const list = await rows<Bug>(
|
||||
supa().from('bugs').select('*').eq('project_id', projectId).order('created_at', { ascending: false })
|
||||
).then(normalizeBugs)
|
||||
const logs = await rows<Pick<TimeLog, 'bug_id' | 'minutes'>>(
|
||||
supa().from('time_logs').select('bug_id, minutes').eq('project_id', projectId)
|
||||
)
|
||||
const times: Record<string, number> = {}
|
||||
for (const l of logs) if (l.bug_id) times[l.bug_id] = (times[l.bug_id] ?? 0) + l.minutes
|
||||
return { bugs: list, times }
|
||||
}, [projectId])
|
||||
|
||||
const q = useQuery(fetchAll, { bugs: [] as Bug[], times: {} as Record<string, number> }, {
|
||||
enabled: Boolean(projectId)
|
||||
})
|
||||
const { bugs, times } = q.data
|
||||
|
||||
// Temps réel — il manquait complètement sur cette page.
|
||||
useRealtime(
|
||||
projectId ? `bugs-${projectId}` : null,
|
||||
[
|
||||
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'time_logs', filter: `project_id=eq.${projectId}` }
|
||||
],
|
||||
q.reload
|
||||
)
|
||||
|
||||
// Ouverture d'un bug demandé par une notification.
|
||||
useEffect(() => {
|
||||
if (focusEntity?.kind !== 'bug') return
|
||||
const b = bugs.find((x) => x.id === focusEntity.id)
|
||||
@@ -65,253 +155,405 @@ export default function Bugs(): JSX.Element {
|
||||
}
|
||||
}, [focusEntity, bugs, setFocusEntity])
|
||||
|
||||
const shown = bugs.filter((b) => filter === 'all' || b.status === filter)
|
||||
const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
const fresh = bugs.find((b) => b.id === editing.id)
|
||||
if (fresh && fresh.updated_at !== editing.updated_at) setEditing(fresh)
|
||||
}, [bugs, editing])
|
||||
|
||||
// -------------------------------------------------------------- filtrage
|
||||
const shown = useMemo(() => {
|
||||
const needle = filters.q.trim().toLowerCase()
|
||||
const list = bugs.filter((b) => {
|
||||
// Aucun statut coché = on ne montre que les bugs encore à traiter.
|
||||
if (filters.statuses.length === 0) {
|
||||
if (!ACTIVE_BUG_STATUSES.includes(b.status)) return false
|
||||
} else if (!filters.statuses.includes(b.status)) return false
|
||||
|
||||
if (!matchesOne(filters.severities, b.severity)) return false
|
||||
if (!matchesAny(filters.poles, b.poles)) return false
|
||||
if (!matchesAny(filters.assignees, b.assignee_ids)) return false
|
||||
if (needle && !`${b.title} ${b.description ?? ''}`.toLowerCase().includes(needle)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const dir = filters.desc ? -1 : 1
|
||||
return list.sort((a, b) => {
|
||||
switch (filters.sort) {
|
||||
case 'severity':
|
||||
return (SEVERITY_WEIGHT[a.severity] - SEVERITY_WEIGHT[b.severity]) * dir
|
||||
case 'status':
|
||||
return (BUG_STATUS_ORDER.indexOf(a.status) - BUG_STATUS_ORDER.indexOf(b.status)) * -dir
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title, 'fr') * -dir
|
||||
default:
|
||||
return a.created_at.localeCompare(b.created_at) * dir
|
||||
}
|
||||
})
|
||||
}, [bugs, filters])
|
||||
|
||||
const openCount = bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status)).length
|
||||
const criticalCount = bugs.filter(
|
||||
(b) => b.severity === 'critical' && ACTIVE_BUG_STATUSES.includes(b.status)
|
||||
).length
|
||||
|
||||
const activeFilters = countFilters(filters)
|
||||
|
||||
// --------------------------------------------------------- options de filtre
|
||||
const memberOptions = useMemo<MultiOption[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Non assigné', pinned: true },
|
||||
...members.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.full_name,
|
||||
hint: polesLabel(memberPoles(m)),
|
||||
leading: <Avatar profile={m} size={20} />
|
||||
}))
|
||||
],
|
||||
[members]
|
||||
)
|
||||
|
||||
const poleOptions = useMemo<MultiOption[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Sans pôle', pinned: true },
|
||||
...POLE_ORDER.map((p) => ({
|
||||
value: p as string,
|
||||
label: POLE_LABELS[p],
|
||||
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${poleDot(p)}`} />
|
||||
}))
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const severityOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
SEVERITY_ORDER.map((sv) => ({
|
||||
value: sv as string,
|
||||
label: SEVERITY_LABELS[sv],
|
||||
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${severityDot(sv)}`} />
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const statusOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
BUG_STATUS_ORDER.map((st) => ({
|
||||
value: st as string,
|
||||
label: BUG_STATUS_LABELS[st],
|
||||
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${TONE_DOT[BUG_STATUS_TONE[st]]}`} />
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const removeFilter = (
|
||||
key: 'statuses' | 'severities' | 'poles' | 'assignees',
|
||||
value: string
|
||||
): void => setFilters((f) => ({ ...f, [key]: f[key].filter((v) => v !== value) }))
|
||||
|
||||
const onlyMine = Boolean(
|
||||
profile && filters.assignees.length === 1 && filters.assignees[0] === profile.id
|
||||
)
|
||||
const toggleMine = (): void => {
|
||||
if (!profile) return
|
||||
setFilters((f) => ({ ...f, assignees: onlyMine ? [] : [profile.id] }))
|
||||
setShowFilters(true)
|
||||
}
|
||||
|
||||
const toggleSort = (key: SortKey): void =>
|
||||
setFilters((f) => (f.sort === key ? { ...f, desc: !f.desc } : { ...f, sort: key, desc: true }))
|
||||
|
||||
usePageSubtitle(
|
||||
projectId
|
||||
? `${currentProject?.name} · ${plural(openCount, 'bug actif', 'bugs actifs')}${
|
||||
criticalCount ? ` · ${criticalCount} bloquant${criticalCount > 1 ? 's' : ''}` : ''
|
||||
}`
|
||||
: null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Bugs" subtitle={`${openCount} bug(s) actif(s)`}>
|
||||
<Select
|
||||
className="!w-auto"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as BugStatus | 'all')}
|
||||
>
|
||||
<option value="all">Tous les statuts</option>
|
||||
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<button className="btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={16} /> Signaler un bug
|
||||
</button>
|
||||
</PageHeader>
|
||||
<PageActions>
|
||||
<SearchInput
|
||||
className="hidden w-52 md:block"
|
||||
value={filters.q}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
|
||||
placeholder="Filtrer les bugs…"
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Filtres"
|
||||
active={showFilters || activeFilters > 0}
|
||||
icon={<SlidersHorizontal size={17} />}
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
/>
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={() => setCreating(true)}>
|
||||
Signaler un bug
|
||||
</Button>
|
||||
</PageActions>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{showFilters && (
|
||||
<div className="flex flex-col gap-1.5 border-b border-border bg-panel px-6 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SearchInput
|
||||
className="w-44 md:hidden"
|
||||
value={filters.q}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
|
||||
placeholder="Filtrer…"
|
||||
/>
|
||||
|
||||
{profile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMine}
|
||||
aria-pressed={onlyMine}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
onlyMine
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border bg-panel2 text-muted hover:border-border-strong hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<UserCheck size={13} /> Mes bugs
|
||||
</button>
|
||||
)}
|
||||
|
||||
<MultiSelect
|
||||
label="Membres"
|
||||
options={memberOptions}
|
||||
selected={filters.assignees}
|
||||
onChange={(assignees) => setFilters((f) => ({ ...f, assignees }))}
|
||||
emptyLabel="Aucun membre."
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Pôles"
|
||||
options={poleOptions}
|
||||
selected={filters.poles}
|
||||
onChange={(poles) => setFilters((f) => ({ ...f, poles }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Sévérités"
|
||||
options={severityOptions}
|
||||
selected={filters.severities}
|
||||
onChange={(severities) => setFilters((f) => ({ ...f, severities }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Statuts"
|
||||
options={statusOptions}
|
||||
selected={filters.statuses}
|
||||
onChange={(statuses) => setFilters((f) => ({ ...f, statuses }))}
|
||||
/>
|
||||
|
||||
{(activeFilters > 0 || filters.q) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters({ ...EMPTY, sort: filters.sort, desc: filters.desc })}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
|
||||
>
|
||||
<X size={12} /> Tout réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{filters.assignees.map((id) => (
|
||||
<FilterChip key={`a-${id}`} onRemove={() => removeFilter('assignees', id)}>
|
||||
{id === NONE ? 'Non assigné' : (memberById(id)?.full_name ?? 'Inconnu')}
|
||||
</FilterChip>
|
||||
))}
|
||||
{filters.poles.map((pl) => (
|
||||
<FilterChip key={`p-${pl}`} onRemove={() => removeFilter('poles', pl)}>
|
||||
{pl === NONE ? 'Sans pôle' : POLE_LABELS[pl as keyof typeof POLE_LABELS]}
|
||||
</FilterChip>
|
||||
))}
|
||||
{filters.severities.map((sv) => (
|
||||
<FilterChip key={`s-${sv}`} onRemove={() => removeFilter('severities', sv)}>
|
||||
{SEVERITY_LABELS[sv as keyof typeof SEVERITY_LABELS]}
|
||||
</FilterChip>
|
||||
))}
|
||||
{filters.statuses.map((st) => (
|
||||
<FilterChip key={`st-${st}`} onRemove={() => removeFilter('statuses', st)}>
|
||||
{BUG_STATUS_LABELS[st as keyof typeof BUG_STATUS_LABELS]}
|
||||
</FilterChip>
|
||||
))}
|
||||
<span className="text-[11px] text-subtle">
|
||||
{filters.statuses.length === 0 && 'Bugs actifs uniquement · '}
|
||||
{shown.length} sur {bugs.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<AsyncContent
|
||||
loading={q.loading}
|
||||
error={q.error}
|
||||
onRetry={q.reload}
|
||||
skeleton={<SkeletonList rows={6} />}
|
||||
>
|
||||
{shown.length === 0 ? (
|
||||
<EmptyState icon={<BugIcon size={40} />} text="Aucun bug. 🎉" />
|
||||
bugs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<CheckCircle2 size={40} />}
|
||||
title="Aucun bug signalé"
|
||||
text="Quand quelque chose casse, signale-le ici avec une capture d'écran : l'équipe saura quoi reproduire."
|
||||
action={
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={() => setCreating(true)}>
|
||||
Signaler un bug
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-separate border-spacing-y-2 text-sm">
|
||||
<thead className="text-left text-xs uppercase text-subtle">
|
||||
<EmptyState
|
||||
icon={<BugIcon size={40} />}
|
||||
title="Aucun bug ne correspond aux filtres"
|
||||
text={`${bugs.length} bug(s) au total dans ce projet.`}
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
setFilters({ ...EMPTY, statuses: [...BUG_STATUS_ORDER], sort: filters.sort, desc: filters.desc })
|
||||
}
|
||||
>
|
||||
Afficher tous les bugs
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-panel2">
|
||||
<tr>
|
||||
<th className="px-3">Bug</th>
|
||||
<th className="px-3">Pôle</th>
|
||||
<th className="px-3">Sévérité</th>
|
||||
<th className="px-3">Priorité</th>
|
||||
<th className="px-3">Statut</th>
|
||||
<th className="px-3">Assigné</th>
|
||||
<SortHeader label="Bug" sortKey="title" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
|
||||
<SortHeader label="Sévérité" sortKey="severity" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
|
||||
<SortHeader label="Statut" sortKey="status" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
|
||||
<SortHeader label="Pôle" />
|
||||
<SortHeader label="Assigné" />
|
||||
<SortHeader label="Temps" />
|
||||
<SortHeader label="Signalé" sortKey="created_at" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((b) => (
|
||||
{shown.map((b) => {
|
||||
const mins = times[b.id] ?? 0
|
||||
const done = b.status === 'resolved' || b.status === 'closed'
|
||||
return (
|
||||
<tr
|
||||
key={b.id}
|
||||
className="cursor-pointer border border-border bg-panel shadow-sm hover:bg-panel2"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => setEditing(b)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setEditing(b)
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer border-t border-border transition-colors hover:bg-hover"
|
||||
>
|
||||
<td className="rounded-l-lg px-3 py-3 font-medium text-ink">{b.title}</td>
|
||||
<td className="max-w-[22rem] px-3 py-2.5">
|
||||
<span
|
||||
className={`block truncate font-medium ${done ? 'text-muted' : 'text-ink'}`}
|
||||
>
|
||||
{b.title}
|
||||
</span>
|
||||
{b.description && (
|
||||
<span className="block truncate text-xs text-subtle">{b.description}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3">
|
||||
{b.pole ? (
|
||||
<span className={`badge ${poleColor(b.pole)}`}>{POLE_LABELS[b.pole]}</span>
|
||||
<Badge className={severityBadge(b.severity)}>
|
||||
{SEVERITY_LABELS[b.severity]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<Badge className={bugStatusBadge(b.status)}>
|
||||
{BUG_STATUS_LABELS[b.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<PoleList poles={b.poles} />
|
||||
</td>
|
||||
<td className="px-3">
|
||||
{b.assignee_ids.length ? (
|
||||
<AvatarStack ids={b.assignee_ids} resolve={memberById} size={26} />
|
||||
) : (
|
||||
<span className="text-xs text-subtle">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
|
||||
{SEVERITY_LABELS[b.severity]}
|
||||
{mins > 0 ? (
|
||||
<span className="flex items-center gap-1 text-xs text-accent2 tabular">
|
||||
<Clock size={12} />
|
||||
{formatMinutes(mins)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-subtle">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<PriorityBadge priority={b.priority} />
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<span className={`badge ${STATUS_STYLES[b.status]}`}>
|
||||
{BUG_STATUS_LABELS[b.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-r-lg px-3">
|
||||
<Avatar profile={byId(b.assignee_id)} size={26} />
|
||||
<td className="whitespace-nowrap px-3 text-xs text-subtle">
|
||||
{format(new Date(b.created_at), 'd MMM', { locale: fr })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AsyncContent>
|
||||
</div>
|
||||
|
||||
{(creating || editing) && (
|
||||
{(creating || editing) && projectId && (
|
||||
<BugModal
|
||||
bug={editing}
|
||||
projectId={projectId!}
|
||||
reporterId={profile?.id ?? null}
|
||||
members={members}
|
||||
projectId={projectId}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={load}
|
||||
onSaved={q.reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BugModal({
|
||||
bug,
|
||||
projectId,
|
||||
reporterId,
|
||||
members,
|
||||
onClose,
|
||||
onSaved
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** En-tête de colonne, triable ou non. Défini au niveau du module : un
|
||||
* composant recréé à chaque rendu remonterait le tableau entier. */
|
||||
function SortHeader({
|
||||
label,
|
||||
sortKey,
|
||||
sort,
|
||||
desc,
|
||||
onSort
|
||||
}: {
|
||||
bug: Bug | null
|
||||
projectId: string
|
||||
reporterId: string | null
|
||||
members: ReturnType<typeof useMembers>['members']
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
label: string
|
||||
sortKey?: SortKey
|
||||
sort?: SortKey
|
||||
desc?: boolean
|
||||
onSort?: (k: SortKey) => void
|
||||
}): JSX.Element {
|
||||
const [title, setTitle] = useState(bug?.title ?? '')
|
||||
const [description, setDescription] = useState(bug?.description ?? '')
|
||||
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
|
||||
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
|
||||
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
|
||||
const [pole, setPole] = useState<Pole | ''>(bug?.pole ?? '')
|
||||
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!title.trim()) return
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
severity,
|
||||
status,
|
||||
priority,
|
||||
pole: pole || null,
|
||||
assignee_id: assignee || null
|
||||
}
|
||||
|
||||
const poleChanged = pole && (!bug || bug.pole !== pole)
|
||||
let entityId = bug?.id ?? null
|
||||
|
||||
if (bug) {
|
||||
await supa().from('bugs').update(payload).eq('id', bug.id)
|
||||
} else {
|
||||
const { data } = await supa()
|
||||
.from('bugs')
|
||||
.insert({ ...payload, reporter_id: reporterId })
|
||||
.select('id')
|
||||
.single()
|
||||
entityId = (data as { id: string } | null)?.id ?? null
|
||||
}
|
||||
|
||||
if (poleChanged && pole && entityId) {
|
||||
await notifyPole({
|
||||
projectId,
|
||||
pole,
|
||||
kind: 'bug',
|
||||
title: 'Nouveau bug',
|
||||
body: title.trim(),
|
||||
entityId,
|
||||
actorId: reporterId,
|
||||
members
|
||||
})
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!bug || !confirm('Supprimer ce bug ?')) return
|
||||
await supa().from('bugs').delete().eq('id', bug.id)
|
||||
await onSaved()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={bug ? 'Modifier le bug' : 'Signaler un bug'} onClose={onClose} wide>
|
||||
<label className="label">Titre</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Le joueur traverse le mur en sautant"
|
||||
/>
|
||||
<label className="label">Description / étapes pour reproduire</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">Sévérité</label>
|
||||
<Select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)}>
|
||||
{Object.entries(SEVERITY_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</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">Statut</label>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value as BugStatus)}>
|
||||
{Object.entries(BUG_STATUS_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 mt-3">Pôle</label>
|
||||
<PoleChips
|
||||
selected={pole ? [pole] : []}
|
||||
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{bug ? (
|
||||
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
|
||||
<Trash2 size={16} /> Supprimer
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={
|
||||
sortKey && sort === sortKey ? (desc ? 'descending' : 'ascending') : undefined
|
||||
}
|
||||
className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-subtle"
|
||||
>
|
||||
{sortKey && onSort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(sortKey)}
|
||||
className="inline-flex items-center gap-1 hover:text-ink"
|
||||
>
|
||||
{label}
|
||||
{sort === sortKey && (desc ? <ArrowDown size={12} /> : <ArrowUp size={12} />)}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
label
|
||||
)}
|
||||
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
|
||||
{bug ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
+534
-129
@@ -1,198 +1,603 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { KanbanSquare, Bug as BugIcon, Clock, CheckCircle2, CalendarClock } from 'lucide-react'
|
||||
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import {
|
||||
Task,
|
||||
differenceInCalendarDays,
|
||||
endOfWeek,
|
||||
format,
|
||||
isPast,
|
||||
startOfWeek,
|
||||
subDays
|
||||
} from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bug as BugIcon,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
KanbanSquare,
|
||||
MessageSquare,
|
||||
PartyPopper
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { rows, useQuery, useRealtime } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { usePageSubtitle } from '../components/Layout'
|
||||
import {
|
||||
ACTIVE_BUG_STATUSES,
|
||||
Bug,
|
||||
TimeLog,
|
||||
TASK_STATUS_ORDER,
|
||||
BugSeverity,
|
||||
Comment,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_WEIGHT,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_ORDER,
|
||||
SEVERITY_TONE,
|
||||
Task,
|
||||
TASK_STATUS_LABELS,
|
||||
TaskStatus,
|
||||
formatMinutes
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_TONE,
|
||||
TimeLog,
|
||||
formatMinutes,
|
||||
normalizeBugs,
|
||||
normalizeTasks,
|
||||
plural,
|
||||
priorityBadge,
|
||||
taskStatusDot
|
||||
} from '../lib/types'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { Stagger, staggerItem } from '../components/motion'
|
||||
import { TONE_DOT, TONE_TEXT } from '../lib/tones'
|
||||
import { Avatar } from '../components/people'
|
||||
import { AsyncContent, Badge, ProgressBar, Skeleton } from '../components/ui'
|
||||
import { staggerItem, Stagger } from '../components/motion'
|
||||
|
||||
const COL_COLORS: Record<TaskStatus, string> = {
|
||||
todo: 'bg-slate-400',
|
||||
in_progress: 'bg-sky-500',
|
||||
review: 'bg-amber-500',
|
||||
done: 'bg-emerald-500'
|
||||
}
|
||||
const DAY = (d: Date): string => format(d, 'yyyy-MM-dd')
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const { currentProject } = useApp()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [bugs, setBugs] = useState<Bug[]>([])
|
||||
const [totalMinutes, setTotalMinutes] = useState(0)
|
||||
const { currentProject, profile, memberById, setPage, setFocusEntity } = useApp()
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const [{ data: t }, { data: b }] = await Promise.all([
|
||||
supa().from('tasks').select('*').eq('project_id', projectId),
|
||||
supa().from('bugs').select('*').eq('project_id', projectId)
|
||||
])
|
||||
const taskList = (t ?? []) as Task[]
|
||||
setTasks(taskList)
|
||||
setBugs((b ?? []) as Bug[])
|
||||
const weekStart = useMemo(() => startOfWeek(new Date(), { weekStartsOn: 1 }), [])
|
||||
const weekEnd = useMemo(() => endOfWeek(new Date(), { weekStartsOn: 1 }), [])
|
||||
|
||||
if (taskList.length) {
|
||||
const { data: tl } = await supa()
|
||||
.from('time_logs')
|
||||
.select('minutes, task_id')
|
||||
.in(
|
||||
'task_id',
|
||||
taskList.map((x) => x.id)
|
||||
)
|
||||
setTotalMinutes(
|
||||
((tl ?? []) as Pick<TimeLog, 'minutes'>[]).reduce((s, l) => s + l.minutes, 0)
|
||||
)
|
||||
} else {
|
||||
setTotalMinutes(0)
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
return { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[], comments: [] as Comment[] }
|
||||
}
|
||||
const [tasks, bugs, logs, comments] = await Promise.all([
|
||||
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
|
||||
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
|
||||
rows<TimeLog>(supa().from('time_logs').select('*').eq('project_id', projectId)),
|
||||
rows<Comment>(
|
||||
supa()
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(6)
|
||||
)
|
||||
])
|
||||
return { tasks, bugs, logs, comments }
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
const q = useQuery(
|
||||
fetchAll,
|
||||
{ tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[], comments: [] as Comment[] },
|
||||
{ enabled: Boolean(projectId) }
|
||||
)
|
||||
const { tasks, bugs, logs, comments } = q.data
|
||||
|
||||
// Le tableau de bord n'avait AUCUNE synchro : ses chiffres étaient périmés
|
||||
// en permanence dès qu'un coéquipier travaillait.
|
||||
useRealtime(
|
||||
projectId ? `dashboard-${projectId}` : null,
|
||||
[
|
||||
{ table: 'tasks', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'time_logs', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'comments', filter: `project_id=eq.${projectId}` }
|
||||
],
|
||||
q.reload
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------ calculs
|
||||
const me = profile?.id
|
||||
|
||||
const doneTasks = tasks.filter((t) => t.status === 'done').length
|
||||
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
|
||||
const openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
|
||||
const upcoming = tasks
|
||||
|
||||
const activeBugs = bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status))
|
||||
const blockers = activeBugs.filter((b) => b.severity === 'critical').length
|
||||
|
||||
const weekLogs = useMemo(
|
||||
() => logs.filter((l) => l.logged_at >= DAY(weekStart) && l.logged_at <= DAY(weekEnd)),
|
||||
[logs, weekStart, weekEnd]
|
||||
)
|
||||
const weekMinutes = weekLogs.reduce((s, l) => s + l.minutes, 0)
|
||||
const myWeekMinutes = weekLogs.filter((l) => l.user_id === me).reduce((s, l) => s + l.minutes, 0)
|
||||
const totalMinutes = logs.reduce((s, l) => s + l.minutes, 0)
|
||||
|
||||
/** Ce que J'AI à faire — la question à laquelle le tableau de bord doit répondre. */
|
||||
const myTasks = useMemo(() => {
|
||||
if (!me) return []
|
||||
return tasks
|
||||
.filter((t) => t.status !== 'done' && t.assignee_ids.includes(me))
|
||||
.sort((a, b) => {
|
||||
const ad = a.due_date ?? '9999'
|
||||
const bd = b.due_date ?? '9999'
|
||||
if (ad !== bd) return ad.localeCompare(bd)
|
||||
return PRIORITY_WEIGHT[b.priority] - PRIORITY_WEIGHT[a.priority]
|
||||
})
|
||||
}, [tasks, me])
|
||||
|
||||
const myBugs = useMemo(
|
||||
() => (me ? activeBugs.filter((b) => b.assignee_ids.includes(me)) : []),
|
||||
[activeBugs, me]
|
||||
)
|
||||
|
||||
/** Échéances : en retard d'abord, puis les 7 prochains jours. */
|
||||
const deadlines = useMemo(
|
||||
() =>
|
||||
tasks
|
||||
.filter((t) => t.status !== 'done' && t.due_date)
|
||||
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
|
||||
.slice(0, 5)
|
||||
.slice(0, 6),
|
||||
[tasks]
|
||||
)
|
||||
const overdueCount = deadlines.filter(
|
||||
(t) => t.due_date && isPast(new Date(`${t.due_date}T23:59:59`))
|
||||
).length
|
||||
|
||||
/** Temps de la semaine par membre, du plus au moins actif. */
|
||||
const weekByMember = useMemo(() => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const l of weekLogs) totals.set(l.user_id ?? '', (totals.get(l.user_id ?? '') ?? 0) + l.minutes)
|
||||
return [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
}, [weekLogs])
|
||||
const topWeek = weekByMember[0]?.[1] ?? 0
|
||||
|
||||
const severityCounts = useMemo(() => {
|
||||
const map = {} as Record<BugSeverity, number>
|
||||
for (const s of SEVERITY_ORDER) map[s] = activeBugs.filter((b) => b.severity === s).length
|
||||
return map
|
||||
}, [activeBugs])
|
||||
|
||||
/** Rythme des 14 derniers jours (mini-histogramme du temps saisi). */
|
||||
const sparkline = useMemo(() => {
|
||||
const days: { day: string; minutes: number }[] = []
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = subDays(new Date(), i)
|
||||
const key = DAY(d)
|
||||
days.push({ day: key, minutes: logs.filter((l) => l.logged_at === key).reduce((s, l) => s + l.minutes, 0) })
|
||||
}
|
||||
return days
|
||||
}, [logs])
|
||||
const sparkMax = Math.max(...sparkline.map((d) => d.minutes), 60)
|
||||
|
||||
const openTask = (id: string): void => {
|
||||
setFocusEntity({ kind: 'task', id })
|
||||
setPage('kanban')
|
||||
}
|
||||
const openBug = (id: string): void => {
|
||||
setFocusEntity({ kind: 'bug', id })
|
||||
setPage('bugs')
|
||||
}
|
||||
|
||||
usePageSubtitle(
|
||||
projectId
|
||||
? `${currentProject?.name} · ${plural(tasks.length, 'tâche')} · ${formatMinutes(totalMinutes)} au total`
|
||||
: null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Tableau de bord" subtitle={currentProject?.name} />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Cartes stats */}
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<AsyncContent
|
||||
loading={q.loading}
|
||||
error={q.error}
|
||||
onRetry={q.reload}
|
||||
skeleton={
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-[76px]" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* ------------------------------ Chiffres clés ------------------------------ */}
|
||||
<Stagger className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard icon={<KanbanSquare />} label="Tâches" value={tasks.length} color="text-accent" />
|
||||
<StatCard
|
||||
icon={<CheckCircle2 />}
|
||||
label="Terminées"
|
||||
value={`${doneTasks} (${pct}%)`}
|
||||
color="text-emerald-600"
|
||||
icon={<KanbanSquare size={20} />}
|
||||
label={myTasks.length ? 'À faire pour moi' : 'Rien pour moi'}
|
||||
value={myTasks.length}
|
||||
hint={myBugs.length ? `+ ${plural(myBugs.length, 'bug')}` : undefined}
|
||||
tone="accent"
|
||||
onClick={() => setPage('kanban')}
|
||||
/>
|
||||
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-600" />
|
||||
<StatCard
|
||||
icon={<Clock />}
|
||||
label="Temps total"
|
||||
value={formatMinutes(totalMinutes)}
|
||||
color="text-accent2"
|
||||
icon={<CheckCircle2 size={20} />}
|
||||
label="Avancement"
|
||||
value={`${pct} %`}
|
||||
hint={`${doneTasks}/${tasks.length} terminées`}
|
||||
tone="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BugIcon size={20} />}
|
||||
label="Bugs actifs"
|
||||
value={activeBugs.length}
|
||||
hint={blockers ? `${blockers} bloquant${blockers > 1 ? 's' : ''}` : undefined}
|
||||
tone={blockers ? 'rose' : 'slate'}
|
||||
onClick={() => setPage('bugs')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={20} />}
|
||||
label="Cette semaine"
|
||||
value={formatMinutes(weekMinutes)}
|
||||
hint={myWeekMinutes ? `dont ${formatMinutes(myWeekMinutes)} par moi` : 'aucune saisie'}
|
||||
tone="teal"
|
||||
onClick={() => setPage('planning')}
|
||||
/>
|
||||
</Stagger>
|
||||
|
||||
{/* Avancement global */}
|
||||
<div className="card mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-ink">Avancement global</h3>
|
||||
<span className="text-sm text-muted">{pct}%</span>
|
||||
{/* ------------------------------ Avancement ------------------------------ */}
|
||||
<section className="card mt-4">
|
||||
<div className="mb-2 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-medium text-ink">Avancement du projet</h2>
|
||||
<span className="text-sm text-muted tabular">{pct} %</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-panel2">
|
||||
<motion.div
|
||||
className="h-full bg-accent2"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Répartition des tâches */}
|
||||
<div className="card">
|
||||
<h3 className="mb-3 font-medium text-ink">Répartition des tâches</h3>
|
||||
<div className="space-y-2">
|
||||
<ProgressBar pct={pct} height="h-2.5" barClassName="bg-accent" label="Avancement du projet" />
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{TASK_STATUS_ORDER.map((s) => {
|
||||
const n = tasks.filter((t) => t.status === s).length
|
||||
const w = tasks.length ? (n / tasks.length) * 100 : 0
|
||||
return (
|
||||
<div key={s}>
|
||||
<div className="mb-1 flex justify-between text-xs text-muted">
|
||||
<span>{TASK_STATUS_LABELS[s]}</span>
|
||||
<span>{n}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-panel2">
|
||||
<div className={`h-full ${COL_COLORS[s]}`} style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setPage('kanban')}
|
||||
className="rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className={`h-2 w-2 rounded-full ${taskStatusDot(s)}`} />
|
||||
{TASK_STATUS_LABELS[s]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-lg font-semibold tabular text-ink">{n}</span>
|
||||
<ProgressBar
|
||||
className="mt-1"
|
||||
height="h-1"
|
||||
pct={tasks.length ? (n / tasks.length) * 100 : 0}
|
||||
barClassName={TONE_DOT[TASK_STATUS_TONE[s]]}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{/* ------------------------------ Mes tâches ------------------------------ */}
|
||||
<section className="card xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink">Mes tâches</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage('kanban')}
|
||||
className="flex items-center gap-1 text-xs text-accent hover:underline"
|
||||
>
|
||||
Voir le Kanban <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prochaines échéances (tâches) */}
|
||||
<div className="card">
|
||||
<h3 className="mb-3 font-medium text-ink">Tâches à échéance</h3>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-subtle">Aucune échéance à venir.</p>
|
||||
{myTasks.length === 0 && myBugs.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<PartyPopper size={28} className="text-subtle" />
|
||||
<p className="text-sm text-muted">
|
||||
Rien ne t'est assigné. Pioche une tâche dans le Kanban.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{upcoming.map((t) => {
|
||||
const overdue = isPast(new Date(t.due_date! + 'T23:59:59'))
|
||||
const d = differenceInCalendarDays(new Date(t.due_date!), new Date())
|
||||
<ul className="space-y-1.5">
|
||||
{myTasks.slice(0, 6).map((t) => {
|
||||
const late = t.due_date && isPast(new Date(`${t.due_date}T23:59:59`))
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
className="flex items-center justify-between rounded-lg bg-panel2 px-3 py-2"
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTask(t.id)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg border border-border bg-panel2/50 px-3 py-2 text-left transition-colors hover:border-border-strong hover:bg-hover"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate text-sm text-ink">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${COL_COLORS[t.status]}`} />
|
||||
<span className="truncate">{t.title}</span>
|
||||
</span>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${taskStatusDot(t.status)}`} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink">{t.title}</span>
|
||||
<Badge className={priorityBadge(t.priority)}>
|
||||
{PRIORITY_LABELS[t.priority]}
|
||||
</Badge>
|
||||
{t.due_date && (
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1 text-xs ${
|
||||
overdue ? 'text-rose-600' : 'text-muted'
|
||||
className={`shrink-0 text-xs tabular ${
|
||||
late ? 'font-medium text-rose-600 dark:text-rose-400' : 'text-subtle'
|
||||
}`}
|
||||
>
|
||||
<CalendarClock size={12} />
|
||||
{format(new Date(t.due_date!), 'd MMM', { locale: fr })}
|
||||
{' · '}
|
||||
{overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
|
||||
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{myBugs.slice(0, 3).map((b) => (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBug(b.id)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg border border-border bg-panel2/50 px-3 py-2 text-left transition-colors hover:border-border-strong hover:bg-hover"
|
||||
>
|
||||
<BugIcon size={13} className="shrink-0 text-rose-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink">{b.title}</span>
|
||||
<Badge className={`${TONE_TEXT[SEVERITY_TONE[b.severity]]} bg-transparent`}>
|
||||
{SEVERITY_LABELS[b.severity]}
|
||||
</Badge>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{myTasks.length > 6 && (
|
||||
<li className="pt-1 text-center text-xs text-subtle">
|
||||
+ {myTasks.length - 6} autre{myTasks.length - 6 > 1 ? 's' : ''}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------ Bugs ------------------------------ */}
|
||||
<section className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink">Bugs par sévérité</h2>
|
||||
{blockers > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-rose-600 dark:text-rose-400">
|
||||
<AlertTriangle size={12} /> {blockers} bloquant{blockers > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeBugs.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<CheckCircle2 size={28} className="text-emerald-500" />
|
||||
<p className="text-sm text-muted">Aucun bug actif. 🎉</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{SEVERITY_ORDER.map((s) => {
|
||||
const n = severityCounts[s]
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage('bugs')}
|
||||
className="block w-full text-left"
|
||||
disabled={n === 0}
|
||||
>
|
||||
<span className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className={n ? TONE_TEXT[SEVERITY_TONE[s]] : 'text-subtle'}>
|
||||
{SEVERITY_LABELS[s]}
|
||||
</span>
|
||||
<span className="text-muted tabular">{n}</span>
|
||||
</span>
|
||||
<ProgressBar
|
||||
height="h-1.5"
|
||||
pct={activeBugs.length ? (n / activeBugs.length) * 100 : 0}
|
||||
barClassName={TONE_DOT[SEVERITY_TONE[s]]}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------ Échéances ------------------------------ */}
|
||||
<section className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink">Échéances</h2>
|
||||
{overdueCount > 0 && (
|
||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
|
||||
{overdueCount} en retard
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{deadlines.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-subtle">Aucune échéance fixée.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{deadlines.map((t) => {
|
||||
const late = isPast(new Date(`${t.due_date}T23:59:59`))
|
||||
const days = differenceInCalendarDays(new Date(t.due_date as string), new Date())
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTask(t.id)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${taskStatusDot(t.status)}`} />
|
||||
<span className="truncate text-sm text-ink">{t.title}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1 text-xs tabular ${
|
||||
late ? 'font-medium text-rose-600 dark:text-rose-400' : 'text-muted'
|
||||
}`}
|
||||
>
|
||||
<CalendarClock size={12} />
|
||||
{late
|
||||
? `${-days} j de retard`
|
||||
: days === 0
|
||||
? "aujourd'hui"
|
||||
: `dans ${days} j`}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------ Temps équipe ------------------------------ */}
|
||||
<section className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink">Temps de l'équipe</h2>
|
||||
<span className="text-xs text-subtle">
|
||||
{format(weekStart, 'd', { locale: fr })}–{format(weekEnd, 'd MMM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
{weekByMember.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-subtle">
|
||||
Aucune heure saisie cette semaine.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{weekByMember.map(([uid, mins]) => (
|
||||
<li key={uid || 'none'} className="flex items-center gap-2">
|
||||
<Avatar profile={memberById(uid)} size={22} />
|
||||
<span className="w-24 shrink-0 truncate text-xs text-ink">
|
||||
{memberById(uid)?.full_name ?? 'Sans membre'}
|
||||
</span>
|
||||
<ProgressBar
|
||||
className="flex-1"
|
||||
height="h-1.5"
|
||||
pct={topWeek ? (mins / topWeek) * 100 : 0}
|
||||
barClassName="bg-accent2"
|
||||
/>
|
||||
<span className="w-16 shrink-0 text-right text-xs text-accent2 tabular">
|
||||
{formatMinutes(mins)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Rythme des 14 derniers jours */}
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<p className="mb-2 text-[11px] uppercase tracking-wide text-subtle">
|
||||
14 derniers jours
|
||||
</p>
|
||||
<div className="flex h-10 items-end gap-1">
|
||||
{sparkline.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
title={`${format(new Date(d.day), 'EEEE d MMM', { locale: fr })} — ${formatMinutes(d.minutes)}`}
|
||||
className="flex-1 rounded-sm bg-accent2/70 transition-all hover:bg-accent2"
|
||||
style={{ height: `${Math.max(4, (d.minutes / sparkMax) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------ Discussions ------------------------------ */}
|
||||
<section className="card xl:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-medium text-ink">Derniers messages</h2>
|
||||
{comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<MessageSquare size={26} className="text-subtle" />
|
||||
<p className="text-sm text-muted">
|
||||
Aucune discussion. Les échanges sur une tâche ou un bug apparaîtront ici.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{comments.map((c) => {
|
||||
const target =
|
||||
c.entity_kind === 'bug'
|
||||
? bugs.find((b) => b.id === c.entity_id)?.title
|
||||
: tasks.find((t) => t.id === c.entity_id)?.title
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
c.entity_kind === 'bug' ? openBug(c.entity_id) : openTask(c.entity_id)
|
||||
}
|
||||
className="flex w-full gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<Avatar profile={memberById(c.author_id)} size={24} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-medium text-ink">
|
||||
{memberById(c.author_id)?.full_name ?? 'Membre'}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-subtle">
|
||||
sur {target ?? (c.entity_kind === 'bug' ? 'un bug' : 'une tâche')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 text-sm text-muted">{c.body}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const TONE_BG: Record<string, string> = {
|
||||
accent: 'bg-accent/12 text-accent',
|
||||
emerald: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
|
||||
rose: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
|
||||
teal: 'bg-teal-500/12 text-teal-600 dark:text-teal-400',
|
||||
slate: 'bg-slate-500/12 text-slate-600 dark:text-slate-300'
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color
|
||||
hint,
|
||||
tone,
|
||||
onClick
|
||||
}: {
|
||||
icon: JSX.Element
|
||||
label: string
|
||||
value: string | number
|
||||
color: string
|
||||
hint?: string
|
||||
tone: keyof typeof TONE_BG
|
||||
onClick?: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<motion.div
|
||||
variants={staggerItem}
|
||||
whileHover={{ y: -3 }}
|
||||
className="card flex items-center gap-3"
|
||||
>
|
||||
<div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-ink">{value}</div>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
const inner = (
|
||||
<>
|
||||
<div className={`shrink-0 rounded-lg p-2.5 ${TONE_BG[tone]}`}>{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-2xl font-semibold leading-tight tabular text-ink">{value}</div>
|
||||
<div className="truncate text-xs text-muted">{label}</div>
|
||||
{hint && <div className="truncate text-[11px] text-subtle">{hint}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!onClick) {
|
||||
return (
|
||||
<motion.div variants={staggerItem} className="card flex items-center gap-3">
|
||||
{inner}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
variants={staggerItem}
|
||||
whileHover={{ y: -2 }}
|
||||
onClick={onClick}
|
||||
className="card flex items-center gap-3 text-left transition-shadow hover:shadow-raised"
|
||||
>
|
||||
{inner}
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
// =====================================================================
|
||||
// Documentation — fiches créées dans l'app ou importées (.md, .docx).
|
||||
//
|
||||
// Deux volets : la liste à gauche, la fiche à droite. C'est la mise en
|
||||
// page d'un wiki, celle qu'on attend quand on vient chercher une info
|
||||
// précise dans un ensemble de documents.
|
||||
// =====================================================================
|
||||
|
||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
List,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate, rows, run, useQuery, useRealtime } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { PageActions, usePageSubtitle } from '../components/Layout'
|
||||
import { Doc, normalizeDocs, plural, tagBadge } from '../lib/types'
|
||||
import { IMPORT_ACCEPT, excerpt, importFile, markdownToText } from '../lib/markdown'
|
||||
import Markdown, { Heading } from '../components/Markdown'
|
||||
import { Avatar, TagInput } from '../components/people'
|
||||
import {
|
||||
AsyncContent,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
SearchInput,
|
||||
SkeletonList
|
||||
} from '../components/ui'
|
||||
|
||||
/** Contenu proposé à la création : une fiche vide décourage l'écriture. */
|
||||
const TEMPLATE = `## Objectif
|
||||
|
||||
Décris ici à quoi sert ce document.
|
||||
|
||||
## Contenu
|
||||
|
||||
- Premier point
|
||||
- Deuxième point
|
||||
|
||||
> Astuce : cette fiche s'écrit en Markdown. **gras**, *italique*,
|
||||
> \`code\`, listes, tableaux et liens fonctionnent.
|
||||
`
|
||||
|
||||
interface Draft {
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export default function Docs(): JSX.Element {
|
||||
const { currentProject, profile, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<Draft | null>(null) // non-null = en édition
|
||||
const [q, setQ] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// ------------------------------------------------------------------ data
|
||||
const fetchDocs = useCallback(async () => {
|
||||
if (!projectId) return [] as Doc[]
|
||||
return rows<Doc>(
|
||||
supa()
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('updated_at', { ascending: false })
|
||||
).then(normalizeDocs)
|
||||
}, [projectId])
|
||||
|
||||
const docs = useQuery<Doc[]>(fetchDocs, [], { enabled: Boolean(projectId) })
|
||||
const { reload } = docs
|
||||
|
||||
useRealtime(
|
||||
projectId ? `docs-${projectId}` : null,
|
||||
[{ table: 'documents', filter: `project_id=eq.${projectId}` }],
|
||||
docs.reload
|
||||
)
|
||||
|
||||
const list = docs.data
|
||||
const selected = useMemo(() => list.find((d) => d.id === selectedId) ?? null, [list, selectedId])
|
||||
|
||||
// Sélection automatique de la première fiche, tant qu'on n'édite pas.
|
||||
useEffect(() => {
|
||||
if (!draft && !selectedId && list.length > 0) setSelectedId(list[0].id)
|
||||
}, [list, selectedId, draft])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return list
|
||||
return list.filter((d) =>
|
||||
`${d.title} ${d.tags.join(' ')} ${markdownToText(d.content)}`.toLowerCase().includes(needle)
|
||||
)
|
||||
}, [list, q])
|
||||
|
||||
// ------------------------------------------------------------------ écriture
|
||||
const create = (initial?: Partial<Draft>): void => {
|
||||
setSelectedId(null)
|
||||
setDraft({ title: '', content: TEMPLATE, tags: [], ...initial })
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!draft || !projectId) return
|
||||
const title = draft.title.trim()
|
||||
if (!title) {
|
||||
toast('Donne un titre à la fiche.', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
if (selected) {
|
||||
await mutate(
|
||||
supa()
|
||||
.from('documents')
|
||||
.update({
|
||||
title,
|
||||
content: draft.content,
|
||||
tags: draft.tags,
|
||||
updated_by: profile?.id ?? null
|
||||
})
|
||||
.eq('id', selected.id)
|
||||
)
|
||||
} else {
|
||||
const created = await run<{ id: string }>(
|
||||
supa()
|
||||
.from('documents')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
title,
|
||||
content: draft.content,
|
||||
tags: draft.tags,
|
||||
created_by: profile?.id ?? null,
|
||||
updated_by: profile?.id ?? null
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
)
|
||||
if (created) setSelectedId(created.id)
|
||||
}
|
||||
await docs.reload()
|
||||
setDraft(null)
|
||||
toast('Fiche enregistrée.', 'success')
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (doc: Doc): Promise<void> => {
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer cette fiche ?',
|
||||
message: `« ${doc.title} » sera définitivement supprimée.`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await mutate(supa().from('documents').delete().eq('id', doc.id))
|
||||
setSelectedId(null)
|
||||
setDraft(null)
|
||||
await docs.reload()
|
||||
toast('Fiche supprimée.', 'success')
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/** Réenregistre la fiche en .md — la doc n'est jamais prisonnière de l'app. */
|
||||
const exportMd = (doc: Doc): void => {
|
||||
const blob = new Blob([`# ${doc.title}\n\n${doc.content}`], {
|
||||
type: 'text/markdown;charset=utf-8'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${doc.title.replace(/[^\w\s.-]/g, '').trim() || 'fiche'}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ import
|
||||
const runImport = async (files: File[]): Promise<void> => {
|
||||
if (!projectId || files.length === 0) return
|
||||
setImporting(true)
|
||||
const errors: string[] = []
|
||||
let lastId: string | null = null
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const doc = await importFile(file)
|
||||
const created = await run<{ id: string }>(
|
||||
supa()
|
||||
.from('documents')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
title: doc.title,
|
||||
content: doc.content,
|
||||
source_name: doc.sourceName,
|
||||
created_by: profile?.id ?? null,
|
||||
updated_by: profile?.id ?? null
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
)
|
||||
if (created) lastId = created.id
|
||||
} catch (e) {
|
||||
errors.push(`${file.name} — ${humanize(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await reload()
|
||||
setImporting(false)
|
||||
if (lastId) {
|
||||
setDraft(null)
|
||||
setSelectedId(lastId)
|
||||
}
|
||||
|
||||
const ok = files.length - errors.length
|
||||
if (ok > 0) toast(`${plural(ok, 'fiche')} importée${ok > 1 ? 's' : ''}.`, 'success')
|
||||
// Chaque échec est signalé nommément : un import silencieusement
|
||||
// partiel laisserait croire que tout est passé.
|
||||
for (const err of errors) toast(err, 'error')
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>): void => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
void runImport(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
usePageSubtitle(
|
||||
projectId ? `${currentProject?.name} · ${plural(list.length, 'fiche')}` : null
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------ rendu
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col"
|
||||
onDragOver={(e) => {
|
||||
if (!projectId) return
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false)
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<PageActions>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={IMPORT_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void runImport(Array.from(e.target.files ?? []))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<Upload size={15} />}
|
||||
loading={importing}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
Importer
|
||||
</Button>
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={() => create()}>
|
||||
Nouvelle fiche
|
||||
</Button>
|
||||
</PageActions>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{/* ------------------------------ liste ------------------------------ */}
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-border">
|
||||
<div className="border-b border-border p-3">
|
||||
<SearchInput value={q} onChange={setQ} placeholder="Chercher dans la doc…" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{docs.loading ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : shown.length === 0 ? (
|
||||
<p className="px-2 py-8 text-center text-xs text-subtle">
|
||||
{list.length === 0 ? 'Aucune fiche.' : 'Aucun résultat.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{shown.map((d) => {
|
||||
const on = d.id === selectedId && !draft
|
||||
return (
|
||||
<li key={d.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(null)
|
||||
setSelectedId(d.id)
|
||||
}}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
on ? 'bg-accent/12 ring-1 ring-inset ring-accent/25' : 'hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileText size={13} className={on ? 'text-accent' : 'text-subtle'} />
|
||||
<span
|
||||
className={`truncate text-sm font-medium ${on ? 'text-accent' : 'text-ink'}`}
|
||||
>
|
||||
{d.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-snug text-subtle">
|
||||
{excerpt(d.content, 90) || 'Fiche vide'}
|
||||
</span>
|
||||
<span className="mt-1 block text-[10px] text-subtle">
|
||||
{formatDistanceToNow(new Date(d.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ------------------------------ fiche ------------------------------ */}
|
||||
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<AsyncContent loading={docs.loading} error={docs.error} onRetry={docs.reload}>
|
||||
{draft ? (
|
||||
<Editor
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
busy={busy}
|
||||
onSave={save}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
) : selected ? (
|
||||
<Viewer
|
||||
doc={selected}
|
||||
author={memberById(selected.updated_by ?? selected.created_by)}
|
||||
onEdit={() =>
|
||||
setDraft({
|
||||
title: selected.title,
|
||||
content: selected.content,
|
||||
tags: selected.tags
|
||||
})
|
||||
}
|
||||
onExport={() => exportMd(selected)}
|
||||
onDelete={() => remove(selected)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<BookOpen size={40} />}
|
||||
title={list.length === 0 ? 'Aucune documentation' : 'Choisis une fiche'}
|
||||
text={
|
||||
list.length === 0
|
||||
? 'Crée une fiche, ou dépose un fichier .md ou .docx n’importe où sur cette page.'
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
list.length === 0 ? (
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Upload size={15} />} onClick={() => fileRef.current?.click()}>
|
||||
Importer un fichier
|
||||
</Button>
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={() => create()}>
|
||||
Nouvelle fiche
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AsyncContent>
|
||||
</section>
|
||||
|
||||
{/* Dépôt de fichier : n'importe où sur la page. */}
|
||||
{dragging && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-accent bg-accent/10">
|
||||
<p className="rounded-lg bg-panel px-4 py-2 text-sm font-medium text-accent shadow-pop">
|
||||
Déposer pour importer ({IMPORT_ACCEPT})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lecture
|
||||
// =====================================================================
|
||||
|
||||
function Viewer({
|
||||
doc,
|
||||
author,
|
||||
onEdit,
|
||||
onExport,
|
||||
onDelete
|
||||
}: {
|
||||
doc: Doc
|
||||
author: ReturnType<ReturnType<typeof useApp>['memberById']>
|
||||
onEdit: () => void
|
||||
onExport: () => void
|
||||
onDelete: () => void
|
||||
}): JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [headings, setHeadings] = useState<Heading[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
|
||||
// Le plan se reconstruit à chaque fiche : on repart du haut.
|
||||
useEffect(() => {
|
||||
setHeadings([])
|
||||
setActive(null)
|
||||
scrollRef.current?.scrollTo({ top: 0 })
|
||||
}, [doc.id])
|
||||
|
||||
// Suivi de lecture : le titre surligné est le dernier franchi. On lit la
|
||||
// position au défilement plutôt qu'avec un IntersectionObserver — une
|
||||
// section plus haute que la fenêtre ne déclencherait aucune intersection
|
||||
// et le plan resterait bloqué sur le titre précédent.
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current
|
||||
if (!root || headings.length === 0) return
|
||||
|
||||
const onScroll = (): void => {
|
||||
const limit = root.scrollTop + 24
|
||||
let current = headings[0].id
|
||||
for (const h of headings) {
|
||||
const el = root.querySelector<HTMLElement>(`#${CSS.escape(h.id)}`)
|
||||
if (el && el.offsetTop <= limit) current = h.id
|
||||
else break
|
||||
}
|
||||
setActive(current)
|
||||
}
|
||||
|
||||
onScroll()
|
||||
root.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => root.removeEventListener('scroll', onScroll)
|
||||
}, [headings])
|
||||
|
||||
const goTo = (id: string): void => {
|
||||
const root = scrollRef.current
|
||||
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`)
|
||||
if (!root || !el) return
|
||||
root.scrollTo({ top: el.offsetTop - 12, behavior: 'smooth' })
|
||||
setActive(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
|
||||
<article className="mx-auto max-w-3xl px-8 py-6">
|
||||
<header className="mb-5 border-b border-border pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold leading-tight text-ink">{doc.title}</h1>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<IconButton size="sm" label="Modifier" icon={<Pencil size={16} />} onClick={onEdit} />
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Exporter en Markdown"
|
||||
icon={<Download size={16} />}
|
||||
onClick={onExport}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
tone="danger"
|
||||
label="Supprimer"
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-subtle">
|
||||
{author && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Avatar profile={author} size={18} />
|
||||
{author.full_name}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Modifiée le {format(new Date(doc.updated_at), 'd MMMM yyyy à HH:mm', { locale: fr })}
|
||||
</span>
|
||||
{doc.source_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Upload size={11} /> importée de {doc.source_name}
|
||||
</span>
|
||||
)}
|
||||
{doc.tags.map((t) => (
|
||||
<Badge key={t} className={tagBadge(t)}>
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Markdown content={doc.content} onHeadings={setHeadings} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Plan du document — masqué s'il n'y a pas de quoi naviguer, ou si
|
||||
la fenêtre est trop étroite pour l'afficher sans serrer le texte. */}
|
||||
{headings.length > 1 && (
|
||||
<nav
|
||||
aria-label="Plan du document"
|
||||
className="hidden w-56 shrink-0 overflow-y-auto border-l border-border px-3 py-6 xl:block"
|
||||
>
|
||||
<p className="mb-2 flex items-center gap-1.5 px-2 text-[10px] font-semibold uppercase tracking-wide text-subtle">
|
||||
<List size={12} /> Plan
|
||||
</p>
|
||||
<ul>
|
||||
{headings.map((h) => {
|
||||
const on = h.id === active
|
||||
return (
|
||||
<li key={h.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(h.id)}
|
||||
style={{ paddingLeft: `${(h.level - 1) * 12 + 8}px` }}
|
||||
className={`block w-full border-l-2 py-1 pr-2 text-left text-xs leading-snug transition-colors ${
|
||||
on
|
||||
? 'border-accent font-medium text-accent'
|
||||
: 'border-transparent text-muted hover:border-border-strong hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{h.text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Écriture
|
||||
// =====================================================================
|
||||
|
||||
function Editor({
|
||||
draft,
|
||||
setDraft,
|
||||
busy,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
draft: Draft
|
||||
setDraft: (d: Draft) => void
|
||||
busy: boolean
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}): JSX.Element {
|
||||
const [preview, setPreview] = useState(false)
|
||||
|
||||
// Ctrl+S : le réflexe de toute personne qui écrit un document.
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent): void => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onSave])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col px-8 py-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
placeholder="Titre de la fiche"
|
||||
aria-label="Titre de la fiche"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-2xl font-semibold text-ink outline-none placeholder:text-subtle"
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={preview ? 'Revenir à l’édition' : 'Aperçu'}
|
||||
active={preview}
|
||||
icon={preview ? <Pencil size={16} /> : <Eye size={16} />}
|
||||
onClick={() => setPreview((v) => !v)}
|
||||
/>
|
||||
<IconButton size="sm" label="Annuler" icon={<X size={16} />} onClick={onCancel} />
|
||||
<Button variant="primary" icon={<Check size={15} />} loading={busy} onClick={onSave}>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<TagInput value={draft.tags} onChange={(tags) => setDraft({ ...draft, tags })} />
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-panel p-4">
|
||||
<Markdown content={draft.content} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={draft.content}
|
||||
onChange={(e) => setDraft({ ...draft, content: e.target.value })}
|
||||
spellCheck
|
||||
placeholder="Écris ici, en Markdown…"
|
||||
className="min-h-0 flex-1 resize-none rounded-lg border border-border bg-panel2 p-4 font-mono text-[13px] leading-relaxed text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="mt-2 shrink-0 text-[11px] text-subtle">
|
||||
Markdown : <code className="text-muted">## Titre</code> ·{' '}
|
||||
<code className="text-muted">**gras**</code> · <code className="text-muted">- liste</code> ·{' '}
|
||||
<code className="text-muted">[lien](url)</code> — <kbd className="kbd">Ctrl</kbd>{' '}
|
||||
<kbd className="kbd">S</kbd> pour enregistrer
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// =====================================================================
|
||||
// Écran d'installation — fenêtre sans bordure, au design de l'application.
|
||||
//
|
||||
// Remplace l'assistant NSIS pour la PREMIÈRE installation. Les mises à
|
||||
// jour, elles, restent silencieuses (aucune fenêtre du tout).
|
||||
// =====================================================================
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AlertTriangle, ArrowRight, Check, Minus, X } from 'lucide-react'
|
||||
import {
|
||||
InstallerStatus,
|
||||
closeWindow,
|
||||
install,
|
||||
launchInstalled,
|
||||
minimizeWindow,
|
||||
startDragging
|
||||
} from '../lib/installer'
|
||||
import { Checkbox } from '../components/ui'
|
||||
import Logo from '../components/Logo'
|
||||
|
||||
type Step = 'ready' | 'installing' | 'done' | 'error'
|
||||
|
||||
/** Étapes affichées pendant la copie — purement indicatives, mais elles
|
||||
* donnent à l'attente un déroulé lisible plutôt qu'une barre muette. */
|
||||
const PHASES = [
|
||||
'Copie des fichiers…',
|
||||
'Création des raccourcis…',
|
||||
'Enregistrement auprès de Windows…'
|
||||
]
|
||||
|
||||
export default function Installer({ status }: { status: InstallerStatus | null }): JSX.Element {
|
||||
const [step, setStep] = useState<Step>('ready')
|
||||
const [desktopShortcut, setDesktopShortcut] = useState(true)
|
||||
const [phase, setPhase] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [installDir, setInstallDir] = useState(status?.install_dir ?? '')
|
||||
|
||||
const reinstall = status?.already_installed ?? false
|
||||
|
||||
// Progression indicative : l'installation réelle est trop rapide pour
|
||||
// qu'on puisse en suivre l'avancement, mais un écran figé inquiète.
|
||||
useEffect(() => {
|
||||
if (step !== 'installing') return
|
||||
const t = setInterval(() => setPhase((p) => Math.min(p + 1, PHASES.length - 1)), 550)
|
||||
return () => clearInterval(t)
|
||||
}, [step])
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
setStep('installing')
|
||||
setPhase(0)
|
||||
const started = Date.now()
|
||||
try {
|
||||
const dir = await install(desktopShortcut)
|
||||
setInstallDir(dir)
|
||||
// On laisse les étapes défiler au moins une seconde : une installation
|
||||
// « instantanée » donne l'impression que rien ne s'est passé.
|
||||
const remaining = Math.max(0, 1100 - (Date.now() - started))
|
||||
setTimeout(() => setStep('done'), remaining)
|
||||
} catch (e) {
|
||||
setError(typeof e === 'string' ? e : ((e as Error)?.message ?? String(e)))
|
||||
setStep('error')
|
||||
}
|
||||
}
|
||||
|
||||
const launch = async (): Promise<void> => {
|
||||
try {
|
||||
await launchInstalled()
|
||||
} catch (e) {
|
||||
setError(typeof e === 'string' ? e : ((e as Error)?.message ?? String(e)))
|
||||
setStep('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-bg">
|
||||
{/* Barre de fenêtre maison : la fenêtre n'a pas de décoration Windows. */}
|
||||
<header
|
||||
onMouseDown={startDragging}
|
||||
className="flex h-9 shrink-0 items-center justify-between pl-4 pr-1 select-none"
|
||||
>
|
||||
<span className="text-[11px] font-medium text-subtle">
|
||||
Installation de {status?.product_name ?? 'GameDev Tracker'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Réduire"
|
||||
onClick={minimizeWindow}
|
||||
className="flex h-8 w-10 items-center justify-center text-muted transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
onClick={closeWindow}
|
||||
className="flex h-8 w-10 items-center justify-center text-muted transition-colors hover:bg-rose-600 hover:text-white"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center px-8 pb-7 text-center">
|
||||
{/* Logo + halo, écho du panneau de l'ancien installeur */}
|
||||
<div className="relative mt-4 shrink-0">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10 rounded-full blur-2xl"
|
||||
style={{ background: 'radial-gradient(circle, rgb(var(--c-accent)/0.45), transparent 70%)' }}
|
||||
/>
|
||||
<Logo size={76} className="rounded-2xl shadow-pop" />
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-xl font-semibold text-ink">
|
||||
{status?.product_name ?? 'GameDev Tracker'}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
Gestion de projet de jeu vidéo
|
||||
{status?.version && <span className="text-subtle"> · version {status.version}</span>}
|
||||
</p>
|
||||
|
||||
<div className="mt-7 w-full flex-1">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* ------------------------------------------------ prêt */}
|
||||
{step === 'ready' && (
|
||||
<motion.div
|
||||
key="ready"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-muted">
|
||||
{reinstall
|
||||
? 'Une version est déjà installée. Elle sera remplacée par celle-ci.'
|
||||
: "L'application va s'installer sur ton compte utilisateur. Aucun droit administrateur n'est nécessaire."}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border bg-panel2 px-3 py-2 text-left">
|
||||
<span className="block text-[10px] font-medium uppercase tracking-wide text-subtle">
|
||||
Dossier d'installation
|
||||
</span>
|
||||
<span className="mt-0.5 block break-all font-mono text-[11px] text-muted">
|
||||
{installDir || '…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
className="mt-4 justify-center"
|
||||
checked={desktopShortcut}
|
||||
onChange={setDesktopShortcut}
|
||||
label={<span className="text-sm text-muted">Créer un raccourci sur le bureau</span>}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
className="btn-primary mt-auto w-full py-2.5 text-[15px]"
|
||||
>
|
||||
{reinstall ? 'Réinstaller' : 'Installer'}
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------- installation */}
|
||||
{step === 'installing' && (
|
||||
<motion.div
|
||||
key="installing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex h-full flex-col items-center justify-center gap-4"
|
||||
>
|
||||
<div className="h-1 w-56 overflow-hidden rounded-full bg-panel2">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-accent"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ repeat: Infinity, duration: 1.1, ease: 'easeInOut' }}
|
||||
style={{ width: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{PHASES[phase]}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------- terminé */}
|
||||
{step === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3">
|
||||
<motion.span
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 18 }}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500"
|
||||
>
|
||||
<Check size={26} strokeWidth={3} />
|
||||
</motion.span>
|
||||
<p className="text-sm font-medium text-ink">Installation terminée</p>
|
||||
<p className="text-xs text-subtle">
|
||||
{status?.product_name ?? 'GameDev Tracker'} est prêt à être utilisé.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={launch}
|
||||
className="btn-primary mt-auto w-full py-2.5 text-[15px]"
|
||||
>
|
||||
Démarrer l'application
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------ erreur */}
|
||||
{step === 'error' && (
|
||||
<motion.div
|
||||
key="error"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3">
|
||||
<AlertTriangle size={30} className="text-rose-500" />
|
||||
<p className="text-sm font-medium text-ink">L'installation a échoué</p>
|
||||
<p className="max-w-full break-words px-2 text-xs text-muted">{error}</p>
|
||||
</div>
|
||||
<div className="mt-auto flex w-full gap-2">
|
||||
<button type="button" onClick={closeWindow} className="btn-ghost flex-1 py-2.5">
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('ready')}
|
||||
className="btn-primary flex-1 py-2.5"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+678
-233
@@ -1,84 +1,145 @@
|
||||
import { useEffect, useState, useCallback, DragEvent, useMemo } from 'react'
|
||||
import { Plus, CalendarDays, Clock, Filter, X } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { format, isPast } 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,
|
||||
Pole,
|
||||
TimeLog,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_LABELS,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
GripVertical,
|
||||
ListChecks,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
UserCheck,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, rows, useQuery, useRealtime } from '../lib/db'
|
||||
import { NONE, matchesAny, matchesOne, readList, readText } from '../lib/filters'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import {
|
||||
POLE_LABELS,
|
||||
POLE_ORDER,
|
||||
poleColor,
|
||||
taskPoles,
|
||||
formatMinutes
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_WEIGHT,
|
||||
Profile,
|
||||
Task,
|
||||
TaskStatus,
|
||||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_ORDER,
|
||||
TASK_STATUS_TONE,
|
||||
TimeLog,
|
||||
checklistProgress,
|
||||
formatMinutes,
|
||||
memberPoles,
|
||||
normalizeTasks,
|
||||
plural,
|
||||
poleDot,
|
||||
polesLabel,
|
||||
priorityBadge,
|
||||
priorityDot,
|
||||
taskStatusDot
|
||||
} from '../lib/types'
|
||||
import { Avatar, PriorityBadge, Select } from '../components/ui'
|
||||
import { TONE_DOT } from '../lib/tones'
|
||||
import { PageActions, usePageSubtitle } from '../components/Layout'
|
||||
import { Avatar, AvatarStack, PoleBadge, Tag } from '../components/people'
|
||||
import {
|
||||
AsyncContent,
|
||||
Badge,
|
||||
Button,
|
||||
FilterChip,
|
||||
IconButton,
|
||||
ProgressBar,
|
||||
SearchInput,
|
||||
Skeleton
|
||||
} from '../components/ui'
|
||||
import { MultiOption, MultiSelect } from '../components/MultiSelect'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
// Couleur d'accent par colonne (en-tête, compteur, liseré supérieur).
|
||||
const STATUS_ACCENT: Record<TaskStatus, { dot: string; badge: string; bar: string }> = {
|
||||
todo: { dot: 'bg-slate-400', badge: 'bg-slate-100 text-slate-600', bar: 'bg-slate-400' },
|
||||
in_progress: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700', bar: 'bg-sky-500' },
|
||||
review: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700', bar: 'bg-amber-500' },
|
||||
done: { dot: 'bg-emerald-500', badge: 'bg-emerald-100 text-emerald-700', bar: 'bg-emerald-500' }
|
||||
const FILTER_KEY = 'gamedev-tracker-kanban-filters'
|
||||
|
||||
/**
|
||||
* Chaque filtre accepte PLUSIEURS valeurs : on peut regarder les tâches
|
||||
* d'Alex **et** de Sam, ou des pôles Art **et** Audio, d'un seul coup.
|
||||
*/
|
||||
interface Filters {
|
||||
q: string
|
||||
assignees: string[]
|
||||
poles: string[]
|
||||
priorities: string[]
|
||||
}
|
||||
|
||||
const EMPTY: Filters = { q: '', assignees: [], poles: [], priorities: [] }
|
||||
|
||||
function loadFilters(): Filters {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(FILTER_KEY) ?? '{}') as Record<string, unknown>
|
||||
// `raw.assignee` / `raw.pole` : ancien format à valeur unique, repris tel quel.
|
||||
return {
|
||||
q: readText(raw.q),
|
||||
assignees: readList(raw.assignees, raw.assignee),
|
||||
poles: readList(raw.poles, raw.pole),
|
||||
priorities: readList(raw.priorities, raw.priority)
|
||||
}
|
||||
} catch {
|
||||
return EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
function countFilters(f: Filters): number {
|
||||
return f.assignees.length + f.poles.length + f.priorities.length
|
||||
}
|
||||
|
||||
/** Renumérote une liste ordonnée : position = index. */
|
||||
function renumber(list: Task[], status: TaskStatus): Task[] {
|
||||
return list.map((t, i) => ({ ...t, status, position: i }))
|
||||
}
|
||||
|
||||
export default function Kanban(): JSX.Element {
|
||||
const { currentProject, focusEntity, setFocusEntity } = useApp()
|
||||
const { members, byId } = useMembers()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [times, setTimes] = useState<Record<string, number>>({})
|
||||
const [editing, setEditing] = useState<Task | null>(null)
|
||||
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const [overStatus, setOverStatus] = useState<TaskStatus | null>(null)
|
||||
|
||||
// Filtres
|
||||
const [fAssignee, setFAssignee] = useState('')
|
||||
const [fPole, setFPole] = useState('')
|
||||
|
||||
const { currentProject, profile, members, memberById, focusEntity, setFocusEntity } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return
|
||||
const { data } = await supa()
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('project_id', projectId)
|
||||
.order('position')
|
||||
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) => {
|
||||
if (l.task_id) totals[l.task_id] = (totals[l.task_id] ?? 0) + l.minutes
|
||||
})
|
||||
setTimes(totals)
|
||||
} else {
|
||||
setTimes({})
|
||||
}
|
||||
}, [projectId])
|
||||
const [editing, setEditing] = useState<Task | null>(null)
|
||||
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
|
||||
const [filters, setFilters] = useState<Filters>(loadFilters)
|
||||
const [showFilters, setShowFilters] = useState(() => countFilters(loadFilters()) > 0)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
localStorage.setItem(FILTER_KEY, JSON.stringify(filters))
|
||||
}, [filters])
|
||||
|
||||
// Ouverture d'une tâche demandée par une notification.
|
||||
// ------------------------------------------------------------------ data
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (!projectId) return { tasks: [] as Task[], times: {} as Record<string, number> }
|
||||
const list = await rows<Task>(
|
||||
supa().from('tasks').select('*').eq('project_id', projectId).order('position')
|
||||
).then(normalizeTasks)
|
||||
const logs = await rows<Pick<TimeLog, 'task_id' | 'minutes'>>(
|
||||
supa().from('time_logs').select('task_id, minutes').eq('project_id', projectId)
|
||||
)
|
||||
const times: Record<string, number> = {}
|
||||
for (const l of logs) if (l.task_id) times[l.task_id] = (times[l.task_id] ?? 0) + l.minutes
|
||||
return { tasks: list, times }
|
||||
}, [projectId])
|
||||
|
||||
const q = useQuery(fetchAll, { tasks: [] as Task[], times: {} as Record<string, number> }, {
|
||||
enabled: Boolean(projectId)
|
||||
})
|
||||
const { tasks, times } = q.data
|
||||
|
||||
// Temps réel — FILTRÉ sur le projet : avant, toute écriture de temps de
|
||||
// n'importe quel projet rechargeait ce Kanban.
|
||||
useRealtime(
|
||||
projectId ? `kanban-${projectId}` : null,
|
||||
[
|
||||
{ table: 'tasks', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'time_logs', filter: `project_id=eq.${projectId}` }
|
||||
],
|
||||
q.reload
|
||||
)
|
||||
|
||||
// Ouverture d'une tâche demandée par une notification / la palette.
|
||||
useEffect(() => {
|
||||
if (focusEntity?.kind !== 'task') return
|
||||
const t = tasks.find((x) => x.id === focusEntity.id)
|
||||
@@ -88,220 +149,604 @@ export default function Kanban(): JSX.Element {
|
||||
}
|
||||
}, [focusEntity, tasks, setFocusEntity])
|
||||
|
||||
// Synchro temps réel
|
||||
// Garde la tâche ouverte à jour quand le temps réel la modifie.
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
const ch = supa()
|
||||
.channel(`tasks-${projectId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
|
||||
() => load()
|
||||
if (!editing) return
|
||||
const fresh = tasks.find((t) => t.id === editing.id)
|
||||
if (fresh && fresh.updated_at !== editing.updated_at) setEditing(fresh)
|
||||
}, [tasks, editing])
|
||||
|
||||
// ------------------------------------------------------------- filtrage
|
||||
const visible = useMemo(() => {
|
||||
const needle = filters.q.trim().toLowerCase()
|
||||
return tasks.filter((t) => {
|
||||
// « OU » à l'intérieur d'un filtre, « ET » entre les filtres :
|
||||
// Alex OU Sam, ET pôle Art OU Audio, ET priorité haute.
|
||||
if (!matchesAny(filters.assignees, t.assignee_ids)) return false
|
||||
if (!matchesAny(filters.poles, t.poles)) return false
|
||||
if (!matchesOne(filters.priorities, t.priority)) return false
|
||||
if (needle) {
|
||||
const hay = `${t.title} ${t.description ?? ''} ${t.tags.join(' ')}`.toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [tasks, filters])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const map = {} as Record<TaskStatus, Task[]>
|
||||
for (const s of TASK_STATUS_ORDER) {
|
||||
map[s] = visible
|
||||
.filter((t) => t.status === s)
|
||||
.sort((a, b) => a.position - b.position || PRIORITY_WEIGHT[b.priority] - PRIORITY_WEIGHT[a.priority])
|
||||
}
|
||||
return map
|
||||
}, [visible])
|
||||
|
||||
const activeFilters = countFilters(filters)
|
||||
const filtered = visible.length !== tasks.length
|
||||
|
||||
// --------------------------------------------------------- options de filtre
|
||||
const memberOptions = useMemo<MultiOption[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Non assigné', pinned: true },
|
||||
...members.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.full_name,
|
||||
hint: polesLabel(memberPoles(m)),
|
||||
leading: <Avatar profile={m} size={20} />
|
||||
}))
|
||||
],
|
||||
[members]
|
||||
)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'time_logs' }, () => load())
|
||||
.subscribe()
|
||||
return () => {
|
||||
supa().removeChannel(ch)
|
||||
}
|
||||
}, [projectId, load])
|
||||
|
||||
const moveTask = async (taskId: string, status: TaskStatus): Promise<void> => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status } : t)))
|
||||
await supa().from('tasks').update({ status }).eq('id', taskId)
|
||||
}
|
||||
const poleOptions = useMemo<MultiOption[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Sans pôle', pinned: true },
|
||||
...POLE_ORDER.map((p) => ({
|
||||
value: p as string,
|
||||
label: POLE_LABELS[p],
|
||||
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${poleDot(p)}`} />
|
||||
}))
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onDrop = (e: DragEvent, status: TaskStatus): void => {
|
||||
e.preventDefault()
|
||||
if (dragId) {
|
||||
const t = tasks.find((x) => x.id === dragId)
|
||||
if (t && t.status !== status) moveTask(dragId, status)
|
||||
}
|
||||
setDragId(null)
|
||||
}
|
||||
|
||||
const visible = useMemo(
|
||||
const priorityOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
tasks.filter(
|
||||
(t) =>
|
||||
(!fAssignee || (t.assignee_ids ?? []).includes(fAssignee)) &&
|
||||
(!fPole || taskPoles(t).includes(fPole as Pole))
|
||||
),
|
||||
[tasks, fAssignee, fPole]
|
||||
PRIORITY_ORDER.map((p) => ({
|
||||
value: p as string,
|
||||
label: PRIORITY_LABELS[p],
|
||||
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${priorityDot(p)}`} />
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const hasFilter = fAssignee || fPole
|
||||
const removeFilter = (key: 'assignees' | 'poles' | 'priorities', value: string): void =>
|
||||
setFilters((f) => ({ ...f, [key]: f[key].filter((v) => v !== value) }))
|
||||
|
||||
/** Raccourci « ce qui m'est assigné » : le filtre le plus demandé. */
|
||||
const onlyMine = Boolean(profile && filters.assignees.length === 1 && filters.assignees[0] === profile.id)
|
||||
const toggleMine = (): void => {
|
||||
if (!profile) return
|
||||
setFilters((f) => ({ ...f, assignees: onlyMine ? [] : [profile.id] }))
|
||||
setShowFilters(true)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- réordonnancement
|
||||
/**
|
||||
* Déplace une tâche AVANT `beforeId` (ou en fin de colonne si null).
|
||||
*
|
||||
* On raisonne en « avant telle carte » et non en index : les colonnes
|
||||
* affichées peuvent être filtrées, alors que l'ordre écrit en base porte
|
||||
* sur la colonne COMPLÈTE. Un index issu de la vue filtrée rangeait la
|
||||
* carte au mauvais endroit dès qu'un filtre était actif.
|
||||
*
|
||||
* Mise à jour optimiste locale, puis UNE requête par colonne touchée
|
||||
* (fonction SQL `reorder_tasks`, transactionnelle).
|
||||
*/
|
||||
const applyMove = useCallback(
|
||||
async (id: string, toStatus: TaskStatus, beforeId: string | null): Promise<void> => {
|
||||
const moved = tasks.find((t) => t.id === id)
|
||||
if (!moved) return
|
||||
|
||||
const fromStatus = moved.status
|
||||
const ordered = (status: TaskStatus): Task[] =>
|
||||
tasks
|
||||
.filter((t) => t.status === status && t.id !== id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
|
||||
const source = ordered(fromStatus)
|
||||
const target = fromStatus === toStatus ? source : ordered(toStatus)
|
||||
|
||||
const at = beforeId ? target.findIndex((t) => t.id === beforeId) : -1
|
||||
const index = at >= 0 ? at : target.length
|
||||
const nextTarget = renumber([...target.slice(0, index), moved, ...target.slice(index)], toStatus)
|
||||
const nextSource = fromStatus === toStatus ? [] : renumber(source, fromStatus)
|
||||
|
||||
// Rien n'a bougé ? on n'écrit pas.
|
||||
const unchanged = (list: Task[]): boolean =>
|
||||
list.every((t) => {
|
||||
const before = tasks.find((x) => x.id === t.id)
|
||||
return before?.status === t.status && before?.position === t.position
|
||||
})
|
||||
if (unchanged(nextTarget) && unchanged(nextSource)) return
|
||||
|
||||
const byId = new Map<string, Task>()
|
||||
for (const t of [...nextTarget, ...nextSource]) byId.set(t.id, t)
|
||||
q.set((cur) => ({ ...cur, tasks: cur.tasks.map((t) => byId.get(t.id) ?? t) }))
|
||||
|
||||
try {
|
||||
await supa()
|
||||
.rpc('reorder_tasks', { p_status: toStatus, p_ids: nextTarget.map((t) => t.id) })
|
||||
.throwOnError()
|
||||
if (nextSource.length) {
|
||||
await supa()
|
||||
.rpc('reorder_tasks', { p_status: fromStatus, p_ids: nextSource.map((t) => t.id) })
|
||||
.throwOnError()
|
||||
}
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
q.reload() // on remet l'affichage en accord avec la base
|
||||
}
|
||||
},
|
||||
[tasks, q, toast]
|
||||
)
|
||||
|
||||
// -------------------------------------------------------- glisser-déposer
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const [drop, setDrop] = useState<{ status: TaskStatus; beforeId: string | null } | null>(null)
|
||||
|
||||
const clearDrag = (): void => {
|
||||
setDragId(null)
|
||||
setDrop(null)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ déplacement clavier
|
||||
// Le glisser-déposer HTML5 est totalement inutilisable au clavier : on
|
||||
// double l'interaction par « prendre / déplacer / déposer » à la touche.
|
||||
const [grabbed, setGrabbed] = useState<string | null>(null)
|
||||
const [announce, setAnnounce] = useState('')
|
||||
const cardRefs = useRef(new Map<string, HTMLDivElement>())
|
||||
|
||||
const positionOf = (id: string): { status: TaskStatus; index: number } | null => {
|
||||
for (const s of TASK_STATUS_ORDER) {
|
||||
const i = columns[s].findIndex((t) => t.id === id)
|
||||
if (i >= 0) return { status: s, index: i }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const onCardKeyDown = (e: React.KeyboardEvent, task: Task): void => {
|
||||
const here = positionOf(task.id)
|
||||
if (!here) return
|
||||
|
||||
if (e.key === ' ' || e.key === 'Spacebar') {
|
||||
e.preventDefault()
|
||||
if (grabbed === task.id) {
|
||||
setGrabbed(null)
|
||||
setAnnounce(`${task.title} déposée dans ${TASK_STATUS_LABELS[here.status]}.`)
|
||||
} else {
|
||||
setGrabbed(task.id)
|
||||
setAnnounce(`${task.title} prise. Flèches pour déplacer, Espace pour déposer, Échap pour annuler.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (grabbed === task.id) setGrabbed(null)
|
||||
else setEditing(task)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && grabbed) {
|
||||
e.preventDefault()
|
||||
setGrabbed(null)
|
||||
q.reload()
|
||||
setAnnounce('Déplacement annulé.')
|
||||
return
|
||||
}
|
||||
|
||||
if (grabbed !== task.id) return
|
||||
|
||||
const colIndex = TASK_STATUS_ORDER.indexOf(here.status)
|
||||
|
||||
// Cible exprimée « avant telle carte », calculée sur la colonne AFFICHÉE
|
||||
// puis traduite par applyMove sur la colonne complète.
|
||||
const targetAt = (
|
||||
status: TaskStatus,
|
||||
index: number
|
||||
): { status: TaskStatus; beforeId: string | null; index: number } => {
|
||||
const list = columns[status].filter((t) => t.id !== task.id)
|
||||
const clamped = Math.max(0, Math.min(index, list.length))
|
||||
return { status, beforeId: list[clamped]?.id ?? null, index: clamped }
|
||||
}
|
||||
|
||||
let next: { status: TaskStatus; beforeId: string | null; index: number } | null = null
|
||||
if (e.key === 'ArrowLeft' && colIndex > 0) {
|
||||
next = targetAt(TASK_STATUS_ORDER[colIndex - 1], here.index)
|
||||
} else if (e.key === 'ArrowRight' && colIndex < TASK_STATUS_ORDER.length - 1) {
|
||||
next = targetAt(TASK_STATUS_ORDER[colIndex + 1], here.index)
|
||||
} else if (e.key === 'ArrowUp' && here.index > 0) {
|
||||
next = targetAt(here.status, here.index - 1)
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
next = targetAt(here.status, here.index + 1)
|
||||
}
|
||||
|
||||
if (!next) return
|
||||
e.preventDefault()
|
||||
void applyMove(task.id, next.status, next.beforeId)
|
||||
setAnnounce(
|
||||
`${task.title} déplacée vers ${TASK_STATUS_LABELS[next.status]}, position ${next.index + 1}.`
|
||||
)
|
||||
requestAnimationFrame(() => cardRefs.current.get(task.id)?.focus())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ chrome
|
||||
usePageSubtitle(
|
||||
projectId
|
||||
? `${currentProject?.name} · ${plural(tasks.length, 'tâche')}${
|
||||
filtered ? ` · ${visible.length} affichée${visible.length > 1 ? 's' : ''}` : ''
|
||||
}`
|
||||
: null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Tâches" subtitle={currentProject?.name}>
|
||||
<button className="btn-primary" onClick={() => setCreatingIn('todo')}>
|
||||
<Plus size={16} /> Nouvelle tâche
|
||||
</button>
|
||||
</PageHeader>
|
||||
<PageActions>
|
||||
<SearchInput
|
||||
className="hidden w-52 md:block"
|
||||
value={filters.q}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
|
||||
placeholder="Filtrer les tâches…"
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
label="Filtres"
|
||||
active={showFilters || activeFilters > 0}
|
||||
icon={<SlidersHorizontal size={17} />}
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
/>
|
||||
<Button variant="primary" icon={<Plus size={16} />} onClick={() => setCreatingIn('todo')}>
|
||||
Nouvelle tâche
|
||||
</Button>
|
||||
</PageActions>
|
||||
|
||||
{/* 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={fPole}
|
||||
onChange={(e) => setFPole(e.target.value)}
|
||||
>
|
||||
<option value="">Tous les pôles</option>
|
||||
{POLE_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{POLE_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{hasFilter && (
|
||||
{showFilters && (
|
||||
<div className="flex flex-col gap-1.5 border-b border-border bg-panel px-6 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SearchInput
|
||||
className="w-48 md:hidden"
|
||||
value={filters.q}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
|
||||
placeholder="Filtrer…"
|
||||
/>
|
||||
|
||||
{profile && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
|
||||
onClick={() => {
|
||||
setFAssignee('')
|
||||
setFPole('')
|
||||
}}
|
||||
type="button"
|
||||
onClick={toggleMine}
|
||||
aria-pressed={onlyMine}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
onlyMine
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border bg-panel2 text-muted hover:border-border-strong hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<X size={12} /> Réinitialiser
|
||||
<UserCheck size={13} /> Mes tâches
|
||||
</button>
|
||||
)}
|
||||
|
||||
<MultiSelect
|
||||
label="Membres"
|
||||
options={memberOptions}
|
||||
selected={filters.assignees}
|
||||
onChange={(assignees) => setFilters((f) => ({ ...f, assignees }))}
|
||||
emptyLabel="Aucun membre."
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Pôles"
|
||||
options={poleOptions}
|
||||
selected={filters.poles}
|
||||
onChange={(poles) => setFilters((f) => ({ ...f, poles }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Priorités"
|
||||
options={priorityOptions}
|
||||
selected={filters.priorities}
|
||||
onChange={(priorities) => setFilters((f) => ({ ...f, priorities }))}
|
||||
/>
|
||||
|
||||
{(activeFilters > 0 || filters.q) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(EMPTY)}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
|
||||
>
|
||||
<X size={12} /> Tout réinitialiser
|
||||
</button>
|
||||
)}
|
||||
{q.refreshing && <span className="ml-auto text-xs text-subtle">Actualisation…</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 gap-3 overflow-x-auto p-6">
|
||||
{TASK_STATUS_ORDER.map((status) => {
|
||||
const colTasks = visible.filter((t) => t.status === status)
|
||||
const accent = STATUS_ACCENT[status]
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className={`flex min-w-[240px] flex-1 flex-col overflow-hidden rounded-xl border bg-panel2/50 transition-colors ${
|
||||
overStatus === status && dragId
|
||||
? 'border-accent ring-2 ring-accent/30'
|
||||
: 'border-border'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
if (overStatus !== status) setOverStatus(status)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
onDrop(e, status)
|
||||
setOverStatus(null)
|
||||
}}
|
||||
>
|
||||
<div className={`h-1 w-full ${accent.bar}`} />
|
||||
<div className="flex items-center justify-between px-3 py-3">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<span className={`h-2 w-2 rounded-full ${accent.dot}`} />
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
{/* Rappel de ce qui est filtré, retirable valeur par valeur. */}
|
||||
{activeFilters > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{filters.assignees.map((id) => (
|
||||
<FilterChip key={`a-${id}`} onRemove={() => removeFilter('assignees', id)}>
|
||||
{id === NONE ? 'Non assigné' : (memberById(id)?.full_name ?? 'Inconnu')}
|
||||
</FilterChip>
|
||||
))}
|
||||
{filters.poles.map((p) => (
|
||||
<FilterChip key={`p-${p}`} onRemove={() => removeFilter('poles', p)}>
|
||||
{p === NONE ? 'Sans pôle' : POLE_LABELS[p as keyof typeof POLE_LABELS]}
|
||||
</FilterChip>
|
||||
))}
|
||||
{filters.priorities.map((p) => (
|
||||
<FilterChip key={`pr-${p}`} onRemove={() => removeFilter('priorities', p)}>
|
||||
Priorité {PRIORITY_LABELS[p as keyof typeof PRIORITY_LABELS].toLowerCase()}
|
||||
</FilterChip>
|
||||
))}
|
||||
<span className="ml-1 text-[11px] text-subtle">
|
||||
{visible.length} tâche{visible.length > 1 ? 's' : ''} sur {tasks.length}
|
||||
</span>
|
||||
<span className={`badge ${accent.badge}`}>{colTasks.length}</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2 pt-2">
|
||||
{colTasks.map((t) => {
|
||||
const mins = times[t.id] ?? 0
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annonces pour lecteur d'écran (déplacement au clavier). */}
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{announce}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<AsyncContent
|
||||
loading={q.loading}
|
||||
error={q.error}
|
||||
onRetry={q.reload}
|
||||
skeleton={
|
||||
<div className="flex h-full gap-3 p-6">
|
||||
{TASK_STATUS_ORDER.map((s) => (
|
||||
<div key={s} className="well flex-1 space-y-2 p-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full gap-3 overflow-x-auto p-6">
|
||||
{TASK_STATUS_ORDER.map((status) => {
|
||||
const colTasks = columns[status]
|
||||
const colMinutes = colTasks.reduce((s, t) => s + (times[t.id] ?? 0), 0)
|
||||
const isOver = drop?.status === status && dragId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
draggable
|
||||
<section
|
||||
key={status}
|
||||
aria-label={TASK_STATUS_LABELS[status]}
|
||||
onDragOver={(e) => {
|
||||
if (!dragId) return
|
||||
e.preventDefault()
|
||||
setDrop({ status, beforeId: null })
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (dragId && drop) void applyMove(dragId, drop.status, drop.beforeId)
|
||||
clearDrag()
|
||||
}}
|
||||
className={`flex min-w-[264px] flex-1 flex-col overflow-hidden rounded-xl border transition-colors ${
|
||||
isOver ? 'border-accent bg-accent/5' : 'border-border bg-panel2/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`h-0.5 w-full ${taskStatusDot(status)}`} />
|
||||
<header className="flex items-center justify-between gap-2 px-3 py-2.5">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm font-semibold text-ink">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${TONE_DOT[TASK_STATUS_TONE[status]]}`} />
|
||||
<span className="truncate">{TASK_STATUS_LABELS[status]}</span>
|
||||
<span className="shrink-0 text-xs font-normal text-subtle tabular">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</span>
|
||||
{colMinutes > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-accent2 tabular">
|
||||
{formatMinutes(colMinutes)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
|
||||
{colTasks.length === 0 && !dragId && (
|
||||
<p className="px-2 py-6 text-center text-xs text-subtle">
|
||||
{filtered ? 'Rien ne correspond au filtre.' : 'Aucune tâche ici.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{colTasks.map((t) => {
|
||||
const showLine =
|
||||
!!dragId &&
|
||||
dragId !== t.id &&
|
||||
drop?.status === status &&
|
||||
drop.beforeId === t.id
|
||||
return (
|
||||
<div key={t.id}>
|
||||
{showLine && <div className="mb-2 h-0.5 rounded-full bg-accent" />}
|
||||
<TaskCard
|
||||
task={t}
|
||||
minutes={times[t.id] ?? 0}
|
||||
grabbed={grabbed === t.id}
|
||||
dragging={dragId === t.id}
|
||||
resolve={memberById}
|
||||
innerRef={(el) => {
|
||||
if (el) cardRefs.current.set(t.id, el)
|
||||
else cardRefs.current.delete(t.id)
|
||||
}}
|
||||
onOpen={() => setEditing(t)}
|
||||
onKeyDown={(e) => onCardKeyDown(e, t)}
|
||||
onDragStart={(e) => {
|
||||
setDragId(t.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', t.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragId(null)
|
||||
setOverStatus(null)
|
||||
onDragEnd={clearDrag}
|
||||
onDragOver={(e) => {
|
||||
if (!dragId || t.id === dragId) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
const after = e.clientY > r.top + r.height / 2
|
||||
const others = colTasks.filter((x) => x.id !== dragId)
|
||||
const oi = others.findIndex((x) => x.id === t.id)
|
||||
const beforeId = after ? (others[oi + 1]?.id ?? null) : t.id
|
||||
setDrop((cur) =>
|
||||
cur?.status === status && cur.beforeId === beforeId
|
||||
? cur
|
||||
: { status, beforeId }
|
||||
)
|
||||
}}
|
||||
onClick={() => setEditing(t)}
|
||||
className={`card cursor-pointer p-3 transition-shadow hover:border-accent hover:shadow-md ${
|
||||
dragId === t.id ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-ink">{t.title}</p>
|
||||
{t.assignee_ids?.length ? (
|
||||
<div className="flex shrink-0 -space-x-1.5">
|
||||
{t.assignee_ids.slice(0, 3).map((id) => (
|
||||
<span key={id} className="rounded-full ring-2 ring-panel">
|
||||
<Avatar profile={byId(id)} size={24} />
|
||||
</span>
|
||||
))}
|
||||
{t.assignee_ids.length > 3 && (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-panel2 text-[10px] font-medium text-muted ring-2 ring-panel">
|
||||
+{t.assignee_ids.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{taskPoles(t).length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{taskPoles(t).map((p) => (
|
||||
<span key={p} className={`badge ${poleColor(p)}`}>
|
||||
{POLE_LABELS[p]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<PriorityBadge priority={t.priority} />
|
||||
{mins > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-accent2">
|
||||
<Clock size={12} />
|
||||
{formatMinutes(mins)}
|
||||
</span>
|
||||
)}
|
||||
{t.due_date && (
|
||||
<span className="ml-auto flex items-center gap-1 text-xs text-muted">
|
||||
<CalendarDays size={12} />
|
||||
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!!dragId && drop?.status === status && drop.beforeId === null && (
|
||||
<div className="h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
onClick={() => setCreatingIn(status)}
|
||||
className="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-subtle transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Plus size={14} /> Ajouter
|
||||
<Plus size={14} /> Ajouter une tâche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
|
||||
{(editing || creatingIn) && (
|
||||
{(editing || creatingIn) && projectId && (
|
||||
<TaskModal
|
||||
task={editing}
|
||||
defaultStatus={creatingIn ?? 'todo'}
|
||||
projectId={projectId!}
|
||||
members={members}
|
||||
projectId={projectId}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
setCreatingIn(null)
|
||||
}}
|
||||
onSaved={load}
|
||||
onSaved={q.reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Carte
|
||||
// =====================================================================
|
||||
|
||||
function TaskCard({
|
||||
task,
|
||||
minutes,
|
||||
grabbed,
|
||||
dragging,
|
||||
resolve,
|
||||
innerRef,
|
||||
onOpen,
|
||||
onKeyDown,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver
|
||||
}: {
|
||||
task: Task
|
||||
minutes: number
|
||||
grabbed: boolean
|
||||
dragging: boolean
|
||||
resolve: (id: string) => Profile | undefined
|
||||
innerRef: (el: HTMLDivElement | null) => void
|
||||
onOpen: () => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
}): JSX.Element {
|
||||
const progress = checklistProgress(task.checklist)
|
||||
const overdue = task.due_date && task.status !== 'done' && isPast(new Date(`${task.due_date}T23:59:59`))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
aria-grabbed={grabbed}
|
||||
aria-label={`${task.title}. ${TASK_STATUS_LABELS[task.status]}. Priorité ${PRIORITY_LABELS[task.priority]}.`}
|
||||
onClick={onOpen}
|
||||
onKeyDown={onKeyDown}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
className={`group card cursor-pointer p-3 transition-all hover:border-border-strong hover:shadow-raised ${
|
||||
dragging ? 'opacity-40' : ''
|
||||
} ${grabbed ? 'border-accent ring-2 ring-accent/40' : ''}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<GripVertical
|
||||
size={14}
|
||||
className="mt-0.5 shrink-0 text-subtle opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 text-sm font-medium leading-snug text-ink">{task.title}</p>
|
||||
<AvatarStack ids={task.assignee_ids} resolve={resolve} />
|
||||
</div>
|
||||
|
||||
{(task.poles.length > 0 || task.tags.length > 0) && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{task.poles.map((p) => (
|
||||
<PoleBadge key={p} pole={p} />
|
||||
))}
|
||||
{task.tags.map((t) => (
|
||||
<Tag key={t} label={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress.total > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 flex items-center gap-1 text-[11px] text-muted">
|
||||
<ListChecks size={12} />
|
||||
<span className="tabular">
|
||||
{progress.done}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar pct={progress.pct} barClassName="bg-accent2" height="h-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Badge className={priorityBadge(task.priority)}>{PRIORITY_LABELS[task.priority]}</Badge>
|
||||
{minutes > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-accent2 tabular">
|
||||
<Clock size={12} />
|
||||
{formatMinutes(minutes)}
|
||||
</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span
|
||||
className={`ml-auto flex items-center gap-1 text-xs ${
|
||||
overdue ? 'font-medium text-rose-600 dark:text-rose-400' : 'text-muted'
|
||||
}`}
|
||||
title={overdue ? 'Échéance dépassée' : 'Échéance'}
|
||||
>
|
||||
<CalendarDays size={12} />
|
||||
{format(new Date(task.due_date), 'd MMM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+124
-27
@@ -1,21 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Mail } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { Button, Field, Input } from '../components/ui'
|
||||
import Logo from '../components/Logo'
|
||||
|
||||
/** Traduit les messages d'erreur de Supabase Auth. */
|
||||
function translate(msg: string): string {
|
||||
if (/invalid login/i.test(msg)) return 'Email ou mot de passe incorrect.'
|
||||
if (/email not confirmed/i.test(msg)) return "Cette adresse email n'a pas encore été confirmée."
|
||||
if (/rate limit|too many/i.test(msg)) return 'Trop de tentatives. Réessaie dans une minute.'
|
||||
if (/failed to fetch|network/i.test(msg)) {
|
||||
return 'Impossible de joindre le serveur. Vérifie ta connexion internet.'
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
export default function Login(): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const [mode, setMode] = useState<'signin' | 'reset'>('signin')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const signIn = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!email.trim() || !password) {
|
||||
setError('Renseigne ton email et ton mot de passe.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { error } = await supa().auth.signInWithPassword({ email, password })
|
||||
if (error) throw error
|
||||
const { error: e } = await supa().auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password
|
||||
})
|
||||
if (e) throw e
|
||||
// La bascule vers l'application est pilotée par onAuthStateChange.
|
||||
} catch (e) {
|
||||
setError(translate((e as Error).message))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendReset = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!email.trim()) {
|
||||
setError('Indique ton adresse email.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { error: e } = await supa().auth.resetPasswordForEmail(email.trim())
|
||||
if (e) throw e
|
||||
setSent(true)
|
||||
toast('Email de réinitialisation envoyé.', 'success')
|
||||
} catch (e) {
|
||||
setError(translate((e as Error).message))
|
||||
} finally {
|
||||
@@ -26,52 +68,107 @@ export default function Login(): JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 26 }}
|
||||
className="card w-full max-w-sm"
|
||||
>
|
||||
<div className="mb-6 flex flex-col items-center gap-2 text-center">
|
||||
<Logo size={56} className="rounded-xl shadow-sm" />
|
||||
<Logo size={52} className="rounded-xl shadow-card" />
|
||||
<h1 className="text-xl font-semibold text-ink">GameDev Tracker</h1>
|
||||
<p className="text-sm text-muted">Connecte-toi à ton équipe</p>
|
||||
<p className="text-sm text-muted">
|
||||
{mode === 'signin' ? 'Connecte-toi à ton équipe' : 'Réinitialiser le mot de passe'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
{/* Un vrai <form> : les gestionnaires de mots de passe fonctionnent,
|
||||
l'autocomplétion aussi, et Entrée valide naturellement. */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void (mode === 'signin' ? signIn() : sendReset())
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Field label="Email">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
placeholder="toi@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label className="label">Mot de passe</label>
|
||||
<input
|
||||
className="input mb-4"
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{mode === 'signin' && (
|
||||
<Field label="Mot de passe">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-sm text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
|
||||
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||
Se connecter
|
||||
{sent && mode === 'reset' && (
|
||||
<p className="flex items-start gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
<Mail size={15} className="mt-0.5 shrink-0" />
|
||||
Si un compte existe pour cette adresse, un lien de réinitialisation vient d'être envoyé.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" full loading={busy}>
|
||||
{mode === 'signin' ? 'Se connecter' : 'Envoyer le lien'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{mode === 'signin' ? (
|
||||
<div className="mt-4 space-y-1.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('reset')
|
||||
setError('')
|
||||
}}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Mot de passe oublié ?
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-subtle">
|
||||
<p className="text-xs text-subtle">
|
||||
Pas de compte ? Demande à ton administrateur de t'en créer un.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('signin')
|
||||
setError('')
|
||||
setSent(false)
|
||||
}}
|
||||
className="mx-auto mt-4 flex items-center gap-1 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ArrowLeft size={12} /> Revenir à la connexion
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function translate(msg: string): string {
|
||||
if (msg.includes('Invalid login')) return 'Email ou mot de passe incorrect.'
|
||||
if (msg.includes('Email not confirmed')) return "L'email n'a pas encore été confirmé."
|
||||
return msg
|
||||
}
|
||||
|
||||
+272
-169
@@ -1,189 +1,292 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Users, Pencil, Camera, Loader2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { endOfWeek, format, startOfWeek } from 'date-fns'
|
||||
import { Bug as BugIcon, Clock, KanbanSquare, Pencil, Users } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { rows, useQuery, useRealtime } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { uploadAvatar } from '../lib/avatar'
|
||||
import { Profile, MemberRole, POLE_LABELS, poleColor, memberPoles } from '../lib/types'
|
||||
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { PageActions, usePageSubtitle } from '../components/Layout'
|
||||
import {
|
||||
ACTIVE_BUG_STATUSES,
|
||||
Bug,
|
||||
Pole,
|
||||
POLE_ORDER,
|
||||
POLE_LABELS,
|
||||
Profile,
|
||||
Task,
|
||||
TimeLog,
|
||||
formatMinutes,
|
||||
memberPoles,
|
||||
normalizeBugs,
|
||||
normalizeTasks,
|
||||
plural
|
||||
} from '../lib/types'
|
||||
import { Avatar, PoleList } from '../components/people'
|
||||
import { AsyncContent, Button, EmptyState, SearchInput, Skeleton } from '../components/ui'
|
||||
import { Select } from '../components/Select'
|
||||
import EditProfileModal from '../components/EditProfileModal'
|
||||
|
||||
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||
const DAY = (d: Date): string => format(d, 'yyyy-MM-dd')
|
||||
|
||||
interface Stats {
|
||||
active: number
|
||||
done: number
|
||||
bugs: number
|
||||
weekMinutes: number
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export default function Members(): JSX.Element {
|
||||
const { profile, refreshProfile } = useApp()
|
||||
const [members, setMembers] = useState<Profile[]>([])
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { profile, members, membersLoading, currentProject, refreshProfile, setPage, setFocusEntity } =
|
||||
useApp()
|
||||
const projectId = currentProject?.id
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
const { data } = await supa().from('profiles').select('*').order('full_name')
|
||||
setMembers((data ?? []) as Profile[])
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const [pole, setPole] = useState('')
|
||||
|
||||
const weekStart = useMemo(() => startOfWeek(new Date(), { weekStartsOn: 1 }), [])
|
||||
const weekEnd = useMemo(() => endOfWeek(new Date(), { weekStartsOn: 1 }), [])
|
||||
|
||||
const fetchWork = useCallback(async () => {
|
||||
if (!projectId) return { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[] }
|
||||
const [tasks, bugs, logs] = await Promise.all([
|
||||
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
|
||||
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
|
||||
rows<TimeLog>(supa().from('time_logs').select('*').eq('project_id', projectId))
|
||||
])
|
||||
return { tasks, bugs, logs }
|
||||
}, [projectId])
|
||||
|
||||
const work = useQuery(fetchWork, { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[] }, {
|
||||
enabled: Boolean(projectId)
|
||||
})
|
||||
|
||||
useRealtime(
|
||||
projectId ? `members-${projectId}` : null,
|
||||
[
|
||||
{ table: 'tasks', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
|
||||
{ table: 'time_logs', filter: `project_id=eq.${projectId}` }
|
||||
],
|
||||
work.reload
|
||||
)
|
||||
|
||||
/** Statistiques par membre — la page ne montrait qu'un nom et des pastilles. */
|
||||
const statsOf = useCallback(
|
||||
(id: string): Stats => {
|
||||
const { tasks, bugs, logs } = work.data
|
||||
const mine = logs.filter((l) => l.user_id === id)
|
||||
return {
|
||||
active: tasks.filter((t) => t.status !== 'done' && t.assignee_ids.includes(id)).length,
|
||||
done: tasks.filter((t) => t.status === 'done' && t.assignee_ids.includes(id)).length,
|
||||
bugs: bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status) && b.assignee_ids.includes(id))
|
||||
.length,
|
||||
weekMinutes: mine
|
||||
.filter((l) => l.logged_at >= DAY(weekStart) && l.logged_at <= DAY(weekEnd))
|
||||
.reduce((s, l) => s + l.minutes, 0),
|
||||
totalMinutes: mine.reduce((s, l) => s + l.minutes, 0)
|
||||
}
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
},
|
||||
[work.data, weekStart, weekEnd]
|
||||
)
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
return members.filter((m) => {
|
||||
if (pole && !memberPoles(m).includes(pole as Pole)) return false
|
||||
if (needle && !m.full_name.toLowerCase().includes(needle)) return false
|
||||
return true
|
||||
})
|
||||
}, [members, q, pole])
|
||||
|
||||
usePageSubtitle(plural(members.length, 'membre'))
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title="Équipe" subtitle={`${members.length} membre(s)`}>
|
||||
<button className="btn-ghost" onClick={() => setEditing(true)}>
|
||||
<Pencil size={16} /> Modifier mon profil
|
||||
</button>
|
||||
</PageHeader>
|
||||
<PageActions>
|
||||
<SearchInput
|
||||
className="hidden w-48 md:block"
|
||||
value={q}
|
||||
onChange={setQ}
|
||||
placeholder="Chercher un membre…"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
className="!w-40"
|
||||
ariaLabel="Filtrer par pôle"
|
||||
value={pole}
|
||||
onChange={setPole}
|
||||
options={[
|
||||
{ value: '', label: 'Tous les pôles' },
|
||||
...POLE_ORDER.map((p) => ({ value: p as string, label: POLE_LABELS[p] }))
|
||||
]}
|
||||
/>
|
||||
<Button icon={<Pencil size={15} />} onClick={() => setEditing(true)}>
|
||||
Mon profil
|
||||
</Button>
|
||||
</PageActions>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{members.length === 0 ? (
|
||||
<EmptyState icon={<Users size={40} />} text="Aucun membre." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="card flex items-center gap-3">
|
||||
<Avatar profile={m} size={44} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-ink">
|
||||
{m.full_name}
|
||||
{m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{memberPoles(m).length === 0 ? (
|
||||
<span className="text-sm text-subtle">Aucun pôle</span>
|
||||
) : (
|
||||
memberPoles(m).map((p) => (
|
||||
<span key={p} className={`badge ${poleColor(p)}`}>
|
||||
{POLE_LABELS[p]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{membersLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-[132px]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<EditProfileModal
|
||||
profile={profile!}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
await refreshProfile()
|
||||
await load()
|
||||
) : shown.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users size={40} />}
|
||||
title={members.length === 0 ? 'Aucun membre' : 'Aucun membre ne correspond'}
|
||||
text={
|
||||
members.length === 0
|
||||
? "Les comptes se créent depuis l'onglet Administration."
|
||||
: 'Change le filtre ou la recherche.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AsyncContent loading={false} error={work.error} onRetry={work.reload}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{shown.map((m) => (
|
||||
<MemberCard
|
||||
key={m.id}
|
||||
member={m}
|
||||
isMe={m.id === profile?.id}
|
||||
stats={statsOf(m.id)}
|
||||
loading={work.loading && Boolean(projectId)}
|
||||
hasProject={Boolean(projectId)}
|
||||
onOpenTasks={() => {
|
||||
setFocusEntity(null)
|
||||
setPage('kanban')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditProfileModal({
|
||||
profile,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
profile: Profile
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}): JSX.Element {
|
||||
const { toast } = useFeedback()
|
||||
const [name, setName] = useState(profile.full_name)
|
||||
const [roles, setRoles] = useState<MemberRole[]>(memberPoles(profile))
|
||||
const [color, setColor] = useState(profile.avatar_color)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile.avatar_url ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleRole = (pole: MemberRole): void =>
|
||||
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
|
||||
|
||||
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
setAvatarUrl(await uploadAvatar(file, profile.id))
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
await supa()
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: name.trim() || 'Membre',
|
||||
roles,
|
||||
role: roles[0] ?? 'other',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl
|
||||
})
|
||||
.eq('id', profile.id)
|
||||
await onSaved()
|
||||
setBusy(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const preview = { full_name: name || '?', avatar_color: color, avatar_url: avatarUrl } as Profile
|
||||
|
||||
return (
|
||||
<Modal title="Mon profil" onClose={onClose}>
|
||||
<label className="label">Nom complet</label>
|
||||
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<label className="label">Photo de profil</label>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar profile={preview} size={56} />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<Loader2 className="animate-spin text-white" size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
|
||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
className="text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="label">Mes pôles (un ou plusieurs)</label>
|
||||
<div className="mb-3">
|
||||
<PoleChips selected={roles} onToggle={toggleRole} />
|
||||
</div>
|
||||
{!avatarUrl && (
|
||||
<>
|
||||
<label className="label">Couleur d'avatar (sans photo)</label>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
|
||||
style={{ background: c }}
|
||||
onEdit={m.id === profile?.id ? () => setEditing(true) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</AsyncContent>
|
||||
)}
|
||||
<button className="btn-primary w-full" onClick={save} disabled={busy || uploading}>
|
||||
Enregistrer
|
||||
</button>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{editing && profile && (
|
||||
<EditProfileModal
|
||||
profile={profile}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={refreshProfile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function MemberCard({
|
||||
member,
|
||||
isMe,
|
||||
stats,
|
||||
loading,
|
||||
hasProject,
|
||||
onOpenTasks,
|
||||
onEdit
|
||||
}: {
|
||||
member: Profile
|
||||
isMe: boolean
|
||||
stats: Stats
|
||||
loading: boolean
|
||||
hasProject: boolean
|
||||
onOpenTasks: () => void
|
||||
onEdit?: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<article className="card flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar profile={member} size={44} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate font-medium text-ink">{member.full_name}</h3>
|
||||
{isMe && <span className="shrink-0 text-xs text-accent">(moi)</span>}
|
||||
{member.is_admin && (
|
||||
<span className="badge shrink-0 bg-amber-500/12 text-amber-700 dark:text-amber-300">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<PoleList poles={memberPoles(member)} />
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Modifier mon profil"
|
||||
onClick={onEdit}
|
||||
className="shrink-0 text-subtle hover:text-accent"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasProject ? (
|
||||
<p className="text-xs text-subtle">Ouvre un projet pour voir l'activité de chacun.</p>
|
||||
) : loading ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-1 border-t border-border pt-2.5">
|
||||
<Stat
|
||||
icon={<KanbanSquare size={13} />}
|
||||
value={stats.active}
|
||||
label={stats.active > 1 ? 'tâches' : 'tâche'}
|
||||
hint={`${stats.done} terminée${stats.done > 1 ? 's' : ''}`}
|
||||
onClick={onOpenTasks}
|
||||
/>
|
||||
<Stat
|
||||
icon={<BugIcon size={13} />}
|
||||
value={stats.bugs}
|
||||
label={stats.bugs > 1 ? 'bugs' : 'bug'}
|
||||
tone={stats.bugs > 0 ? 'text-rose-600 dark:text-rose-400' : undefined}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Clock size={13} />}
|
||||
value={formatMinutes(stats.weekMinutes)}
|
||||
label="cette semaine"
|
||||
hint={stats.totalMinutes ? `${formatMinutes(stats.totalMinutes)} au total` : undefined}
|
||||
tone="text-accent2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({
|
||||
icon,
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
tone = 'text-ink',
|
||||
onClick
|
||||
}: {
|
||||
icon: JSX.Element
|
||||
value: string | number
|
||||
label: string
|
||||
hint?: string
|
||||
tone?: string
|
||||
onClick?: () => void
|
||||
}): JSX.Element {
|
||||
const content = (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-[11px] text-subtle">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span className={`block text-sm font-semibold tabular ${tone}`}>{value}</span>
|
||||
{hint && <span className="block truncate text-[10px] text-subtle">{hint}</span>}
|
||||
</>
|
||||
)
|
||||
if (!onClick) return <div className="min-w-0">{content}</div>
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="min-w-0 rounded-lg text-left hover:bg-hover">
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
+107
-153
@@ -1,129 +1,102 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowRight, ArrowLeft, Check, Loader2, Sparkles, Camera } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Check, Sparkles } from 'lucide-react'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { mutate, humanize } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { uploadAvatar } from '../lib/avatar'
|
||||
import {
|
||||
MemberRole,
|
||||
Profile,
|
||||
POLE_LABELS,
|
||||
poleColor,
|
||||
SELF_ASSIGNABLE_POLES,
|
||||
CRITICAL_POLES,
|
||||
memberPoles
|
||||
Pole,
|
||||
POLE_LABELS,
|
||||
SELF_ASSIGNABLE_POLES,
|
||||
memberPoles,
|
||||
poleBadge
|
||||
} from '../lib/types'
|
||||
import { Avatar } from '../components/ui'
|
||||
import { AVATAR_COLORS, AvatarUploader, ColorPicker } from '../components/people'
|
||||
import { Button, Field, Input } from '../components/ui'
|
||||
import Logo from '../components/Logo'
|
||||
|
||||
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||
const STEPS = ['Ton nom', 'Ta photo', 'Tes pôles'] as const
|
||||
|
||||
export default function Onboarding(): JSX.Element {
|
||||
const { profile, refreshProfile } = useApp()
|
||||
const { profile, refreshProfile, signOut } = useApp()
|
||||
const { toast } = useFeedback()
|
||||
|
||||
// Rôles critiques déjà attribués par l'admin (ex : chef de projet) : on les
|
||||
// Pôles critiques déjà attribués par un admin (ex : chef de projet) : on les
|
||||
// conserve, mais le membre ne peut pas se les donner lui-même.
|
||||
const keptCritical = memberPoles(profile ?? {}).filter((p) => CRITICAL_POLES.includes(p))
|
||||
|
||||
// Découpe le nom existant (« Prénom Nom ») s'il y en a un.
|
||||
const existingName = profile && profile.full_name !== 'Membre' ? profile.full_name : ''
|
||||
const [step, setStep] = useState(0)
|
||||
const [firstName, setFirstName] = useState(existingName.split(' ')[0] ?? '')
|
||||
const [lastName, setLastName] = useState(existingName.split(' ').slice(1).join(' '))
|
||||
const [color, setColor] = useState(profile?.avatar_color ?? COLORS[0])
|
||||
const [color, setColor] = useState(profile?.avatar_color ?? AVATAR_COLORS[0])
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [poles, setPoles] = useState<MemberRole[]>(
|
||||
const [poles, setPoles] = useState<Pole[]>(
|
||||
memberPoles(profile ?? {}).filter((p) => SELF_ASSIGNABLE_POLES.includes(p))
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const togglePole = (p: MemberRole): void =>
|
||||
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
||||
|
||||
const fullName = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||
const previewProfile = {
|
||||
full_name: fullName || '?',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl
|
||||
} as unknown as Profile
|
||||
|
||||
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !profile) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast('Choisis un fichier image.', 'error')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const url = await uploadAvatar(file, profile.id)
|
||||
setAvatarUrl(url)
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 'error')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const steps = ['Ton nom', 'Ta photo', 'Tes pôles']
|
||||
const canNext = step === 0 ? firstName.trim().length > 0 : true
|
||||
|
||||
const togglePole = (p: Pole): void =>
|
||||
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
|
||||
|
||||
const finish = async (): Promise<void> => {
|
||||
if (!profile) return
|
||||
setBusy(true)
|
||||
const roles = Array.from(new Set([...poles, ...keptCritical]))
|
||||
const finalRoles = roles.length ? roles : (['other'] as MemberRole[])
|
||||
const { error } = await supa()
|
||||
const roles = [...new Set([...poles, ...keptCritical])]
|
||||
try {
|
||||
await mutate(
|
||||
supa()
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: fullName || 'Membre',
|
||||
avatar_color: color,
|
||||
avatar_url: avatarUrl,
|
||||
roles: finalRoles,
|
||||
role: finalRoles[0],
|
||||
roles: roles.length ? roles : (['other'] as Pole[]),
|
||||
onboarded: true
|
||||
})
|
||||
.eq('id', profile.id)
|
||||
if (error) {
|
||||
setBusy(false)
|
||||
toast(error.message, 'error')
|
||||
return
|
||||
}
|
||||
)
|
||||
await refreshProfile()
|
||||
// Le passage onboarded=true bascule automatiquement vers l'app.
|
||||
// Le passage onboarded=true bascule automatiquement vers l'application.
|
||||
} catch (e) {
|
||||
// Sans ce reset, un échec laissait l'utilisateur bloqué sur un spinner.
|
||||
setBusy(false)
|
||||
toast(humanize(e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const next = (): void => {
|
||||
if (step < steps.length - 1) setStep((s) => s + 1)
|
||||
else finish()
|
||||
if (step < STEPS.length - 1) setStep((s) => s + 1)
|
||||
else void finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 14, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 26 }}
|
||||
className="card w-full max-w-lg"
|
||||
>
|
||||
{/* En-tête */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<Logo size={44} className="rounded-xl shadow-sm" />
|
||||
<div>
|
||||
<header className="mb-5 flex items-center gap-3">
|
||||
<Logo size={42} className="rounded-xl shadow-card" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold text-ink">Bienvenue dans l'équipe 👋</h1>
|
||||
<p className="text-sm text-muted">Configurons ton profil en 3 étapes.</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Étape {step + 1} sur {STEPS.length} — {STEPS[step]}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Barre de progression */}
|
||||
<div className="mb-6 flex gap-1.5">
|
||||
{steps.map((_, i) => (
|
||||
<div className="mb-6 flex gap-1.5" role="progressbar" aria-valuenow={step + 1} aria-valuemin={1} aria-valuemax={STEPS.length}>
|
||||
{STEPS.map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={label}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= step ? 'bg-accent' : 'bg-panel2'
|
||||
}`}
|
||||
@@ -134,107 +107,83 @@ export default function Onboarding(): JSX.Element {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canNext) next()
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Prénom</label>
|
||||
<input
|
||||
className="input"
|
||||
<Field label="Prénom" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
autoComplete="given-name"
|
||||
placeholder="Alex"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nom</label>
|
||||
<input
|
||||
className="input"
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Nom">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoComplete="family-name"
|
||||
placeholder="Martin"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-subtle">
|
||||
C'est le nom que tes coéquipiers verront sur les tâches.
|
||||
C'est le nom que tes coéquipiers verront sur les tâches et les messages.
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<label className="label">Ta photo de profil</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Avatar profile={previewProfile} size={72} />
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||
<Loader2 className="animate-spin text-white" size={22} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={pickPhoto}
|
||||
<Field label="Ta photo de profil">
|
||||
{profile && (
|
||||
<AvatarUploader
|
||||
userId={profile.id}
|
||||
name={fullName}
|
||||
color={color}
|
||||
avatarUrl={avatarUrl}
|
||||
onChange={setAvatarUrl}
|
||||
size={72}
|
||||
/>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Camera size={16} /> {avatarUrl ? 'Changer la photo' : 'Ajouter une photo'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
className="ml-2 text-xs text-muted hover:text-rose-600"
|
||||
onClick={() => setAvatarUrl(null)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Repli : couleur des initiales si aucune photo */}
|
||||
{!avatarUrl && (
|
||||
<div className="mt-4">
|
||||
<p className="label">Ou choisis une couleur</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
|
||||
color === c ? 'ring-2 ring-ink ring-offset-2 ring-offset-panel' : ''
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Ou choisis une couleur pour tes initiales" className="mt-4">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-subtle">
|
||||
Optionnel — tu pourras la changer plus tard depuis « Équipe ».
|
||||
Optionnel — tu pourras la changer plus tard depuis « Mon profil ».
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<label className="label">Dans quel(s) pôle(s) travailles-tu ?</label>
|
||||
<Field
|
||||
label="Dans quel(s) pôle(s) travailles-tu ?"
|
||||
hint="Tu recevras une notification quand une tâche ou un bug est confié à l'un de tes pôles."
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SELF_ASSIGNABLE_POLES.map((p) => {
|
||||
const on = poles.includes(p)
|
||||
@@ -242,10 +191,11 @@ export default function Onboarding(): JSX.Element {
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => togglePole(p)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
on
|
||||
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
|
||||
? `${poleBadge(p)} border-transparent ring-2 ring-accent/40`
|
||||
: 'border-border text-muted hover:border-accent hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
@@ -255,33 +205,37 @@ export default function Onboarding(): JSX.Element {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{keptCritical.length > 0 && (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent2">
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent">
|
||||
<Sparkles size={13} />
|
||||
Ton rôle {keptCritical.map((p) => POLE_LABELS[p]).join(', ')} t'a été attribué par
|
||||
l'administrateur.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-subtle">
|
||||
Tu recevras une notification quand une tâche est confiée à un de tes pôles.
|
||||
{poles.length === 0 && keptCritical.length === 0 && (
|
||||
<p className="mt-3 text-xs text-amber-600 dark:text-amber-400">
|
||||
Sans pôle, tu ne recevras aucune notification d'équipe. Tu pourras en ajouter plus tard.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
{step > 0 ? (
|
||||
<button className="btn-ghost" onClick={() => setStep((s) => s - 1)} disabled={busy}>
|
||||
<ArrowLeft size={16} /> Retour
|
||||
</button>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={() => setStep((s) => s - 1)} disabled={busy}>
|
||||
Retour
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
<Button variant="quiet" onClick={signOut} disabled={busy}>
|
||||
Se déconnecter
|
||||
</Button>
|
||||
)}
|
||||
<button className="btn-primary" onClick={next} disabled={!canNext || busy}>
|
||||
{busy && <Loader2 className="animate-spin" size={16} />}
|
||||
{step < steps.length - 1 ? (
|
||||
<Button variant="primary" onClick={next} disabled={!canNext} loading={busy}>
|
||||
{step < STEPS.length - 1 ? (
|
||||
<>
|
||||
Continuer <ArrowRight size={16} />
|
||||
</>
|
||||
@@ -290,7 +244,7 @@ export default function Onboarding(): JSX.Element {
|
||||
<Check size={16} /> Terminer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
+610
-248
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
|
||||
export default function Setup(): JSX.Element {
|
||||
const { saveConfig } = useApp()
|
||||
const [url, setUrl] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setError('')
|
||||
if (!url.includes('supabase') || key.length < 20) {
|
||||
setError('URL ou clé invalide. Vérifie tes valeurs dans Supabase > Settings > API.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await saveConfig(url, key)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||
className="card w-full max-w-lg"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-accent/20 p-2 text-accent">
|
||||
<Database size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink">Connexion à la base de données</h1>
|
||||
<p className="text-sm text-muted">Configuration unique pour toute l'équipe</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-ink">Comment obtenir ces clés (gratuit) :</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted">
|
||||
<li>
|
||||
Crée un compte sur{' '}
|
||||
<a
|
||||
href="https://supabase.com"
|
||||
className="inline-flex items-center gap-1 text-accent2 hover:underline"
|
||||
>
|
||||
supabase.com <ExternalLink size={12} />
|
||||
</a>
|
||||
</li>
|
||||
<li>Crée un nouveau projet (gratuit)</li>
|
||||
<li>Onglet SQL Editor : colle le contenu de supabase/schema.sql et exécute-le</li>
|
||||
<li>Onglet Settings → API : copie l'URL et la clé « anon public »</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<label className="label">URL du projet</label>
|
||||
<input
|
||||
className="input mb-3"
|
||||
placeholder="https://xxxxx.supabase.co"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<label className="label">Clé anon public</label>
|
||||
<input
|
||||
className="input mb-4"
|
||||
placeholder="eyJhbGciOi..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{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} />}
|
||||
Connecter
|
||||
</button>
|
||||
<p className="mt-3 text-center text-xs text-subtle">
|
||||
Les clés sont stockées localement sur ton ordinateur uniquement.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// =====================================================================
|
||||
// Edge Function : admin-users
|
||||
// Gère les comptes (lister / créer / supprimer / modifier rôle & admin).
|
||||
// Gère les comptes (lister / créer / supprimer / modifier).
|
||||
// Tourne sur les serveurs Supabase et garde la clé service_role en
|
||||
// sécurité — elle n'est JAMAIS exposée dans l'appli.
|
||||
// sécurité — elle n'est JAMAIS exposée dans l'application.
|
||||
//
|
||||
// Déploiement (voir le guide) :
|
||||
// Déploiement :
|
||||
// supabase functions deploy admin-users --no-verify-jwt
|
||||
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous.)
|
||||
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous)
|
||||
//
|
||||
// v10 : la colonne `profiles.role` a disparu ; seul `roles[]` fait foi.
|
||||
// =====================================================================
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
@@ -20,6 +22,17 @@ 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')!
|
||||
|
||||
const VALID_ROLES = [
|
||||
'lead',
|
||||
'programmer',
|
||||
'artist',
|
||||
'game_designer',
|
||||
'sound',
|
||||
'writer',
|
||||
'qa',
|
||||
'other'
|
||||
]
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
@@ -27,8 +40,13 @@ function json(body: unknown, status = 200): Response {
|
||||
})
|
||||
}
|
||||
|
||||
/** Ne garde que des pôles valides (l'enum SQL rejetterait le reste). */
|
||||
function cleanRoles(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return []
|
||||
return [...new Set(input.filter((r) => typeof r === 'string' && VALID_ROLES.includes(r)))]
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Pré-vol CORS (envoyé automatiquement par le navigateur/webview).
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
|
||||
|
||||
try {
|
||||
@@ -43,7 +61,7 @@ Deno.serve(async (req) => {
|
||||
} = await caller.auth.getUser()
|
||||
if (userErr || !user) return json({ error: 'Non authentifié.' }, 401)
|
||||
|
||||
// --- 2) Client "admin" (service_role) pour les opérations sensibles ---
|
||||
// --- 2) Client « admin » (service_role) pour les opérations sensibles ---
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
|
||||
auth: { autoRefreshToken: false, persistSession: false }
|
||||
})
|
||||
@@ -61,63 +79,61 @@ Deno.serve(async (req) => {
|
||||
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
// Emails dans auth.users + métadonnées dans profiles → on fusionne.
|
||||
// Les emails sont dans auth.users, le reste dans profiles → fusion.
|
||||
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) ?? {}
|
||||
const roles =
|
||||
Array.isArray(pr.roles) && pr.roles.length
|
||||
? pr.roles
|
||||
: pr.role
|
||||
? [pr.role]
|
||||
: []
|
||||
const byId = new Map((profiles ?? []).map((pr: Record<string, unknown>) => [pr.id, pr]))
|
||||
|
||||
const users = list.users
|
||||
.map((u) => {
|
||||
const pr = (byId.get(u.id) ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
full_name: pr.full_name ?? '',
|
||||
role: pr.role ?? 'other',
|
||||
roles,
|
||||
is_admin: pr.is_admin ?? false,
|
||||
avatar_color: pr.avatar_color ?? '#7c5cff',
|
||||
avatar_url: pr.avatar_url ?? null,
|
||||
created_at: pr.created_at ?? u.created_at
|
||||
full_name: (pr.full_name as string) ?? '',
|
||||
roles: Array.isArray(pr.roles) ? pr.roles : [],
|
||||
is_admin: Boolean(pr.is_admin),
|
||||
avatar_color: (pr.avatar_color as string) ?? '#7c5cff',
|
||||
avatar_url: (pr.avatar_url as string) ?? null,
|
||||
created_at: (pr.created_at as string) ?? u.created_at
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'fr'))
|
||||
|
||||
return json(users)
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
const { email, password, full_name, role, roles, is_admin } = p
|
||||
const { email, password, full_name, roles, is_admin } = p
|
||||
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
|
||||
if (String(password).length < 6)
|
||||
if (String(password).length < 6) {
|
||||
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
|
||||
}
|
||||
|
||||
// Liste de pôles (rôles) ; le premier fait office de rôle principal.
|
||||
const roleList: string[] = Array.isArray(roles) && roles.length ? roles : role ? [role] : []
|
||||
const roleList = cleanRoles(roles)
|
||||
const primary = roleList[0] ?? 'other'
|
||||
const name = full_name || String(email).split('@')[0]
|
||||
|
||||
// Crée le compte (email déjà confirmé : connexion immédiate possible).
|
||||
// email_confirm : le membre peut se connecter immédiatement.
|
||||
const { data: created, error } = await admin.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: full_name || email.split('@')[0], role: primary }
|
||||
user_metadata: { full_name: name, role: primary }
|
||||
})
|
||||
if (error) return json({ error: error.message }, 400)
|
||||
|
||||
// Le trigger handle_new_user a créé le profil ; on cale les champs.
|
||||
await admin
|
||||
const { error: profErr } = await admin
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: full_name || email.split('@')[0],
|
||||
role: primary,
|
||||
full_name: name,
|
||||
roles: roleList.length ? roleList : [primary],
|
||||
is_admin: !!is_admin
|
||||
is_admin: Boolean(is_admin)
|
||||
})
|
||||
.eq('id', created.user.id)
|
||||
if (profErr) return json({ error: profErr.message }, 400)
|
||||
|
||||
return json({ ok: true, id: created.user.id })
|
||||
}
|
||||
@@ -125,58 +141,61 @@ Deno.serve(async (req) => {
|
||||
case 'delete': {
|
||||
const { id } = p
|
||||
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
|
||||
if (id === user.id)
|
||||
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, email, password, role, roles, is_admin, avatar_url } = p
|
||||
const { id, full_name, email, password, roles, is_admin, avatar_url } = p
|
||||
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
|
||||
if (id === user.id && is_admin === false)
|
||||
if (id === user.id && is_admin === false) {
|
||||
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
|
||||
}
|
||||
|
||||
// --- a) Champs du compte auth (email / mot de passe / métadonnées) ---
|
||||
// --- a) Compte auth (email / mot de passe / métadonnées) ---
|
||||
const authPatch: Record<string, unknown> = {}
|
||||
if (email !== undefined && String(email).trim()) {
|
||||
authPatch.email = String(email).trim()
|
||||
authPatch.email_confirm = true // évite le mail de confirmation
|
||||
}
|
||||
if (password !== undefined && String(password) !== '') {
|
||||
if (String(password).length < 6)
|
||||
if (String(password).length < 6) {
|
||||
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
|
||||
}
|
||||
authPatch.password = String(password)
|
||||
}
|
||||
if (full_name !== undefined)
|
||||
authPatch.user_metadata = { full_name, role: Array.isArray(roles) ? roles[0] : role }
|
||||
if (full_name !== undefined) {
|
||||
// On fusionne avec les métadonnées existantes plutôt que de les écraser.
|
||||
const { data: existing } = await admin.auth.admin.getUserById(id)
|
||||
authPatch.user_metadata = {
|
||||
...(existing?.user?.user_metadata ?? {}),
|
||||
full_name
|
||||
}
|
||||
}
|
||||
if (Object.keys(authPatch).length > 0) {
|
||||
const { error: authErr } = await admin.auth.admin.updateUserById(id, authPatch)
|
||||
if (authErr) return json({ error: authErr.message }, 400)
|
||||
}
|
||||
|
||||
// --- b) Champs du profil (nom, pôles, admin) ---
|
||||
// --- b) Profil (nom, pôles, admin, avatar) ---
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (full_name !== undefined) patch.full_name = full_name
|
||||
if (is_admin !== undefined) patch.is_admin = !!is_admin
|
||||
if (is_admin !== undefined) patch.is_admin = Boolean(is_admin)
|
||||
if (avatar_url !== undefined) patch.avatar_url = avatar_url || null
|
||||
// Gestion des pôles : roles = liste complète, role = principal (roles[0]).
|
||||
if (Array.isArray(roles)) {
|
||||
const primary = roles[0] ?? 'other'
|
||||
patch.roles = roles.length ? roles : [primary]
|
||||
patch.role = primary
|
||||
} else if (role !== undefined) {
|
||||
patch.role = role
|
||||
patch.roles = [role]
|
||||
}
|
||||
if (roles !== undefined) patch.roles = cleanRoles(roles)
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const { error } = await admin.from('profiles').update(patch).eq('id', id)
|
||||
if (error) return json({ error: error.message }, 400)
|
||||
}
|
||||
|
||||
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0)
|
||||
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0) {
|
||||
return json({ error: 'Rien à modifier.' }, 400)
|
||||
}
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v10 « Refonte »
|
||||
-- À coller dans : Supabase > SQL Editor > New query > Run
|
||||
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
|
||||
--
|
||||
-- Ce que fait cette migration :
|
||||
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
|
||||
-- 1. Commentaires sur les tâches et les bugs
|
||||
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
|
||||
-- 3. Parité tâches/bugs : plusieurs pôles par bug
|
||||
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
|
||||
-- (profiles.role, tasks.pole, bugs.assignee_id)
|
||||
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
|
||||
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
|
||||
-- 7. Temps réel sur les projets
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
|
||||
--
|
||||
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
|
||||
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
|
||||
-- pas à pas, la colonne manquait et l'application plantait en tentant
|
||||
-- d'afficher les étiquettes d'une tâche.
|
||||
--
|
||||
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
|
||||
-- tableau : jamais NULL, toujours un tableau vide par défaut.
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.tasks add column if not exists tags text[] not null default '{}';
|
||||
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
|
||||
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
|
||||
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
|
||||
|
||||
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
|
||||
do $$
|
||||
declare
|
||||
r record;
|
||||
begin
|
||||
for r in
|
||||
select * from (values
|
||||
('tasks', 'tags', '''{}''::text[]'),
|
||||
('tasks', 'poles', '''{}''::member_role[]'),
|
||||
('tasks', 'assignee_ids', '''{}''::uuid[]'),
|
||||
('tasks', 'checklist', '''[]''::jsonb'),
|
||||
('bugs', 'poles', '''{}''::member_role[]'),
|
||||
('bugs', 'assignee_ids', '''{}''::uuid[]'),
|
||||
('profiles', 'roles', '''{}''::member_role[]')
|
||||
) as t(tbl, col, empty)
|
||||
loop
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
|
||||
) then
|
||||
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
|
||||
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
|
||||
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
|
||||
end if;
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1) Parité bugs / tâches : plusieurs pôles par bug
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.bugs
|
||||
add column if not exists poles member_role[] not null default '{}';
|
||||
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
|
||||
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table if not exists public.comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task', 'bug')),
|
||||
entity_id uuid not null,
|
||||
author_id uuid references public.profiles(id) on delete set null,
|
||||
body text not null check (length(btrim(body)) > 0),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
|
||||
create index if not exists idx_comments_project on public.comments(project_id);
|
||||
|
||||
alter table public.comments enable row level security;
|
||||
|
||||
drop policy if exists comments_select on public.comments;
|
||||
drop policy if exists comments_insert on public.comments;
|
||||
drop policy if exists comments_update on public.comments;
|
||||
drop policy if exists comments_delete on public.comments;
|
||||
|
||||
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
|
||||
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
|
||||
create policy comments_select on public.comments
|
||||
for select to authenticated using (true);
|
||||
create policy comments_insert on public.comments
|
||||
for insert to authenticated with check (author_id = auth.uid());
|
||||
create policy comments_update on public.comments
|
||||
for update to authenticated
|
||||
using (author_id = auth.uid()) with check (author_id = auth.uid());
|
||||
create policy comments_delete on public.comments
|
||||
for delete to authenticated
|
||||
using (
|
||||
author_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table if not exists public.attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task', 'bug')),
|
||||
entity_id uuid not null,
|
||||
uploader_id uuid references public.profiles(id) on delete set null,
|
||||
name text not null,
|
||||
path text not null, -- chemin dans le bucket "attachments"
|
||||
size bigint not null default 0,
|
||||
mime text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
|
||||
create index if not exists idx_attachments_project on public.attachments(project_id);
|
||||
|
||||
alter table public.attachments enable row level security;
|
||||
|
||||
drop policy if exists attachments_select on public.attachments;
|
||||
drop policy if exists attachments_insert on public.attachments;
|
||||
drop policy if exists attachments_delete on public.attachments;
|
||||
|
||||
create policy attachments_select on public.attachments
|
||||
for select to authenticated using (true);
|
||||
create policy attachments_insert on public.attachments
|
||||
for insert to authenticated with check (uploader_id = auth.uid());
|
||||
create policy attachments_delete on public.attachments
|
||||
for delete to authenticated
|
||||
using (
|
||||
uploader_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- Bucket de stockage des fichiers joints.
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('attachments', 'attachments', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists attachments_read on storage.objects;
|
||||
drop policy if exists attachments_write on storage.objects;
|
||||
drop policy if exists attachments_remove on storage.objects;
|
||||
|
||||
create policy attachments_read on storage.objects
|
||||
for select using (bucket_id = 'attachments');
|
||||
create policy attachments_write on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'attachments');
|
||||
create policy attachments_remove on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'attachments');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4) Notifications : sur QUOI pointe la notification
|
||||
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.notifications
|
||||
add column if not exists entity_kind text not null default 'task';
|
||||
|
||||
update public.notifications
|
||||
set entity_kind = kind
|
||||
where kind in ('task', 'bug') and entity_kind is distinct from kind;
|
||||
|
||||
create index if not exists idx_notifications_user_unread
|
||||
on public.notifications(user_id, read, created_at desc);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5) Réordonnancement du Kanban en UNE requête
|
||||
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
|
||||
-- allers-retours non transactionnels — un échec corrompait l'ordre).
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker -- la RLS de l'appelant s'applique normalement
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
update public.tasks t
|
||||
set status = p_status,
|
||||
position = ordered.idx
|
||||
from (
|
||||
select id, (ord - 1)::double precision as idx
|
||||
from unnest(p_ids) with ordinality as u(id, ord)
|
||||
) ordered
|
||||
where t.id = ordered.id;
|
||||
end $$;
|
||||
|
||||
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 6) Suppression d'un projet : créateur ou administrateur seulement
|
||||
-- (avant : n'importe quel membre pouvait effacer un projet entier
|
||||
-- et tout son contenu en cascade)
|
||||
-- ---------------------------------------------------------------------
|
||||
drop policy if exists projects_all on public.projects;
|
||||
drop policy if exists projects_select on public.projects;
|
||||
drop policy if exists projects_insert on public.projects;
|
||||
drop policy if exists projects_update on public.projects;
|
||||
drop policy if exists projects_delete on public.projects;
|
||||
|
||||
create policy projects_select on public.projects
|
||||
for select to authenticated using (true);
|
||||
create policy projects_insert on public.projects
|
||||
for insert to authenticated with check (true);
|
||||
create policy projects_update on public.projects
|
||||
for update to authenticated using (true) with check (true);
|
||||
create policy projects_delete on public.projects
|
||||
for delete to authenticated
|
||||
using (
|
||||
created_by = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 7) Temps réel : projets, commentaires, pièces jointes
|
||||
-- ---------------------------------------------------------------------
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.projects;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.comments;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.attachments;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 8) Création de profil à l'inscription : couleur d'avatar variée
|
||||
-- (avant, tout le monde héritait du même violet #7c5cff)
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger language plpgsql security definer set search_path = public as $$
|
||||
declare
|
||||
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
|
||||
r member_role;
|
||||
begin
|
||||
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
|
||||
insert into public.profiles (id, full_name, roles, avatar_color)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
|
||||
array[r],
|
||||
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 9) Suppression des colonnes « de compatibilité »
|
||||
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
|
||||
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
|
||||
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
|
||||
-- utilise plus).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- a) profiles.role → profiles.roles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
|
||||
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
|
||||
alter table public.profiles drop column role;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- b) tasks.pole → tasks.poles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
|
||||
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
|
||||
alter table public.tasks drop column pole;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
|
||||
update public.bugs set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null and assignee_ids = '{}';
|
||||
alter table public.bugs drop column assignee_id;
|
||||
end if;
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
|
||||
alter table public.bugs drop column pole;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
|
||||
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
|
||||
-- perdre — mais plus personne ne la remplit.
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
|
||||
alter table public.bugs alter column priority set default 'medium';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 10) Index manquants sur les colonnes les plus filtrées
|
||||
-- ---------------------------------------------------------------------
|
||||
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
|
||||
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
|
||||
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 11) Temps passé sur un BUG (parité avec les tâches)
|
||||
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
|
||||
-- rien (activité libre : réunion, veille…). Jamais les deux.
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.time_logs
|
||||
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
|
||||
|
||||
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
|
||||
|
||||
do $$ begin
|
||||
alter table public.time_logs
|
||||
add constraint time_logs_one_target
|
||||
check (not (task_id is not null and bug_id is not null));
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Dernière ligne : forcer l'API à relire le schéma.
|
||||
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
|
||||
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
|
||||
-- pendant plusieurs minutes après la migration.
|
||||
-- ---------------------------------------------------------------------
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v11 « Documentation »
|
||||
-- À coller dans : Supabase > SQL Editor > New query > Run
|
||||
-- Idempotent : sans danger si déjà appliquée.
|
||||
--
|
||||
-- Ajoute les fiches de documentation : GDD, conventions de code, notes
|
||||
-- de réunion, procédures… Créées dans l'app ou importées depuis un
|
||||
-- fichier Markdown / Word.
|
||||
-- =====================================================================
|
||||
|
||||
create table if not exists public.documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
title text not null,
|
||||
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
|
||||
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
|
||||
-- cherchables et modifiables dans l'application.
|
||||
content text not null default '',
|
||||
tags text[] not null default '{}',
|
||||
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
|
||||
source_name text,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
|
||||
|
||||
drop trigger if exists trg_documents_touch on public.documents;
|
||||
create trigger trg_documents_touch before update on public.documents
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
alter table public.documents enable row level security;
|
||||
|
||||
drop policy if exists documents_all on public.documents;
|
||||
|
||||
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
|
||||
create policy documents_all on public.documents
|
||||
for all to authenticated using (true) with check (true);
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.documents;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -1,70 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v2
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (pour une base déjà créée avec schema.sql v1)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Suppression des JALONS (milestones)
|
||||
-- • Notion de PÔLE sur les tâches et les bugs
|
||||
-- • Plusieurs personnes assignables sur une tâche (assignee_ids)
|
||||
-- • Système de NOTIFICATIONS (assignation à un pôle)
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- Pôle sur tâches & bugs (réutilise l'enum member_role) -----
|
||||
alter table public.tasks add column if not exists pole member_role;
|
||||
alter table public.bugs add column if not exists pole member_role;
|
||||
|
||||
-- ---------- Plusieurs assignés par tâche -----------------------------
|
||||
alter table public.tasks
|
||||
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
|
||||
-- Reprend l'assigné unique existant dans le nouveau tableau.
|
||||
update public.tasks
|
||||
set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null
|
||||
and (assignee_ids is null or assignee_ids = '{}');
|
||||
|
||||
alter table public.tasks drop column if exists assignee_id;
|
||||
|
||||
-- ---------- Suppression des jalons -----------------------------------
|
||||
alter table public.tasks drop column if exists milestone_id;
|
||||
drop table if exists public.milestones cascade;
|
||||
drop type if exists milestone_status;
|
||||
|
||||
-- ---------- Notifications --------------------------------------------
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null, -- 'task' | 'bug'
|
||||
title text not null, -- ex : « Nouvelle tâche »
|
||||
body text, -- titre de la tâche / du bug
|
||||
entity_id uuid, -- id de la tâche / du bug
|
||||
pole member_role, -- pôle destinataire
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
|
||||
alter table public.notifications enable row level security;
|
||||
|
||||
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
|
||||
-- membre connecté (afin de notifier les autres membres d'un pôle).
|
||||
drop policy if exists notifications_select on public.notifications;
|
||||
drop policy if exists notifications_insert on public.notifications;
|
||||
drop policy if exists notifications_update on public.notifications;
|
||||
drop policy if exists notifications_delete on public.notifications;
|
||||
|
||||
create policy notifications_select on public.notifications
|
||||
for select to authenticated using (user_id = auth.uid());
|
||||
create policy notifications_insert on public.notifications
|
||||
for insert to authenticated with check (true);
|
||||
create policy notifications_update on public.notifications
|
||||
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
create policy notifications_delete on public.notifications
|
||||
for delete to authenticated using (user_id = auth.uid());
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.notifications;
|
||||
exception when duplicate_object then null; end $$;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v3
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (après migration_v2.sql)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Temps « libre » dans la feuille de temps (réunion, etc.) :
|
||||
-- une entrée peut ne pas être liée à une tâche du Kanban.
|
||||
-- =====================================================================
|
||||
|
||||
-- La tâche devient optionnelle ; un libellé libre la remplace si absente.
|
||||
alter table public.time_logs alter column task_id drop not null;
|
||||
alter table public.time_logs add column if not exists label text;
|
||||
|
||||
-- Rattachement direct au projet (pour retrouver aussi les entrées sans tâche).
|
||||
alter table public.time_logs
|
||||
add column if not exists project_id uuid references public.projects(id) on delete cascade;
|
||||
|
||||
-- Backfill : les entrées existantes héritent du projet de leur tâche.
|
||||
update public.time_logs tl
|
||||
set project_id = t.project_id
|
||||
from public.tasks t
|
||||
where tl.task_id = t.id and tl.project_id is null;
|
||||
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
@@ -1,18 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v4
|
||||
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- (après migration_v3.sql)
|
||||
--
|
||||
-- Contenu :
|
||||
-- • Un membre peut appartenir à PLUSIEURS pôles (rôles).
|
||||
-- profiles.role reste le rôle « principal » ; profiles.roles
|
||||
-- contient l'ensemble des pôles.
|
||||
-- =====================================================================
|
||||
|
||||
alter table public.profiles
|
||||
add column if not exists roles member_role[] not null default '{}';
|
||||
|
||||
-- Backfill : les profils existants héritent de leur rôle unique actuel.
|
||||
update public.profiles
|
||||
set roles = array[role]
|
||||
where roles = '{}';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =====================================================================
|
||||
-- Bucket « releases » — hébergement des mises à jour de l'application
|
||||
-- À exécuter UNE SEULE FOIS : Supabase > SQL Editor > New query > Run
|
||||
--
|
||||
-- Ce bucket contient l'installeur signé et le fichier « latest.json »
|
||||
-- que chaque application interroge au lancement.
|
||||
--
|
||||
-- Lecture PUBLIQUE : les collègues doivent pouvoir télécharger la mise
|
||||
-- à jour avant même d'être connectés. Aucun risque : le paquet est
|
||||
-- signé, et une mise à jour non signée par la clé privée du projet est
|
||||
-- purement et simplement refusée par Tauri.
|
||||
--
|
||||
-- Écriture : réservée au script « npm run release », qui s'authentifie
|
||||
-- avec la clé service_role depuis la machine de l'administrateur.
|
||||
-- =====================================================================
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('releases', 'releases', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists releases_read on storage.objects;
|
||||
|
||||
create policy releases_read on storage.objects
|
||||
for select using (bucket_id = 'releases');
|
||||
|
||||
-- Personne d'autre que service_role n'écrit ici : aucune policy d'insert
|
||||
-- n'est créée volontairement (service_role contourne la RLS).
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
+299
-138
@@ -1,6 +1,10 @@
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Schéma de base de données (Supabase / PostgreSQL)
|
||||
-- À exécuter UNE SEULE FOIS dans : Supabase > SQL Editor > New query
|
||||
-- GameDev Tracker — Schéma complet (Supabase / PostgreSQL)
|
||||
--
|
||||
-- NOUVELLE base : exécute CE fichier, une seule fois.
|
||||
-- Base EXISTANTE : exécute plutôt « update_all.sql » (ne perd rien).
|
||||
--
|
||||
-- Où ? Supabase > SQL Editor > New query > coller > Run.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- Types énumérés -------------------------------------------
|
||||
@@ -25,15 +29,20 @@ do $$ begin
|
||||
create type bug_status as enum ('open','in_progress','resolved','closed');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- TABLES
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- Profils (étend auth.users) -------------------------------
|
||||
-- `roles` est la SEULE source de vérité pour les pôles d'un membre.
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
full_name text not null default 'Membre',
|
||||
role member_role not null default 'other', -- rôle principal (= roles[0])
|
||||
roles member_role[] not null default '{}', -- tous les pôles du membre
|
||||
roles member_role[] not null default '{}',
|
||||
avatar_color text not null default '#7c5cff',
|
||||
avatar_url text, -- photo de profil (optionnelle)
|
||||
onboarded boolean not null default false, -- a terminé l'accueil
|
||||
avatar_url text,
|
||||
is_admin boolean not null default false,
|
||||
onboarded boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
@@ -47,8 +56,8 @@ create table if not exists public.projects (
|
||||
);
|
||||
|
||||
-- ---------- Tâches ---------------------------------------------------
|
||||
-- Un « pôle » (= member_role) reçoit la tâche ; une ou plusieurs
|
||||
-- personnes de ce pôle peuvent y être assignées (assignee_ids).
|
||||
-- Une tâche est confiée à un ou plusieurs PÔLES (poles) et assignée
|
||||
-- nommément à une ou plusieurs PERSONNES (assignee_ids).
|
||||
create table if not exists public.tasks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
@@ -56,9 +65,10 @@ create table if not exists public.tasks (
|
||||
description text,
|
||||
status task_status not null default 'todo',
|
||||
priority priority_level not null default 'medium',
|
||||
pole member_role, -- pôle principal (= poles[0], compat)
|
||||
poles member_role[] not null default '{}', -- tous les pôles concernés par la tâche
|
||||
poles member_role[] not null default '{}',
|
||||
assignee_ids uuid[] not null default '{}',
|
||||
checklist jsonb not null default '[]'::jsonb, -- [{id, text, done}]
|
||||
tags text[] not null default '{}',
|
||||
due_date date,
|
||||
position double precision not null default 0,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
@@ -67,6 +77,9 @@ create table if not exists public.tasks (
|
||||
);
|
||||
|
||||
-- ---------- Bugs -----------------------------------------------------
|
||||
-- Même modèle que les tâches (pôles + assignés multiples). La SÉVÉRITÉ
|
||||
-- est le seul axe d'importance : la colonne `priority` faisait doublon
|
||||
-- et n'est plus exposée dans l'interface.
|
||||
create table if not exists public.bugs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
@@ -75,15 +88,110 @@ create table if not exists public.bugs (
|
||||
severity bug_severity not null default 'medium',
|
||||
status bug_status not null default 'open',
|
||||
priority priority_level not null default 'medium',
|
||||
pole member_role,
|
||||
assignee_id uuid references public.profiles(id) on delete set null,
|
||||
poles member_role[] not null default '{}',
|
||||
assignee_ids uuid[] not null default '{}',
|
||||
reporter_id uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Journal du temps (tâche OU activité libre) ---------------
|
||||
create table if not exists public.time_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
task_id uuid references public.tasks(id) on delete cascade,
|
||||
bug_id uuid references public.bugs(id) on delete cascade,
|
||||
label text, -- si ni tâche ni bug
|
||||
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(),
|
||||
-- Une entrée porte sur une tâche OU un bug OU rien : jamais les deux.
|
||||
constraint time_logs_one_target check (not (task_id is not null and bug_id is not null))
|
||||
);
|
||||
|
||||
-- ---------- Commentaires (tâches et bugs) ----------------------------
|
||||
create table if not exists public.comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task','bug')),
|
||||
entity_id uuid not null,
|
||||
author_id uuid references public.profiles(id) on delete set null,
|
||||
body text not null check (length(btrim(body)) > 0),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Pièces jointes (captures d'écran, logs) ------------------
|
||||
create table if not exists public.attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task','bug')),
|
||||
entity_id uuid not null,
|
||||
uploader_id uuid references public.profiles(id) on delete set null,
|
||||
name text not null,
|
||||
path text not null,
|
||||
size bigint not null default 0,
|
||||
mime text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Documentation (fiches créées ou importées) ----------------
|
||||
-- Le contenu est du Markdown : format pivot, lisible et cherchable. Un
|
||||
-- .docx importé est converti en Markdown au moment de l'import.
|
||||
create table if not exists public.documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
title text not null,
|
||||
content text not null default '',
|
||||
tags text[] not null default '{}',
|
||||
source_name text,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ---------- Notifications --------------------------------------------
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null, -- 'task' | 'bug' | 'comment'
|
||||
entity_kind text not null default 'task', -- ce qu'on ouvre au clic
|
||||
entity_id uuid,
|
||||
title text not null,
|
||||
body text,
|
||||
pole member_role,
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- INDEX
|
||||
-- =====================================================================
|
||||
create index if not exists idx_tasks_project on public.tasks(project_id);
|
||||
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
|
||||
create index if not exists idx_bugs_project on public.bugs(project_id);
|
||||
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
|
||||
create index if not exists idx_time_logs_task on public.time_logs(task_id);
|
||||
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
create index if not exists idx_time_logs_user on public.time_logs(user_id);
|
||||
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
|
||||
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
|
||||
create index if not exists idx_comments_project on public.comments(project_id);
|
||||
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
|
||||
create index if not exists idx_attachments_project on public.attachments(project_id);
|
||||
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
create index if not exists idx_notifications_user_unread
|
||||
on public.notifications(user_id, read, created_at desc);
|
||||
|
||||
-- =====================================================================
|
||||
-- FONCTIONS & TRIGGERS
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- Maj automatique de updated_at ----------------------------
|
||||
create or replace function public.touch_updated_at()
|
||||
@@ -98,18 +206,28 @@ drop trigger if exists trg_bugs_touch on public.bugs;
|
||||
create trigger trg_bugs_touch before update on public.bugs
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
drop trigger if exists trg_documents_touch on public.documents;
|
||||
create trigger trg_documents_touch before update on public.documents
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
drop trigger if exists trg_comments_touch on public.comments;
|
||||
create trigger trg_comments_touch before update on public.comments
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
-- ---------- Création auto du profil à l'inscription ------------------
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger language plpgsql security definer set search_path = public as $$
|
||||
declare r member_role;
|
||||
declare
|
||||
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
|
||||
r member_role;
|
||||
begin
|
||||
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
|
||||
insert into public.profiles (id, full_name, role, roles)
|
||||
insert into public.profiles (id, full_name, roles, avatar_color)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
|
||||
r,
|
||||
array[r]
|
||||
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
|
||||
array[r],
|
||||
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
return new;
|
||||
@@ -120,65 +238,29 @@ create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
|
||||
-- ---------- Sécurité (RLS) -------------------------------------------
|
||||
-- Équipe privée et de confiance : tout membre connecté a accès complet.
|
||||
alter table public.profiles enable row level security;
|
||||
alter table public.projects enable row level security;
|
||||
alter table public.tasks enable row level security;
|
||||
alter table public.bugs enable row level security;
|
||||
|
||||
do $$
|
||||
declare t text;
|
||||
-- ---------- Réordonnancement du Kanban en une transaction ------------
|
||||
-- L'appli envoie la liste ordonnée des tâches d'une colonne ; une seule
|
||||
-- requête réécrit statut + position pour toute la colonne.
|
||||
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
foreach t in array array['profiles','projects','tasks','bugs'] loop
|
||||
execute format('drop policy if exists %I_all on public.%I', t, t);
|
||||
execute format(
|
||||
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
|
||||
t, t);
|
||||
end loop;
|
||||
update public.tasks t
|
||||
set status = p_status,
|
||||
position = ordered.idx
|
||||
from (
|
||||
select id, (ord - 1)::double precision as idx
|
||||
from unnest(p_ids) with ordinality as u(id, ord)
|
||||
) ordered
|
||||
where t.id = ordered.id;
|
||||
end $$;
|
||||
|
||||
-- ---------- Realtime (synchro live entre membres) --------------------
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.tasks;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.bugs;
|
||||
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());
|
||||
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
|
||||
|
||||
-- ---------- 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
|
||||
@@ -196,72 +278,106 @@ 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
|
||||
-- SÉCURITÉ (RLS)
|
||||
-- Équipe privée et de confiance : tout membre connecté lit et écrit le
|
||||
-- contenu des projets. Les exceptions sont explicites ci-dessous :
|
||||
-- · un profil ne se modifie que par son propriétaire
|
||||
-- · un projet ne se supprime que par son créateur ou un admin
|
||||
-- · un commentaire ne s'édite que par son auteur
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------- 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 OU activité libre) -----
|
||||
-- task_id null + label => temps « libre » (réunion, etc.).
|
||||
create table if not exists public.time_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
task_id uuid references public.tasks(id) on delete cascade,
|
||||
label text,
|
||||
user_id uuid references public.profiles(id) on delete set null,
|
||||
minutes integer not null check (minutes > 0),
|
||||
note text,
|
||||
logged_at date not null default current_date,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_time_logs_task on public.time_logs(task_id);
|
||||
create index if not exists idx_time_logs_project on public.time_logs(project_id);
|
||||
create index if not exists idx_time_logs_user on public.time_logs(user_id);
|
||||
|
||||
alter table public.profiles enable row level security;
|
||||
alter table public.projects enable row level security;
|
||||
alter table public.tasks enable row level security;
|
||||
alter table public.bugs enable row level security;
|
||||
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 $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- NOTIFICATIONS (assignation d'une tâche / d'un bug à un pôle)
|
||||
-- =====================================================================
|
||||
create table if not exists public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
kind text not null, -- 'task' | 'bug'
|
||||
title text not null, -- ex : « Nouvelle tâche »
|
||||
body text, -- titre de la tâche / du bug
|
||||
entity_id uuid, -- id de la tâche / du bug
|
||||
pole member_role, -- pôle destinataire
|
||||
read boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notifications_user on public.notifications(user_id);
|
||||
|
||||
alter table public.comments enable row level security;
|
||||
alter table public.attachments enable row level security;
|
||||
alter table public.documents enable row level security;
|
||||
alter table public.notifications enable row level security;
|
||||
|
||||
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
|
||||
-- membre connecté (afin de notifier les autres membres d'un pôle).
|
||||
-- ---------- Contenu partagé (tâches, bugs, temps) --------------------
|
||||
do $$
|
||||
declare t text;
|
||||
begin
|
||||
foreach t in array array['tasks','bugs','time_logs','documents'] loop
|
||||
execute format('drop policy if exists %I_all on public.%I', t, t);
|
||||
execute format(
|
||||
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
|
||||
t, t);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------- Profils ---------------------------------------------------
|
||||
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());
|
||||
-- La création / suppression de comptes passe par l'Edge Function
|
||||
-- « admin-users » (clé service_role, côté serveur).
|
||||
|
||||
-- ---------- Projets ---------------------------------------------------
|
||||
drop policy if exists projects_all on public.projects;
|
||||
drop policy if exists projects_select on public.projects;
|
||||
drop policy if exists projects_insert on public.projects;
|
||||
drop policy if exists projects_update on public.projects;
|
||||
drop policy if exists projects_delete on public.projects;
|
||||
|
||||
create policy projects_select on public.projects
|
||||
for select to authenticated using (true);
|
||||
create policy projects_insert on public.projects
|
||||
for insert to authenticated with check (true);
|
||||
create policy projects_update on public.projects
|
||||
for update to authenticated using (true) with check (true);
|
||||
create policy projects_delete on public.projects
|
||||
for delete to authenticated
|
||||
using (
|
||||
created_by = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------- Commentaires ----------------------------------------------
|
||||
drop policy if exists comments_select on public.comments;
|
||||
drop policy if exists comments_insert on public.comments;
|
||||
drop policy if exists comments_update on public.comments;
|
||||
drop policy if exists comments_delete on public.comments;
|
||||
|
||||
create policy comments_select on public.comments
|
||||
for select to authenticated using (true);
|
||||
create policy comments_insert on public.comments
|
||||
for insert to authenticated with check (author_id = auth.uid());
|
||||
create policy comments_update on public.comments
|
||||
for update to authenticated
|
||||
using (author_id = auth.uid()) with check (author_id = auth.uid());
|
||||
create policy comments_delete on public.comments
|
||||
for delete to authenticated
|
||||
using (
|
||||
author_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------- Pièces jointes --------------------------------------------
|
||||
drop policy if exists attachments_select on public.attachments;
|
||||
drop policy if exists attachments_insert on public.attachments;
|
||||
drop policy if exists attachments_delete on public.attachments;
|
||||
|
||||
create policy attachments_select on public.attachments
|
||||
for select to authenticated using (true);
|
||||
create policy attachments_insert on public.attachments
|
||||
for insert to authenticated with check (uploader_id = auth.uid());
|
||||
create policy attachments_delete on public.attachments
|
||||
for delete to authenticated
|
||||
using (
|
||||
uploader_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------- Notifications ---------------------------------------------
|
||||
drop policy if exists notifications_select on public.notifications;
|
||||
drop policy if exists notifications_insert on public.notifications;
|
||||
drop policy if exists notifications_update on public.notifications;
|
||||
@@ -269,22 +385,37 @@ drop policy if exists notifications_delete on public.notifications;
|
||||
|
||||
create policy notifications_select on public.notifications
|
||||
for select to authenticated using (user_id = auth.uid());
|
||||
-- Insertion ouverte : un membre doit pouvoir notifier ses coéquipiers.
|
||||
create policy notifications_insert on public.notifications
|
||||
for insert to authenticated with check (true);
|
||||
create policy notifications_update on public.notifications
|
||||
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
for update to authenticated
|
||||
using (user_id = auth.uid()) with check (user_id = auth.uid());
|
||||
create policy notifications_delete on public.notifications
|
||||
for delete to authenticated using (user_id = auth.uid());
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.notifications;
|
||||
exception when duplicate_object then null; end $$;
|
||||
-- =====================================================================
|
||||
-- TEMPS RÉEL (synchro live entre membres)
|
||||
-- =====================================================================
|
||||
do $$
|
||||
declare t text;
|
||||
begin
|
||||
foreach t in array array['tasks','bugs','profiles','projects','time_logs',
|
||||
'notifications','comments','attachments','documents'] loop
|
||||
begin
|
||||
execute format('alter publication supabase_realtime add table public.%I', t);
|
||||
exception when duplicate_object then null;
|
||||
end;
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- =====================================================================
|
||||
-- STOCKAGE : photos de profil (bucket "avatars")
|
||||
-- STOCKAGE
|
||||
-- · avatars : photos de profil
|
||||
-- · attachments : captures d'écran et fichiers joints aux bugs/tâches
|
||||
-- =====================================================================
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatars', 'avatars', true)
|
||||
values ('avatars', 'avatars', true), ('attachments', 'attachments', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists avatars_read on storage.objects;
|
||||
@@ -300,3 +431,33 @@ create policy avatars_update on storage.objects
|
||||
for update to authenticated using (bucket_id = 'avatars');
|
||||
create policy avatars_delete on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'avatars');
|
||||
|
||||
drop policy if exists attachments_read on storage.objects;
|
||||
drop policy if exists attachments_write on storage.objects;
|
||||
drop policy if exists attachments_remove on storage.objects;
|
||||
|
||||
create policy attachments_read on storage.objects
|
||||
for select using (bucket_id = 'attachments');
|
||||
create policy attachments_write on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'attachments');
|
||||
create policy attachments_remove on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'attachments');
|
||||
|
||||
-- =====================================================================
|
||||
-- DERNIÈRE ÉTAPE — devenir administrateur (à faire UNE fois)
|
||||
-- Après avoir créé ton compte dans l'application, exécute :
|
||||
--
|
||||
-- update public.profiles set is_admin = true
|
||||
-- where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
|
||||
--
|
||||
-- Tu pourras ensuite gérer tous les comptes depuis l'onglet Administration.
|
||||
-- =====================================================================
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Dernière ligne : forcer l'API à relire le schéma.
|
||||
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
|
||||
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
|
||||
-- pendant plusieurs minutes après la migration.
|
||||
-- ---------------------------------------------------------------------
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
@@ -154,3 +154,410 @@ alter table public.tasks
|
||||
update public.tasks
|
||||
set poles = array[pole]
|
||||
where pole is not null and poles = '{}';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v8 — Checklists (cases à cocher) sur les tâches
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Liste d'éléments [{ "id": "...", "text": "...", "done": false }, ...]
|
||||
alter table public.tasks
|
||||
add column if not exists checklist jsonb not null default '[]'::jsonb;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- v9 — Assignation MULTIPLE des bugs (parité avec les tâches)
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.bugs
|
||||
add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
|
||||
-- Reprend l'assigné unique existant dans le tableau (si présent).
|
||||
update public.bugs
|
||||
set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null and assignee_ids = '{}';
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- GameDev Tracker — Migration v10 « Refonte »
|
||||
-- À coller dans : Supabase > SQL Editor > New query > Run
|
||||
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
|
||||
--
|
||||
-- Ce que fait cette migration :
|
||||
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
|
||||
-- 1. Commentaires sur les tâches et les bugs
|
||||
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
|
||||
-- 3. Parité tâches/bugs : plusieurs pôles par bug
|
||||
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
|
||||
-- (profiles.role, tasks.pole, bugs.assignee_id)
|
||||
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
|
||||
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
|
||||
-- 7. Temps réel sur les projets
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
|
||||
--
|
||||
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
|
||||
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
|
||||
-- pas à pas, la colonne manquait et l'application plantait en tentant
|
||||
-- d'afficher les étiquettes d'une tâche.
|
||||
--
|
||||
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
|
||||
-- tableau : jamais NULL, toujours un tableau vide par défaut.
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.tasks add column if not exists tags text[] not null default '{}';
|
||||
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
|
||||
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
|
||||
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
|
||||
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
|
||||
|
||||
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
|
||||
do $$
|
||||
declare
|
||||
r record;
|
||||
begin
|
||||
for r in
|
||||
select * from (values
|
||||
('tasks', 'tags', '''{}''::text[]'),
|
||||
('tasks', 'poles', '''{}''::member_role[]'),
|
||||
('tasks', 'assignee_ids', '''{}''::uuid[]'),
|
||||
('tasks', 'checklist', '''[]''::jsonb'),
|
||||
('bugs', 'poles', '''{}''::member_role[]'),
|
||||
('bugs', 'assignee_ids', '''{}''::uuid[]'),
|
||||
('profiles', 'roles', '''{}''::member_role[]')
|
||||
) as t(tbl, col, empty)
|
||||
loop
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
|
||||
) then
|
||||
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
|
||||
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
|
||||
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
|
||||
end if;
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1) Parité bugs / tâches : plusieurs pôles par bug
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.bugs
|
||||
add column if not exists poles member_role[] not null default '{}';
|
||||
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
|
||||
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table if not exists public.comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task', 'bug')),
|
||||
entity_id uuid not null,
|
||||
author_id uuid references public.profiles(id) on delete set null,
|
||||
body text not null check (length(btrim(body)) > 0),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
|
||||
create index if not exists idx_comments_project on public.comments(project_id);
|
||||
|
||||
alter table public.comments enable row level security;
|
||||
|
||||
drop policy if exists comments_select on public.comments;
|
||||
drop policy if exists comments_insert on public.comments;
|
||||
drop policy if exists comments_update on public.comments;
|
||||
drop policy if exists comments_delete on public.comments;
|
||||
|
||||
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
|
||||
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
|
||||
create policy comments_select on public.comments
|
||||
for select to authenticated using (true);
|
||||
create policy comments_insert on public.comments
|
||||
for insert to authenticated with check (author_id = auth.uid());
|
||||
create policy comments_update on public.comments
|
||||
for update to authenticated
|
||||
using (author_id = auth.uid()) with check (author_id = auth.uid());
|
||||
create policy comments_delete on public.comments
|
||||
for delete to authenticated
|
||||
using (
|
||||
author_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
|
||||
-- ---------------------------------------------------------------------
|
||||
create table if not exists public.attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
entity_kind text not null check (entity_kind in ('task', 'bug')),
|
||||
entity_id uuid not null,
|
||||
uploader_id uuid references public.profiles(id) on delete set null,
|
||||
name text not null,
|
||||
path text not null, -- chemin dans le bucket "attachments"
|
||||
size bigint not null default 0,
|
||||
mime text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
|
||||
create index if not exists idx_attachments_project on public.attachments(project_id);
|
||||
|
||||
alter table public.attachments enable row level security;
|
||||
|
||||
drop policy if exists attachments_select on public.attachments;
|
||||
drop policy if exists attachments_insert on public.attachments;
|
||||
drop policy if exists attachments_delete on public.attachments;
|
||||
|
||||
create policy attachments_select on public.attachments
|
||||
for select to authenticated using (true);
|
||||
create policy attachments_insert on public.attachments
|
||||
for insert to authenticated with check (uploader_id = auth.uid());
|
||||
create policy attachments_delete on public.attachments
|
||||
for delete to authenticated
|
||||
using (
|
||||
uploader_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- Bucket de stockage des fichiers joints.
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('attachments', 'attachments', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists attachments_read on storage.objects;
|
||||
drop policy if exists attachments_write on storage.objects;
|
||||
drop policy if exists attachments_remove on storage.objects;
|
||||
|
||||
create policy attachments_read on storage.objects
|
||||
for select using (bucket_id = 'attachments');
|
||||
create policy attachments_write on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'attachments');
|
||||
create policy attachments_remove on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'attachments');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4) Notifications : sur QUOI pointe la notification
|
||||
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.notifications
|
||||
add column if not exists entity_kind text not null default 'task';
|
||||
|
||||
update public.notifications
|
||||
set entity_kind = kind
|
||||
where kind in ('task', 'bug') and entity_kind is distinct from kind;
|
||||
|
||||
create index if not exists idx_notifications_user_unread
|
||||
on public.notifications(user_id, read, created_at desc);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5) Réordonnancement du Kanban en UNE requête
|
||||
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
|
||||
-- allers-retours non transactionnels — un échec corrompait l'ordre).
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker -- la RLS de l'appelant s'applique normalement
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
update public.tasks t
|
||||
set status = p_status,
|
||||
position = ordered.idx
|
||||
from (
|
||||
select id, (ord - 1)::double precision as idx
|
||||
from unnest(p_ids) with ordinality as u(id, ord)
|
||||
) ordered
|
||||
where t.id = ordered.id;
|
||||
end $$;
|
||||
|
||||
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 6) Suppression d'un projet : créateur ou administrateur seulement
|
||||
-- (avant : n'importe quel membre pouvait effacer un projet entier
|
||||
-- et tout son contenu en cascade)
|
||||
-- ---------------------------------------------------------------------
|
||||
drop policy if exists projects_all on public.projects;
|
||||
drop policy if exists projects_select on public.projects;
|
||||
drop policy if exists projects_insert on public.projects;
|
||||
drop policy if exists projects_update on public.projects;
|
||||
drop policy if exists projects_delete on public.projects;
|
||||
|
||||
create policy projects_select on public.projects
|
||||
for select to authenticated using (true);
|
||||
create policy projects_insert on public.projects
|
||||
for insert to authenticated with check (true);
|
||||
create policy projects_update on public.projects
|
||||
for update to authenticated using (true) with check (true);
|
||||
create policy projects_delete on public.projects
|
||||
for delete to authenticated
|
||||
using (
|
||||
created_by = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 7) Temps réel : projets, commentaires, pièces jointes
|
||||
-- ---------------------------------------------------------------------
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.projects;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.comments;
|
||||
exception when duplicate_object then null; end $$;
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.attachments;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 8) Création de profil à l'inscription : couleur d'avatar variée
|
||||
-- (avant, tout le monde héritait du même violet #7c5cff)
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger language plpgsql security definer set search_path = public as $$
|
||||
declare
|
||||
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
|
||||
r member_role;
|
||||
begin
|
||||
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
|
||||
insert into public.profiles (id, full_name, roles, avatar_color)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
|
||||
array[r],
|
||||
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
return new;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 9) Suppression des colonnes « de compatibilité »
|
||||
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
|
||||
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
|
||||
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
|
||||
-- utilise plus).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- a) profiles.role → profiles.roles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
|
||||
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
|
||||
alter table public.profiles drop column role;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- b) tasks.pole → tasks.poles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
|
||||
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
|
||||
alter table public.tasks drop column pole;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
|
||||
update public.bugs set assignee_ids = array[assignee_id]
|
||||
where assignee_id is not null and assignee_ids = '{}';
|
||||
alter table public.bugs drop column assignee_id;
|
||||
end if;
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
|
||||
alter table public.bugs drop column pole;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
|
||||
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
|
||||
-- perdre — mais plus personne ne la remplit.
|
||||
do $$ begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
|
||||
alter table public.bugs alter column priority set default 'medium';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 10) Index manquants sur les colonnes les plus filtrées
|
||||
-- ---------------------------------------------------------------------
|
||||
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
|
||||
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
|
||||
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 11) Temps passé sur un BUG (parité avec les tâches)
|
||||
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
|
||||
-- rien (activité libre : réunion, veille…). Jamais les deux.
|
||||
-- ---------------------------------------------------------------------
|
||||
alter table public.time_logs
|
||||
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
|
||||
|
||||
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
|
||||
|
||||
do $$ begin
|
||||
alter table public.time_logs
|
||||
add constraint time_logs_one_target
|
||||
check (not (task_id is not null and bug_id is not null));
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Dernière ligne : forcer l'API à relire le schéma.
|
||||
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
|
||||
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
|
||||
-- pendant plusieurs minutes après la migration.
|
||||
-- ---------------------------------------------------------------------
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- v11 — Documentation (fiches créées ou importées)
|
||||
-- =====================================================================
|
||||
|
||||
create table if not exists public.documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
title text not null,
|
||||
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
|
||||
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
|
||||
-- cherchables et modifiables dans l'application.
|
||||
content text not null default '',
|
||||
tags text[] not null default '{}',
|
||||
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
|
||||
source_name text,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
|
||||
|
||||
drop trigger if exists trg_documents_touch on public.documents;
|
||||
create trigger trg_documents_touch before update on public.documents
|
||||
for each row execute function public.touch_updated_at();
|
||||
|
||||
alter table public.documents enable row level security;
|
||||
|
||||
drop policy if exists documents_all on public.documents;
|
||||
|
||||
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
|
||||
create policy documents_all on public.documents
|
||||
for all to authenticated using (true) with check (true);
|
||||
|
||||
do $$ begin
|
||||
alter publication supabase_realtime add table public.documents;
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
+18
-1
@@ -7,16 +7,33 @@ export default {
|
||||
colors: {
|
||||
// Tokens pilotés par variables CSS (voir :root et .dark dans index.css).
|
||||
bg: 'rgb(var(--c-bg) / <alpha-value>)',
|
||||
sidebar: 'rgb(var(--c-sidebar) / <alpha-value>)',
|
||||
panel: 'rgb(var(--c-panel) / <alpha-value>)',
|
||||
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
|
||||
sidebar: 'rgb(var(--c-sidebar) / <alpha-value>)',
|
||||
hover: 'rgb(var(--c-hover) / <alpha-value>)',
|
||||
border: 'rgb(var(--c-border) / <alpha-value>)',
|
||||
'border-strong': 'rgb(var(--c-border-strong) / <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>)',
|
||||
'accent-soft': 'rgb(var(--c-accent-soft) / <alpha-value>)',
|
||||
accentfg: 'rgb(var(--c-accentfg) / <alpha-value>)',
|
||||
accent2: 'rgb(var(--c-accent2) / <alpha-value>)'
|
||||
},
|
||||
boxShadow: {
|
||||
// Élévation pilotée par le thème : en sombre, les ombres portées
|
||||
// seules sont invisibles — les variables ajoutent un liseré interne.
|
||||
card: 'var(--shadow-card)',
|
||||
raised: 'var(--shadow-raised)',
|
||||
pop: 'var(--shadow-pop)'
|
||||
},
|
||||
spacing: {
|
||||
sidebar: '15rem',
|
||||
'sidebar-sm': '3.75rem'
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
smooth: 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowUnreachableCode": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -8,6 +8,19 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') }
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Isole les grosses dépendances dans leurs propres chunks : elles
|
||||
// changent rarement → meilleur cache navigateur entre deux versions.
|
||||
manualChunks: {
|
||||
react: ['react', 'react-dom'],
|
||||
motion: ['framer-motion'],
|
||||
supabase: ['@supabase/supabase-js']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user