feat: initial sbsports deployment setup
Add Coolify/Woodpecker CI config, .gitignore, and deployment scripts.
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
*.md
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/*.sqlite
|
||||
data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
.env
|
||||
.env.local
|
||||
30
.woodpecker.yml
Normal file
30
.woodpecker.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
image: alpine
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "Dockerfile"
|
||||
- "src/**"
|
||||
- "server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "vite.config.js"
|
||||
- "tailwind.config.js"
|
||||
- "postcss.config.js"
|
||||
- "index.html"
|
||||
environment:
|
||||
COOLIFY_API_TOKEN:
|
||||
from_secret: coolify_api_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
COOLIFY="http://10.0.0.1:8000/api/v1"
|
||||
UUID="ng00g8sskks0ok0okk0cg8kw"
|
||||
echo "Triggering deploy in Coolify..."
|
||||
curl -sSLk -X GET "$COOLIFY/deploy?uuid=$UUID&force=false" \
|
||||
-H "Authorization: Bearer $COOLIFY_API_TOKEN"
|
||||
131
COOLIFY.md
Normal file
131
COOLIFY.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# sbsports — Coolify & Woodpecker Setup
|
||||
|
||||
Despliegue automático de sbsports en Coolify con CI/CD en Woodpecker.
|
||||
|
||||
## URLs
|
||||
|
||||
| Servicio | URL |
|
||||
|----------|-----|
|
||||
| App Web | `https://sbsports.thax.es` |
|
||||
| Registry | `git.thax.es/alexandrump/sbsports` |
|
||||
|
||||
## Setup en Coolify
|
||||
|
||||
### Paso 1: Crear el servicio
|
||||
|
||||
1. Accede a https://panel.thax.es
|
||||
2. Crea un nuevo **Proyecto** (o usa uno existente)
|
||||
3. Dentro del proyecto, crea un nuevo servicio:
|
||||
- **Tipo**: Docker Compose
|
||||
- **Nombre**: sbsports
|
||||
- **Contenido**: copia el contenido de `coolify/docker-compose.yml`
|
||||
4. Click en **Deploy**
|
||||
|
||||
### Paso 2: Obtener el UUID
|
||||
|
||||
Una vez creado, copia el UUID del servicio. Lo encontrarás en:
|
||||
- URL: `https://panel.thax.es/project/{projectId}/service/{UUID}`
|
||||
- O en los detalles del servicio
|
||||
|
||||
### Paso 3: Configurar variables de entorno
|
||||
|
||||
En Coolify, ve a **Environment** del servicio y verifica:
|
||||
- `NODE_ENV=production`
|
||||
- `PORT=3000`
|
||||
- `DATA_DIR=/app/data`
|
||||
|
||||
## Setup en Woodpecker CI
|
||||
|
||||
### Paso 1: Activar el repositorio
|
||||
|
||||
1. Accede a https://ci.thax.es
|
||||
2. El repositorio sbsports debería aparecer en la lista
|
||||
3. Haz click en **Activate** (si no está ya activado)
|
||||
4. Guarda el **Repository ID** (aparece en la URL: `/repos/{id}`)
|
||||
|
||||
### Paso 2: Configurar secretos
|
||||
|
||||
En Woodpecker, ve a **Secrets** del repositorio sbsports y agrega:
|
||||
|
||||
| Secreto | Valor | Origen |
|
||||
|---------|-------|--------|
|
||||
| `gitea_user` | Tu usuario de Gitea | `git.thax.es` |
|
||||
| `gitea_token` | Token de Gitea | https://git.thax.es/user/settings/applications |
|
||||
| `coolify_api_token` | Token de Coolify API | `panel.thax.es` (del README de invest) |
|
||||
| `SBSPORTS_COOLIFY_UUID` | UUID del servicio | De Coolify (Paso 2) |
|
||||
|
||||
### Paso 3: Variables del pipeline
|
||||
|
||||
Añade la variable `SBSPORTS_COOLIFY_UUID` a los secretos de Woodpecker con el UUID obtenido en Coolify.
|
||||
|
||||
## Flujo de despliegue
|
||||
|
||||
```
|
||||
git push → Gitea
|
||||
↓
|
||||
Woodpecker detects push
|
||||
↓
|
||||
1. Build imagen: Dockerfile → git.thax.es/alexandrump/sbsports:latest
|
||||
↓
|
||||
2. PATCH compose en Coolify con la nueva imagen
|
||||
↓
|
||||
3. Trigger deploy en Coolify (redeploy automático)
|
||||
↓
|
||||
Contenedor reinicia con la nueva imagen
|
||||
```
|
||||
|
||||
## Despliegue manual (sin Woodpecker)
|
||||
|
||||
Si necesitas hacer deploy manualmente:
|
||||
|
||||
```bash
|
||||
# 1. Build y push a registry
|
||||
docker build -t git.thax.es/alexandrump/sbsports:latest .
|
||||
docker login git.thax.es
|
||||
docker push git.thax.es/alexandrump/sbsports:latest
|
||||
|
||||
# 2. Redeploy en Coolify
|
||||
COOLIFY_TOKEN="8|qF9w8JziRgUjUsbTMTUIQUP6C2PjD8bpbQ34Gf4e6254af98"
|
||||
COOLIFY_UUID="<obtén de https://panel.thax.es>"
|
||||
|
||||
curl -sSLk -X GET "https://panel.thax.es/api/v1/deploy?uuid=$COOLIFY_UUID&force=false" \
|
||||
-H "Authorization: Bearer $COOLIFY_TOKEN"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Imagen no actualiza en Coolify**
|
||||
- Verifica que `pull_policy: always` está en el compose
|
||||
- Força el redeploy con `force=true` en la URL de deploy
|
||||
|
||||
**Woodpecker falla al buildar**
|
||||
- Verifica credenciales de Gitea en secretos
|
||||
- Comprueba que el Dockerfile existe en la raíz del repo
|
||||
|
||||
**Dominio no resuelve**
|
||||
- Verifica que Traefik está corriendo en Coolify
|
||||
- Comprueba las labels de la configuración en `coolify/docker-compose.yml`
|
||||
|
||||
**Contenedor no arranca**
|
||||
- Revisa logs en Coolify: **Monitoring** → **Logs**
|
||||
- Verifica que el puerto 3000 es accesible
|
||||
|
||||
## IDs clave
|
||||
|
||||
```bash
|
||||
# Coolify service UUID (reemplazar después de crear)
|
||||
SBSPORTS_COOLIFY_UUID="obtén_de_coolify"
|
||||
|
||||
# Gitea registry (ya configurado)
|
||||
git.thax.es/alexandrump/sbsports
|
||||
|
||||
# Credenciales (guardadas en memoria de Claude Code)
|
||||
# Ver: /Users/alex/.claude/projects/*/memory/
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- La imagen se buildeará en el registry de Gitea: `git.thax.es/alexandrump/sbsports:latest`
|
||||
- El volumen `sbsports-data` persiste entre deployments
|
||||
- Traefik maneja el HTTPS automáticamente para `sbsports.thax.es`
|
||||
- Los logs se pueden ver en Coolify → Monitoring → Logs
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# ── Stage 1: build ───────────────────────────────────────────────────────────
|
||||
# Needs build tools (python3, make, g++) to compile better-sqlite3 native addon
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: production ───────────────────────────────────────────────────────
|
||||
# Lean image with only the runtime artifacts
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/app/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only production dependencies (re-install native modules for final arch)
|
||||
COPY package*.json ./
|
||||
RUN apk add --no-cache python3 make g++ \
|
||||
&& npm ci --omit=dev \
|
||||
&& apk del python3 make g++
|
||||
|
||||
# Copy server source and built frontend
|
||||
COPY server/ ./server/
|
||||
COPY --from=builder /app/dist ./dist/
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
28
coolify/docker-compose.yml
Normal file
28
coolify/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
sbsports:
|
||||
image: git.thax.es/alexandrump/sbsports:latest
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATA_DIR: /app/data
|
||||
volumes:
|
||||
- sbsports-data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.sbsports.entrypoints: "https"
|
||||
traefik.http.routers.sbsports.rule: "Host(`sbsports.thax.es`)"
|
||||
traefik.http.routers.sbsports.tls: "true"
|
||||
traefik.http.routers.sbsports.service: "sbsports"
|
||||
traefik.http.services.sbsports.loadbalancer.server.port: "3000"
|
||||
|
||||
volumes:
|
||||
sbsports-data:
|
||||
driver: local
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- sbsports_data:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATA_DIR: /app/data
|
||||
|
||||
volumes:
|
||||
sbsports_data:
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -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>Sanae Benkhlifa Sports - Système de Gestion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3968
package-lock.json
generated
Normal file
3968
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "sbsports",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n api,vue -c blue,green \"node server/index.js\" \"vite\"",
|
||||
"build": "vite build",
|
||||
"serve": "node server/index.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"express": "^4.21.2",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
39
scripts/activate-woodpecker.sh
Executable file
39
scripts/activate-woodpecker.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Activate sbsports repository in Woodpecker CI
|
||||
# Prerequisites: WP_TOKEN, Gitea remote ID
|
||||
|
||||
WP_TOKEN="${WP_TOKEN:-}"
|
||||
WP_API="https://ci.thax.es/api"
|
||||
GITEA_REMOTE_ID="${SBSPORTS_REPO_ID:-}"
|
||||
|
||||
if [ -z "$WP_TOKEN" ]; then
|
||||
echo "Error: WP_TOKEN not set"
|
||||
echo "Usage: WP_TOKEN=your_token SBSPORTS_REPO_ID=34 ./scripts/activate-woodpecker.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$GITEA_REMOTE_ID" ]; then
|
||||
echo "Error: SBSPORTS_REPO_ID not set (Gitea remote ID)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Activating sbsports in Woodpecker ==="
|
||||
echo ""
|
||||
|
||||
# 1. Activate the repository
|
||||
echo "Activating repository (Gitea remote ID: $GITEA_REMOTE_ID)..."
|
||||
curl -sSLk -X POST "$WP_API/repos?forge_remote_id=$GITEA_REMOTE_ID" \
|
||||
-H "Authorization: Bearer $WP_TOKEN" | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "2. Set the following secrets in Woodpecker (via UI or API):"
|
||||
echo " - gitea_user (username for git.thax.es registry)"
|
||||
echo " - gitea_token (token for git.thax.es registry)"
|
||||
echo " - coolify_api_token (Coolify API token)"
|
||||
echo ""
|
||||
echo "3. After setting up Coolify, add:"
|
||||
echo " - SBSPORTS_COOLIFY_UUID (service UUID from Coolify)"
|
||||
echo ""
|
||||
echo "Done!"
|
||||
37
scripts/coolify-setup.sh
Executable file
37
scripts/coolify-setup.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Coolify setup script for sbsports
|
||||
# Prerequisites: Coolify API token
|
||||
|
||||
COOLIFY_API_TOKEN="${COOLIFY_API_TOKEN:-}"
|
||||
COOLIFY_API="https://panel.thax.es/api/v1"
|
||||
|
||||
if [ -z "$COOLIFY_API_TOKEN" ]; then
|
||||
echo "Error: COOLIFY_API_TOKEN not set"
|
||||
echo "Usage: COOLIFY_API_TOKEN=your_token ./scripts/coolify-setup.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== sbsports Coolify Setup ==="
|
||||
echo ""
|
||||
|
||||
# 1. List existing projects
|
||||
echo "1. Listing existing projects..."
|
||||
curl -sSLk "$COOLIFY_API/projects" \
|
||||
-H "Authorization: Bearer $COOLIFY_API_TOKEN" | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "2. Create a Docker Compose service in Coolify:"
|
||||
echo " - Go to: https://panel.thax.es"
|
||||
echo " - Create a new project (or use existing)"
|
||||
echo " - Add a new Docker Compose service"
|
||||
echo " - Paste the contents of coolify/docker-compose.yml"
|
||||
echo " - Deploy"
|
||||
echo ""
|
||||
echo "3. After creation, get the UUID from the service details"
|
||||
echo " - Set SBSPORTS_COOLIFY_UUID environment variable"
|
||||
echo " - Add it to Woodpecker secrets"
|
||||
echo ""
|
||||
echo "4. To activate the repo in Woodpecker (optional):"
|
||||
echo " SBSPORTS_REPO_ID=your_gitea_remote_id ./scripts/activate-woodpecker.sh"
|
||||
45
server/db.js
Normal file
45
server/db.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { mkdirSync } from "fs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = process.env.DATA_DIR || join(__dirname, "../data");
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(join(DATA_DIR, "db.sqlite"));
|
||||
|
||||
// Enable WAL for better concurrent read performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id TEXT PRIMARY KEY,
|
||||
firstName TEXT NOT NULL,
|
||||
lastName TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
sex TEXT,
|
||||
weight REAL,
|
||||
height REAL,
|
||||
imc REAL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
studentId TEXT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
type TEXT,
|
||||
durationInput REAL,
|
||||
durationUnit TEXT,
|
||||
duration REAL,
|
||||
displayDuration TEXT,
|
||||
intensity TEXT,
|
||||
lifestyle TEXT,
|
||||
anxiety INTEGER DEFAULT 0,
|
||||
grade TEXT,
|
||||
date TEXT,
|
||||
createdAt TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
150
server/index.js
Normal file
150
server/index.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import express from "express";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import db from "./db.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Serve Vue build in production
|
||||
const distPath = join(__dirname, "../dist");
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// ── Students ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/students", (_req, res) => {
|
||||
res.json(
|
||||
db.prepare("SELECT * FROM students ORDER BY lastName, firstName").all(),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/students", (req, res) => {
|
||||
const { id, firstName, lastName, age, sex, weight, height, imc } = req.body;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO students (id, firstName, lastName, age, sex, weight, height, imc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(id, firstName, lastName, age, sex, weight, height, imc);
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
app.put("/api/students/:id", (req, res) => {
|
||||
const { firstName, lastName, age, sex, weight, height, imc } = req.body;
|
||||
const info = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE students SET firstName=?, lastName=?, age=?, sex=?, weight=?, height=?, imc=?
|
||||
WHERE id=?
|
||||
`,
|
||||
)
|
||||
.run(firstName, lastName, age, sex, weight, height, imc, req.params.id);
|
||||
if (info.changes === 0) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/students/:id", (req, res) => {
|
||||
// CASCADE deletes linked activities automatically (FK ON DELETE CASCADE)
|
||||
db.prepare("DELETE FROM students WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Activities ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/activities", (_req, res) => {
|
||||
res.json(
|
||||
db.prepare("SELECT * FROM activities ORDER BY createdAt DESC").all(),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/activities", (req, res) => {
|
||||
const {
|
||||
id,
|
||||
studentId,
|
||||
type,
|
||||
durationInput,
|
||||
durationUnit,
|
||||
duration,
|
||||
displayDuration,
|
||||
intensity,
|
||||
lifestyle,
|
||||
anxiety,
|
||||
grade,
|
||||
date,
|
||||
} = req.body;
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO activities
|
||||
(id, studentId, type, durationInput, durationUnit, duration, displayDuration, intensity, lifestyle, anxiety, grade, date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
studentId,
|
||||
type,
|
||||
durationInput,
|
||||
durationUnit,
|
||||
duration,
|
||||
displayDuration,
|
||||
intensity,
|
||||
lifestyle,
|
||||
anxiety ?? 0,
|
||||
grade,
|
||||
date,
|
||||
);
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
app.put("/api/activities/:id", (req, res) => {
|
||||
const {
|
||||
type,
|
||||
durationInput,
|
||||
durationUnit,
|
||||
duration,
|
||||
displayDuration,
|
||||
intensity,
|
||||
lifestyle,
|
||||
anxiety,
|
||||
grade,
|
||||
date,
|
||||
} = req.body;
|
||||
const info = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE activities
|
||||
SET type=?, durationInput=?, durationUnit=?, duration=?, displayDuration=?,
|
||||
intensity=?, lifestyle=?, anxiety=?, grade=?, date=?
|
||||
WHERE id=?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
type,
|
||||
durationInput,
|
||||
durationUnit,
|
||||
duration,
|
||||
displayDuration,
|
||||
intensity,
|
||||
lifestyle,
|
||||
anxiety ?? 0,
|
||||
grade,
|
||||
date,
|
||||
req.params.id,
|
||||
);
|
||||
if (info.changes === 0) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/activities/:id", (req, res) => {
|
||||
db.prepare("DELETE FROM activities WHERE id=?").run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── SPA fallback ──────────────────────────────────────────────────────────
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(join(distPath, "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`SB Sports API running on :${PORT}`));
|
||||
45
src/App.vue
Normal file
45
src/App.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import { onMounted } from "vue";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppNotification from "./components/AppNotification.vue";
|
||||
import { useSportsStore } from "./stores/sports";
|
||||
import { User } from "lucide-vue-next";
|
||||
|
||||
const store = useSportsStore();
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => store.loadData());
|
||||
|
||||
const titles = {
|
||||
dashboard: "Tableau de Bord",
|
||||
students: "Annuaire Étudiants",
|
||||
activity: "Suivi Personnel",
|
||||
results: "Résultats Académiques",
|
||||
history: "Historique Global",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col lg:flex-row">
|
||||
<AppSidebar />
|
||||
|
||||
<main class="flex-1 p-6 lg:p-10 overflow-y-auto">
|
||||
<AppNotification :message="store.notification" />
|
||||
|
||||
<header class="mb-8 flex justify-between items-center">
|
||||
<h2 class="text-3xl font-bold text-blue-900">
|
||||
{{ titles[route.name] ?? "SB Sports" }}
|
||||
</h2>
|
||||
<div
|
||||
class="flex items-center gap-3 text-blue-900 bg-blue-100 px-4 py-2 rounded-full border border-blue-200 shadow-sm"
|
||||
>
|
||||
<User class="w-4 h-4" />
|
||||
<span class="font-medium">Coach Sanae</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
64
src/assets/main.css
Normal file
64
src/assets/main.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;900&display=swap");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.gradient-brand {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo-shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logo-shine::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1),
|
||||
transparent
|
||||
);
|
||||
transform: rotate(45deg);
|
||||
animation: shine 3s infinite;
|
||||
}
|
||||
@keyframes shine {
|
||||
0% {
|
||||
transform: translateX(-100%) rotate(45deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #dbeafe;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: #1e40af;
|
||||
border-radius: 50%;
|
||||
margin-top: -6px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
22
src/components/AppNotification.vue
Normal file
22
src/components/AppNotification.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { Info } from "lucide-vue-next";
|
||||
|
||||
defineProps({ message: { type: String, default: "" } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="message"
|
||||
class="fixed top-5 right-5 bg-blue-900 text-white px-6 py-3 rounded-2xl shadow-2xl z-50 flex items-center gap-3 border border-blue-400"
|
||||
>
|
||||
<Info class="w-4 h-4" />
|
||||
{{ message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
109
src/components/AppSidebar.vue
Normal file
109
src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { useRoute, RouterLink } from "vue-router";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Activity,
|
||||
GraduationCap,
|
||||
History,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: "/",
|
||||
name: "dashboard",
|
||||
icon: LayoutDashboard,
|
||||
label: "Tableau de bord",
|
||||
},
|
||||
{
|
||||
to: "/students",
|
||||
name: "students",
|
||||
icon: Users,
|
||||
label: "Annuaire Étudiants",
|
||||
},
|
||||
{
|
||||
to: "/activity",
|
||||
name: "activity",
|
||||
icon: Activity,
|
||||
label: "Suivi Personnel",
|
||||
},
|
||||
{
|
||||
to: "/results",
|
||||
name: "results",
|
||||
icon: GraduationCap,
|
||||
label: "Résultats Académiques",
|
||||
},
|
||||
{
|
||||
to: "/history",
|
||||
name: "history",
|
||||
icon: History,
|
||||
label: "Historique Global",
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (name) => route.name === name;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="lg:w-72 w-full gradient-brand text-white flex flex-col p-6 shadow-xl"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3 mb-10 overflow-hidden">
|
||||
<div class="bg-white p-2 rounded-lg logo-shine">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="50" cy="50" r="45" fill="#1e40af" />
|
||||
<path
|
||||
d="M30 70 L45 30 L55 50 L70 20"
|
||||
stroke="white"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="70" cy="20" r="5" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-black text-xl leading-tight">SB SPORT</h1>
|
||||
<p
|
||||
class="text-[10px] text-blue-100 uppercase tracking-widest font-medium"
|
||||
>
|
||||
Sanae Benkhlifa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav links -->
|
||||
<div class="flex flex-col gap-2 flex-grow">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
:class="[
|
||||
'flex items-center gap-3 p-3 rounded-xl transition-all',
|
||||
isActive(item.name)
|
||||
? 'bg-white/20 font-semibold'
|
||||
: 'hover:bg-white/10',
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-auto pt-6 border-t border-white/10 text-[10px] text-blue-200 text-center uppercase tracking-tighter"
|
||||
>
|
||||
© 2024 Sanae Benkhlifa Sports
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
94
src/composables/useHelpers.js
Normal file
94
src/composables/useHelpers.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Pure utility functions shared across all views
|
||||
|
||||
export const gradeWeights = {
|
||||
Excellent: 4,
|
||||
"Très bien": 3,
|
||||
Bien: 2,
|
||||
"Assez bien": 1,
|
||||
Redouble: 0,
|
||||
};
|
||||
|
||||
export function calcIMC(weight, height) {
|
||||
if (!weight || !height) return 0;
|
||||
const h = height / 100;
|
||||
return parseFloat((weight / (h * h)).toFixed(1));
|
||||
}
|
||||
|
||||
export function getIMCInterpretation(imc) {
|
||||
if (!imc || imc <= 0) return "-";
|
||||
if (imc < 16) return "Maigreur sévère";
|
||||
if (imc < 17) return "Maigreur modérée";
|
||||
if (imc < 18.5) return "Maigreur légère";
|
||||
if (imc < 25) return "Corpulence normale";
|
||||
if (imc < 30) return "Surpoids";
|
||||
if (imc < 35) return "Obésité modérée (I)";
|
||||
if (imc < 40) return "Obésité sévère (II)";
|
||||
return "Obésité morbide (III)";
|
||||
}
|
||||
|
||||
export function getIMCBgClass(imc) {
|
||||
if (!imc || imc <= 0) return "bg-gray-300";
|
||||
if (imc >= 18.5 && imc < 25) return "bg-green-500";
|
||||
if (imc < 18.5) return "bg-sky-400";
|
||||
if (imc < 30) return "bg-amber-500";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
export function getAnxietyLabel(s) {
|
||||
if (s <= 4) return "Minime";
|
||||
if (s <= 9) return "Légère";
|
||||
if (s <= 14) return "Modérée";
|
||||
return "Sévère";
|
||||
}
|
||||
|
||||
export function getAnxietyColor(s) {
|
||||
if (s <= 4) return "bg-green-50 text-green-700 border-green-200";
|
||||
if (s <= 9) return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
if (s <= 14) return "bg-orange-50 text-orange-700 border-orange-200";
|
||||
return "bg-red-50 text-red-700 border-red-200";
|
||||
}
|
||||
|
||||
export function getLifestyleBg(l) {
|
||||
return (
|
||||
{
|
||||
Inactif: "bg-red-500",
|
||||
Sédentaire: "bg-orange-500",
|
||||
Actif: "bg-blue-500",
|
||||
"Très actif": "bg-green-500",
|
||||
}[l] || "bg-gray-400"
|
||||
);
|
||||
}
|
||||
|
||||
export function getGradeStyle(g) {
|
||||
return (
|
||||
{
|
||||
Redouble: "bg-red-50 text-red-600 border-red-200",
|
||||
"Assez bien": "bg-sky-50 text-sky-600 border-sky-200",
|
||||
Bien: "bg-blue-50 text-blue-600 border-blue-200",
|
||||
"Très bien": "bg-indigo-50 text-indigo-600 border-indigo-200",
|
||||
Excellent: "bg-green-50 text-green-600 border-green-200",
|
||||
"Non noté": "bg-gray-50 text-gray-400 border-gray-100",
|
||||
}[g] || "bg-gray-50"
|
||||
);
|
||||
}
|
||||
|
||||
export function getGradeBgClass(g) {
|
||||
return (
|
||||
{
|
||||
Excellent: "bg-green-500",
|
||||
"Très bien": "bg-indigo-600",
|
||||
Bien: "bg-blue-600",
|
||||
"Assez bien": "bg-sky-500",
|
||||
Redouble: "bg-red-500",
|
||||
"Non noté": "bg-gray-300",
|
||||
}[g] || "bg-gray-400"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(d) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
21
src/composables/useSportsEditBus.js
Normal file
21
src/composables/useSportsEditBus.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Simple shared state bus: HistoryView sets an activity to edit,
|
||||
* ActivityView picks it up on mount / route enter.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
|
||||
const pending = ref(null);
|
||||
|
||||
export function useSportsEditBus() {
|
||||
function set(act) {
|
||||
pending.value = act;
|
||||
}
|
||||
|
||||
function consume() {
|
||||
const act = pending.value;
|
||||
pending.value = null;
|
||||
return act;
|
||||
}
|
||||
|
||||
return { pending, set, consume };
|
||||
}
|
||||
7
src/main.js
Normal file
7
src/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
import "./assets/main.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
19
src/router/index.js
Normal file
19
src/router/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import DashboardView from "../views/DashboardView.vue";
|
||||
import StudentsView from "../views/StudentsView.vue";
|
||||
import ActivityView from "../views/ActivityView.vue";
|
||||
import ResultsView from "../views/ResultsView.vue";
|
||||
import HistoryView from "../views/HistoryView.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "dashboard", component: DashboardView },
|
||||
{ path: "/students", name: "students", component: StudentsView },
|
||||
{ path: "/activity", name: "activity", component: ActivityView },
|
||||
{ path: "/results", name: "results", component: ResultsView },
|
||||
{ path: "/history", name: "history", component: HistoryView },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
206
src/stores/sports.js
Normal file
206
src/stores/sports.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { gradeWeights, calcIMC } from '../composables/useHelpers'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) throw new Error(`API ${path} → ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const useSportsStore = defineStore('sports', () => {
|
||||
const activities = ref([])
|
||||
const students = ref([])
|
||||
const notification = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, a] = await Promise.all([
|
||||
apiFetch('/students'),
|
||||
apiFetch('/activities'),
|
||||
])
|
||||
students.value = s
|
||||
activities.value = a
|
||||
} catch (e) {
|
||||
showNotification('Erreur de connexion au serveur')
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification ─────────────────────────────────────────────────────────
|
||||
function showNotification(msg) {
|
||||
notification.value = msg
|
||||
setTimeout(() => (notification.value = ''), 3000)
|
||||
}
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────
|
||||
async function saveStudent(form, editingId) {
|
||||
const imc = calcIMC(form.weight, form.height)
|
||||
if (editingId) {
|
||||
await apiFetch(`/students/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...form, imc }),
|
||||
})
|
||||
const idx = students.value.findIndex((s) => s.id === editingId)
|
||||
if (idx !== -1) students.value[idx] = { ...form, id: editingId, imc }
|
||||
showNotification('Profil mis à jour')
|
||||
} else {
|
||||
const id = Date.now().toString()
|
||||
await apiFetch('/students', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id, ...form, imc }),
|
||||
})
|
||||
students.value.push({ id, ...form, imc })
|
||||
showNotification('Étudiant inscrit')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStudent(id) {
|
||||
await apiFetch(`/students/${id}`, { method: 'DELETE' })
|
||||
students.value = students.value.filter((s) => s.id !== id)
|
||||
activities.value = activities.value.filter((a) => a.studentId !== id)
|
||||
showNotification('Étudiant supprimé')
|
||||
}
|
||||
|
||||
// ── Activities ───────────────────────────────────────────────────────────
|
||||
async function saveActivity(act, editingId) {
|
||||
let norm = act.durationInput
|
||||
if (act.durationUnit === 'sec') norm /= 60
|
||||
if (act.durationUnit === 'h') norm *= 60
|
||||
|
||||
const record = {
|
||||
...act,
|
||||
id: editingId || Date.now().toString(),
|
||||
duration: norm,
|
||||
displayDuration: `${act.durationInput} ${act.durationUnit}`,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await apiFetch(`/activities/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(record),
|
||||
})
|
||||
const idx = activities.value.findIndex((a) => a.id === editingId)
|
||||
if (idx !== -1) activities.value[idx] = record
|
||||
showNotification('Activité mise à jour')
|
||||
} else {
|
||||
await apiFetch('/activities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
})
|
||||
activities.value.unshift(record)
|
||||
showNotification('Activité enregistrée')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteActivity(index) {
|
||||
const act = activities.value[index]
|
||||
await apiFetch(`/activities/${act.id}`, { method: 'DELETE' })
|
||||
activities.value.splice(index, 1)
|
||||
showNotification('Supprimé')
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
function getStudentName(id) {
|
||||
const s = students.value.find((st) => st.id === id)
|
||||
return s ? `${s.lastName} ${s.firstName}` : 'Inconnu'
|
||||
}
|
||||
|
||||
function getSelectedStudentIMC(id) {
|
||||
return students.value.find((s) => s.id === id)?.imc || 0
|
||||
}
|
||||
|
||||
function getStudentAveragePoints(studentId) {
|
||||
const evals = activities.value.filter((a) => a.studentId === studentId && a.grade)
|
||||
if (!evals.length) return -1
|
||||
return evals.reduce((acc, a) => acc + gradeWeights[a.grade], 0) / evals.length
|
||||
}
|
||||
|
||||
function getStudentAverageLiteral(studentId) {
|
||||
const avg = getStudentAveragePoints(studentId)
|
||||
if (avg === -1) return 'Non noté'
|
||||
const rounded = Math.round(avg)
|
||||
return Object.keys(gradeWeights).find((k) => gradeWeights[k] === rounded)
|
||||
}
|
||||
|
||||
function getStudentEvaluationsCount(id) {
|
||||
return activities.value.filter((a) => a.studentId === id && a.grade).length
|
||||
}
|
||||
|
||||
// ── Computed stats ────────────────────────────────────────────────────────
|
||||
const totalMinutes = computed(() =>
|
||||
activities.value.reduce((acc, c) => acc + (c.duration || 0), 0),
|
||||
)
|
||||
|
||||
const successRate = computed(() =>
|
||||
activities.value.length
|
||||
? Math.round(
|
||||
(activities.value.filter((a) => a.grade && a.grade !== 'Redouble').length /
|
||||
activities.value.length) * 100,
|
||||
)
|
||||
: 0,
|
||||
)
|
||||
|
||||
const gradeStats = computed(() => {
|
||||
const total = students.value.length || 1
|
||||
const averages = students.value.map((s) => getStudentAverageLiteral(s.id))
|
||||
const pct = (g) => Math.round((averages.filter((v) => v === g).length / total) * 100)
|
||||
return {
|
||||
excellent: pct('Excellent'),
|
||||
tresBien: pct('Très bien'),
|
||||
bien: pct('Bien'),
|
||||
assezBien: pct('Assez bien'),
|
||||
redouble: pct('Redouble'),
|
||||
}
|
||||
})
|
||||
|
||||
const activityLifestyleStats = computed(() => {
|
||||
const stats = {
|
||||
Inactif: { count: 0, percentage: 0 },
|
||||
Sédentaire: { count: 0, percentage: 0 },
|
||||
Actif: { count: 0, percentage: 0 },
|
||||
'Très actif': { count: 0, percentage: 0 },
|
||||
}
|
||||
activities.value.forEach((a) => {
|
||||
const key = a.lifestyle || 'Sédentaire'
|
||||
if (stats[key]) stats[key].count++
|
||||
})
|
||||
const total = activities.value.length || 1
|
||||
Object.keys(stats).forEach(
|
||||
(k) => (stats[k].percentage = Math.round((stats[k].count / total) * 100)),
|
||||
)
|
||||
return stats
|
||||
})
|
||||
|
||||
return {
|
||||
activities,
|
||||
students,
|
||||
notification,
|
||||
loading,
|
||||
loadData,
|
||||
showNotification,
|
||||
saveStudent,
|
||||
deleteStudent,
|
||||
saveActivity,
|
||||
deleteActivity,
|
||||
getStudentName,
|
||||
getSelectedStudentIMC,
|
||||
getStudentAveragePoints,
|
||||
getStudentAverageLiteral,
|
||||
getStudentEvaluationsCount,
|
||||
totalMinutes,
|
||||
successRate,
|
||||
gradeStats,
|
||||
activityLifestyleStats,
|
||||
}
|
||||
})
|
||||
325
src/views/ActivityView.vue
Normal file
325
src/views/ActivityView.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { AlertTriangle } from "lucide-vue-next";
|
||||
import { useSportsStore } from "../stores/sports";
|
||||
import { useSportsEditBus } from "../composables/useSportsEditBus";
|
||||
import {
|
||||
getIMCBgClass,
|
||||
getIMCInterpretation,
|
||||
getGradeBgClass,
|
||||
getAnxietyLabel,
|
||||
getAnxietyColor,
|
||||
} from "../composables/useHelpers";
|
||||
|
||||
const store = useSportsStore();
|
||||
const router = useRouter();
|
||||
const editBus = useSportsEditBus();
|
||||
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
onMounted(() => {
|
||||
const act = editBus.consume();
|
||||
if (act) startEdit(act);
|
||||
});
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
studentId: "",
|
||||
type: "",
|
||||
durationInput: null,
|
||||
durationUnit: "min",
|
||||
intensity: "Moyenne",
|
||||
lifestyle: "Actif",
|
||||
anxiety: 0,
|
||||
grade: "Bien",
|
||||
date: new Date().toISOString().substr(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function startEdit(act) {
|
||||
editingId.value = act.id;
|
||||
form.value = JSON.parse(JSON.stringify(act));
|
||||
store.showNotification("Modification en cours...");
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
}
|
||||
|
||||
function saveActivity() {
|
||||
const v = form.value;
|
||||
if (!v.studentId || !v.type || !v.durationInput) return;
|
||||
store.saveActivity(v, editingId.value);
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
router.push("/history");
|
||||
}
|
||||
|
||||
const grades = ["Excellent", "Très bien", "Bien", "Assez bien", "Redouble"];
|
||||
const lifestyles = ["Inactif", "Sédentaire", "Actif", "Très actif"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<!-- No students warning -->
|
||||
<div
|
||||
v-if="store.students.length === 0"
|
||||
class="bg-orange-100 p-6 rounded-3xl border border-orange-200 text-orange-800 flex gap-4 items-center italic"
|
||||
>
|
||||
<AlertTriangle class="w-5 h-5 shrink-0" />
|
||||
Inscrire d'abord des étudiants.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-white p-8 rounded-3xl card-shadow border-t-8 transition-all"
|
||||
:class="editingId ? 'border-amber-500' : 'border-sky-400'"
|
||||
>
|
||||
<header class="mb-6 flex justify-between items-center">
|
||||
<h4 class="text-xl font-bold text-blue-900 italic">
|
||||
{{ editingId ? "Modifier l'Activité" : "Nouveau Log d'Activité" }}
|
||||
</h4>
|
||||
<button
|
||||
v-if="editingId"
|
||||
@click="cancelEdit"
|
||||
class="text-xs font-black text-rose-500 uppercase border border-rose-200 px-3 py-1 rounded-full hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
Annuler l'édition
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="saveActivity" class="space-y-6">
|
||||
<!-- Student selector -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-2 uppercase italic"
|
||||
>Étudiant concerné</label
|
||||
>
|
||||
<select
|
||||
v-model="form.studentId"
|
||||
:disabled="!!editingId"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none font-bold text-blue-900"
|
||||
>
|
||||
<option value="">Sélectionnez un étudiant</option>
|
||||
<option v-for="s in store.students" :key="s.id" :value="s.id">
|
||||
{{ s.lastName }} {{ s.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Student quick stats -->
|
||||
<div v-if="form.studentId" class="mt-4 grid grid-cols-2 gap-3">
|
||||
<div class="p-4 bg-blue-50 border border-blue-100 rounded-2xl">
|
||||
<p class="text-[9px] font-black text-blue-400 uppercase mb-1">
|
||||
Moyenne Académique
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase text-white',
|
||||
getGradeBgClass(
|
||||
store.getStudentAverageLiteral(form.studentId),
|
||||
),
|
||||
]"
|
||||
>
|
||||
{{ store.getStudentAverageLiteral(form.studentId) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 bg-gray-50 border border-gray-100 rounded-2xl text-right"
|
||||
>
|
||||
<p class="text-[9px] font-black text-gray-400 uppercase mb-1">
|
||||
Statut Physique
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase text-white',
|
||||
getIMCBgClass(store.getSelectedStudentIMC(form.studentId)),
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getIMCInterpretation(
|
||||
store.getSelectedStudentIMC(form.studentId),
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grade buttons -->
|
||||
<div class="bg-indigo-50/50 p-6 rounded-2xl border border-indigo-100">
|
||||
<label
|
||||
class="block text-sm font-black text-indigo-900 mb-4 uppercase italic"
|
||||
>Saisie de l'Évaluation</label
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<button
|
||||
v-for="g in grades"
|
||||
:key="g"
|
||||
type="button"
|
||||
@click="form.grade = g"
|
||||
:class="[
|
||||
'py-2 px-1 rounded-xl text-[9px] font-black uppercase transition-all border-2',
|
||||
form.grade === g
|
||||
? 'bg-indigo-600 text-white border-transparent shadow-md'
|
||||
: 'bg-white text-indigo-400 border-indigo-100 hover:bg-indigo-50',
|
||||
]"
|
||||
>
|
||||
{{ g }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lifestyle buttons -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-3 uppercase italic"
|
||||
>Profil de Sédentarisme</label
|
||||
>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="level in lifestyles"
|
||||
:key="level"
|
||||
type="button"
|
||||
@click="form.lifestyle = level"
|
||||
:class="[
|
||||
'py-3 px-2 rounded-xl text-[10px] font-black uppercase transition-all border-2',
|
||||
form.lifestyle === level
|
||||
? 'gradient-brand text-white border-transparent shadow-md'
|
||||
: 'bg-blue-50 text-blue-400 border-transparent hover:bg-blue-100',
|
||||
]"
|
||||
>
|
||||
{{ level }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anxiety slider -->
|
||||
<div class="bg-blue-50/50 p-6 rounded-2xl border border-blue-100">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<label class="text-sm font-black text-blue-900 uppercase italic"
|
||||
>Anxiété (0–21)</label
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-black uppercase border shadow-sm',
|
||||
getAnxietyColor(form.anxiety),
|
||||
]"
|
||||
>
|
||||
{{ getAnxietyLabel(form.anxiety) }}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.anxiety"
|
||||
type="range"
|
||||
min="0"
|
||||
max="21"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-2 uppercase italic"
|
||||
>Date</label
|
||||
>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Activity type -->
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-2 uppercase italic"
|
||||
>Type d'activité physique</label
|
||||
>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
>
|
||||
<option value="">Quelle activité ?</option>
|
||||
<optgroup label="Sports Marocains">
|
||||
<option value="Football">Football</option>
|
||||
<option value="Course de durée">Course de durée</option>
|
||||
<option value="Course de vitesse">Course de vitesse</option>
|
||||
<option value="Basketball">Basketball</option>
|
||||
<option value="Boxe">Boxe</option>
|
||||
<option value="Tennis">Tennis</option>
|
||||
<option value="Badminton">Badminton</option>
|
||||
<option value="Pétanque">Pétanque</option>
|
||||
</optgroup>
|
||||
<optgroup label="Autres">
|
||||
<option value="Marche">Marche</option>
|
||||
<option value="Cyclisme">Cyclisme</option>
|
||||
<option value="Natation">Natation</option>
|
||||
<option value="Yoga">Yoga</option>
|
||||
<option value="Musculation">Musculation</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Intensity + Duration -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-2 uppercase italic"
|
||||
>Intensité</label
|
||||
>
|
||||
<select
|
||||
v-model="form.intensity"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none font-bold text-sm"
|
||||
>
|
||||
<option value="Basse">Basse</option>
|
||||
<option value="Moyenne">Moyenne</option>
|
||||
<option value="Haute">Haute</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-black text-blue-900 mb-2 uppercase italic"
|
||||
>Durée</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model.number="form.durationInput"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none font-bold text-sm"
|
||||
/>
|
||||
<select
|
||||
v-model="form.durationUnit"
|
||||
class="p-4 rounded-2xl bg-blue-50 border-none font-black text-blue-900 text-xs"
|
||||
>
|
||||
<option value="sec">sec</option>
|
||||
<option value="min">min</option>
|
||||
<option value="h">h</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-4 rounded-2xl text-white font-black shadow-lg mt-4 uppercase tracking-widest transition-all"
|
||||
:class="
|
||||
editingId
|
||||
? 'bg-amber-500 hover:bg-amber-600'
|
||||
: 'gradient-brand hover:opacity-90'
|
||||
"
|
||||
>
|
||||
{{
|
||||
editingId
|
||||
? "Mettre à jour l'activité"
|
||||
: "Enregistrer Activité & Évaluation"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
135
src/views/DashboardView.vue
Normal file
135
src/views/DashboardView.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { Brain, PieChart, Circle } from "lucide-vue-next";
|
||||
import { useSportsStore } from "../stores/sports";
|
||||
import { getLifestyleBg } from "../composables/useHelpers";
|
||||
|
||||
const store = useSportsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="bg-white p-6 rounded-3xl card-shadow border-l-8 border-blue-500"
|
||||
>
|
||||
<p class="text-gray-500 text-sm font-medium uppercase tracking-tighter">
|
||||
Étudiants Inscrits
|
||||
</p>
|
||||
<h3 class="text-4xl font-black text-blue-900">
|
||||
{{ store.students.length }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-6 rounded-3xl card-shadow border-l-8 border-sky-400"
|
||||
>
|
||||
<p class="text-gray-500 text-sm font-medium uppercase tracking-tighter">
|
||||
Activités Loguées
|
||||
</p>
|
||||
<h3 class="text-4xl font-black text-blue-900">
|
||||
{{ store.activities.length }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-6 rounded-3xl card-shadow border-l-8 border-indigo-500"
|
||||
>
|
||||
<p class="text-gray-500 text-sm font-medium uppercase tracking-tighter">
|
||||
Minutes Totales
|
||||
</p>
|
||||
<h3 class="text-4xl font-black text-blue-900">
|
||||
{{ Math.round(store.totalMinutes) }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-6 rounded-3xl card-shadow border-l-8 border-green-500"
|
||||
>
|
||||
<p class="text-gray-500 text-sm font-medium uppercase tracking-tighter">
|
||||
% Réussite Méd.
|
||||
</p>
|
||||
<h3 class="text-4xl font-black text-blue-900">
|
||||
{{ store.successRate }}%
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Lifestyle chart -->
|
||||
<div class="bg-white p-8 rounded-3xl card-shadow">
|
||||
<h4
|
||||
class="text-xl font-bold text-blue-900 mb-6 flex items-center gap-2"
|
||||
>
|
||||
<Brain class="w-5 h-5" /> État de Sédentarité (Global)
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(stat, key) in store.activityLifestyleStats" :key="key">
|
||||
<div
|
||||
class="flex justify-between text-[10px] font-black uppercase mb-1"
|
||||
>
|
||||
<span class="text-blue-900">{{ key }}</span>
|
||||
<span class="text-blue-500">{{ stat.count }} logs</span>
|
||||
</div>
|
||||
<div class="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
:style="{ width: stat.percentage + '%' }"
|
||||
:class="[
|
||||
'h-full transition-all duration-700',
|
||||
getLifestyleBg(key),
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grade chart -->
|
||||
<div class="bg-white p-8 rounded-3xl card-shadow">
|
||||
<h4
|
||||
class="text-xl font-bold text-blue-900 mb-6 flex items-center gap-2"
|
||||
>
|
||||
<PieChart class="w-5 h-5" /> Résultats Académiques (Moyennes)
|
||||
</h4>
|
||||
<div
|
||||
class="flex h-10 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100"
|
||||
>
|
||||
<div
|
||||
:style="{ width: store.gradeStats.excellent + '%' }"
|
||||
class="bg-green-500 h-full"
|
||||
/>
|
||||
<div
|
||||
:style="{ width: store.gradeStats.tresBien + '%' }"
|
||||
class="bg-indigo-500 h-full"
|
||||
/>
|
||||
<div
|
||||
:style="{ width: store.gradeStats.bien + '%' }"
|
||||
class="bg-blue-500 h-full"
|
||||
/>
|
||||
<div
|
||||
:style="{ width: store.gradeStats.assezBien + '%' }"
|
||||
class="bg-sky-400 h-full"
|
||||
/>
|
||||
<div
|
||||
:style="{ width: store.gradeStats.redouble + '%' }"
|
||||
class="bg-red-400 h-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 text-[9px] font-black uppercase">
|
||||
<span class="text-green-600 flex items-center gap-1"
|
||||
><Circle class="w-2 h-2 fill-current" /> Excellent</span
|
||||
>
|
||||
<span class="text-indigo-600 flex items-center gap-1"
|
||||
><Circle class="w-2 h-2 fill-current" /> Très Bien</span
|
||||
>
|
||||
<span class="text-blue-600 flex items-center gap-1"
|
||||
><Circle class="w-2 h-2 fill-current" /> Bien</span
|
||||
>
|
||||
<span class="text-sky-500 flex items-center gap-1"
|
||||
><Circle class="w-2 h-2 fill-current" /> Assez Bien</span
|
||||
>
|
||||
<span class="text-red-500 flex items-center gap-1"
|
||||
><Circle class="w-2 h-2 fill-current" /> Redouble</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
120
src/views/HistoryView.vue
Normal file
120
src/views/HistoryView.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
Trophy,
|
||||
CircleDot,
|
||||
Droplets,
|
||||
Target,
|
||||
Disc3,
|
||||
Bike,
|
||||
Zap,
|
||||
Timer,
|
||||
Activity,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-vue-next";
|
||||
import { useSportsStore } from "../stores/sports";
|
||||
import {
|
||||
formatDate,
|
||||
getGradeBgClass,
|
||||
getLifestyleBg,
|
||||
} from "../composables/useHelpers";
|
||||
import { useSportsEditBus } from "../composables/useSportsEditBus";
|
||||
|
||||
const store = useSportsStore();
|
||||
const router = useRouter();
|
||||
const editBus = useSportsEditBus();
|
||||
|
||||
const iconMap = {
|
||||
Football: Trophy,
|
||||
Basketball: CircleDot,
|
||||
Natation: Droplets,
|
||||
Tennis: Target,
|
||||
Badminton: Target,
|
||||
Pétanque: Disc3,
|
||||
Cyclisme: Bike,
|
||||
Boxe: Zap,
|
||||
"Course de durée": Timer,
|
||||
"Course de vitesse": Zap,
|
||||
Marche: Activity,
|
||||
};
|
||||
|
||||
function getIconComponent(type) {
|
||||
return iconMap[type] || Activity;
|
||||
}
|
||||
|
||||
function editActivity(act) {
|
||||
editBus.set(act);
|
||||
router.push("/activity");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="store.activities.length === 0"
|
||||
class="text-center py-20 bg-white rounded-3xl"
|
||||
>
|
||||
<p class="text-gray-300 font-bold uppercase tracking-widest italic">
|
||||
Aucune activité enregistrée.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(act, index) in store.activities"
|
||||
:key="act.id || index"
|
||||
class="bg-white p-6 rounded-3xl card-shadow flex justify-between items-center group border border-transparent hover:border-blue-200 transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 shadow-inner"
|
||||
>
|
||||
<component :is="getIconComponent(act.type)" class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-black text-blue-900 uppercase text-xs">
|
||||
{{ act.type }} <span class="text-blue-300 mx-1">|</span>
|
||||
{{ store.getStudentName(act.studentId) }}
|
||||
</h5>
|
||||
<p class="text-xs text-gray-500 mt-0.5 italic">
|
||||
{{ formatDate(act.date) }} • <b>{{ act.displayDuration }}</b> •
|
||||
{{ act.intensity }}
|
||||
</p>
|
||||
<div v-if="act.grade" class="mt-1.5 flex gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded-full text-[7px] font-black uppercase text-white',
|
||||
getGradeBgClass(act.grade),
|
||||
]"
|
||||
>
|
||||
{{ act.grade }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded-full text-[7px] font-black uppercase text-white',
|
||||
getLifestyleBg(act.lifestyle),
|
||||
]"
|
||||
>
|
||||
{{ act.lifestyle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="editActivity(act)"
|
||||
class="text-blue-300 hover:text-amber-500 transition-colors p-2"
|
||||
>
|
||||
<Pencil class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="store.deleteActivity(index)"
|
||||
class="text-blue-300 hover:text-red-500 transition-colors p-2"
|
||||
>
|
||||
<Trash2 class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
83
src/views/ResultsView.vue
Normal file
83
src/views/ResultsView.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { Award } from "lucide-vue-next";
|
||||
import { useSportsStore } from "../stores/sports";
|
||||
import { getGradeStyle, getGradeBgClass } from "../composables/useHelpers";
|
||||
|
||||
const store = useSportsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="bg-white rounded-3xl card-shadow overflow-hidden">
|
||||
<div
|
||||
class="p-6 bg-blue-900 text-white font-black uppercase text-sm tracking-widest flex justify-between items-center"
|
||||
>
|
||||
<span>Récapitulatif des Moyennes Académiques</span>
|
||||
<Award class="w-5 h-5" />
|
||||
</div>
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-blue-50">
|
||||
<tr>
|
||||
<th class="p-6 text-[10px] font-black text-blue-900 uppercase">
|
||||
Étudiant
|
||||
</th>
|
||||
<th
|
||||
class="p-6 text-[10px] font-black text-blue-900 uppercase text-center"
|
||||
>
|
||||
Nbr Eval.
|
||||
</th>
|
||||
<th class="p-6 text-[10px] font-black text-blue-900 uppercase">
|
||||
Moyenne Littérale
|
||||
</th>
|
||||
<th
|
||||
class="p-6 text-[10px] font-black text-blue-900 uppercase text-right"
|
||||
>
|
||||
Niveau
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
<tr
|
||||
v-for="student in store.students"
|
||||
:key="student.id"
|
||||
class="hover:bg-blue-50/50 transition-colors"
|
||||
>
|
||||
<td class="p-6 font-bold text-blue-900">
|
||||
{{ student.lastName }} {{ student.firstName }}
|
||||
</td>
|
||||
<td class="p-6 text-center text-gray-500 font-bold">
|
||||
{{ store.getStudentEvaluationsCount(student.id) }}
|
||||
</td>
|
||||
<td class="p-6">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-[10px] font-black uppercase border',
|
||||
getGradeStyle(store.getStudentAverageLiteral(student.id)),
|
||||
]"
|
||||
>
|
||||
{{ store.getStudentAverageLiteral(student.id) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-6 text-right">
|
||||
<div
|
||||
class="w-24 bg-gray-100 h-2 rounded-full overflow-hidden inline-block"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
width:
|
||||
(store.getStudentAveragePoints(student.id) / 4) * 100 +
|
||||
'%',
|
||||
}"
|
||||
:class="[
|
||||
'h-full transition-all duration-1000',
|
||||
getGradeBgClass(store.getStudentAverageLiteral(student.id)),
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
278
src/views/StudentsView.vue
Normal file
278
src/views/StudentsView.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { UserCog, UserPlus, Pencil, Trash2 } from "lucide-vue-next";
|
||||
import { useSportsStore } from "../stores/sports";
|
||||
import {
|
||||
calcIMC,
|
||||
getIMCInterpretation,
|
||||
getIMCBgClass,
|
||||
} from "../composables/useHelpers";
|
||||
|
||||
const store = useSportsStore();
|
||||
|
||||
const editingId = ref(null);
|
||||
const form = ref(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
age: null,
|
||||
sex: "H",
|
||||
weight: null,
|
||||
height: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentFormIMC = computed(() =>
|
||||
calcIMC(form.value.weight, form.value.height),
|
||||
);
|
||||
|
||||
function saveStudent() {
|
||||
const f = form.value;
|
||||
if (!f.firstName || !f.lastName || !f.weight || !f.height) return;
|
||||
store.saveStudent(f, editingId.value);
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
}
|
||||
|
||||
function prepareEdit(student) {
|
||||
editingId.value = student.id;
|
||||
form.value = { ...student };
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
store.showNotification("Modification du profil...");
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Form card -->
|
||||
<div
|
||||
class="bg-white p-8 rounded-3xl card-shadow border-t-8 transition-all"
|
||||
:class="editingId ? 'border-amber-500' : 'border-blue-500'"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h4
|
||||
class="text-xl font-bold text-blue-900 italic flex items-center gap-2"
|
||||
>
|
||||
<component :is="editingId ? UserCog : UserPlus" class="w-5 h-5" />
|
||||
{{
|
||||
editingId
|
||||
? "Modifier le profil étudiant"
|
||||
: "Inscrire un nouvel étudiant"
|
||||
}}
|
||||
</h4>
|
||||
<button
|
||||
v-if="editingId"
|
||||
@click="cancelEdit"
|
||||
class="text-xs font-black text-rose-500 uppercase px-3 py-1 border border-rose-200 rounded-full hover:bg-rose-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveStudent" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Prénom</label
|
||||
>
|
||||
<input
|
||||
v-model="form.firstName"
|
||||
type="text"
|
||||
placeholder="Prénom"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Nom</label
|
||||
>
|
||||
<input
|
||||
v-model="form.lastName"
|
||||
type="text"
|
||||
placeholder="Nom de famille"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Âge</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.age"
|
||||
type="number"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Sexe</label
|
||||
>
|
||||
<select
|
||||
v-model="form.sex"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
>
|
||||
<option value="H">Homme</option>
|
||||
<option value="F">Femme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Poids (kg)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.weight"
|
||||
type="number"
|
||||
step="0.1"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-black text-blue-400 mb-2 uppercase tracking-widest"
|
||||
>Taille (cm)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.height"
|
||||
type="number"
|
||||
class="w-full p-4 rounded-2xl bg-blue-50 border-none outline-none focus:ring-2 focus:ring-blue-500 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live IMC preview -->
|
||||
<div
|
||||
v-if="form.weight && form.height"
|
||||
class="p-6 bg-blue-50 rounded-2xl border border-blue-100 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-black text-blue-900 uppercase">
|
||||
IMC actualisé :
|
||||
</p>
|
||||
<h5 class="text-2xl font-black text-blue-600">
|
||||
{{ currentFormIMC }} <small class="text-xs">kg/m²</small>
|
||||
</h5>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-xs font-black uppercase text-white shadow-sm',
|
||||
getIMCBgClass(currentFormIMC),
|
||||
]"
|
||||
>
|
||||
{{ getIMCInterpretation(currentFormIMC) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-4 rounded-2xl text-white font-black shadow-lg transition-all uppercase tracking-widest"
|
||||
:class="
|
||||
editingId
|
||||
? 'bg-amber-500 hover:bg-amber-600'
|
||||
: 'gradient-brand hover:opacity-90'
|
||||
"
|
||||
>
|
||||
{{
|
||||
editingId ? "Mettre à jour le profil" : "Finaliser l'inscription"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Students table -->
|
||||
<div class="bg-white rounded-3xl card-shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-blue-50 border-b border-blue-100">
|
||||
<tr>
|
||||
<th class="p-6 text-xs font-black text-blue-900 uppercase">
|
||||
Étudiant
|
||||
</th>
|
||||
<th
|
||||
class="p-6 text-xs font-black text-blue-900 uppercase text-center"
|
||||
>
|
||||
IMC
|
||||
</th>
|
||||
<th
|
||||
class="p-6 text-xs font-black text-blue-900 uppercase text-right"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-blue-50">
|
||||
<tr
|
||||
v-for="student in store.students"
|
||||
:key="student.id"
|
||||
class="hover:bg-blue-50/30 transition-colors"
|
||||
>
|
||||
<td class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl gradient-brand flex items-center justify-center text-white font-black"
|
||||
>
|
||||
{{ student.firstName.charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-blue-900">
|
||||
{{ student.lastName }} {{ student.firstName }}
|
||||
</p>
|
||||
<p class="text-[10px] text-gray-400 font-bold uppercase">
|
||||
{{ student.age }} ans •
|
||||
{{ student.sex === "H" ? "H" : "F" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-6 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-black text-blue-900">{{
|
||||
student.imc
|
||||
}}</span>
|
||||
<span
|
||||
:class="[
|
||||
'text-[8px] px-2 py-0.5 rounded-full font-black uppercase text-white w-fit',
|
||||
getIMCBgClass(student.imc),
|
||||
]"
|
||||
>
|
||||
{{ getIMCInterpretation(student.imc) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-6 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="prepareEdit(student)"
|
||||
class="text-blue-300 hover:text-amber-500 transition-colors"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="store.deleteStudent(student.id)"
|
||||
class="text-red-300 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
tailwind.config.js
Normal file
6
tailwind.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{vue,js}"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user