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:
parent
5ecda7eef7
commit
ca05e334a7
48
dashboard-sqdc/DATABASE_SETUP.md
Normal file
48
dashboard-sqdc/DATABASE_SETUP.md
Normal 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
|
||||||
83
dashboard-sqdc/database/db.ts
Normal file
83
dashboard-sqdc/database/db.ts
Normal 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
203
dashboard-sqdc/database/populate_db.py
Normal file
203
dashboard-sqdc/database/populate_db.py
Normal 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()
|
||||||
52
dashboard-sqdc/database/schema.sql
Normal file
52
dashboard-sqdc/database/schema.sql
Normal 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);
|
||||||
BIN
dashboard-sqdc/database/sqdc.db
Normal file
BIN
dashboard-sqdc/database/sqdc.db
Normal file
Binary file not shown.
1382
dashboard-sqdc/package-lock.json
generated
1382
dashboard-sqdc/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
152
dashboard-sqdc/server.js
Normal 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);
|
||||||
|
});
|
||||||
@ -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' && (
|
||||||
|
|||||||
92
dashboard-sqdc/src/components/ChartModal.tsx
Normal file
92
dashboard-sqdc/src/components/ChartModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
103
dashboard-sqdc/src/components/ExportModal.tsx
Normal file
103
dashboard-sqdc/src/components/ExportModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
|||||||
159
dashboard-sqdc/src/components/RangeChartModal.tsx
Normal file
159
dashboard-sqdc/src/components/RangeChartModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
113
dashboard-sqdc/src/database/useSQLiteDatabase.ts
Normal file
113
dashboard-sqdc/src/database/useSQLiteDatabase.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
104
dashboard-sqdc/src/styles/ChartModal.css
Normal file
104
dashboard-sqdc/src/styles/ChartModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
dashboard-sqdc/src/styles/ExportModal.css
Normal file
168
dashboard-sqdc/src/styles/ExportModal.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
152
dashboard-sqdc/src/styles/RangeChartModal.css
Normal file
152
dashboard-sqdc/src/styles/RangeChartModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user