feat: Implement server-side API for categories, KPIs, and measurements

- Added Express server with SQLite database connection.
- Created API endpoints to fetch categories, KPIs, measurements, and statistics.
- Implemented error handling for database operations.

feat: Create ChartModal component for visualizing KPI data

- Developed ChartModal to display line charts for KPI measurements.
- Integrated Chart.js for rendering charts with responsive design.
- Added styling for modal and chart components.

feat: Add ExportModal component for exporting KPI data

- Implemented ExportModal to allow users to select data ranges for export.
- Included radio buttons for predefined time ranges (last week, month, year, all data).
- Styled modal for better user experience.

feat: Introduce RangeChartModal for dynamic range selection

- Created RangeChartModal to visualize KPI data over user-selected time ranges.
- Integrated radio buttons for selecting different time ranges.
- Enhanced chart rendering with Chart.js.

refactor: Create useSQLiteDatabase hook for data fetching

- Developed custom hook to manage fetching categories, KPIs, and measurements.
- Improved error handling and loading states for better user feedback.

style: Add CSS styles for modals and charts

- Created styles for ChartModal, ExportModal, and RangeChartModal.
- Ensured responsive design for various screen sizes.
This commit is contained in:
paul.roost 2025-10-21 13:31:14 +02:00
parent 5ecda7eef7
commit ca05e334a7
21 changed files with 3637 additions and 514 deletions

View File

@ -0,0 +1,48 @@
# Instructions de configuration
## 1. Créer la base de données SQLite
Exécute le script Python pour peupler la base de données sur 1 an:
```bash
cd /home/paul/PFEE/dashboard-sqdc
python3 database/populate_db.py
```
Cela va créer `database/sqdc.db` avec:
- ✅ Toutes les catégories
- ✅ Les 21 KPIs avec leurs fréquences
- ✅ 1 an de mesures (365 jours minimum)
## 2. Installer les dépendances Node
```bash
npm install express sqlite3 sqlite cors
npm install -D @types/express @types/node
```
## 3. Lancer le serveur
```bash
npm run server
```
## 4. Configurer React
Le dashboard React se connectera à `http://localhost:3001/api`
## Fréquences des mesures:
- **per_10min**: Toutes les 10 minutes (qualité, délais)
- **per_30min**: Toutes les 30 minutes (rendement)
- **hourly**: Toutes les heures (délais, coûts)
- **daily**: Quotidiennement (sécurité, coûts, maintenance)
- **weekly**: Hebdomadairement (audits, maintenance)
- **per_3days**: Tous les 3 jours (maintenance)
## Base de données
La structure SQLite est logique:
- Chaque mesure a une date précise
- Les KPI ont une fréquence définie
- Les mesures sont filtrables par plage de temps

View File

