feat: initial sbsports deployment setup

Add Coolify/Woodpecker CI config, .gitignore, and deployment scripts.
This commit is contained in:
alexandrump
2026-04-22 01:01:42 +02:00
commit 78c5ed52ac
32 changed files with 6088 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
data
.git
*.md

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
.env
.env.local

30
.woodpecker.yml Normal file
View 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
View 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
View 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"]

View 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
View File

15
docker-compose.yml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

39
scripts/activate-woodpecker.sh Executable file
View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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>

View 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"
>
&copy; 2024 Sanae Benkhlifa Sports
</div>
</nav>
</template>

View 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",
});
}

View 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
View 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
View 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
View 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
View 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é (021)</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
View 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
View 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
View 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
View 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/</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
View 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
View 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,
},
},
},
})