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