@ -0,0 +1,83 @@
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_PATH = path.join(__dirname, '../../database/sqdc.db');
export async function initDB() {
const db = await open({
filename: DB_PATH,
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS kpis (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
unit TEXT,
target REAL,
formula TEXT,
description TEXT,
frequency TEXT,
FOREIGN KEY(category_id) REFERENCES categories(id)
);
CREATE TABLE IF NOT EXISTS measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
measurement_date DATETIME NOT NULL,
value REAL NOT NULL,
status TEXT,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
alert_type TEXT,
severity TEXT,
message TEXT,
created_at DATETIME,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
);
CREATE INDEX IF NOT EXISTS idx_measurements_kpi ON measurements(kpi_id);
CREATE INDEX IF NOT EXISTS idx_measurements_date ON measurements(measurement_date);
CREATE INDEX IF NOT EXISTS idx_alerts_kpi ON alerts(kpi_id);
`);
return db;
}
export async function getKPIs(db) {
return await db.all('SELECT * FROM kpis');
}
export async function getMeasurements(db, kpiId, days = 30) {
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
return await db.all(
'SELECT * FROM measurements WHERE kpi_id = ? AND measurement_date >= ? ORDER BY measurement_date ASC',
[kpiId, fromDate.toISOString()]
);
}
export async function getLatestMeasurement(db, kpiId) {
return await db.get(
'SELECT * FROM measurements WHERE kpi_id = ? ORDER BY measurement_date DESC LIMIT 1',
[kpiId]
);
}

View File

@ -0,0 +1,203 @@
#!/usr/bin/env python3
import sqlite3
import random
from datetime import datetime, timedelta
import math
# Configuration de la base de données
DB_PATH = 'database/sqdc.db'
# Catégories
CATEGORIES = [
{'id': 1, 'name': 'Sécurité', 'emoji': '🛡️', 'description': 'Indicateurs de sécurité et prévention des accidents'},
{'id': 2, 'name': 'Qualité', 'emoji': '🎯', 'description': 'Indicateurs de qualité des produits'},
{'id': 3, 'name': 'Délais & Livraison', 'emoji': '⏱️', 'description': 'Indicateurs de délais et livraison'},
{'id': 4, 'name': 'Coûts', 'emoji': '💰', 'description': 'Indicateurs de coûts de production'},
{'id': 5, 'name': 'Maintenance', 'emoji': '🔧', 'description': 'Indicateurs de maintenance'}
]
# KPIs avec fréquences
KPIS = [
# SÉCURITÉ (1 mesure par jour)
{'id': 1, 'category_id': 1, 'name': 'Taux de Fréquence (TF)', 'unit': 'par 1M heures', 'target': 1.0, 'frequency': 'daily', 'formula': '(Nombre d\'Accidents avec Arrêt / Nombre d\'Heures Travaillées) × 1000000', 'description': 'Mesurer la fréquence des accidents avec arrêt.'},
{'id': 2, 'category_id': 1, 'name': 'Nombre d\'Incidents/Near Miss', 'unit': 'incidents', 'target': 8, 'frequency': 'daily', 'formula': 'Compte des rapports d\'incidents', 'description': 'Évaluer la culture de sécurité.'},
{'id': 3, 'category_id': 1, 'name': 'Taux de Conformité aux Audits', 'unit': '%', 'target': 95, 'frequency': 'weekly', 'formula': '(Points Conformes / Total Points) × 100', 'description': 'Mesurer le respect des procédures.'},
# QUALITÉ (toutes les 10 minutes)
{'id': 4, 'category_id': 2, 'name': 'Taux de Rebut (Scrap Rate)', 'unit': '%', 'target': 1.5, 'frequency': 'per_10min', 'formula': '(Unités Rebutées / Unités Produites) × 100', 'description': 'Mesurer le % d\'unités jetées.'},
{'id': 5, 'category_id': 2, 'name': 'Taux de Retouche (Rework Rate)', 'unit': '%', 'target': 2.0, 'frequency': 'per_10min', 'formula': '(Unités Retouchées / Unités Totales) × 100', 'description': 'Mesurer le % d\'unités retouchées.'},
{'id': 6, 'category_id': 2, 'name': 'Nombre de Défauts par Unité (DPU)', 'unit': 'défauts/unité', 'target': 0.5, 'frequency': 'per_10min', 'formula': 'Défauts Totaux / Unités Inspectées', 'description': 'Mesurer le nombre moyen de défauts.'},
{'id': 7, 'category_id': 2, 'name': 'Taux de Retours Clients', 'unit': '%', 'target': 0.8, 'frequency': 'daily', 'formula': '(Unités Retournées / Unités Vendues) × 100', 'description': 'Mesurer l\'impact de la non-qualité.'},
{'id': 8, 'category_id': 2, 'name': 'Taux de rendement synthétique (TRS)', 'unit': '%', 'target': 85, 'frequency': 'per_30min', 'formula': 'Pièces bonnes × Temps cycle / Temps ouverture', 'description': 'Rendement global de la ligne.'},
{'id': 9, 'category_id': 2, 'name': 'Efficacité Globale de l\'Équipement (OEE)', 'unit': '%', 'target': 80, 'frequency': 'per_30min', 'formula': 'Disponibilité × Performance × Qualité', 'description': 'Mesurer l\'efficacité combinée.'},
# DÉLAIS (toutes les heures)
{'id': 10, 'category_id': 3, 'name': 'Taux de Respect du Plan', 'unit': '%', 'target': 95, 'frequency': 'hourly', 'formula': '(Produite / Planifiée) × 100', 'description': 'Mesurer la capacité à atteindre le volume.'},
{'id': 11, 'category_id': 3, 'name': 'Temps de Cycle (Cycle Time)', 'unit': 'min/unité', 'target': 50, 'frequency': 'per_10min', 'formula': 'Temps Total / Nombre d\'Unités', 'description': 'Mesurer le temps par unité.'},
{'id': 12, 'category_id': 3, 'name': 'Tack Time', 'unit': 'min/unité', 'target': 50, 'frequency': 'per_10min', 'formula': 'Temps production / Pièces demandées', 'description': 'Temps de production requis par unité.'},
{'id': 13, 'category_id': 3, 'name': 'Temps d\'Arrêt Imprévu (Downtime)', 'unit': 'h/jour', 'target': 1.5, 'frequency': 'daily', 'formula': 'Somme des Arrêts Non Planifiés', 'description': 'Mesurer l\'arrêt non planifié.'},
# COÛTS (quotidiennement)
{'id': 14, 'category_id': 4, 'name': 'Coût par Unité (CPU)', 'unit': '', 'target': 240, 'frequency': 'daily', 'formula': 'Coût Total / Unités Produites', 'description': 'Mesurer l\'efficacité des coûts.'},
{'id': 15, 'category_id': 4, 'name': 'Productivité de la Main-d\'œuvre', 'unit': 'unités/h', 'target': 8.0, 'frequency': 'hourly', 'formula': 'Unités Produites / Heures Main-d\'œuvre', 'description': 'Mesurer l\'efficacité de l\'équipe.'},
{'id': 16, 'category_id': 4, 'name': 'Coût des Non-Qualité (CNQ)', 'unit': '', 'target': 10000, 'frequency': 'daily', 'formula': 'Rebuts + Retouches + Retours', 'description': 'Mesurer le coût des défauts.'},
# MAINTENANCE (tous les 3 jours)
{'id': 17, 'category_id': 5, 'name': 'Temps Moyen Entre Pannes (MTBF)', 'unit': 'heures', 'target': 400, 'frequency': 'per_3days', 'formula': 'Temps Fonctionnement / Pannes', 'description': 'Mesurer la fiabilité.'},
{'id': 18, 'category_id': 5, 'name': 'Temps Moyen de Réparation (MTTR)', 'unit': 'heures', 'target': 2.5, 'frequency': 'per_3days', 'formula': 'Temps Réparation / Pannes', 'description': 'Mesurer la rapidité.'},
{'id': 19, 'category_id': 5, 'name': 'Ratio Maintenance Préventive/Corrective', 'unit': '%', 'target': 70, 'frequency': 'weekly', 'formula': 'Heures MP / (MP + MC)', 'description': 'Évaluer la stratégie.'},
{'id': 20, 'category_id': 5, 'name': 'Taux d\'Achèvement du Plan Préventif', 'unit': '%', 'target': 95, 'frequency': 'weekly', 'formula': '(Tâches Terminées / Tâches Planifiées) × 100', 'description': 'Mesurer le respect du plan.'},
{'id': 21, 'category_id': 5, 'name': 'Coût de Maintenance par Unité Produite', 'unit': '', 'target': 30, 'frequency': 'daily', 'formula': 'Coûts Maintenance / Unités Produites', 'description': 'Relier dépenses à production.'}
]
def get_frequency_minutes(frequency):
"""Retourne le nombre de minutes entre les mesures"""
frequencies = {
'per_10min': 10,
'per_30min': 30,
'hourly': 60,
'daily': 1440, # 24h
'weekly': 10080, # 7 jours
'per_3days': 4320 # 3 jours
}
return frequencies.get(frequency, 1440)
def generate_value(kpi_id, target, variance_range=0.2):
"""Génère une valeur réaliste autour de la cible"""
variance = (random.random() - 0.5) * 2 * variance_range
value = target * (1 + variance)
# Ajouter du bruit réaliste pour certains KPI
if kpi_id in [4, 5, 6, 11, 12]: # Qualité et délais - plus de variabilité
noise = (random.random() - 0.5) * 0.3 * target
value += noise
return max(0, round(value, 2))
def determine_status(kpi_id, value, target):
"""Détermine le statut (good, warning, critical)"""
tolerance = abs(target * 0.1)
# KPI où plus bas est mieux
if kpi_id in [2, 4, 5, 6, 13, 16]:
if value > target + tolerance * 2:
return 'critical'
elif value > target + tolerance:
return 'warning'
else:
# KPI où plus haut est mieux
if value < target - tolerance * 2:
return 'critical'
elif value < target - tolerance:
return 'warning'
return 'good'
def populate_database():
"""Remplit la base de données sur 1 an"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Créer les tables d'abord
print("🔄 Création du schéma...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT,
description TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS kpis (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
unit TEXT,
target REAL,
frequency TEXT,
formula TEXT,
description TEXT,
FOREIGN KEY(category_id) REFERENCES categories(id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
measurement_date DATETIME NOT NULL,
value REAL NOT NULL,
status TEXT,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
alert_type TEXT,
severity TEXT,
message TEXT,
created_at DATETIME,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
)
''')
# Supprimer les anciennes données
cursor.execute('DELETE FROM measurements')
cursor.execute('DELETE FROM alerts')
cursor.execute('DELETE FROM kpis')
cursor.execute('DELETE FROM categories')
# Insérer les catégories
for cat in CATEGORIES:
cursor.execute(
'INSERT INTO categories (id, name, emoji, description) VALUES (?, ?, ?, ?)',
(cat['id'], cat['name'], cat['emoji'], cat['description'])
)
# Insérer les KPI
for kpi in KPIS:
cursor.execute(
'INSERT INTO kpis (id, category_id, name, unit, target, frequency, formula, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(kpi['id'], kpi['category_id'], kpi['name'], kpi['unit'], kpi['target'], kpi['frequency'], kpi['formula'], kpi['description'])
)
# Générer les mesures sur 1 an
end_date = datetime.now()
start_date = end_date - timedelta(days=365)
print("🔄 Génération des mesures sur 1 an...")
measurements_count = 0
for kpi in KPIS:
kpi_id = kpi['id']
target = kpi['target']
frequency_minutes = get_frequency_minutes(kpi['frequency'])
current_date = start_date
while current_date <= end_date:
value = generate_value(kpi_id, target)
status = determine_status(kpi_id, value, target)
cursor.execute(
'INSERT INTO measurements (kpi_id, measurement_date, value, status) VALUES (?, ?, ?, ?)',
(kpi_id, current_date.isoformat(), value, status)
)
measurements_count += 1
current_date += timedelta(minutes=frequency_minutes)
conn.commit()
conn.close()
print(f"✅ Base de données remplie avec succès!")
print(f"📊 {measurements_count} mesures créées")
print(f"🎯 {len(KPIS)} KPI configurés")
print(f"📁 Fichier: {DB_PATH}")
if __name__ == '__main__':
populate_database()

View File

@ -0,0 +1,52 @@
-- Base de données SQDC
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS kpis (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
unit TEXT,
target REAL,
formula TEXT,
description TEXT,
frequency TEXT, -- 'daily', 'per_10min', 'per_3days', etc.
FOREIGN KEY(category_id) REFERENCES categories(id)
);
CREATE TABLE IF NOT EXISTS measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
measurement_date DATETIME NOT NULL,
value REAL NOT NULL,
status TEXT, -- 'good', 'warning', 'critical'
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
alert_type TEXT,
severity TEXT, -- 'warning', 'critical'
message TEXT,
created_at DATETIME,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_id INTEGER NOT NULL,
comment TEXT,
created_at DATETIME,
FOREIGN KEY(kpi_id) REFERENCES kpis(id)
);
-- Créer les indices pour les performances
CREATE INDEX IF NOT EXISTS idx_measurements_kpi ON measurements(kpi_id);
CREATE INDEX IF NOT EXISTS idx_measurements_date ON measurements(measurement_date);
CREATE INDEX IF NOT EXISTS idx_alerts_kpi ON alerts(kpi_id);
CREATE INDEX IF NOT EXISTS idx_comments_kpi ON comments(kpi_id);

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -12,19 +12,30 @@
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"server": "node server.js",
"populate-db": "python3 database/populate_db.py"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [

152
dashboard-sqdc/server.js Normal file
View File

@ -0,0 +1,152 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
let db;
// Initialiser la base de données
function initDatabase() {
return new Promise((resolve, reject) => {
db = new sqlite3.Database(
path.join(__dirname, 'database', 'sqdc.db'),
(err) => {
if (err) {
console.error('❌ Erreur de connexion:', err);
reject(err);
} else {
console.log('✅ Base de données connectée');
resolve();
}
}
);
});
}
// Routes API
// Obtenir les catégories
app.get('/api/categories', (req, res) => {
db.all('SELECT * FROM categories', (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows || []);
});
});
// Obtenir les KPI
app.get('/api/kpis', (req, res) => {
db.all('SELECT * FROM kpis', (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows || []);
});
});
// Obtenir les mesures pour un KPI
app.get('/api/measurements/:kpiId', (req, res) => {
try {
const { kpiId } = req.params;
const days = parseInt(req.query.days || 30);
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
let query = 'SELECT * FROM measurements WHERE kpi_id = ?';
const params = [kpiId];
if (days > 0) {
query += ' AND measurement_date >= ?';
params.push(fromDate.toISOString());
}
query += ' ORDER BY measurement_date ASC';
db.all(query, params, (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows || []);
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtenir la dernière mesure pour un KPI
app.get('/api/latest/:kpiId', (req, res) => {
try {
const { kpiId } = req.params;
db.get(
'SELECT * FROM measurements WHERE kpi_id = ? ORDER BY measurement_date DESC LIMIT 1',
[kpiId],
(err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(row || {});
}
);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Obtenir les statistiques pour un KPI
app.get('/api/stats/:kpiId', (req, res) => {
try {
const { kpiId } = req.params;
const days = parseInt(req.query.days || 30);
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
let query = `SELECT
COUNT(*) as count,
AVG(value) as avg,
MIN(value) as min,
MAX(value) as max
FROM measurements
WHERE kpi_id = ?`;
const params = [kpiId];
if (days > 0) {
query += ' AND measurement_date >= ?';
params.push(fromDate.toISOString());
}
db.get(query, params, (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(row || {});
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Démarrer le serveur
initDatabase().then(() => {
app.listen(PORT, () => {
console.log(`🚀 Serveur SQDC démarré sur http://localhost:${PORT}`);
console.log(`📊 API disponible sur http://localhost:${PORT}/api`);
});
}).catch(err => {
console.error('❌ Erreur lors de l\'initialisation:', err);
process.exit(1);
});

