base
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.vite
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 🎮 GameDev Tracker
|
||||||
|
|
||||||
|
Application **desktop** de gestion de projet de jeu vidéo, pensée pour une **petite équipe (5-6 personnes)**.
|
||||||
|
Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base de données **gratuite Supabase**.
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
|
- 🔐 **Connexion / inscription** par email + mot de passe
|
||||||
|
- 👥 **Équipe & rôles** (programmeur, artiste, game designer, sound, scénariste, QA…)
|
||||||
|
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*
|
||||||
|
- 🎯 **Jalons & planning** — échéances, progression par jalon (Prototype, Alpha, Beta, Release…)
|
||||||
|
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, assignation
|
||||||
|
- 📊 **Tableau de bord** — avancement global, répartition des tâches, prochaines échéances
|
||||||
|
- 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Mise en route
|
||||||
|
|
||||||
|
### 1. Créer la base de données (gratuit, une seule fois pour toute l'équipe)
|
||||||
|
|
||||||
|
1. Va sur **[supabase.com](https://supabase.com)** et crée un compte.
|
||||||
|
2. Clique **New project** (choisis un mot de passe de base de données, garde-le quelque part).
|
||||||
|
3. Attends ~2 min que le projet se crée.
|
||||||
|
4. Dans le menu de gauche : **SQL Editor → New query**.
|
||||||
|
5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**.
|
||||||
|
> Cela crée les tables (profils, projets, tâches, jalons, bugs), la sécurité et la synchro temps réel.
|
||||||
|
6. Menu **Settings (⚙️) → API**. Note deux valeurs :
|
||||||
|
- **Project URL** → ressemble à `https://xxxxx.supabase.co`
|
||||||
|
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...`
|
||||||
|
7. **Recommandé pour une petite équipe** : désactive la confirmation d'email pour pouvoir se connecter tout de suite après l'inscription.
|
||||||
|
- Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »** et enregistre.
|
||||||
|
- (Sinon, chaque membre devra cliquer sur un lien reçu par email avant de pouvoir se connecter.)
|
||||||
|
|
||||||
|
> ℹ️ La clé *anon public* est faite pour être distribuée à l'app cliente : pas de secret sensible exposé.
|
||||||
|
|
||||||
|
### 2. Lancer l'application
|
||||||
|
|
||||||
|
Prérequis (déjà installés sur cette machine) : **Node.js**, **Rust**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Au premier lancement, l'app affiche un écran de **configuration** : colle l'**URL** et la **clé anon** obtenues à l'étape 1, puis **Connecter**.
|
||||||
|
Ensuite : crée ton compte (avec ton rôle), crée un **projet**, et c'est parti. 🎉
|
||||||
|
|
||||||
|
### 3. Distribuer à l'équipe
|
||||||
|
|
||||||
|
Pour générer un installeur (`.exe`/`.msi` sur Windows) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fichier d'installation se trouve dans `src-tauri/target/release/bundle/`.
|
||||||
|
Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la même URL + clé anon** que toi — ils rejoignent ainsi le même projet partagé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧰 Scripts utiles
|
||||||
|
|
||||||
|
| Commande | Effet |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run tauri:dev` | Lance l'app en mode développement (rechargement à chaud) |
|
||||||
|
| `npm run tauri:build` | Génère l'installeur de l'app |
|
||||||
|
| `npm run dev` | Lance uniquement le front-end dans le navigateur (sans la fenêtre desktop) |
|
||||||
|
| `npm run typecheck` | Vérifie les types TypeScript |
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
├─ index.html # point d'entrée du front
|
||||||
|
├─ src/ # application React (front-end)
|
||||||
|
│ ├─ pages/ # Dashboard, Kanban, Milestones, Bugs, Members, Login, Setup
|
||||||
|
│ ├─ components/ # Layout, UI, animations
|
||||||
|
│ └─ lib/ # client Supabase, types, contexte global, config
|
||||||
|
├─ src-tauri/ # enveloppe desktop Tauri (Rust)
|
||||||
|
└─ supabase/schema.sql # schéma de la base à exécuter dans Supabase
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Note de sécurité
|
||||||
|
|
||||||
|
L'app suppose une **équipe de confiance** : tout membre connecté peut lire et modifier les données du projet (politique RLS « accès complet pour les utilisateurs authentifiés »). C'est volontaire pour rester simple sur une petite équipe. Si un jour tu veux des permissions plus fines (ex. seul le lead supprime un projet), il faudra ajuster les *policies* dans `supabase/schema.sql`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GameDev Tracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "gamedev-tracker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Application de gestion de projet de jeu vidéo pour petite équipe",
|
||||||
|
"type": "module",
|
||||||
|
"author": "Mathew",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.45.4",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"framer-motion": "^11.5.4",
|
||||||
|
"lucide-react": "^0.441.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/node": "^20.16.5",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.45",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.6.3", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.11.3", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
app.handle().plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"productName": "GameDev Tracker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.gamedevtracker.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "GameDev Tracker",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 820,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"center": true,
|
||||||
|
"backgroundColor": "#0f1117"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; script-src 'self'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { AnimatePresence } from 'framer-motion'
|
||||||
|
import { AppProvider, useApp } from './lib/AppContext'
|
||||||
|
import { PageFade } from './components/motion'
|
||||||
|
import Setup from './pages/Setup'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Layout, { Page } from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Kanban from './pages/Kanban'
|
||||||
|
import Milestones from './pages/Milestones'
|
||||||
|
import Bugs from './pages/Bugs'
|
||||||
|
import Members from './pages/Members'
|
||||||
|
|
||||||
|
const PAGES: Record<Page, () => JSX.Element> = {
|
||||||
|
dashboard: Dashboard,
|
||||||
|
kanban: Kanban,
|
||||||
|
milestones: Milestones,
|
||||||
|
bugs: Bugs,
|
||||||
|
members: Members
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell(): JSX.Element {
|
||||||
|
const { stage } = useApp()
|
||||||
|
const [page, setPage] = useState<Page>('dashboard')
|
||||||
|
|
||||||
|
if (stage === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-400">
|
||||||
|
<Loader2 className="animate-spin" size={28} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stage === 'setup') return <Setup />
|
||||||
|
if (stage === 'login') return <Login />
|
||||||
|
|
||||||
|
const ActivePage = PAGES[page]
|
||||||
|
return (
|
||||||
|
<Layout page={page} setPage={setPage}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<PageFade key={page}>
|
||||||
|
<ActivePage />
|
||||||
|
</PageFade>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<Shell />
|
||||||
|
</AppProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
KanbanSquare,
|
||||||
|
Target,
|
||||||
|
Bug,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
Gamepad2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { ROLE_LABELS } from '../lib/types'
|
||||||
|
import { Avatar, Modal } from './ui'
|
||||||
|
|
||||||
|
export type Page = 'dashboard' | 'kanban' | 'milestones' | 'bugs' | 'members'
|
||||||
|
|
||||||
|
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
|
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||||
|
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
|
||||||
|
{ id: 'milestones', label: 'Jalons & planning', icon: Target },
|
||||||
|
{ id: 'bugs', label: 'Bugs', icon: Bug },
|
||||||
|
{ id: 'members', label: 'Équipe', icon: Users }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
page: Page
|
||||||
|
setPage: (p: Page) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}): JSX.Element {
|
||||||
|
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
|
||||||
|
const [picker, setPicker] = useState(false)
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Barre latérale */}
|
||||||
|
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-panel">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sélecteur de projet */}
|
||||||
|
<div className="relative px-3">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-between rounded-lg border border-border bg-panel2 px-3 py-2 text-left text-sm hover:border-accent"
|
||||||
|
onClick={() => setPicker((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="block text-[10px] uppercase text-gray-500">Projet</span>
|
||||||
|
<span className="font-medium text-white">{currentProject?.name ?? 'Aucun projet'}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{picker && (
|
||||||
|
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-border"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentProject(p)
|
||||||
|
setPicker(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-accent2 hover:bg-border"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNew(true)
|
||||||
|
setPicker(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Nouveau projet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-4 flex-1 space-y-1 px-3">
|
||||||
|
{NAV.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 && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="nav-active"
|
||||||
|
className="absolute inset-0 rounded-lg bg-accent/20"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<n.icon size={18} className="relative z-10" />
|
||||||
|
<span className="relative z-10">{n.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Pied : utilisateur */}
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar profile={profile} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-white">{profile?.full_name}</div>
|
||||||
|
<div className="truncate text-xs text-gray-500">
|
||||||
|
{profile ? ROLE_LABELS[profile.role] : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={signOut} title="Se déconnecter" className="text-gray-400 hover:text-rose-400">
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
{currentProject ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-gray-400">
|
||||||
|
<Settings size={40} className="text-gray-600" />
|
||||||
|
<p>Aucun projet pour le moment.</p>
|
||||||
|
<button className="btn-primary" onClick={() => setShowNew(true)}>
|
||||||
|
<Plus size={16} /> Créer le premier projet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={refreshProjects} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewProjectModal({
|
||||||
|
onClose,
|
||||||
|
onCreated
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: () => Promise<void>
|
||||||
|
}): JSX.Element {
|
||||||
|
const { profile, setCurrentProject } = useApp()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const create = async (): Promise<void> => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
const { data } = await supa()
|
||||||
|
.from('projects')
|
||||||
|
.insert({ name: name.trim(), description: desc.trim() || null, created_by: profile?.id })
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
await onCreated()
|
||||||
|
if (data) setCurrentProject(data)
|
||||||
|
setBusy(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Nouveau projet" onClose={onClose}>
|
||||||
|
<label className="label">Nom du jeu / projet</label>
|
||||||
|
<input
|
||||||
|
className="input mb-3"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Mon super jeu"
|
||||||
|
/>
|
||||||
|
<label className="label">Description (optionnel)</label>
|
||||||
|
<textarea
|
||||||
|
className="input mb-4 h-24 resize-none"
|
||||||
|
value={desc}
|
||||||
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
|
placeholder="Genre, plateforme, pitch..."
|
||||||
|
/>
|
||||||
|
<button className="btn-primary w-full" onClick={create} disabled={busy || !name.trim()}>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export default function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-white">{title}</h1>
|
||||||
|
{subtitle && <p className="text-sm text-gray-400">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">{children}</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { motion, Variants } from 'framer-motion'
|
||||||
|
|
||||||
|
// Apparition douce d'une page (fondu + léger glissement vers le haut).
|
||||||
|
export function PageFade({ children }: { children: ReactNode }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneur d'une liste : ses enfants <StaggerItem> apparaissent en cascade.
|
||||||
|
export const staggerContainer: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.04 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const staggerItem: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 10 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stagger({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<motion.div variants={staggerContainer} initial="hidden" animate="show" className={className}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ReactNode, SelectHTMLAttributes } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Profile, Priority, PRIORITY_LABELS } from '../lib/types'
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
wide
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
wide?: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||||
|
onMouseDown={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
|
||||||
|
className={`card max-h-[90vh] w-full overflow-y-auto ${wide ? 'max-w-2xl' : 'max-w-md'}`}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?: number }): JSX.Element {
|
||||||
|
const initials = (profile?.full_name ?? '?')
|
||||||
|
.split(' ')
|
||||||
|
.map((s) => s[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={profile?.full_name ?? 'Non assigné'}
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fontSize: size * 0.4,
|
||||||
|
background: profile?.avatar_color ?? '#3a3f4d'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile ? initials : '–'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_STYLES: Record<Priority, string> = {
|
||||||
|
low: 'bg-slate-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'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element {
|
||||||
|
return <span className={`badge ${PRIORITY_STYLES[priority]}`}>{PRIORITY_LABELS[priority]}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>): JSX.Element {
|
||||||
|
return <select {...props} className={`input ${props.className ?? ''}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-gray-500">
|
||||||
|
{icon}
|
||||||
|
<p className="text-sm">{text}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-bg text-gray-200 antialiased;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #2a2f3d transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-border rounded-full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-accent text-white hover:bg-accent/90;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn bg-panel2 text-gray-200 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;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-gray-400;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-border bg-panel p-4;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
|
||||||
|
import { Session } from '@supabase/supabase-js'
|
||||||
|
import { initSupabase, supa, hasClient } from './supabase'
|
||||||
|
import { getConfig, setConfig } from './config'
|
||||||
|
import { Profile, Project } from './types'
|
||||||
|
|
||||||
|
type Stage = 'loading' | 'setup' | 'login' | 'ready'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
stage: Stage
|
||||||
|
session: Session | null
|
||||||
|
profile: Profile | null
|
||||||
|
projects: Project[]
|
||||||
|
currentProject: Project | null
|
||||||
|
setCurrentProject: (p: Project) => void
|
||||||
|
saveConfig: (url: string, key: string) => Promise<void>
|
||||||
|
refreshProjects: () => Promise<void>
|
||||||
|
refreshProfile: () => Promise<void>
|
||||||
|
signOut: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<AppState | null>(null)
|
||||||
|
|
||||||
|
export function useApp(): AppState {
|
||||||
|
const v = useContext(Ctx)
|
||||||
|
if (!v) throw new Error('useApp doit être utilisé dans <AppProvider>')
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||||
|
const [stage, setStage] = useState<Stage>('loading')
|
||||||
|
const [session, setSession] = useState<Session | null>(null)
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null)
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [currentProject, setCurrentProject] = useState<Project | null>(null)
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
const uid = (await supa().auth.getUser()).data.user?.id
|
||||||
|
if (!uid) return
|
||||||
|
const { data } = await supa().from('profiles').select('*').eq('id', uid).single()
|
||||||
|
if (data) setProfile(data as Profile)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshProjects = useCallback(async () => {
|
||||||
|
const { data } = await supa().from('projects').select('*').order('created_at')
|
||||||
|
const list = (data ?? []) as Project[]
|
||||||
|
setProjects(list)
|
||||||
|
setCurrentProject((prev) => {
|
||||||
|
if (prev) return list.find((p) => p.id === prev.id) ?? list[0] ?? null
|
||||||
|
return list[0] ?? null
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Démarrage : charge la config locale puis tente de restaurer la session.
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
const cfg = getConfig()
|
||||||
|
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
|
||||||
|
setStage('setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey)
|
||||||
|
const { data } = await supa().auth.getSession()
|
||||||
|
setSession(data.session)
|
||||||
|
supa().auth.onAuthStateChange((_e, s) => setSession(s))
|
||||||
|
setStage(data.session ? 'ready' : 'login')
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Quand la session change, on (re)charge profil + projets.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasClient()) return
|
||||||
|
if (session) {
|
||||||
|
setStage('ready')
|
||||||
|
refreshProfile()
|
||||||
|
refreshProjects()
|
||||||
|
} else if (stage !== 'setup' && stage !== 'loading') {
|
||||||
|
setStage('login')
|
||||||
|
setProfile(null)
|
||||||
|
setProjects([])
|
||||||
|
setCurrentProject(null)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
const saveConfig = async (url: string, key: string): Promise<void> => {
|
||||||
|
setConfig({ supabaseUrl: url.trim(), supabaseAnonKey: key.trim() })
|
||||||
|
initSupabase(url.trim(), key.trim())
|
||||||
|
const { data } = await supa().auth.getSession()
|
||||||
|
setSession(data.session)
|
||||||
|
supa().auth.onAuthStateChange((_e, s) => setSession(s))
|
||||||
|
setStage(data.session ? 'ready' : 'login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const signOut = async (): Promise<void> => {
|
||||||
|
await supa().auth.signOut()
|
||||||
|
setSession(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider
|
||||||
|
value={{
|
||||||
|
stage,
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
projects,
|
||||||
|
currentProject,
|
||||||
|
setCurrentProject,
|
||||||
|
saveConfig,
|
||||||
|
refreshProjects,
|
||||||
|
refreshProfile,
|
||||||
|
signOut
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Stockage local des clés Supabase (la clé "anon" est publique par nature,
|
||||||
|
// localStorage convient parfaitement et évite tout code natif).
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
supabaseUrl?: string
|
||||||
|
supabaseAnonKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'gamedev-tracker-config'
|
||||||
|
|
||||||
|
export function getConfig(): AppConfig {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEY) ?? '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfig(config: AppConfig): void {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(config))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
let client: SupabaseClient | null = null
|
||||||
|
|
||||||
|
/** Crée (ou recrée) le client Supabase à partir des clés fournies. */
|
||||||
|
export function initSupabase(url: string, anonKey: string): SupabaseClient {
|
||||||
|
client = createClient(url, anonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
storageKey: 'gamedev-tracker-auth'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère le client courant (lève une erreur si non initialisé). */
|
||||||
|
export function supa(): SupabaseClient {
|
||||||
|
if (!client) throw new Error('Supabase non initialisé. Configure les clés dans Réglages.')
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasClient(): boolean {
|
||||||
|
return client !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Types métier alignés sur le schéma SQL Supabase (voir supabase/schema.sql).
|
||||||
|
|
||||||
|
export type MemberRole =
|
||||||
|
| 'lead'
|
||||||
|
| 'programmer'
|
||||||
|
| 'artist'
|
||||||
|
| 'game_designer'
|
||||||
|
| 'sound'
|
||||||
|
| 'writer'
|
||||||
|
| 'qa'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<MemberRole, string> = {
|
||||||
|
lead: 'Chef de projet',
|
||||||
|
programmer: 'Programmeur',
|
||||||
|
artist: 'Artiste',
|
||||||
|
game_designer: 'Game Designer',
|
||||||
|
sound: 'Sound Designer',
|
||||||
|
writer: 'Scénariste',
|
||||||
|
qa: 'Testeur (QA)',
|
||||||
|
other: 'Autre'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
|
||||||
|
|
||||||
|
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||||
|
todo: 'À faire',
|
||||||
|
in_progress: 'En cours',
|
||||||
|
review: 'En revue',
|
||||||
|
done: 'Terminé'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'review', 'done']
|
||||||
|
|
||||||
|
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||||
|
low: 'Basse',
|
||||||
|
medium: 'Moyenne',
|
||||||
|
high: 'Haute',
|
||||||
|
urgent: 'Urgente'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MilestoneStatus = 'planned' | 'in_progress' | 'done'
|
||||||
|
|
||||||
|
export const MILESTONE_STATUS_LABELS: Record<MilestoneStatus, string> = {
|
||||||
|
planned: 'Planifié',
|
||||||
|
in_progress: 'En cours',
|
||||||
|
done: 'Atteint'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
|
||||||
|
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
|
||||||
|
low: 'Mineur',
|
||||||
|
medium: 'Moyen',
|
||||||
|
high: 'Majeur',
|
||||||
|
critical: 'Critique'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
|
||||||
|
|
||||||
|
export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
|
||||||
|
open: 'Ouvert',
|
||||||
|
in_progress: 'En cours',
|
||||||
|
resolved: 'Résolu',
|
||||||
|
closed: 'Fermé'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string
|
||||||
|
full_name: string
|
||||||
|
role: MemberRole
|
||||||
|
avatar_color: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Milestone {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
|
status: MilestoneStatus
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: TaskStatus
|
||||||
|
priority: Priority
|
||||||
|
assignee_id: string | null
|
||||||
|
milestone_id: string | null
|
||||||
|
due_date: string | null
|
||||||
|
position: number
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bug {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
severity: BugSeverity
|
||||||
|
status: BugStatus
|
||||||
|
priority: Priority
|
||||||
|
assignee_id: string | null
|
||||||
|
reporter_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { supa } from './supabase'
|
||||||
|
import { Profile } from './types'
|
||||||
|
|
||||||
|
/** Charge la liste des membres (profils) pour les listes d'assignation. */
|
||||||
|
export function useMembers(): { members: Profile[]; byId: (id: string | null) => Profile | undefined } {
|
||||||
|
const [members, setMembers] = useState<Profile[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
supa()
|
||||||
|
.from('profiles')
|
||||||
|
.select('*')
|
||||||
|
.order('full_name')
|
||||||
|
.then(({ data }) => setMembers((data ?? []) as Profile[]))
|
||||||
|
}, [])
|
||||||
|
const byId = (id: string | null): Profile | undefined =>
|
||||||
|
id ? members.find((m) => m.id === id) : undefined
|
||||||
|
return { members, byId }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import { useMembers } from '../lib/useMembers'
|
||||||
|
import {
|
||||||
|
Bug,
|
||||||
|
BugSeverity,
|
||||||
|
BugStatus,
|
||||||
|
Priority,
|
||||||
|
SEVERITY_LABELS,
|
||||||
|
BUG_STATUS_LABELS,
|
||||||
|
PRIORITY_LABELS
|
||||||
|
} from '../lib/types'
|
||||||
|
import { Avatar, Modal, Select, EmptyState, PriorityBadge } from '../components/ui'
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Bugs(): JSX.Element {
|
||||||
|
const { currentProject, profile } = useApp()
|
||||||
|
const { members, byId } = useMembers()
|
||||||
|
const [bugs, setBugs] = useState<Bug[]>([])
|
||||||
|
const [filter, setFilter] = useState<BugStatus | 'all'>('all')
|
||||||
|
const [editing, setEditing] = useState<Bug | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const projectId = currentProject?.id
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
const { data } = await supa()
|
||||||
|
.from('bugs')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
setBugs((data ?? []) as Bug[])
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const shown = bugs.filter((b) => filter === 'all' || b.status === filter)
|
||||||
|
const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title="Bugs" subtitle={`${openCount} bug(s) actif(s)`}>
|
||||||
|
<Select
|
||||||
|
className="!w-auto"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value as BugStatus | 'all')}
|
||||||
|
>
|
||||||
|
<option value="all">Tous les statuts</option>
|
||||||
|
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<button className="btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
<Plus size={16} /> Signaler un bug
|
||||||
|
</button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3">Bug</th>
|
||||||
|
<th className="px-3">Sévérité</th>
|
||||||
|
<th className="px-3">Priorité</th>
|
||||||
|
<th className="px-3">Statut</th>
|
||||||
|
<th className="px-3">Assigné</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map((b) => (
|
||||||
|
<tr
|
||||||
|
key={b.id}
|
||||||
|
className="cursor-pointer bg-panel hover:bg-panel2"
|
||||||
|
onClick={() => setEditing(b)}
|
||||||
|
>
|
||||||
|
<td className="rounded-l-lg px-3 py-3 font-medium text-white">{b.title}</td>
|
||||||
|
<td className="px-3">
|
||||||
|
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
|
||||||
|
{SEVERITY_LABELS[b.severity]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3">
|
||||||
|
<PriorityBadge priority={b.priority} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3">
|
||||||
|
<span className={`badge ${STATUS_STYLES[b.status]}`}>
|
||||||
|
{BUG_STATUS_LABELS[b.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="rounded-r-lg px-3">
|
||||||
|
<Avatar profile={byId(b.assignee_id)} size={26} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(creating || editing) && (
|
||||||
|
<BugModal
|
||||||
|
bug={editing}
|
||||||
|
projectId={projectId!}
|
||||||
|
reporterId={profile?.id ?? null}
|
||||||
|
members={members}
|
||||||
|
onClose={() => {
|
||||||
|
setCreating(false)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BugModal({
|
||||||
|
bug,
|
||||||
|
projectId,
|
||||||
|
reporterId,
|
||||||
|
members,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
bug: Bug | null
|
||||||
|
projectId: string
|
||||||
|
reporterId: string | null
|
||||||
|
members: ReturnType<typeof useMembers>['members']
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}): JSX.Element {
|
||||||
|
const [title, setTitle] = useState(bug?.title ?? '')
|
||||||
|
const [description, setDescription] = useState(bug?.description ?? '')
|
||||||
|
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
|
||||||
|
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
|
||||||
|
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
|
||||||
|
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (!title.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
const payload = {
|
||||||
|
project_id: projectId,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
severity,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
assignee_id: assignee || null
|
||||||
|
}
|
||||||
|
if (bug) await supa().from('bugs').update(payload).eq('id', bug.id)
|
||||||
|
else await supa().from('bugs').insert({ ...payload, reporter_id: reporterId })
|
||||||
|
await onSaved()
|
||||||
|
setBusy(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (): Promise<void> => {
|
||||||
|
if (!bug || !confirm('Supprimer ce bug ?')) return
|
||||||
|
await supa().from('bugs').delete().eq('id', bug.id)
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={bug ? 'Modifier le bug' : 'Signaler un bug'} onClose={onClose} wide>
|
||||||
|
<label className="label">Titre</label>
|
||||||
|
<input
|
||||||
|
className="input mb-3"
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Le joueur traverse le mur en sautant"
|
||||||
|
/>
|
||||||
|
<label className="label">Description / étapes pour reproduire</label>
|
||||||
|
<textarea
|
||||||
|
className="input mb-3 h-24 resize-none"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Sévérité</label>
|
||||||
|
<Select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)}>
|
||||||
|
{Object.entries(SEVERITY_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Priorité</label>
|
||||||
|
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
|
||||||
|
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Statut</label>
|
||||||
|
<Select value={status} onChange={(e) => setStatus(e.target.value as BugStatus)}>
|
||||||
|
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Assigné à</label>
|
||||||
|
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
|
||||||
|
<option value="">Personne</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex items-center justify-between">
|
||||||
|
{bug ? (
|
||||||
|
<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()}>
|
||||||
|
{bug ? 'Enregistrer' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { KanbanSquare, Bug as BugIcon, Target, CheckCircle2, CalendarClock } from 'lucide-react'
|
||||||
|
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
Bug,
|
||||||
|
Milestone,
|
||||||
|
TASK_STATUS_ORDER,
|
||||||
|
TASK_STATUS_LABELS,
|
||||||
|
TaskStatus
|
||||||
|
} from '../lib/types'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import { Stagger, staggerItem } from '../components/motion'
|
||||||
|
|
||||||
|
const COL_COLORS: Record<TaskStatus, string> = {
|
||||||
|
todo: 'bg-slate-400',
|
||||||
|
in_progress: 'bg-sky-400',
|
||||||
|
review: 'bg-amber-400',
|
||||||
|
done: 'bg-emerald-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard(): JSX.Element {
|
||||||
|
const { currentProject } = useApp()
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [bugs, setBugs] = useState<Bug[]>([])
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||||
|
const projectId = currentProject?.id
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
const [{ data: t }, { data: b }, { data: m }] = await Promise.all([
|
||||||
|
supa().from('tasks').select('*').eq('project_id', projectId),
|
||||||
|
supa().from('bugs').select('*').eq('project_id', projectId),
|
||||||
|
supa().from('milestones').select('*').eq('project_id', projectId)
|
||||||
|
])
|
||||||
|
setTasks((t ?? []) as Task[])
|
||||||
|
setBugs((b ?? []) as Bug[])
|
||||||
|
setMilestones((m ?? []) as Milestone[])
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const doneTasks = tasks.filter((t) => t.status === 'done').length
|
||||||
|
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
|
||||||
|
const openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
|
||||||
|
const upcoming = milestones
|
||||||
|
.filter((m) => m.status !== 'done' && m.due_date)
|
||||||
|
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
|
||||||
|
.slice(0, 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title="Tableau de bord" subtitle={currentProject?.name} />
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* Cartes stats */}
|
||||||
|
<Stagger className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard icon={<KanbanSquare />} label="Tâches" value={tasks.length} color="text-accent" />
|
||||||
|
<StatCard
|
||||||
|
icon={<CheckCircle2 />}
|
||||||
|
label="Terminées"
|
||||||
|
value={`${doneTasks} (${pct}%)`}
|
||||||
|
color="text-emerald-400"
|
||||||
|
/>
|
||||||
|
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-400" />
|
||||||
|
<StatCard
|
||||||
|
icon={<Target />}
|
||||||
|
label="Jalons"
|
||||||
|
value={`${milestones.filter((m) => m.status === 'done').length}/${milestones.length}`}
|
||||||
|
color="text-accent2"
|
||||||
|
/>
|
||||||
|
</Stagger>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 overflow-hidden rounded-full bg-panel2">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-accent2"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* Répartition des tâches */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="mb-3 font-medium text-white">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">
|
||||||
|
<span>{TASK_STATUS_LABELS[s]}</span>
|
||||||
|
<span>{n}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-panel2">
|
||||||
|
<div className={`h-full ${COL_COLORS[s]}`} style={{ width: `${w}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prochaines échéances */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="mb-3 font-medium text-white">Prochaines échéances</h3>
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-gray-500">Aucune échéance à venir.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{upcoming.map((m) => {
|
||||||
|
const overdue = isPast(new Date(m.due_date! + 'T23:59:59'))
|
||||||
|
const d = differenceInCalendarDays(new Date(m.due_date!), new Date())
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
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">
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarClock size={12} />
|
||||||
|
{format(new Date(m.due_date!), 'd MMM', { locale: fr })}
|
||||||
|
{' · '}
|
||||||
|
{overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
icon: JSX.Element
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
color: string
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={staggerItem}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
className="card flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-white">{value}</div>
|
||||||
|
<div className="text-xs text-gray-400">{label}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useState, useCallback, DragEvent } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Plus, Trash2, CalendarDays } 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 { useMembers } from '../lib/useMembers'
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
TaskStatus,
|
||||||
|
Priority,
|
||||||
|
Milestone,
|
||||||
|
TASK_STATUS_ORDER,
|
||||||
|
TASK_STATUS_LABELS,
|
||||||
|
PRIORITY_LABELS
|
||||||
|
} from '../lib/types'
|
||||||
|
import { Avatar, Modal, Select, PriorityBadge } from '../components/ui'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
||||||
|
export default function Kanban(): JSX.Element {
|
||||||
|
const { currentProject, profile } = useApp()
|
||||||
|
const { members, byId } = useMembers()
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [editing, setEditing] = useState<Task | null>(null)
|
||||||
|
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const projectId = currentProject?.id
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
const { data } = await supa()
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('project_id', projectId)
|
||||||
|
.order('position')
|
||||||
|
setTasks((data ?? []) as Task[])
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Synchro temps réel : recharge quand un membre modifie une tâche.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return
|
||||||
|
const ch = supa()
|
||||||
|
.channel(`tasks-${projectId}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
|
||||||
|
() => load()
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
return () => {
|
||||||
|
supa().removeChannel(ch)
|
||||||
|
}
|
||||||
|
}, [projectId, load])
|
||||||
|
|
||||||
|
const moveTask = async (taskId: string, status: TaskStatus): Promise<void> => {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status } : t)))
|
||||||
|
await supa().from('tasks').update({ status }).eq('id', taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent, status: TaskStatus): void => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragId) {
|
||||||
|
const t = tasks.find((x) => x.id === dragId)
|
||||||
|
if (t && t.status !== status) moveTask(dragId, status)
|
||||||
|
}
|
||||||
|
setDragId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title="Tâches" subtitle={currentProject?.name}>
|
||||||
|
<button className="btn-primary" onClick={() => setCreatingIn('todo')}>
|
||||||
|
<Plus size={16} /> Nouvelle tâche
|
||||||
|
</button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-1 gap-4 overflow-x-auto p-6">
|
||||||
|
{TASK_STATUS_ORDER.map((status) => {
|
||||||
|
const colTasks = tasks.filter((t) => t.status === status)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="flex w-72 shrink-0 flex-col rounded-xl bg-panel/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">
|
||||||
|
{TASK_STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-panel2 text-gray-400">{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>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
onClick={() => setCreatingIn(status)}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(editing || creatingIn) && (
|
||||||
|
<TaskModal
|
||||||
|
task={editing}
|
||||||
|
defaultStatus={creatingIn ?? 'todo'}
|
||||||
|
projectId={projectId!}
|
||||||
|
createdBy={profile?.id ?? null}
|
||||||
|
members={members}
|
||||||
|
onClose={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setCreatingIn(null)
|
||||||
|
}}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(translate((e as Error).message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||||
|
className="card w-full max-w-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex flex-col items-center gap-2 text-center">
|
||||||
|
<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>
|
||||||
|
</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"
|
||||||
|
type="email"
|
||||||
|
placeholder="toi@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
className="input mb-4"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
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>}
|
||||||
|
|
||||||
|
<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"}
|
||||||
|
</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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.'
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Users, Pencil } from 'lucide-react'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import { Profile, MemberRole, ROLE_LABELS } from '../lib/types'
|
||||||
|
import { Avatar, Modal, Select, EmptyState } from '../components/ui'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
||||||
|
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
|
||||||
|
|
||||||
|
export default function Members(): JSX.Element {
|
||||||
|
const { profile, refreshProfile } = useApp()
|
||||||
|
const [members, setMembers] = useState<Profile[]>([])
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
const { data } = await supa().from('profiles').select('*').order('full_name')
|
||||||
|
setMembers((data ?? []) as Profile[])
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title="Équipe" subtitle={`${members.length} membre(s)`}>
|
||||||
|
<button className="btn-ghost" onClick={() => setEditing(true)}>
|
||||||
|
<Pencil size={16} /> Modifier mon profil
|
||||||
|
</button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<EmptyState icon={<Users size={40} />} text="Aucun membre." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="card flex items-center gap-3">
|
||||||
|
<Avatar profile={m} size={44} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-white">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditProfileModal
|
||||||
|
profile={profile!}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSaved={async () => {
|
||||||
|
await refreshProfile()
|
||||||
|
await load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditProfileModal({
|
||||||
|
profile,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
profile: Profile
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}): JSX.Element {
|
||||||
|
const [name, setName] = useState(profile.full_name)
|
||||||
|
const [role, setRole] = useState<MemberRole>(profile.role)
|
||||||
|
const [color, setColor] = useState(profile.avatar_color)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
setBusy(true)
|
||||||
|
await supa()
|
||||||
|
.from('profiles')
|
||||||
|
.update({ full_name: name.trim() || 'Membre', role, avatar_color: color })
|
||||||
|
.eq('id', profile.id)
|
||||||
|
await onSaved()
|
||||||
|
setBusy(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Mon profil" onClose={onClose}>
|
||||||
|
<label className="label">Nom complet</label>
|
||||||
|
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<label className="label">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="label">Couleur d'avatar</label>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-white' : ''}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary w-full" onClick={save} disabled={busy}>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Plus, Target, Trash2, CalendarClock } from 'lucide-react'
|
||||||
|
import { format, isPast, differenceInCalendarDays } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { supa } from '../lib/supabase'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
import {
|
||||||
|
Milestone,
|
||||||
|
MilestoneStatus,
|
||||||
|
Task,
|
||||||
|
MILESTONE_STATUS_LABELS
|
||||||
|
} from '../lib/types'
|
||||||
|
import { Modal, Select, EmptyState } 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Milestones(): JSX.Element {
|
||||||
|
const { currentProject } = useApp()
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [editing, setEditing] = useState<Milestone | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const projectId = currentProject?.id
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
const [{ data: ms }, { data: ts }] = await Promise.all([
|
||||||
|
supa().from('milestones').select('*').eq('project_id', projectId),
|
||||||
|
supa().from('tasks').select('*').eq('project_id', projectId)
|
||||||
|
])
|
||||||
|
const sorted = ((ms ?? []) as Milestone[]).sort((a, b) =>
|
||||||
|
(a.due_date ?? '9999').localeCompare(b.due_date ?? '9999')
|
||||||
|
)
|
||||||
|
setMilestones(sorted)
|
||||||
|
setTasks((ts ?? []) as Task[])
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const progressOf = (m: Milestone): { done: number; total: number; pct: number } => {
|
||||||
|
const linked = tasks.filter((t) => t.milestone_id === m.id)
|
||||||
|
const done = linked.filter((t) => t.status === 'done').length
|
||||||
|
const pct = linked.length ? Math.round((done / linked.length) * 100) : 0
|
||||||
|
return { done, total: linked.length, pct }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title="Jalons & planning" subtitle={currentProject?.name}>
|
||||||
|
<button className="btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
<Plus size={16} /> Nouveau jalon
|
||||||
|
</button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Target size={40} />}
|
||||||
|
text="Aucun jalon. Crée des jalons pour structurer ton planning (ex : Prototype, Alpha, Beta, Release)."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-3">
|
||||||
|
{milestones.map((m) => {
|
||||||
|
const { done, total, pct } = progressOf(m)
|
||||||
|
const overdue =
|
||||||
|
m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59'))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="card cursor-pointer hover:border-accent"
|
||||||
|
onClick={() => setEditing(m)}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">{m.title}</h3>
|
||||||
|
{m.description && (
|
||||||
|
<p className="mt-0.5 text-sm text-gray-400">{m.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${STATUS_STYLES[m.status]}`}>
|
||||||
|
{MILESTONE_STATUS_LABELS[m.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 h-2 overflow-hidden rounded-full bg-panel2">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent2 transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
{done}/{total} tâche(s) — {pct}%
|
||||||
|
</span>
|
||||||
|
{m.due_date && (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1 ${overdue ? 'text-rose-400' : ''}`}
|
||||||
|
>
|
||||||
|
<CalendarClock size={12} />
|
||||||
|
{format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })}
|
||||||
|
{m.status !== 'done' && ` (${daysLabel(m.due_date)})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(creating || editing) && (
|
||||||
|
<MilestoneModal
|
||||||
|
milestone={editing}
|
||||||
|
projectId={projectId!}
|
||||||
|
onClose={() => {
|
||||||
|
setCreating(false)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysLabel(due: string): string {
|
||||||
|
const d = differenceInCalendarDays(new Date(due), new Date())
|
||||||
|
if (d < 0) return `en retard de ${-d} j`
|
||||||
|
if (d === 0) return "aujourd'hui"
|
||||||
|
return `dans ${d} j`
|
||||||
|
}
|
||||||
|
|
||||||
|
function MilestoneModal({
|
||||||
|
milestone,
|
||||||
|
projectId,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
milestone: Milestone | null
|
||||||
|
projectId: string
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}): JSX.Element {
|
||||||
|
const [title, setTitle] = useState(milestone?.title ?? '')
|
||||||
|
const [description, setDescription] = useState(milestone?.description ?? '')
|
||||||
|
const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned')
|
||||||
|
const [dueDate, setDueDate] = useState(milestone?.due_date ?? '')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (!title.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
const payload = {
|
||||||
|
project_id: projectId,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
status,
|
||||||
|
due_date: dueDate || null
|
||||||
|
}
|
||||||
|
if (milestone) await supa().from('milestones').update(payload).eq('id', milestone.id)
|
||||||
|
else await supa().from('milestones').insert(payload)
|
||||||
|
await onSaved()
|
||||||
|
setBusy(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (): Promise<void> => {
|
||||||
|
if (!milestone || !confirm('Supprimer ce jalon ?')) return
|
||||||
|
await supa().from('milestones').delete().eq('id', milestone.id)
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={milestone ? 'Modifier le jalon' : 'Nouveau jalon'} onClose={onClose}>
|
||||||
|
<label className="label">Titre</label>
|
||||||
|
<input
|
||||||
|
className="input mb-3"
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Alpha jouable"
|
||||||
|
/>
|
||||||
|
<label className="label">Description</label>
|
||||||
|
<textarea
|
||||||
|
className="input mb-3 h-20 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 MilestoneStatus)}>
|
||||||
|
{Object.entries(MILESTONE_STATUS_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Date cible</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">
|
||||||
|
{milestone ? (
|
||||||
|
<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()}>
|
||||||
|
{milestone ? 'Enregistrer' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Database, ExternalLink, Loader2 } from 'lucide-react'
|
||||||
|
import { useApp } from '../lib/AppContext'
|
||||||
|
|
||||||
|
export default function Setup(): JSX.Element {
|
||||||
|
const { saveConfig } = useApp()
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
setError('')
|
||||||
|
if (!url.includes('supabase') || key.length < 20) {
|
||||||
|
setError('URL ou clé invalide. Vérifie tes valeurs dans Supabase > Settings > API.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await saveConfig(url, key)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
|
||||||
|
className="card w-full max-w-lg"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-accent/20 p-2 text-accent">
|
||||||
|
<Database size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Connexion à la base de données</h1>
|
||||||
|
<p className="text-sm text-gray-400">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">
|
||||||
|
<li>
|
||||||
|
Crée un compte sur{' '}
|
||||||
|
<a
|
||||||
|
href="https://supabase.com"
|
||||||
|
className="inline-flex items-center gap-1 text-accent2 hover:underline"
|
||||||
|
>
|
||||||
|
supabase.com <ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>Crée un nouveau projet (gratuit)</li>
|
||||||
|
<li>Onglet SQL Editor : colle le contenu de supabase/schema.sql et exécute-le</li>
|
||||||
|
<li>Onglet Settings → API : copie l'URL et la clé « anon public »</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="label">URL du projet</label>
|
||||||
|
<input
|
||||||
|
className="input mb-3"
|
||||||
|
placeholder="https://xxxxx.supabase.co"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="label">Clé anon public</label>
|
||||||
|
<input
|
||||||
|
className="input mb-4"
|
||||||
|
placeholder="eyJhbGciOi..."
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-rose-400">{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">
|
||||||
|
Les clés sont stockées localement sur ton ordinateur uniquement.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GameDev Tracker — Schéma de base de données (Supabase / PostgreSQL)
|
||||||
|
-- À exécuter UNE SEULE FOIS dans : Supabase > SQL Editor > New query
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------- Types énumérés -------------------------------------------
|
||||||
|
do $$ begin
|
||||||
|
create type member_role as enum
|
||||||
|
('lead','programmer','artist','game_designer','sound','writer','qa','other');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type task_status as enum ('todo','in_progress','review','done');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type priority_level as enum ('low','medium','high','urgent');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type milestone_status as enum ('planned','in_progress','done');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type bug_severity as enum ('low','medium','high','critical');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type bug_status as enum ('open','in_progress','resolved','closed');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- ---------- Profils (étend auth.users) -------------------------------
|
||||||
|
create table if not exists public.profiles (
|
||||||
|
id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
full_name text not null default 'Membre',
|
||||||
|
role member_role not null default 'other',
|
||||||
|
avatar_color text not null default '#7c5cff',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Projets --------------------------------------------------
|
||||||
|
create table if not exists public.projects (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
created_by uuid references public.profiles(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Jalons (milestones) --------------------------------------
|
||||||
|
create table if not exists public.milestones (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
project_id uuid not null references public.projects(id) on delete cascade,
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
due_date date,
|
||||||
|
status milestone_status not null default 'planned',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Tâches ---------------------------------------------------
|
||||||
|
create table if not exists public.tasks (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
project_id uuid not null references public.projects(id) on delete cascade,
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
status task_status not null default 'todo',
|
||||||
|
priority priority_level not null default 'medium',
|
||||||
|
assignee_id uuid references public.profiles(id) on delete set null,
|
||||||
|
milestone_id uuid references public.milestones(id) on delete set null,
|
||||||
|
due_date date,
|
||||||
|
position double precision not null default 0,
|
||||||
|
created_by uuid references public.profiles(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Bugs -----------------------------------------------------
|
||||||
|
create table if not exists public.bugs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
project_id uuid not null references public.projects(id) on delete cascade,
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
severity bug_severity not null default 'medium',
|
||||||
|
status bug_status not null default 'open',
|
||||||
|
priority priority_level not null default 'medium',
|
||||||
|
assignee_id uuid references public.profiles(id) on delete set null,
|
||||||
|
reporter_id uuid references public.profiles(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_tasks_project on public.tasks(project_id);
|
||||||
|
create index if not exists idx_bugs_project on public.bugs(project_id);
|
||||||
|
create index if not exists idx_milestones_project on public.milestones(project_id);
|
||||||
|
|
||||||
|
-- ---------- Maj automatique de updated_at ----------------------------
|
||||||
|
create or replace function public.touch_updated_at()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin new.updated_at = now(); return new; end $$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_tasks_touch on public.tasks;
|
||||||
|
create trigger trg_tasks_touch before update on public.tasks
|
||||||
|
for each row execute function public.touch_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists trg_bugs_touch on public.bugs;
|
||||||
|
create trigger trg_bugs_touch before update on public.bugs
|
||||||
|
for each row execute function public.touch_updated_at();
|
||||||
|
|
||||||
|
-- ---------- Création auto du profil à l'inscription ------------------
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger language plpgsql security definer set search_path = public as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, full_name, role)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
|
||||||
|
coalesce((new.raw_user_meta_data->>'role')::member_role, 'other')
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute function public.handle_new_user();
|
||||||
|
|
||||||
|
-- ---------- Sécurité (RLS) -------------------------------------------
|
||||||
|
-- Équipe privée et de confiance : tout membre connecté a accès complet.
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
alter table public.projects enable row level security;
|
||||||
|
alter table public.milestones enable row level security;
|
||||||
|
alter table public.tasks enable row level security;
|
||||||
|
alter table public.bugs enable row level security;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare t text;
|
||||||
|
begin
|
||||||
|
foreach t in array array['profiles','projects','milestones','tasks','bugs'] loop
|
||||||
|
execute format('drop policy if exists %I_all on public.%I', t, t);
|
||||||
|
execute format(
|
||||||
|
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
|
||||||
|
t, t);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ---------- Realtime (synchro live entre membres) --------------------
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.tasks;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.bugs;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
do $$ begin
|
||||||
|
alter publication supabase_realtime add table public.milestones;
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: '#0f1117',
|
||||||
|
panel: '#171a23',
|
||||||
|
panel2: '#1e222e',
|
||||||
|
border: '#2a2f3d',
|
||||||
|
accent: '#7c5cff',
|
||||||
|
accent2: '#00d2b8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
// Configuration Vite pour le front-end React, servi par Tauri.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
// Ne pas surveiller le dossier de build Rust (évite l'erreur EBUSY sur les .dll).
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/src-tauri/**']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||