View File

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { DetailPage } from './pages/DetailPage'; import { DetailPage } from './pages/DetailPage';
import { TrendChart, CategoryDistributionChart, StatusChart, CNQChart } from './components/Charts'; import { TrendChart, CategoryDistributionChart, StatusChart, CNQChart } from './components/Charts';
import { kpiData } from './data/kpiData';
import './App.css'; import './App.css';
type TabType = 'home' | 'security' | 'quality' | 'delays' | 'costs' | 'maintenance' | 'charts'; type TabType = 'home' | 'security' | 'quality' | 'delays' | 'costs' | 'maintenance' | 'charts';
@ -76,27 +75,27 @@ function App() {
<main className="app-content"> <main className="app-content">
{activeTab === 'home' && ( {activeTab === 'home' && (
<HomePage kpiData={kpiData} /> <HomePage />
)} )}
{activeTab === 'security' && ( {activeTab === 'security' && (
<DetailPage category="security" kpis={kpiData.security} /> <DetailPage category="security" />
)} )}
{activeTab === 'quality' && ( {activeTab === 'quality' && (
<DetailPage category="quality" kpis={kpiData.quality} /> <DetailPage category="quality" />
)} )}
{activeTab === 'delays' && ( {activeTab === 'delays' && (
<DetailPage category="delays" kpis={kpiData.delays} /> <DetailPage category="delays" />
)} )}
{activeTab === 'costs' && ( {activeTab === 'costs' && (
<DetailPage category="costs" kpis={kpiData.costs} /> <DetailPage category="costs" />
)} )}
{activeTab === 'maintenance' && ( {activeTab === 'maintenance' && (
<DetailPage category="maintenance" kpis={kpiData.maintenance} /> <DetailPage category="maintenance" />
)} )}
{activeTab === 'charts' && ( {activeTab === 'charts' && (

View File

@ -0,0 +1,92 @@
import React from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
import '../styles/ChartModal.css';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
interface ChartModalProps {
isOpen: boolean;
kpi: any;
measurements: any[];
onClose: () => void;
}
export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurements, onClose }) => {
if (!isOpen || !kpi) return null;
// Préparer les données pour le graphique
const labels = measurements
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
.reverse();
const values = measurements
.map(m => m.value)
.reverse();
const chartData = {
labels,
datasets: [
{
label: kpi.name,
data: values,
borderColor: '#6496ff',
backgroundColor: 'rgba(100, 150, 255, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 4,
pointBackgroundColor: '#6496ff',
pointBorderColor: '#fff',
pointBorderWidth: 2,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top' as const,
},
title: {
display: true,
text: `Graphique Complet: ${kpi.name}`,
font: { size: 16, weight: 'bold' as const },
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: kpi.unit,
},
},
x: {
display: true,
},
},
};
return (
<div className="chart-modal-overlay" onClick={onClose}>
<div className="chart-modal" onClick={(e) => e.stopPropagation()}>
<div className="chart-modal-header">
<h2>📈 {kpi.name}</h2>
<button className="chart-modal-close" onClick={onClose}></button>
</div>
<div className="chart-modal-body">
<div style={{ height: '500px', width: '100%' }}>
<Line data={chartData} options={chartOptions} />
</div>
</div>
<div className="chart-modal-footer">
<p>Nombre de mesures: <strong>{measurements.length}</strong></p>
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import '../styles/ExportModal.css';
interface ExportModalProps {
isOpen: boolean;
kpiName: string;
onExport: (days: number) => void;
onClose: () => void;
}
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, kpiName, onExport, onClose }) => {
const [selectedRange, setSelectedRange] = useState<number | null>(null);
if (!isOpen) return null;
const handleExport = () => {
if (selectedRange !== null) {
onExport(selectedRange);
setSelectedRange(null);
onClose();
}
};
return (
<div className="export-modal-overlay" onClick={onClose}>
<div className="export-modal" onClick={(e) => e.stopPropagation()}>
<div className="export-modal-header">
<h2>💾 Exporter {kpiName}</h2>
<button className="export-modal-close" onClick={onClose}></button>
</div>
<div className="export-modal-body">
<p className="export-modal-description">
Sélectionnez la plage de données à exporter:
</p>
<div className="export-options">
<label className={`export-option ${selectedRange === 7 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="7"
checked={selectedRange === 7}
onChange={() => setSelectedRange(7)}
/>
<span className="option-label">📅 Dernière semaine</span>
<span className="option-sublabel">(7 jours)</span>
</label>
<label className={`export-option ${selectedRange === 30 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="30"
checked={selectedRange === 30}
onChange={() => setSelectedRange(30)}
/>
<span className="option-label">📆 Dernier mois</span>
<span className="option-sublabel">(30 jours)</span>
</label>
<label className={`export-option ${selectedRange === 365 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="365"
checked={selectedRange === 365}
onChange={() => setSelectedRange(365)}
/>
<span className="option-label">📊 Cette année</span>
<span className="option-sublabel">(365 jours)</span>
</label>
<label className={`export-option ${selectedRange === -1 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="-1"
checked={selectedRange === -1}
onChange={() => setSelectedRange(-1)}
/>
<span className="option-label">📈 Toutes les données</span>
<span className="option-sublabel">(Sans limite)</span>
</label>
</div>
</div>
<div className="export-modal-footer">
<button className="btn-cancel" onClick={onClose}>
Annuler
</button>
<button
className="btn-export-confirm"
onClick={handleExport}
disabled={selectedRange === null}
>
Exporter
</button>
</div>
</div>
</div>
);
};

View File

@ -1,9 +1,8 @@
import React from 'react'; import React from 'react';
import { KPI } from '../types';
import '../styles/KPICard.css'; import '../styles/KPICard.css';
interface KPICardProps { interface KPICardProps {
kpi: KPI; kpi: any;
color: string; color: string;
} }
@ -22,16 +21,10 @@ export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => {
}; };
const getTrendIcon = () => { const getTrendIcon = () => {
switch (kpi.trend) { if (!kpi.latest) return '•';
case 'up':
return '📈'; // Déterminer la tendance basée sur les données de la dernière semaine
case 'down': return '📊'; // Placeholder pour l'instant
return '📉';
case 'stable':
return '➡️';
default:
return '•';
}
}; };
const getStatusClass = () => { const getStatusClass = () => {
@ -52,11 +45,11 @@ export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => {
<div className="kpi-footer"> <div className="kpi-footer">
<span className={`kpi-status ${getStatusClass()}`}> <span className={`kpi-status ${getStatusClass()}`}>
{getStatusIcon()} {kpi.status.charAt(0).toUpperCase() + kpi.status.slice(1)} {getStatusIcon()} {kpi.status?.charAt(0).toUpperCase() + kpi.status?.slice(1) || 'N/A'}
</span> </span>
{kpi.target && ( {kpi.target && (
<span className="kpi-target"> <span className="kpi-target">
Objectif: {kpi.target} {kpi.unit} Obj: {kpi.target} {kpi.unit}
</span> </span>
)} )}
</div> </div>

View File

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
import '../styles/RangeChartModal.css';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
interface RangeChartModalProps {
isOpen: boolean;
kpi: any;
measurements: any[];
getMeasurementsForRange: (days: number) => any[];
onClose: () => void;
}
export const RangeChartModal: React.FC<RangeChartModalProps> = ({
isOpen,
kpi,
measurements,
getMeasurementsForRange,
onClose,
}) => {
const [selectedRange, setSelectedRange] = useState<number>(30);
if (!isOpen || !kpi) return null;
// Obtenir les mesures pour la plage sélectionnée
const filteredMeasurements = getMeasurementsForRange(selectedRange);
// Préparer les données pour le graphique
const labels = filteredMeasurements
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
.reverse();
const values = filteredMeasurements
.map(m => m.value)
.reverse();
const chartData = {
labels,
datasets: [
{
label: kpi.name,
data: values,
borderColor: '#6496ff',
backgroundColor: 'rgba(100, 150, 255, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 4,
pointBackgroundColor: '#6496ff',
pointBorderColor: '#fff',
pointBorderWidth: 2,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top' as const,
},
title: {
display: true,
text: `Graphique Complet: ${kpi.name}`,
font: { size: 16, weight: 'bold' as const },
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: kpi.unit,
},
},
x: {
display: true,
},
},
};
return (
<div className="range-chart-modal-overlay" onClick={onClose}>
<div className="range-chart-modal" onClick={(e) => e.stopPropagation()}>
<div className="range-chart-modal-header">
<h2>📈 {kpi.name}</h2>
<button className="range-chart-modal-close" onClick={onClose}></button>
</div>
<div className="range-chart-modal-range-selector">
<label className={`range-option ${selectedRange === 7 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="7"
checked={selectedRange === 7}
onChange={() => setSelectedRange(7)}
/>
<span>📅 Semaine</span>
</label>
<label className={`range-option ${selectedRange === 30 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="30"
checked={selectedRange === 30}
onChange={() => setSelectedRange(30)}
/>
<span>📆 Mois</span>
</label>
<label className={`range-option ${selectedRange === 90 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="90"
checked={selectedRange === 90}
onChange={() => setSelectedRange(90)}
/>
<span>📊 Trimestre</span>
</label>
<label className={`range-option ${selectedRange === 365 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="365"
checked={selectedRange === 365}
onChange={() => setSelectedRange(365)}
/>
<span>📈 Année</span>
</label>
<label className={`range-option ${selectedRange === -1 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="-1"
checked={selectedRange === -1}
onChange={() => setSelectedRange(-1)}
/>
<span>🔄 Tout</span>
</label>
</div>
<div className="range-chart-modal-body">
<div style={{ height: '500px', width: '100%' }}>
<Line data={chartData} options={chartOptions} />
</div>
</div>
<div className="range-chart-modal-footer">
<p>Mesures: <strong>{filteredMeasurements.length}</strong></p>
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,113 @@
import { useState, useEffect, useCallback } from 'react';
const API_BASE = 'http://localhost:3001/api';
export interface Category {
id: number;
name: string;
emoji: string;
description?: string;
}
export interface KPI {
id: number;
category_id: number;
name: string;
unit: string;
target?: number;
formula?: string;
description?: string;
frequency?: string;
}
export interface Measurement {
id?: number;
kpi_id: number;
measurement_date: string;
value: number;
status?: string;
}
export const useSQLiteDatabase = () => {
const [categories, setCategories] = useState<Category[]>([]);
const [kpis, setKpis] = useState<KPI[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Charger les catégories et KPI au démarrage
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const [categoriesRes, kpisRes] = await Promise.all([
fetch(`${API_BASE}/categories`),
fetch(`${API_BASE}/kpis`)
]);
if (!categoriesRes.ok || !kpisRes.ok) {
throw new Error('Erreur lors du chargement des données');
}
const categoriesData = await categoriesRes.json();
const kpisData = await kpisRes.json();
setCategories(categoriesData);
setKpis(kpisData);
setError(null);
} catch (err: any) {
setError(err.message || 'Erreur de connexion à la base de données');
console.error('❌ Erreur:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Obtenir les mesures pour un KPI
const getMeasurementsForKPI = useCallback(async (kpiId: number, days: number = 30) => {
try {
const res = await fetch(`${API_BASE}/measurements/${kpiId}?days=${days}`);
if (!res.ok) throw new Error('Erreur de chargement des mesures');
return await res.json();
} catch (err: any) {
console.error('Erreur getMeasurementsForKPI:', err);
return [];
}
}, []);
// Obtenir la dernière mesure pour un KPI
const getLatestMeasurement = useCallback(async (kpiId: number) => {
try {
const res = await fetch(`${API_BASE}/latest/${kpiId}`);
if (!res.ok) throw new Error('Erreur de chargement');
return await res.json();
} catch (err: any) {
console.error('Erreur getLatestMeasurement:', err);
return null;
}
}, []);
// Obtenir les statistiques pour un KPI
const getKPIStats = useCallback(async (kpiId: number, days: number = 30) => {
try {
const res = await fetch(`${API_BASE}/stats/${kpiId}?days=${days}`);
if (!res.ok) throw new Error('Erreur de chargement');
return await res.json();
} catch (err: any) {
console.error('Erreur getKPIStats:', err);
return null;
}
}, []);
return {
categories,
kpis,
loading,
error,
getMeasurementsForKPI,
getLatestMeasurement,
getKPIStats
};
};

View File

@ -1,153 +1,264 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2'; import * as XLSX from 'xlsx';
import { KPI } from '../types';
import { getCategoryColor, getCategoryName, getCategoryEmoji } from '../data/kpiData'; import { getCategoryColor, getCategoryName, getCategoryEmoji } from '../data/kpiData';
import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
import { ChartModal } from '../components/ChartModal';
import { ExportModal } from '../components/ExportModal';
import { RangeChartModal } from '../components/RangeChartModal';
import '../styles/DetailPage.css'; import '../styles/DetailPage.css';
interface DetailPageProps { interface DetailPageProps {
category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance'; category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance';
kpis: KPI[];
} }
export const DetailPage: React.FC<DetailPageProps> = ({ category, kpis }) => { const categoryMap: Record<string, number> = {
const [selectedKPI, setSelectedKPI] = React.useState<KPI | null>(kpis[0] || null); security: 1,
quality: 2,
delays: 3,
costs: 4,
maintenance: 5
};
const getStatusBadgeClass = (status: string) => { export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
return `badge-${status}`; const db = useSQLiteDatabase();
const categoryId = categoryMap[category];
const [selectedKPIId, setSelectedKPIId] = useState<number | null>(null);
const [selectedKPIMeasurements, setSelectedKPIMeasurements] = useState<any[]>([]);
const [selectedKPIStats, setSelectedKPIStats] = useState<any>(null);
const [showChart, setShowChart] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showChartRangeModal, setShowChartRangeModal] = useState(false);
// Obtenir les KPI de cette catégorie
const categoryKPIs = db.kpis.filter(kpi => kpi.category_id === categoryId);
// Charger les mesures quand un KPI est sélectionné
useEffect(() => {
if (!selectedKPIId) {
if (categoryKPIs.length > 0) {
setSelectedKPIId(categoryKPIs[0].id);
}
return;
}
const fetchMeasurements = async () => {
const measurements = await db.getMeasurementsForKPI(selectedKPIId, 365);
const stats = await db.getKPIStats(selectedKPIId, 30);
setSelectedKPIMeasurements(measurements || []);
setSelectedKPIStats(stats);
};
fetchMeasurements();
}, [selectedKPIId, categoryKPIs, db]);
const selectedKPI = categoryKPIs.find(k => k.id === selectedKPIId);
// Export to Excel
const exportToExcel = (kpi: any, measurements: any[]) => {
if (!kpi || measurements.length === 0) return;
// Préparer les données
const data = measurements.map(m => ({
Date: new Date(m.measurement_date).toLocaleString('fr-FR'),
Valeur: m.value,
Statut: m.status,
}));
// Créer un classeur Excel
const worksheet = XLSX.utils.json_to_sheet(data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Mesures');
// Ajouter une feuille de résumé
const summary = [
['KPI', kpi.name],
['Unité', kpi.unit],
['Cible', kpi.target],
['Fréquence', kpi.frequency],
['Nombre de mesures', measurements.length],
['Date d\'export', new Date().toLocaleString('fr-FR')],
];
const summarySheet = XLSX.utils.aoa_to_sheet(summary);
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Résumé');
// Télécharger
const filename = `${kpi.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename);
};
// Handle export with date range selection
const handleExportClick = (days: number) => {
if (!selectedKPI) return;
let measurementsToExport = selectedKPIMeasurements;
// Si l'utilisateur a sélectionné une plage spécifique (pas -1 pour tous)
if (days > 0 && days !== 365) {
measurementsToExport = selectedKPIMeasurements.filter((m: any) => {
const daysAgo = (Date.now() - new Date(m.measurement_date).getTime()) / (1000 * 60 * 60 * 24);
return daysAgo <= days;
});
}
exportToExcel(selectedKPI, measurementsToExport);
};
// Get measurements for chart by date range
const getMeasurementsForDateRange = (days: number) => {
if (days === -1) return selectedKPIMeasurements;
return selectedKPIMeasurements.filter((m: any) => {
const daysAgo = (Date.now() - new Date(m.measurement_date).getTime()) / (1000 * 60 * 60 * 24);
return daysAgo <= days;
});
}; };
return ( return (
<div className="detail-page"> <div className="detail-page">
<div className="detail-header"> <div className="detail-header">
<h1> <h1 style={{ color: getCategoryColor(category) }}>
{getCategoryEmoji(category)} {getCategoryName(category)} {getCategoryEmoji(category)} {getCategoryName(category)}
</h1> </h1>
<p>Analyse détaillée des indicateurs de performance</p> <p>Analyse détaillée des indicateurs</p>
</div> </div>
<div className="detail-layout"> <div className="detail-content">
<div className="kpi-list"> <div className="kpi-selector">
<h2>Indicateurs</h2> <h3>Sélectionner un KPI</h3>
<div className="kpi-list-items">
{kpis.map(kpi => ( <div className="action-buttons">
<div <button
className="btn btn-export"
onClick={() => setShowExportModal(true)}
disabled={!selectedKPI}
>
📊 Exporter Excel
</button>
<button
className="btn btn-chart"
onClick={() => setShowChartRangeModal(true)}
disabled={!selectedKPI}
>
📈 Graphique Complet
</button>
</div>
<div className="kpi-list">
{categoryKPIs.map(kpi => (
<button
key={kpi.id} key={kpi.id}
className={`kpi-list-item ${selectedKPI?.id === kpi.id ? 'active' : ''}`} className={`kpi-option ${selectedKPIId === kpi.id ? 'active' : ''}`}
onClick={() => setSelectedKPI(kpi)} onClick={() => setSelectedKPIId(kpi.id)}
style={{
borderLeftColor: selectedKPI?.id === kpi.id ? getCategoryColor(category) : '#ddd'
}}
> >
<div className="kpi-list-name">{kpi.name}</div> <div className="kpi-name">{kpi.name}</div>
<div className="kpi-list-value">{kpi.value} {kpi.unit}</div> <div className="kpi-unit">{kpi.unit}</div>
<span className={`badge ${getStatusBadgeClass(kpi.status)}`}> </button>
{kpi.status}
</span>
</div>
))} ))}
</div> </div>
</div> </div>
<div className="kpi-detail"> {selectedKPI && (
{selectedKPI ? ( <div className="kpi-details">
<> <div className="details-header">
<div className="detail-header-card" style={{ borderTopColor: getCategoryColor(category) }}> <h2>{selectedKPI.name}</h2>
<h2>{selectedKPI.name}</h2> <div className="details-info">
<div className="detail-value-section"> <div className="info-item">
<span className="detail-value">{selectedKPI.value}</span> <div className="info-label">Unité</div>
<span className="detail-unit">{selectedKPI.unit}</span> <div className="info-value">{selectedKPI.unit}</div>
</div>
<div className="info-item">
<div className="info-label">Cible</div>
<div className="info-value">{selectedKPI.target}</div>
</div>
<div className="info-item">
<div className="info-label">Fréquence</div>
<div className="info-value">{selectedKPI.frequency || 'N/A'}</div>
</div> </div>
</div> </div>
<div className="detail-info">
<div className="info-row">
<span className="info-label">État:</span>
<span className={`badge ${getStatusBadgeClass(selectedKPI.status)}`}>
{selectedKPI.status}
</span>
</div>
{selectedKPI.target && (
<div className="info-row">
<span className="info-label">Objectif:</span>
<span className="info-value">{selectedKPI.target} {selectedKPI.unit}</span>
</div>
)}
{selectedKPI.trend && (
<div className="info-row">
<span className="info-label">Tendance:</span>
<span className="info-value">
{selectedKPI.trend === 'up' && '📈 En hausse'}
{selectedKPI.trend === 'down' && '📉 En baisse'}
{selectedKPI.trend === 'stable' && '➡️ Stable'}
</span>
</div>
)}
</div>
<div className="detail-description">
<h3>Description</h3>
<p>{selectedKPI.description}</p>
</div>
{selectedKPI.formula && (
<div className="detail-formula">
<h3>Formule de Calcul</h3>
<pre>{selectedKPI.formula}</pre>
</div>
)}
{selectedKPI.data && selectedKPI.labels && (
<div className="detail-chart">
<h3>Évolution sur 4 semaines</h3>
<div className="chart-container-detail">
<Line
data={{
labels: selectedKPI.labels,
datasets: [
{
label: selectedKPI.name,
data: selectedKPI.data,
borderColor: getCategoryColor(category),
backgroundColor: `${getCategoryColor(category)}20`,
tension: 0.4,
fill: true,
pointRadius: 6,
pointBackgroundColor: getCategoryColor(category),
pointBorderColor: '#fff',
pointBorderWidth: 2
}
]
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' as const }
},
scales: {
y: { beginAtZero: true }
}
}}
/>
</div>
</div>
)}
<div className="detail-actions">
<button className="btn btn-primary">📊 Exporter</button>
<button className="btn btn-secondary">🔔 Alertes</button>
<button className="btn btn-secondary">📝 Commentaires</button>
</div>
</>
) : (
<div className="no-selection">
<p>Sélectionnez un indicateur pour voir les détails</p>
</div> </div>
)}
</div> <div className="details-description">
<h4>Description</h4>
<p>{selectedKPI.description}</p>
<h4>Formule</h4>
<p>{selectedKPI.formula}</p>
</div>
{selectedKPIStats && (
<div className="details-stats">
<h4>Statistiques (30 derniers jours)</h4>
<div className="stats-container">
<div className="stat-box">
<div className="stat-box-label">Moyenne</div>
<div className="stat-box-value">{selectedKPIStats.avg?.toFixed(2) || 'N/A'}</div>
</div>
<div className="stat-box">
<div className="stat-box-label">Min</div>
<div className="stat-box-value">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div>
</div>
<div className="stat-box">
<div className="stat-box-label">Max</div>
<div className="stat-box-value">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div>
</div>
<div className="stat-box">
<div className="stat-box-label">Mesures</div>
<div className="stat-box-value">{selectedKPIStats.count || 0}</div>
</div>
</div>
</div>
)}
<div className="measurements-section">
<h4>Dernières mesures ({selectedKPIMeasurements.length})</h4>
<table className="measurements-table">
<thead>
<tr>
<th>Date</th>
<th>Valeur</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{selectedKPIMeasurements.slice(-10).reverse().map((m: any, idx: number) => (
<tr key={idx}>
<td>{new Date(m.measurement_date).toLocaleString('fr-FR')}</td>
<td>{m.value}</td>
<td>
<span className={`status-badge status-${m.status}`}>
{m.status === 'good' ? '✓ Bon' : m.status === 'warning' ? '⚠️ Attention' : '🔴 Critique'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div> </div>
<ChartModal
isOpen={showChart}
kpi={selectedKPI}
measurements={selectedKPIMeasurements}
onClose={() => setShowChart(false)}
/>
<ExportModal
isOpen={showExportModal}
kpiName={selectedKPI?.name || ''}
onExport={handleExportClick}
onClose={() => setShowExportModal(false)}
/>
<RangeChartModal
isOpen={showChartRangeModal}
kpi={selectedKPI}
measurements={selectedKPIMeasurements}
getMeasurementsForRange={getMeasurementsForDateRange}
onClose={() => setShowChartRangeModal(false)}
/>
</div> </div>
); );
}; };

View File

@ -1,53 +1,133 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { KPI } from '../types';
import { KPICard } from '../components/KPICard'; import { KPICard } from '../components/KPICard';
import { getCategoryColor } from '../data/kpiData'; import { getCategoryColor } from '../data/kpiData';
import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
import '../styles/HomePage.css'; import '../styles/HomePage.css';
interface HomePageProps { export const HomePage: React.FC = () => {
kpiData: { const db = useSQLiteDatabase();
security: KPI[]; const [timeRange, setTimeRange] = useState<'today' | 'week' | 'last7' | 'month' | 'year'>('today');
quality: KPI[]; const [stats, setStats] = useState({ total: 0, good: 0, warning: 0, critical: 0 });
delays: KPI[]; const [topKPIs, setTopKPIs] = useState<any>({});
costs: KPI[]; const [avgPerformance, setAvgPerformance] = useState(0);
maintenance: KPI[]; const [criticalAlerts, setCriticalAlerts] = useState<any[]>([]);
};
}
export const HomePage: React.FC<HomePageProps> = ({ kpiData }) => { // Convertir la plage de temps en nombre de jours
// Fonction pour obtenir les KPI les plus importants par catégorie const getDaysFromTimeRange = (range: 'today' | 'week' | 'last7' | 'month' | 'year'): number => {
const getTopKPIs = (category: KPI[]): KPI[] => { switch (range) {
return category.slice(0, 2); case 'today':
return 0;
case 'week': {
const today = new Date();
const dayOfWeek = today.getDay();
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
return daysFromMonday;
}
case 'last7':
return 7;
case 'month':
return 30;
case 'year':
return 365;
default:
return 0;
}
}; };
const topSecurityKPIs = getTopKPIs(kpiData.security); // Charger les données quand les KPI changent ou que la plage change
const topQualityKPIs = getTopKPIs(kpiData.quality); useEffect(() => {
const topDelaysKPIs = getTopKPIs(kpiData.delays); if (db.loading || db.kpis.length === 0) return;
const topCostsKPIs = getTopKPIs(kpiData.costs);
const topMaintenanceKPIs = getTopKPIs(kpiData.maintenance);
// Calculer les statistiques globales const fetchData = async () => {
const allKPIs = [ const days = getDaysFromTimeRange(timeRange);
...kpiData.security, const kpisWithStatus: any[] = [];
...kpiData.quality, const alertList: any[] = [];
...kpiData.delays,
...kpiData.costs,
...kpiData.maintenance
];
const stats = { // Charger les mesures pour chaque KPI
total: allKPIs.length, for (const kpi of db.kpis) {
good: allKPIs.filter(k => k.status === 'good').length, const measurements = await db.getMeasurementsForKPI(kpi.id, days);
warning: allKPIs.filter(k => k.status === 'warning').length,
critical: allKPIs.filter(k => k.status === 'critical').length
};
const avgPerformance = Math.round( let status = 'good';
((stats.good * 100 + stats.warning * 50) / (stats.total * 100)) * 100 let value = 0;
);
// État pour la sélection de la plage temporelle if (measurements && measurements.length > 0) {
const [timeRange, setTimeRange] = React.useState<'day' | 'month' | 'year'>('month'); const values = measurements.map((m: any) => m.value);
value = Math.round((values.reduce((a: number, b: number) => a + b, 0) / values.length) * 100) / 100;
const tolerance = kpi.target! * 0.1;
if ([2, 4, 5, 6, 13, 16].includes(kpi.id)) {
if (value > kpi.target! + tolerance * 2) status = 'critical';
else if (value > kpi.target! + tolerance) status = 'warning';
else status = 'good';
} else {
if (value < kpi.target! - tolerance * 2) status = 'critical';
else if (value < kpi.target! - tolerance) status = 'warning';
else status = 'good';
}
if (status === 'critical' || status === 'warning') {
alertList.push({ ...kpi, status, value });
}
}
kpisWithStatus.push({ ...kpi, status, value });
}
// Calculer les statistiques
const statCounts = {
total: kpisWithStatus.length,
good: kpisWithStatus.filter(k => k.status === 'good').length,
warning: kpisWithStatus.filter(k => k.status === 'warning').length,
critical: kpisWithStatus.filter(k => k.status === 'critical').length
};
// Grouper par catégorie
const topKPIsMap: any = {};
[1, 2, 3, 4, 5].forEach(catId => {
topKPIsMap[catId] = kpisWithStatus
.filter(k => k.category_id === catId)
.slice(0, 2);
});
// Performance globale
const performance = Math.round(
((statCounts.good * 100 + statCounts.warning * 50) / (statCounts.total * 100)) * 100
);
setStats(statCounts);
setTopKPIs(topKPIsMap);
setAvgPerformance(performance);
setCriticalAlerts(alertList.sort((a, b) => {
if (a.status === 'critical' && b.status !== 'critical') return -1;
if (a.status !== 'critical' && b.status === 'critical') return 1;
return 0;
}).slice(0, 4));
};
fetchData();
}, [db, timeRange]);
if (db.loading) {
return (
<div className="home-page">
<div className="stats-overview">
<p style={{ textAlign: 'center', padding: '2rem' }}> Chargement des données...</p>
</div>
</div>
);
}
if (db.error) {
return (
<div className="home-page">
<div className="stats-overview">
<p style={{ textAlign: 'center', padding: '2rem', color: 'red' }}> {db.error}</p>
<p style={{ textAlign: 'center' }}>Assurez-vous que le serveur API est lancé: <code>npm run server</code></p>
</div>
</div>
);
}
return ( return (
<div className="home-page"> <div className="home-page">
@ -56,155 +136,112 @@ export const HomePage: React.FC<HomePageProps> = ({ kpiData }) => {
<h1>📊 Dashboard SQDC</h1> <h1>📊 Dashboard SQDC</h1>
<div className="time-range-selector"> <div className="time-range-selector">
<button <button
className={`time-btn ${timeRange === 'day' ? 'active' : ''}`} className={`time-btn ${timeRange === 'today' ? 'active' : ''}`}
onClick={() => setTimeRange('day')} onClick={() => setTimeRange('today')}
> >
Jour Aujourd'hui
</button>
<button
className={`time-btn ${timeRange === 'week' ? 'active' : ''}`}
onClick={() => setTimeRange('week')}
>
Cette semaine
</button>
<button
className={`time-btn ${timeRange === 'last7' ? 'active' : ''}`}
onClick={() => setTimeRange('last7')}
>
7 derniers jours
</button> </button>
<button <button
className={`time-btn ${timeRange === 'month' ? 'active' : ''}`} className={`time-btn ${timeRange === 'month' ? 'active' : ''}`}
onClick={() => setTimeRange('month')} onClick={() => setTimeRange('month')}
> >
Mois Ce mois
</button> </button>
<button <button
className={`time-btn ${timeRange === 'year' ? 'active' : ''}`} className={`time-btn ${timeRange === 'year' ? 'active' : ''}`}
onClick={() => setTimeRange('year')} onClick={() => setTimeRange('year')}
> >
Année Cette année
</button> </button>
</div> </div>
</div> </div>
<div className="stats-grid"> {criticalAlerts.length > 0 && (
<div className="stat-card"> <div className="alerts-block">
<div className="stat-value" style={{ color: '#27ae60' }}> <h3>🚨 Alertes ({criticalAlerts.length})</h3>
{stats.good} <div className="alerts-list">
</div> {criticalAlerts.map(alert => (
<div className="stat-label">KPI Bon</div> <div key={alert.id} className={`alert-item alert-${alert.status}`}>
<div className="stat-description"> <div className="alert-status">
{Math.round((stats.good / stats.total) * 100)}% des KPI {alert.status === 'critical' ? '🔴' : '⚠️'}
</div>
<div className="alert-info">
<div className="alert-name">{alert.name}</div>
<div className="alert-value">{alert.value} {alert.unit}</div>
</div>
</div>
))}
</div> </div>
</div> </div>
)}
<div className="stat-card">
<div className="stat-value" style={{ color: '#f39c12' }}>
{stats.warning}
</div>
<div className="stat-label">À Améliorer</div>
<div className="stat-description">
{Math.round((stats.warning / stats.total) * 100)}% des KPI
</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#e74c3c' }}>
{stats.critical}
</div>
<div className="stat-label">Critique</div>
<div className="stat-description">
{Math.round((stats.critical / stats.total) * 100)}% des KPI
</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3498db' }}>
{avgPerformance}%
</div>
<div className="stat-label">Performance Globale</div>
<div className="stat-description">Indicateur synthétique</div>
</div>
</div>
</div> </div>
<div className="top-kpis-section"> <div className="main-grid">
<h2>🎯 KPI Clés par Catégorie</h2> <div className="category-section">
<div className="category-header">
<div className="categories-grid"> <h2>📊 Statistiques</h2>
{/* BLOC SYNTHÈSE */}
<div className="summary-section">
<h3>📋 Alertes Prioritaires</h3>
<div className="alerts-list">
<div className="alert-item critical">
<span className="alert-icon">🔴</span>
<div className="alert-content">
<div className="alert-title">Coût des Non-Qualité</div>
<div className="alert-value">18 500 </div>
</div>
</div>
<div className="alert-item warning">
<span className="alert-icon"></span>
<div className="alert-content">
<div className="alert-title">Taux de Retouche</div>
<div className="alert-value">3.8%</div>
</div>
</div>
<div className="alert-item warning">
<span className="alert-icon"></span>
<div className="alert-content">
<div className="alert-title">Downtime</div>
<div className="alert-value">2.5h/jour</div>
</div>
</div>
<div className="alert-item warning">
<span className="alert-icon"></span>
<div className="alert-content">
<div className="alert-title">OEE</div>
<div className="alert-value">72.3%</div>
</div>
</div>
</div>
<div className="score-section">
<div className="score-label">Performance Globale</div>
<div className="score-circle">{avgPerformance}%</div>
</div>
</div> </div>
<div className="stats-grid-inner">
<div className="category-section"> <div className="stat-card-inner">
<h3 style={{ color: getCategoryColor('security') }}>🛡 Sécurité</h3> <div className="stat-number">{stats.total}</div>
<div className="kpis-grid"> <div className="stat-label">KPI Total</div>
{topSecurityKPIs.map(kpi => (
<KPICard key={kpi.id} kpi={kpi} color={getCategoryColor('security')} />
))}
</div> </div>
</div> <div className="stat-card-inner">
<div className="stat-number">{stats.good}</div>
<div className="category-section"> <div className="stat-label"> Bon</div>
<h3 style={{ color: getCategoryColor('quality') }}>🎯 Qualité</h3>
<div className="kpis-grid">
{topQualityKPIs.map(kpi => (
<KPICard key={kpi.id} kpi={kpi} color={getCategoryColor('quality')} />
))}
</div> </div>
</div> <div className="stat-card-inner">
<div className="stat-number">{stats.warning}</div>
<div className="category-section"> <div className="stat-label"> Attention</div>
<h3 style={{ color: getCategoryColor('delays') }}> Délais & Livraison</h3>
<div className="kpis-grid">
{topDelaysKPIs.map(kpi => (
<KPICard key={kpi.id} kpi={kpi} color={getCategoryColor('delays')} />
))}
</div> </div>
</div> <div className="stat-card-inner">
<div className="stat-number">{stats.critical}</div>
<div className="category-section"> <div className="stat-label">🔴 Critique</div>
<h3 style={{ color: getCategoryColor('costs') }}>💰 Coûts</h3>
<div className="kpis-grid">
{topCostsKPIs.map(kpi => (
<KPICard key={kpi.id} kpi={kpi} color={getCategoryColor('costs')} />
))}
</div> </div>
</div> <div className="stat-card-inner">
<div className="stat-number">{avgPerformance}%</div>
<div className="category-section"> <div className="stat-label">Performance</div>
<h3 style={{ color: getCategoryColor('maintenance') }}>🔧 Maintenance</h3>
<div className="kpis-grid">
{topMaintenanceKPIs.map(kpi => (
<KPICard key={kpi.id} kpi={kpi} color={getCategoryColor('maintenance')} />
))}
</div> </div>
</div> </div>
</div> </div>
{[1, 2, 3, 4, 5].map(catId => {
const categoryKPIs = topKPIs[catId] || [];
const category = db.categories.find(c => c.id === catId);
if (!category) return null;
return (
<div key={catId} className="category-section">
<div className="category-header">
<h2 style={{ color: getCategoryColor(category.name) }}>
{category.emoji} {category.name}
</h2>
</div>
<div className="kpi-grid">
{categoryKPIs.map((kpi: any) => (
<KPICard
key={kpi.id}
kpi={kpi}
color={getCategoryColor(category.name)}
/>
))}
</div>
</div>
);
})}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,104 @@
.chart-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.chart-modal {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 90%;
width: 1000px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.chart-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
}
.chart-modal-header h2 {
margin: 0;
font-size: 1.8rem;
color: #333;
}
.chart-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
transition: all 0.3s ease;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-modal-close:hover {
color: #333;
background: #f5f5f5;
border-radius: 4px;
}
.chart-modal-body {
margin-bottom: 1.5rem;
}
.chart-modal-footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-top: 1rem;
border-top: 2px solid #f0f0f0;
color: #666;
}
.chart-modal-footer p {
margin: 0.5rem 0;
font-size: 0.95rem;
}
@media (max-width: 768px) {
.chart-modal {
width: 95%;
padding: 1rem;
}
.chart-modal-body {
height: 300px;
}
.chart-modal-footer {
grid-template-columns: 1fr;
}
}

View File

@ -1,290 +1,325 @@
.detail-page { .detail-page {
color: white; color: white;
padding: 2rem;
} }
/* DETAIL HEADER */
.detail-header { .detail-header {
margin-bottom: 2rem; background: linear-gradient(135deg, rgba(100, 150, 255, 0.2) 0%, rgba(100, 100, 200, 0.1) 100%);
border-radius: 12px;
padding: 2rem;
margin-bottom: 3rem;
border-left: 5px solid rgba(255, 255, 255, 0.3);
} }
.detail-header h1 { .detail-header h1 {
font-size: 2rem; font-size: 2.2rem;
margin-bottom: 0.5rem; margin: 0 0 0.5rem 0;
font-weight: bold;
} }
.detail-header p { .detail-header p {
font-size: 1.1rem; font-size: 1rem;
opacity: 0.9; opacity: 0.9;
margin: 0;
} }
.detail-layout { /* DETAIL CONTENT */
.detail-content {
display: grid; display: grid;
grid-template-columns: 300px 1fr; grid-template-columns: 1fr 2fr;
gap: 2rem; gap: 2rem;
} }
/* KPI LIST */ /* KPI SELECTOR */
.kpi-list { .kpi-selector {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.98);
border-radius: 12px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
color: #333; color: #333;
height: fit-content; height: fit-content;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.kpi-list h2 { .kpi-selector h3 {
font-size: 1.2rem; font-size: 1.2rem;
margin-bottom: 1rem; margin: 0 0 1.2rem 0;
color: #333; color: #333;
font-weight: bold;
} }
.kpi-list-items { /* ACTION BUTTONS */
display: flex; .action-buttons {
flex-direction: column; display: grid;
gap: 0.5rem; grid-template-columns: 1fr 1fr;
gap: 0.8rem;
margin-bottom: 1.2rem;
} }
.kpi-list-item { .btn {
padding: 1rem; padding: 0.8rem 1rem;
border-left: 3px solid #ddd; border: none;
border-radius: 4px; border-radius: 6px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
text-align: center;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-export {
background: #6496ff;
color: white;
}
.btn-export:hover:not(:disabled) {
background: #4472ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(100, 150, 255, 0.4);
}
.btn-chart {
background: #52d273;
color: white;
}
.btn-chart:hover:not(:disabled) {
background: #38c459;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(82, 210, 115, 0.4);
}
.kpi-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.kpi-option {
background: white; background: white;
border: 2px solid #e0e0e0;
border-radius: 6px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
font-size: 0.95rem;
} }
.kpi-list-item:hover { .kpi-option:hover {
background: #f5f7fa; border-color: #6496ff;
background: #f8f9ff;
} }
.kpi-list-item.active { .kpi-option.active {
background: #f0f2ff; background: #6496ff;
color: white;
border-color: #6496ff;
} }
.kpi-list-name { .kpi-name {
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.3rem;
color: #333;
} }
.kpi-list-value { .kpi-unit {
font-size: 0.9rem; font-size: 0.85rem;
color: #666; opacity: 0.7;
margin-bottom: 0.5rem;
} }
.badge { /* KPI DETAILS */
display: inline-block; .kpi-details {
padding: 0.25rem 0.6rem; background: rgba(255, 255, 255, 0.98);
border-radius: 12px; border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-good {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-critical {
background: #f8d7da;
color: #721c24;
}
/* DETAIL SECTION */
.kpi-detail {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 2rem; padding: 2rem;
color: #333; color: #333;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.detail-header-card { .details-header {
border-top: 4px solid; border-bottom: 2px solid #f0f0f0;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-bottom: 1px solid #f0f0f0;
} }
.detail-header-card h2 { .details-header h2 {
font-size: 1.8rem; font-size: 1.8rem;
margin-bottom: 1rem; margin: 0 0 1.2rem 0;
margin-top: 0; color: #333;
} }
.detail-value-section { .details-info {
display: flex; display: grid;
align-items: baseline; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem; gap: 1rem;
} }
.detail-value { .info-item {
font-size: 2.5rem; background: #f9f9f9;
border-left: 3px solid #6496ff;
border-radius: 4px;
padding: 1rem;
}
.info-label {
font-size: 0.85rem;
color: #999;
font-weight: 600;
margin-bottom: 0.3rem;
text-transform: uppercase;
}
.info-value {
font-size: 1.2rem;
font-weight: bold; font-weight: bold;
color: #333; color: #333;
} }
.detail-unit { /* DESCRIPTION SECTION */
font-size: 1rem; .details-description {
color: #999;
}
/* INFO SECTION */
.detail-info {
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #f0f0f0;
} }
.info-row { .details-description h4 {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem;
background: #f9f9f9;
border-radius: 4px;
}
.info-label {
font-weight: 600;
color: #666;
}
.info-value {
color: #333;
}
/* DESCRIPTION & FORMULA */
.detail-description,
.detail-formula {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #f0f0f0;
}
.detail-description h3,
.detail-formula h3 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #333; color: #333;
margin: 1.2rem 0 0.6rem 0;
font-weight: bold;
} }
.detail-description p { .details-description p {
color: #666; color: #666;
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
.detail-formula pre { /* STATISTICS SECTION */
background: #f5f7fa; .details-stats {
padding: 1rem; background: #f9f9f9;
border-radius: 4px; border-radius: 6px;
overflow-x: auto; padding: 1.5rem;
font-size: 0.9rem; margin: 2rem 0;
line-height: 1.4;
color: #333;
} }
/* CHART DETAIL */ .details-stats h4 {
.detail-chart {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #f0f0f0;
}
.detail-chart h3 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #333; color: #333;
margin: 0 0 1rem 0;
font-weight: bold;
} }
.chart-container-detail { .stats-container {
position: relative; display: grid;
height: 350px; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
margin-bottom: 1rem;
}
/* ACTIONS */
.detail-actions {
display: flex;
gap: 1rem; gap: 1rem;
} }
.btn { .stat-box {
padding: 0.75rem 1.5rem; background: white;
border: none; border: 1px solid #e0e0e0;
border-radius: 6px; border-radius: 6px;
font-size: 0.95rem; padding: 1rem;
font-weight: 600; text-align: center;
cursor: pointer;
transition: all 0.3s ease;
} }
.btn-primary { .stat-box-label {
background: #667eea; font-size: 0.85rem;
color: white; color: #999;
margin-bottom: 0.5rem;
} }
.btn-primary:hover { .stat-box-value {
background: #764ba2; font-size: 1.5rem;
} font-weight: bold;
.btn-secondary {
background: #f0f0f0;
color: #333; color: #333;
} }
.btn-secondary:hover { /* MEASUREMENTS TABLE */
background: #e0e0e0; .measurements-section {
margin-top: 2rem;
} }
.no-selection { .measurements-section h4 {
text-align: center;
padding: 3rem;
color: #999;
font-size: 1.1rem; font-size: 1.1rem;
color: #333;
margin: 0 0 1rem 0;
font-weight: bold;
}
.measurements-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 6px;
overflow: hidden;
}
.measurements-table thead {
background: #f9f9f9;
}
.measurements-table th {
padding: 1rem;
text-align: left;
font-weight: bold;
color: #333;
border-bottom: 2px solid #e0e0e0;
}
.measurements-table td {
padding: 0.8rem 1rem;
border-bottom: 1px solid #e0e0e0;
color: #666;
}
.measurements-table tbody tr:hover {
background: #f9f9f9;
}
.status-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
}
.status-good {
background: #d4edda;
color: #155724;
}
.status-warning {
background: #fff3cd;
color: #856404;
}
.status-critical {
background: #f8d7da;
color: #721c24;
} }
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.detail-layout { .detail-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.kpi-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
height: auto;
}
.kpi-list { .kpi-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
height: auto; gap: 0.5rem;
}
.kpi-list-items {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@media (max-width: 768px) {
.detail-header h1 {
font-size: 1.5rem;
}
.detail-value {
font-size: 2rem;
}
.detail-actions {
flex-direction: column;
}
.btn {
width: 100%;
} }
} }

View File

@ -0,0 +1,168 @@
.export-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.export-modal {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.export-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
}
.export-modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.export-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
transition: all 0.3s ease;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.export-modal-close:hover {
color: #333;
background: #f5f5f5;
border-radius: 4px;
}
.export-modal-body {
margin-bottom: 1.5rem;
}
.export-modal-description {
color: #666;
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.export-options {
display: grid;
grid-template-columns: 1fr;
gap: 0.8rem;
}
.export-option {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.export-option input {
display: none;
}
.export-option:hover {
border-color: #6496ff;
background: #f8f9ff;
}
.export-option.active {
background: #f0f4ff;
border-color: #6496ff;
}
.export-option .option-label {
font-weight: 600;
color: #333;
font-size: 1rem;
}
.export-option .option-sublabel {
font-size: 0.85rem;
color: #999;
}
.export-modal-footer {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 2px solid #f0f0f0;
}
.btn-cancel {
padding: 0.8rem 1.5rem;
border: 2px solid #e0e0e0;
background: white;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
color: #666;
}
.btn-cancel:hover {
border-color: #ccc;
background: #f9f9f9;
}
.btn-export-confirm {
padding: 0.8rem 1.5rem;
background: #6496ff;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-export-confirm:hover:not(:disabled) {
background: #4472ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(100, 150, 255, 0.4);
}
.btn-export-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@ -51,20 +51,19 @@
color: white; color: white;
} }
.stats-grid { /* STAT CARDS - UNIFIED WITH CATEGORIES */
display: grid; .stats-cards {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); display: contents;
gap: 1rem;
margin-bottom: 1.5rem;
} }
.stat-card { .stat-card {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.98);
border-radius: 8px; border-radius: 8px;
padding: 1.2rem; padding: 1.5rem;
text-align: center; text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; transition: all 0.3s ease;
color: #333;
} }
.stat-card:hover { .stat-card:hover {
@ -72,22 +71,16 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
} }
.stat-value { .stat-number {
font-size: 2.2rem; font-size: 2.2rem;
font-weight: bold; font-weight: bold;
margin-bottom: 0.3rem; margin-bottom: 0.5rem;
} }
.stat-label { .stat-label {
font-size: 0.85rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: #333; color: #555;
margin-bottom: 0.3rem;
}
.stat-description {
font-size: 0.75rem;
color: #999;
} }
/* TOP KPIs SECTION */ /* TOP KPIs SECTION */
@ -105,10 +98,140 @@
} }
/* CATEGORIES GRID - CÔTE À CÔTE */ /* CATEGORIES GRID - CÔTE À CÔTE */
.categories-grid { .main-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem; gap: 1.5rem;
margin-top: 2rem;
}
/* CATEGORY SECTION */
.category-section {
background: rgba(255, 255, 255, 0.98);
border-radius: 8px;
padding: 1.5rem;
color: #333;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.category-section:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.category-header {
margin-bottom: 1.2rem;
}
.category-header h2 {
font-size: 1.3rem;
margin: 0;
color: #333;
}
/* GRILLE DES STATS À L'INTÉRIEUR DU BLOC */
.stats-grid-inner {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.2rem;
}
.stat-card-inner {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(100, 150, 255, 0.08);
border-radius: 6px;
padding: 1.2rem;
border: 1px solid rgba(100, 150, 255, 0.15);
}
.stat-card-inner:last-child {
grid-column: 1 / -1;
justify-content: center;
gap: 1rem;
}
.stat-card-inner .stat-number {
font-size: 2rem;
font-weight: bold;
color: #333;
min-width: auto;
}
.stat-card-inner .stat-label {
font-size: 0.85rem;
font-weight: 600;
color: #666;
flex: none;
}
/* STAT CARDS - UNIFIED WITH CATEGORIES */
.stats-cards {
display: contents;
}
.stat-card {
background: rgba(255, 255, 255, 0.98);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
color: #333;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.stat-number {
font-size: 2.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9rem;
font-weight: 600;
color: #555;
}
/* TOP KPIs SECTION */
.top-kpis-section {
background: rgba(255, 255, 255, 0.98);
border-radius: 8px;
padding: 1.5rem;
color: #333;
}
.top-kpis-section h2 {
font-size: 1.3rem;
margin-bottom: 1.2rem;
color: #333;
}
/* CATEGORY SECTION */
.category-section {
background: rgba(255, 255, 255, 0.98);
border-radius: 8px;
padding: 1.5rem;
color: #333;
}
.category-header h2 {
font-size: 1.2rem;
margin: 0 0 1rem 0;
padding-bottom: 0.8rem;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
} }
/* SUMMARY SECTION */ /* SUMMARY SECTION */
@ -154,6 +277,11 @@
background: rgba(243, 156, 18, 0.05); background: rgba(243, 156, 18, 0.05);
} }
.alert-item.good {
border-left-color: #27ae60;
background: rgba(39, 174, 96, 0.05);
}
.alert-icon { .alert-icon {
font-size: 1.2rem; font-size: 1.2rem;
flex-shrink: 0; flex-shrink: 0;

View File

@ -0,0 +1,152 @@
.range-chart-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.range-chart-modal {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 90%;
width: 1100px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.range-chart-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
}
.range-chart-modal-header h2 {
margin: 0;
font-size: 1.8rem;
color: #333;
}
.range-chart-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
transition: all 0.3s ease;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.range-chart-modal-close:hover {
color: #333;
background: #f5f5f5;
border-radius: 4px;
}
/* RANGE SELECTOR */
.range-chart-modal-range-selector {
display: flex;
gap: 0.8rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.range-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
font-weight: 600;
color: #333;
}
.range-option input {
display: none;
}
.range-option:hover {
border-color: #6496ff;
background: #f8f9ff;
}
.range-option.active {
background: #6496ff;
color: white;
border-color: #6496ff;
}
.range-chart-modal-body {
margin-bottom: 1.5rem;
background: #f9f9f9;
border-radius: 8px;
padding: 1rem;
}
.range-chart-modal-footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-top: 1rem;
border-top: 2px solid #f0f0f0;
color: #666;
}
.range-chart-modal-footer p {
margin: 0.5rem 0;
font-size: 0.95rem;
}
@media (max-width: 768px) {
.range-chart-modal {
width: 95%;
padding: 1rem;
}
.range-chart-modal-body {
height: 300px;
}
.range-chart-modal-range-selector {
flex-direction: column;
}
.range-option {
flex: 1;
}
.range-chart-modal-footer {
grid-template-columns: 1fr;
}
}