Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9f5b58e0 | ||
|
|
567245a5b0 | ||
|
|
0493a7ef70 | ||
|
|
7ae458f768 | ||
|
|
59e270e3ca | ||
|
|
02c41b9f4e | ||
|
|
9420975f76 | ||
|
|
00331c5d95 | ||
|
|
b98075df82 | ||
|
|
b555203dc7 | ||
|
|
20d0993b06 | ||
|
|
a918615eb4 | ||
|
|
35614903d6 | ||
|
|
af378e0d0e | ||
|
|
1ca02412a9 | ||
|
|
770c41d5e0 | ||
|
|
ca05e334a7 | ||
|
|
5ecda7eef7 |
221
.gitea/workflows/README.md
Normal file
221
.gitea/workflows/README.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Gitea CI/CD Workflow
|
||||
|
||||
This directory contains the Gitea Actions workflow for building and deploying the SQDC Dashboard.
|
||||
|
||||
## Workflow: build-deploy.yml
|
||||
|
||||
### Triggers
|
||||
|
||||
- **Push** to any branch
|
||||
- **Pull Request** to any branch
|
||||
|
||||
### Jobs
|
||||
|
||||
#### 1. Build Job
|
||||
|
||||
Runs on every push and pull request.
|
||||
|
||||
**Steps:**
|
||||
1. Checkout code
|
||||
2. Set up Node.js 18
|
||||
3. Install dependencies (`npm ci`)
|
||||
4. Run tests
|
||||
5. Build React application
|
||||
6. Login to container registry
|
||||
7. Build Docker image
|
||||
8. Tag image with commit SHA and `latest`
|
||||
9. Push to registry: `gitea.vidoks.fr/sortifal/pfee`
|
||||
|
||||
**Artifacts:**
|
||||
- Docker image pushed to registry with tags:
|
||||
- `gitea.vidoks.fr/sortifal/pfee:<commit-sha>`
|
||||
- `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||
|
||||
#### 2. Deploy Job
|
||||
|
||||
Runs on push to any branch (not on PRs).
|
||||
|
||||
**Steps:**
|
||||
1. Checkout code
|
||||
2. Set up kubectl
|
||||
3. Configure kubectl with k3s config
|
||||
4. Create registry credentials secret
|
||||
5. Apply Kubernetes manifests (namespace, deployment, service, ingress)
|
||||
6. Update deployment with new image
|
||||
7. Wait for rollout to complete
|
||||
8. Verify deployment status
|
||||
|
||||
**Requirements:**
|
||||
- Successful build job
|
||||
- Push to protected branches only
|
||||
|
||||
#### 3. Notify Job
|
||||
|
||||
Runs after build and deploy jobs complete (success or failure).
|
||||
|
||||
**Steps:**
|
||||
1. Check deployment result
|
||||
2. Display success or failure message
|
||||
3. Exit with error code if deployment failed
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Configure these in Gitea repository settings:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `KUBE_CONFIG` | Plain text kubeconfig for k3s cluster |
|
||||
| `REGISTRY_URL` | Container registry URL (gitea.vidoks.fr) |
|
||||
| `REGISTRY_USER` | Registry username |
|
||||
| `REGISTRY_PASSWORD` | Registry password or access token |
|
||||
|
||||
## Workflow Behavior
|
||||
|
||||
### On Pull Request
|
||||
|
||||
- Builds and tests the code
|
||||
- Pushes image to registry
|
||||
- **Does not deploy** to Kubernetes
|
||||
|
||||
### On Push to any branch
|
||||
|
||||
- Builds and tests the code
|
||||
- Pushes image to registry
|
||||
- **Deploys** to Kubernetes cluster
|
||||
- Updates running deployment with new image
|
||||
- Verifies deployment success
|
||||
|
||||
## Image Versioning
|
||||
|
||||
Each build creates two image tags:
|
||||
|
||||
1. **Commit SHA tag**: `gitea.vidoks.fr/sortifal/pfee:<commit-sha>`
|
||||
- Immutable, specific version
|
||||
- Used for rollbacks
|
||||
|
||||
2. **Latest tag**: `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||
- Points to most recent build
|
||||
- Used by default in deployment
|
||||
|
||||
## Monitoring
|
||||
|
||||
### View Workflow Runs
|
||||
|
||||
1. Go to repository on Gitea
|
||||
2. Click "Actions" tab
|
||||
3. Select workflow run to view logs
|
||||
|
||||
### Check Deployment Status
|
||||
|
||||
```bash
|
||||
# View all resources
|
||||
kubectl get all -n sqdc-dashboard
|
||||
|
||||
# View deployment status
|
||||
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
|
||||
# View pod logs
|
||||
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
|
||||
**Tests failing:**
|
||||
```bash
|
||||
# Run tests locally
|
||||
npm test
|
||||
```
|
||||
|
||||
**Build errors:**
|
||||
```bash
|
||||
# Run build locally
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Registry Push Failures
|
||||
|
||||
**Authentication errors:**
|
||||
- Verify `REGISTRY_USER` and `REGISTRY_PASSWORD` are correct
|
||||
- Ensure token has `write:package` permission
|
||||
|
||||
**Network errors:**
|
||||
- Check registry URL is accessible: `gitea.vidoks.fr`
|
||||
|
||||
### Deployment Failures
|
||||
|
||||
**kubectl connection errors:**
|
||||
- Verify `KUBE_CONFIG` is valid and not base64 encoded
|
||||
- Test locally: `kubectl get nodes`
|
||||
|
||||
**Image pull errors:**
|
||||
- Check registry credentials secret exists
|
||||
- Verify image was pushed successfully
|
||||
|
||||
**Rollout timeout:**
|
||||
- Increase timeout in workflow (default: 5m)
|
||||
- Check pod logs for errors
|
||||
|
||||
## Manual Operations
|
||||
|
||||
### Manual Deploy
|
||||
|
||||
```bash
|
||||
# Using the deploy script
|
||||
./scripts/deploy.sh gitea.vidoks.fr <user> <password> sortifal/pfee
|
||||
|
||||
# Or manually with kubectl
|
||||
kubectl apply -f k8s/
|
||||
kubectl set image deployment/sqdc-dashboard dashboard=gitea.vidoks.fr/sortifal/pfee:latest -n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
# Using the rollback script
|
||||
./scripts/rollback.sh
|
||||
|
||||
# Or manually
|
||||
kubectl rollout undo deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Skip Workflow
|
||||
|
||||
Add `[skip ci]` to commit message:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: Update README [skip ci]"
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Change Deployment Conditions
|
||||
|
||||
Edit the `if` condition in deploy job to deploy only on specific branches:
|
||||
|
||||
```yaml
|
||||
# Deploy on any push
|
||||
if: github.event_name == 'push'
|
||||
|
||||
# Or deploy only on specific branches
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production'
|
||||
```
|
||||
|
||||
### Add Slack/Email Notifications
|
||||
|
||||
Add steps in notify job to send alerts.
|
||||
|
||||
### Add More Tests
|
||||
|
||||
Add test steps in build job:
|
||||
|
||||
```yaml
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
```
|
||||
|
||||
For more details, see [DEPLOYMENT.md](../../DEPLOYMENT.md) and [SETUP-REGISTRY.md](../../SETUP-REGISTRY.md).
|
||||
100
.gitea/workflows/build-deploy.yml
Normal file
100
.gitea/workflows/build-deploy.yml
Normal file
@ -0,0 +1,100 @@
|
||||
name: Build and Deploy SQDC Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dashboard-sqdc
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: dashboard-sqdc/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm run build
|
||||
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Container Registry
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build and Push API image
|
||||
run: |
|
||||
docker build -f Dockerfile.api -t "$REGISTRY_URL/sortifal/pfee:$IMAGE_TAG" -t "$REGISTRY_URL/sortifal/pfee:latest" .
|
||||
docker push "$REGISTRY_URL/sortifal/pfee:$IMAGE_TAG"
|
||||
docker push "$REGISTRY_URL/sortifal/pfee:latest"
|
||||
|
||||
- name: Build and Push Frontend image
|
||||
run: |
|
||||
docker build -f Dockerfile.frontend -t "$REGISTRY_URL/sortifal/pfee-frontend:$IMAGE_TAG" -t "$REGISTRY_URL/sortifal/pfee-frontend:latest" .
|
||||
docker push "$REGISTRY_URL/sortifal/pfee-frontend:$IMAGE_TAG"
|
||||
docker push "$REGISTRY_URL/sortifal/pfee-frontend:latest"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'push'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dashboard-sqdc
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: azure/setup-kubectl@v3
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
mkdir -p $HOME/.kube
|
||||
echo "${{ secrets.KUBE_CONFIG }}" > $HOME/.kube/config
|
||||
chmod 600 $HOME/.kube/config
|
||||
|
||||
- name: Create registry credentials
|
||||
run: |
|
||||
kubectl create secret docker-registry registry-credentials \
|
||||
--docker-server="$REGISTRY_URL" \
|
||||
--docker-username="$REGISTRY_USER" \
|
||||
--docker-password="$REGISTRY_PASSWORD" \
|
||||
-n sqdc-dashboard \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Install Kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Deploy with Kustomize
|
||||
run: |
|
||||
cd k8s
|
||||
kustomize edit set image gitea.vidoks.fr/sortifal/pfee="$REGISTRY_URL/sortifal/pfee:$IMAGE_TAG"
|
||||
kustomize edit set image gitea.vidoks.fr/sortifal/pfee-frontend="$REGISTRY_URL/sortifal/pfee-frontend:$IMAGE_TAG"
|
||||
kubectl apply -k .
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
kubectl rollout status deployment/sqdc-api -n sqdc-dashboard --timeout=5m
|
||||
kubectl rollout status deployment/sqdc-frontend -n sqdc-dashboard --timeout=5m
|
||||
kubectl get pods,svc,ingress -n sqdc-dashboard
|
||||
14
dashboard-sqdc/.dockerignore
Normal file
14
dashboard-sqdc/.dockerignore
Normal file
@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
coverage
|
||||
.eslintcache
|
||||
23
dashboard-sqdc/.gitignore
vendored
Normal file
23
dashboard-sqdc/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
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
|
||||
446
dashboard-sqdc/DEPLOYMENT.md
Normal file
446
dashboard-sqdc/DEPLOYMENT.md
Normal file
@ -0,0 +1,446 @@
|
||||
# SQDC Dashboard - Deployment Guide
|
||||
|
||||
## Table of Contents
|
||||
- [Quick Start with Registry](#quick-start-with-registry)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Kubernetes Deployment](#kubernetes-deployment)
|
||||
- [Gitea CI/CD Setup](#gitea-cicd-setup)
|
||||
- [Configuration](#configuration)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start with Registry
|
||||
|
||||
For a step-by-step guide to set up the container registry and CI/CD pipeline, see [SETUP-REGISTRY.md](SETUP-REGISTRY.md).
|
||||
|
||||
**Quick Summary:**
|
||||
- Registry: `gitea.vidoks.fr`
|
||||
- Image: `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||
- Required Secrets: `KUBE_CONFIG`, `REGISTRY_URL`, `REGISTRY_USER`, `REGISTRY_PASSWORD`
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Local Development with Docker Compose
|
||||
|
||||
1. **Build and run the application:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Access the application:**
|
||||
- Frontend: http://localhost:8080
|
||||
- API: http://localhost:3001
|
||||
|
||||
3. **View logs:**
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
4. **Stop the application:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Manual Docker Build
|
||||
|
||||
1. **Build the image:**
|
||||
```bash
|
||||
docker build -t sqdc-dashboard:latest .
|
||||
```
|
||||
|
||||
2. **Run the container:**
|
||||
```bash
|
||||
docker run -d \
|
||||
--name sqdc-dashboard \
|
||||
-p 8080:80 \
|
||||
-p 3001:3001 \
|
||||
-v $(pwd)/database:/app/database \
|
||||
sqdc-dashboard:latest
|
||||
```
|
||||
|
||||
3. **Check container status:**
|
||||
```bash
|
||||
docker ps
|
||||
docker logs sqdc-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster (v1.20+)
|
||||
- kubectl configured
|
||||
- Nginx Ingress Controller installed
|
||||
- (Optional) Cert-Manager for HTTPS
|
||||
|
||||
### Quick Deployment
|
||||
|
||||
1. **Create registry credentials secret:**
|
||||
```bash
|
||||
kubectl create secret docker-registry registry-credentials \
|
||||
--docker-server=gitea.vidoks.fr \
|
||||
--docker-username=<your-username> \
|
||||
--docker-password=<your-password> \
|
||||
-n sqdc-dashboard
|
||||
```
|
||||
|
||||
2. **Using the deployment script:**
|
||||
```bash
|
||||
./scripts/deploy.sh gitea.vidoks.fr <registry-user> <registry-password> sortifal/pfee
|
||||
```
|
||||
|
||||
3. **Manual deployment:**
|
||||
```bash
|
||||
# Apply all manifests
|
||||
kubectl apply -f k8s/
|
||||
|
||||
# Or use kustomize
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
3. **Verify deployment:**
|
||||
```bash
|
||||
kubectl get all -n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Detailed Steps
|
||||
|
||||
#### 1. Create Namespace
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
```
|
||||
|
||||
#### 2. Deploy Application
|
||||
```bash
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
```
|
||||
|
||||
#### 3. Create Service
|
||||
```bash
|
||||
kubectl apply -f k8s/service.yaml
|
||||
```
|
||||
|
||||
#### 4. Setup Ingress
|
||||
```bash
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
```
|
||||
|
||||
#### 5. Verify Deployment
|
||||
```bash
|
||||
# Check pods
|
||||
kubectl get pods -n sqdc-dashboard
|
||||
|
||||
# Check service
|
||||
kubectl get svc -n sqdc-dashboard
|
||||
|
||||
# Check ingress
|
||||
kubectl get ingress -n sqdc-dashboard
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Scaling
|
||||
|
||||
```bash
|
||||
# Scale to 3 replicas
|
||||
kubectl scale deployment/sqdc-dashboard --replicas=3 -n sqdc-dashboard
|
||||
|
||||
# Auto-scaling (optional)
|
||||
kubectl autoscale deployment/sqdc-dashboard \
|
||||
--cpu-percent=70 \
|
||||
--min=2 \
|
||||
--max=10 \
|
||||
-n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
# Using the rollback script
|
||||
./scripts/rollback.sh
|
||||
|
||||
# Or manually
|
||||
kubectl rollout undo deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gitea CI/CD Setup
|
||||
|
||||
### 1. Configure Gitea Actions
|
||||
|
||||
Ensure Gitea Actions is enabled in your Gitea instance:
|
||||
- Go to Site Administration → Configuration
|
||||
- Enable Actions Runner
|
||||
|
||||
### 2. Set up Secrets
|
||||
|
||||
In your Gitea repository, add the following secrets:
|
||||
|
||||
**Settings → Secrets → Actions**
|
||||
|
||||
- `KUBE_CONFIG`: Plain text kubeconfig file for k3s
|
||||
```bash
|
||||
cat ~/.kube/config
|
||||
```
|
||||
|
||||
- `REGISTRY_URL`: Container registry URL (e.g., `gitea.vidoks.fr`)
|
||||
|
||||
- `REGISTRY_USER`: Registry username
|
||||
|
||||
- `REGISTRY_PASSWORD`: Registry password or access token
|
||||
|
||||
### 3. Configure Workflow
|
||||
|
||||
The workflow file is already created at `.gitea/workflows/build-deploy.yml`
|
||||
|
||||
**Workflow triggers:**
|
||||
- Push to any branch
|
||||
- Pull requests to any branch
|
||||
|
||||
**Workflow steps:**
|
||||
1. Build - Runs tests, builds Docker image, and pushes to container registry
|
||||
2. Deploy - Pulls image from registry and deploys to Kubernetes (on any push)
|
||||
3. Notify - Sends deployment status
|
||||
|
||||
### 4. First Deployment
|
||||
|
||||
```bash
|
||||
# Commit and push
|
||||
git add .
|
||||
git commit -m "Initial deployment setup"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
The workflow will automatically:
|
||||
- Install dependencies
|
||||
- Run tests
|
||||
- Build the application
|
||||
- Create Docker image
|
||||
- Push image to container registry (gitea.vidoks.fr)
|
||||
- Pull image from registry and deploy to Kubernetes cluster
|
||||
|
||||
### 5. Monitor Workflow
|
||||
|
||||
- Go to your Gitea repository
|
||||
- Click on "Actions" tab
|
||||
- View workflow runs and logs
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Docker Compose:**
|
||||
Edit `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PORT=3001
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
Edit `k8s/configmap.yaml`:
|
||||
```yaml
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
API_PORT: "3001"
|
||||
```
|
||||
|
||||
### Ingress Hostname
|
||||
|
||||
Edit `k8s/ingress.yaml` to change the hostname:
|
||||
```yaml
|
||||
spec:
|
||||
rules:
|
||||
- host: your-domain.com # Change this
|
||||
```
|
||||
|
||||
Update `/etc/hosts` for local testing:
|
||||
```bash
|
||||
echo "127.0.0.1 sqdc-dashboard.local" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Edit `k8s/deployment.yaml` to adjust resources:
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Adjust persistent volume size in `k8s/deployment.yaml`:
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi # Change this
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Issues
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
docker logs sqdc-dashboard
|
||||
docker inspect sqdc-dashboard
|
||||
```
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
# Find process using port
|
||||
lsof -i :8080
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Kubernetes Issues
|
||||
|
||||
**Pods not running:**
|
||||
```bash
|
||||
kubectl describe pod <pod-name> -n sqdc-dashboard
|
||||
kubectl logs <pod-name> -n sqdc-dashboard
|
||||
```
|
||||
|
||||
**Image pull errors:**
|
||||
```bash
|
||||
# Check if image exists
|
||||
docker images | grep sqdc-dashboard
|
||||
|
||||
# Load image into cluster (minikube)
|
||||
minikube image load sqdc-dashboard:latest
|
||||
|
||||
# Or use kind
|
||||
kind load docker-image sqdc-dashboard:latest
|
||||
```
|
||||
|
||||
**Service not accessible:**
|
||||
```bash
|
||||
# Check service endpoints
|
||||
kubectl get endpoints -n sqdc-dashboard
|
||||
|
||||
# Port-forward for testing
|
||||
kubectl port-forward svc/sqdc-dashboard-service 8080:80 -n sqdc-dashboard
|
||||
```
|
||||
|
||||
**Ingress not working:**
|
||||
```bash
|
||||
# Check ingress controller
|
||||
kubectl get pods -n ingress-nginx
|
||||
|
||||
# Check ingress resource
|
||||
kubectl describe ingress sqdc-dashboard-ingress -n sqdc-dashboard
|
||||
|
||||
# Verify nginx ingress controller is installed
|
||||
kubectl get ingressclass
|
||||
```
|
||||
|
||||
### Gitea Workflow Issues
|
||||
|
||||
**Workflow not triggering:**
|
||||
- Check if Actions is enabled
|
||||
- Verify branch names match trigger conditions
|
||||
- Check workflow file syntax
|
||||
|
||||
**Deployment fails:**
|
||||
- Verify KUBECONFIG secret is correctly set
|
||||
- Check kubectl has access to cluster
|
||||
- Review workflow logs in Gitea Actions tab
|
||||
|
||||
**Test failures:**
|
||||
```bash
|
||||
# Run tests locally
|
||||
npm test
|
||||
|
||||
# Skip tests (not recommended)
|
||||
# Modify workflow to add: -- --passWithNoTests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### View Application Logs
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker logs -f sqdc-dashboard
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
```bash
|
||||
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Check Health Status
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
curl http://localhost:8080
|
||||
curl http://localhost:3001/categories
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
```bash
|
||||
kubectl get pods -n sqdc-dashboard
|
||||
kubectl exec -it <pod-name> -n sqdc-dashboard -- wget -O- http://localhost/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup Database
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker cp sqdc-dashboard:/app/database/sqdc.db ./backup-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
```bash
|
||||
kubectl cp sqdc-dashboard/<pod-name>:/app/database/sqdc.db \
|
||||
./backup-$(date +%Y%m%d).db \
|
||||
-n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker cp ./backup.db sqdc-dashboard:/app/database/sqdc.db
|
||||
docker restart sqdc-dashboard
|
||||
```
|
||||
|
||||
**Kubernetes:**
|
||||
```bash
|
||||
kubectl cp ./backup.db sqdc-dashboard/<pod-name>:/app/database/sqdc.db \
|
||||
-n sqdc-dashboard
|
||||
kubectl rollout restart deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Kubernetes Documentation](https://kubernetes.io/docs/)
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [Gitea Actions](https://docs.gitea.io/en-us/actions/)
|
||||
- [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
|
||||
49
dashboard-sqdc/Dockerfile
Normal file
49
dashboard-sqdc/Dockerfile
Normal file
@ -0,0 +1,49 @@
|
||||
# Multi-stage build for optimized production image
|
||||
|
||||
# Stage 1: Build the React application
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including dev) needed for build
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Install only production dependencies for runtime
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Stage 2: Production image with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy database and server files for API
|
||||
COPY --from=builder /app/database /app/database
|
||||
COPY --from=builder /app/server.js /app/server.js
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
# Install Node.js in the nginx image to run the API server
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start both nginx and the API server
|
||||
CMD ["sh", "-c", "node /app/server.js & nginx -g 'daemon off;'"]
|
||||
24
dashboard-sqdc/Dockerfile.api
Normal file
24
dashboard-sqdc/Dockerfile.api
Normal file
@ -0,0 +1,24 @@
|
||||
# API Server Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy database and server files
|
||||
COPY database ./database
|
||||
COPY server.js .
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/categories', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||
|
||||
# Start the API server
|
||||
CMD ["node", "server.js"]
|
||||
35
dashboard-sqdc/Dockerfile.frontend
Normal file
35
dashboard-sqdc/Dockerfile.frontend
Normal file
@ -0,0 +1,35 @@
|
||||
# Multi-stage build for React frontend with Nginx
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including dev) needed for build
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production image with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
46
dashboard-sqdc/README.md
Normal file
46
dashboard-sqdc/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
205
dashboard-sqdc/SETUP-REGISTRY.md
Normal file
205
dashboard-sqdc/SETUP-REGISTRY.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Registry and CI/CD Setup Guide
|
||||
|
||||
This guide walks you through setting up the container registry and CI/CD pipeline for the SQDC Dashboard.
|
||||
|
||||
## Registry Information
|
||||
|
||||
- **Registry URL**: `gitea.vidoks.fr`
|
||||
- **Repository**: `sortifal/pfee`
|
||||
- **Full Image Path**: `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Gitea account with access to `sortifal/pfee` repository
|
||||
2. Kubernetes cluster (k3s) with kubectl configured
|
||||
3. Registry credentials (username and password/token)
|
||||
|
||||
## Step 1: Configure Gitea Secrets
|
||||
|
||||
Go to your Gitea repository: **Settings → Secrets → Actions**
|
||||
|
||||
Add the following secrets:
|
||||
|
||||
### Required Secrets
|
||||
|
||||
| Secret Name | Description | Example Value |
|
||||
|------------|-------------|---------------|
|
||||
| `KUBE_CONFIG` | Plain text kubeconfig for k3s | Contents of `~/.kube/config` |
|
||||
| `REGISTRY_URL` | Container registry URL | `gitea.vidoks.fr` |
|
||||
| `REGISTRY_USER` | Registry username | Your Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Registry password or token | Your Gitea password/token |
|
||||
|
||||
### How to get KUBE_CONFIG
|
||||
|
||||
```bash
|
||||
# Display your kubeconfig
|
||||
cat ~/.kube/config
|
||||
|
||||
# Copy the entire output and paste it as the KUBE_CONFIG secret
|
||||
```
|
||||
|
||||
### How to create a Gitea Token
|
||||
|
||||
1. Go to your Gitea profile → Settings → Applications
|
||||
2. Create a new token with `write:package` permission
|
||||
3. Use this token as `REGISTRY_PASSWORD`
|
||||
|
||||
## Step 2: Verify Workflow Configuration
|
||||
|
||||
The workflow file at [.gitea/workflows/build-deploy.yml](.gitea/workflows/build-deploy.yml) is already configured with:
|
||||
|
||||
- Image path: `gitea.vidoks.fr/sortifal/pfee`
|
||||
- Triggers: Push to `main` or `dashboard` branches
|
||||
- Build, deploy, and notify jobs
|
||||
|
||||
## Step 3: Test Local Build (Optional)
|
||||
|
||||
Before pushing, you can test the Docker build locally:
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t gitea.vidoks.fr/sortifal/pfee:test .
|
||||
|
||||
# Test run locally
|
||||
docker run -p 8080:80 -p 3001:3001 gitea.vidoks.fr/sortifal/pfee:test
|
||||
```
|
||||
|
||||
## Step 4: Manual Registry Push (Optional)
|
||||
|
||||
If you want to manually push to the registry:
|
||||
|
||||
```bash
|
||||
# Login to registry
|
||||
docker login gitea.vidoks.fr -u <username>
|
||||
|
||||
# Build and tag
|
||||
docker build -t gitea.vidoks.fr/sortifal/pfee:latest .
|
||||
|
||||
# Push to registry
|
||||
docker push gitea.vidoks.fr/sortifal/pfee:latest
|
||||
```
|
||||
|
||||
## Step 5: Deploy to Kubernetes
|
||||
|
||||
### Option A: Using the Deployment Script
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh gitea.vidoks.fr <username> <password> sortifal/pfee
|
||||
```
|
||||
|
||||
### Option B: Manual Deployment
|
||||
|
||||
```bash
|
||||
# Create namespace
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# Create registry credentials secret
|
||||
kubectl create secret docker-registry registry-credentials \
|
||||
--docker-server=gitea.vidoks.fr \
|
||||
--docker-username=<your-username> \
|
||||
--docker-password=<your-password> \
|
||||
-n sqdc-dashboard
|
||||
|
||||
# Apply manifests
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
kubectl apply -f k8s/service.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
## Step 6: Trigger CI/CD Pipeline
|
||||
|
||||
Once secrets are configured, simply push to trigger the pipeline:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: Configure CI/CD with registry"
|
||||
git push origin dashboard
|
||||
```
|
||||
|
||||
The workflow will:
|
||||
1. Install dependencies and run tests
|
||||
2. Build the React application
|
||||
3. Build Docker image and push to `gitea.vidoks.fr/sortifal/pfee`
|
||||
4. Deploy to Kubernetes cluster
|
||||
5. Update deployment with the new image
|
||||
6. Verify deployment status
|
||||
|
||||
## Monitoring Deployment
|
||||
|
||||
### View Workflow Logs
|
||||
|
||||
1. Go to your Gitea repository
|
||||
2. Click on "Actions" tab
|
||||
3. Select the workflow run
|
||||
4. View logs for each job (build, deploy, notify)
|
||||
|
||||
### Check Kubernetes Status
|
||||
|
||||
```bash
|
||||
# Check pods
|
||||
kubectl get pods -n sqdc-dashboard
|
||||
|
||||
# Check deployment
|
||||
kubectl get deployment -n sqdc-dashboard
|
||||
|
||||
# Check service
|
||||
kubectl get svc -n sqdc-dashboard
|
||||
|
||||
# Check ingress
|
||||
kubectl get ingress -n sqdc-dashboard
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Image Pull Errors
|
||||
|
||||
If pods show `ImagePullBackOff`:
|
||||
|
||||
```bash
|
||||
# Check if secret exists
|
||||
kubectl get secret registry-credentials -n sqdc-dashboard
|
||||
|
||||
# Describe the secret
|
||||
kubectl describe secret registry-credentials -n sqdc-dashboard
|
||||
|
||||
# Recreate the secret
|
||||
kubectl delete secret registry-credentials -n sqdc-dashboard
|
||||
kubectl create secret docker-registry registry-credentials \
|
||||
--docker-server=gitea.vidoks.fr \
|
||||
--docker-username=<username> \
|
||||
--docker-password=<password> \
|
||||
-n sqdc-dashboard
|
||||
```
|
||||
|
||||
### Workflow Authentication Errors
|
||||
|
||||
If the workflow fails during image push:
|
||||
|
||||
1. Verify `REGISTRY_USER` and `REGISTRY_PASSWORD` secrets are correct
|
||||
2. Ensure the token has `write:package` permission
|
||||
3. Check registry URL matches exactly: `gitea.vidoks.fr`
|
||||
|
||||
### Kubectl Connection Errors
|
||||
|
||||
If deployment step fails:
|
||||
|
||||
1. Verify `KUBE_CONFIG` secret contains valid kubeconfig
|
||||
2. Ensure the config is in plain text (not base64 encoded)
|
||||
3. Check cluster is accessible from Gitea Actions runner
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once deployment is successful:
|
||||
|
||||
1. Access the application via the ingress URL
|
||||
2. Set up monitoring and alerts
|
||||
3. Configure backup procedures for the database
|
||||
4. Review and adjust resource limits based on usage
|
||||
|
||||
For detailed documentation, see [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||
17
dashboard-sqdc/components.json
Normal file
17
dashboard-sqdc/components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
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.
27
dashboard-sqdc/docker-compose.yml
Normal file
27
dashboard-sqdc/docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dashboard:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sqdc-dashboard
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
# Mount database directory for persistence
|
||||
- ./database:/app/database
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: sqdc-network
|
||||
60
dashboard-sqdc/k8s/api-deployment.yaml
Normal file
60
dashboard-sqdc/k8s/api-deployment.yaml
Normal file
@ -0,0 +1,60 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: sqdc-api
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: sqdc-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: sqdc-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: gitea.vidoks.fr/sortifal/pfee:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
name: api
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/categories
|
||||
port: 3001
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/categories
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
volumeMounts:
|
||||
- name: database
|
||||
mountPath: /app/database
|
||||
volumes:
|
||||
- name: database
|
||||
persistentVolumeClaim:
|
||||
claimName: sqdc-database-pvc
|
||||
imagePullSecrets:
|
||||
- name: registry-credentials
|
||||
16
dashboard-sqdc/k8s/api-service.yaml
Normal file
16
dashboard-sqdc/k8s/api-service.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: sqdc-api
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: 3001
|
||||
protocol: TCP
|
||||
name: api
|
||||
selector:
|
||||
app: sqdc-api
|
||||
9
dashboard-sqdc/k8s/configmap.yaml
Normal file
9
dashboard-sqdc/k8s/configmap.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: sqdc-dashboard-config
|
||||
namespace: sqdc-dashboard
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
API_PORT: "3001"
|
||||
# Add other environment variables as needed
|
||||
58
dashboard-sqdc/k8s/db-init-job.yaml
Normal file
58
dashboard-sqdc/k8s/db-init-job.yaml
Normal file
@ -0,0 +1,58 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: sqdc-db-init
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-api
|
||||
spec:
|
||||
backoffLimit: 3
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: sqdc-api-init
|
||||
spec:
|
||||
serviceAccountName: sqdc-db-init
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: db-init
|
||||
image: gitea.vidoks.fr/sortifal/pfee:latest
|
||||
imagePullPolicy: Always
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Starting database initialization..."
|
||||
|
||||
if [ ! -f /app/database/sqdc.db ]; then
|
||||
echo "Creating new database from schema..."
|
||||
sqlite3 /app/database/sqdc.db < /app/database/schema.sql
|
||||
|
||||
echo "Populating database with sample data..."
|
||||
python3 /app/database/populate_db.py
|
||||
|
||||
echo "✅ Database initialized successfully"
|
||||
else
|
||||
echo "✅ Database already exists, skipping initialization"
|
||||
fi
|
||||
|
||||
echo "Verifying database integrity..."
|
||||
sqlite3 /app/database/sqdc.db "SELECT COUNT(*) as table_count FROM sqlite_master WHERE type='table';"
|
||||
|
||||
echo "Database initialization complete"
|
||||
volumeMounts:
|
||||
- name: database
|
||||
mountPath: /app/database
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
volumes:
|
||||
- name: database
|
||||
persistentVolumeClaim:
|
||||
claimName: sqdc-database-pvc
|
||||
imagePullSecrets:
|
||||
- name: registry-credentials
|
||||
7
dashboard-sqdc/k8s/db-init-sa.yaml
Normal file
7
dashboard-sqdc/k8s/db-init-sa.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: sqdc-db-init
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-api
|
||||
50
dashboard-sqdc/k8s/frontend-deployment.yaml
Normal file
50
dashboard-sqdc/k8s/frontend-deployment.yaml
Normal file
@ -0,0 +1,50 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: sqdc-frontend
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: sqdc-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: sqdc-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: gitea.vidoks.fr/sortifal/pfee-frontend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
imagePullSecrets:
|
||||
- name: registry-credentials
|
||||
16
dashboard-sqdc/k8s/frontend-service.yaml
Normal file
16
dashboard-sqdc/k8s/frontend-service.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: sqdc-frontend
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: sqdc-frontend
|
||||
29
dashboard-sqdc/k8s/ingress.yaml
Normal file
29
dashboard-sqdc/k8s/ingress.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: sqdc-dashboard-ingress
|
||||
namespace: sqdc-dashboard
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /$2
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: diwii.sortifal.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /api(/|$)(.*)
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: sqdc-api
|
||||
port:
|
||||
number: 3001
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: sqdc-frontend
|
||||
port:
|
||||
number: 80
|
||||
28
dashboard-sqdc/k8s/kustomization.yaml
Normal file
28
dashboard-sqdc/k8s/kustomization.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: sqdc-dashboard
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- pvc.yaml
|
||||
- db-init-sa.yaml
|
||||
- db-init-job.yaml
|
||||
- api-deployment.yaml
|
||||
- api-service.yaml
|
||||
- frontend-deployment.yaml
|
||||
- frontend-service.yaml
|
||||
- ingress.yaml
|
||||
- configmap.yaml
|
||||
|
||||
commonLabels:
|
||||
managed-by: kustomize
|
||||
|
||||
# Image tags will be set via kustomize edit during deployment
|
||||
images:
|
||||
- name: gitea.vidoks.fr/sortifal/pfee
|
||||
newTag: latest
|
||||
newName: gitea.vidoks.fr/sortifal/pfee
|
||||
- name: gitea.vidoks.fr/sortifal/pfee-frontend
|
||||
newTag: latest
|
||||
newName: gitea.vidoks.fr/sortifal/pfee-frontend
|
||||
7
dashboard-sqdc/k8s/namespace.yaml
Normal file
7
dashboard-sqdc/k8s/namespace.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: sqdc-dashboard
|
||||
labels:
|
||||
name: sqdc-dashboard
|
||||
environment: production
|
||||
13
dashboard-sqdc/k8s/pvc.yaml
Normal file
13
dashboard-sqdc/k8s/pvc.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: sqdc-database-pvc
|
||||
namespace: sqdc-dashboard
|
||||
labels:
|
||||
app: sqdc-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
23
dashboard-sqdc/k8s/registry-secret.yaml
Normal file
23
dashboard-sqdc/k8s/registry-secret.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
# Registry credentials secret
|
||||
# This is a template - create the actual secret using:
|
||||
# kubectl create secret docker-registry registry-credentials \
|
||||
# --docker-server=<REGISTRY_URL> \
|
||||
# --docker-username=<REGISTRY_USER> \
|
||||
# --docker-password=<REGISTRY_PASSWORD> \
|
||||
# -n sqdc-dashboard
|
||||
|
||||
# Or use this manifest (replace values):
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: registry-credentials
|
||||
namespace: sqdc-dashboard
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
data:
|
||||
.dockerconfigjson: <base64-encoded-docker-config>
|
||||
# To generate the base64 encoded value:
|
||||
# kubectl create secret docker-registry registry-credentials \
|
||||
# --docker-server=REGISTRY_URL \
|
||||
# --docker-username=REGISTRY_USER \
|
||||
# --docker-password=REGISTRY_PASSWORD \
|
||||
# --dry-run=client -o yaml | grep .dockerconfigjson
|
||||
32
dashboard-sqdc/nginx.conf
Normal file
32
dashboard-sqdc/nginx.conf
Normal file
@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# Static assets with caching
|
||||
location /static/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# React app - handle client-side routing (must be last)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
}
|
||||
19902
dashboard-sqdc/package-lock.json
generated
Normal file
19902
dashboard-sqdc/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
dashboard-sqdc/package.json
Normal file
70
dashboard-sqdc/package.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "dashboard-sqdc",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.2.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^3.4.18"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"server": "node server.js",
|
||||
"populate-db": "python3 database/populate_db.py"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
dashboard-sqdc/postcss.config.js
Normal file
6
dashboard-sqdc/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
dashboard-sqdc/public/favicon.ico
Normal file
BIN
dashboard-sqdc/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
dashboard-sqdc/public/index.html
Normal file
43
dashboard-sqdc/public/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
dashboard-sqdc/public/logo192.png
Normal file
BIN
dashboard-sqdc/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
dashboard-sqdc/public/logo512.png
Normal file
BIN
dashboard-sqdc/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
dashboard-sqdc/public/manifest.json
Normal file
25
dashboard-sqdc/public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
dashboard-sqdc/public/robots.txt
Normal file
3
dashboard-sqdc/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
79
dashboard-sqdc/scripts/deploy.sh
Executable file
79
dashboard-sqdc/scripts/deploy.sh
Executable file
@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SQDC Dashboard Deployment Script
|
||||
# Usage: ./scripts/deploy.sh [registry_url] [registry_user] [registry_password] [repository_path]
|
||||
|
||||
set -e
|
||||
|
||||
REGISTRY_URL=${1:-"gitea.vidoks.fr"}
|
||||
REGISTRY_USER=${2}
|
||||
REGISTRY_PASSWORD=${3}
|
||||
REPOSITORY_PATH=${4:-"sortifal/pfee"}
|
||||
NAMESPACE="sqdc-dashboard"
|
||||
|
||||
echo "🚀 Deploying SQDC Dashboard..."
|
||||
|
||||
# Check if registry credentials are provided
|
||||
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then
|
||||
echo "⚠️ Warning: Registry credentials not provided. Using local image."
|
||||
REGISTRY_URL="local"
|
||||
fi
|
||||
|
||||
# Build Docker image
|
||||
echo "📦 Building Docker image..."
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
if [ "$REGISTRY_URL" != "local" ]; then
|
||||
# Login to registry
|
||||
echo "🔐 Logging in to registry..."
|
||||
echo "$REGISTRY_PASSWORD" | docker login $REGISTRY_URL -u $REGISTRY_USER --password-stdin
|
||||
|
||||
# Build and push to registry
|
||||
IMAGE_NAME="$REGISTRY_URL/$REPOSITORY_PATH"
|
||||
docker build -t $IMAGE_NAME:$TIMESTAMP .
|
||||
docker tag $IMAGE_NAME:$TIMESTAMP $IMAGE_NAME:latest
|
||||
docker push $IMAGE_NAME:$TIMESTAMP
|
||||
docker push $IMAGE_NAME:latest
|
||||
echo "✅ Docker image pushed: $IMAGE_NAME:$TIMESTAMP"
|
||||
else
|
||||
# Build local image
|
||||
docker build -t sqdc-dashboard:latest .
|
||||
docker tag sqdc-dashboard:latest sqdc-dashboard:$TIMESTAMP
|
||||
echo "✅ Docker image built: sqdc-dashboard:$TIMESTAMP"
|
||||
fi
|
||||
|
||||
# Apply Kubernetes manifests
|
||||
echo "☸️ Applying Kubernetes manifests..."
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# Create registry secret if credentials provided
|
||||
if [ "$REGISTRY_URL" != "local" ]; then
|
||||
echo "🔑 Creating registry credentials secret..."
|
||||
kubectl create secret docker-registry registry-credentials \
|
||||
--docker-server=$REGISTRY_URL \
|
||||
--docker-username=$REGISTRY_USER \
|
||||
--docker-password=$REGISTRY_PASSWORD \
|
||||
-n $NAMESPACE \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
fi
|
||||
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
kubectl apply -f k8s/service.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# Wait for rollout
|
||||
echo "⏳ Waiting for deployment to complete..."
|
||||
kubectl rollout status deployment/sqdc-dashboard -n $NAMESPACE --timeout=5m
|
||||
|
||||
# Display deployment info
|
||||
echo ""
|
||||
echo "📊 Deployment Status:"
|
||||
kubectl get pods -n $NAMESPACE
|
||||
echo ""
|
||||
kubectl get svc -n $NAMESPACE
|
||||
echo ""
|
||||
kubectl get ingress -n $NAMESPACE
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo "🌐 Access the dashboard at: http://sqdc-dashboard.local"
|
||||
18
dashboard-sqdc/scripts/rollback.sh
Executable file
18
dashboard-sqdc/scripts/rollback.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SQDC Dashboard Rollback Script
|
||||
# Usage: ./scripts/rollback.sh
|
||||
|
||||
set -e
|
||||
|
||||
NAMESPACE="sqdc-dashboard"
|
||||
|
||||
echo "⏪ Rolling back SQDC Dashboard deployment..."
|
||||
|
||||
kubectl rollout undo deployment/sqdc-dashboard -n $NAMESPACE
|
||||
|
||||
echo "⏳ Waiting for rollback to complete..."
|
||||
kubectl rollout status deployment/sqdc-dashboard -n $NAMESPACE --timeout=5m
|
||||
|
||||
echo "✅ Rollback complete!"
|
||||
kubectl get pods -n $NAMESPACE
|
||||
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);
|
||||
});
|
||||
145
dashboard-sqdc/src/App.css
Normal file
145
dashboard-sqdc/src/App.css
Normal file
@ -0,0 +1,145 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
opacity: 0.9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* NAVIGATION */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow-x: auto;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
}
|
||||
|
||||
/* CONTENT */
|
||||
.app-content {
|
||||
flex: 1;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* CHARTS PAGE */
|
||||
.charts-page {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.charts-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.charts-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.charts-header p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.app-footer {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
margin-top: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 100px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
9
dashboard-sqdc/src/App.test.tsx
Normal file
9
dashboard-sqdc/src/App.test.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
160
dashboard-sqdc/src/App.tsx
Normal file
160
dashboard-sqdc/src/App.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { DetailPage } from './pages/DetailPage';
|
||||
import { TrendChart, CategoryDistributionChart, StatusChart, CNQChart } from './components/Charts';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
|
||||
import { Card } from './components/ui/card';
|
||||
import { Shield, Target, Clock, DollarSign, Wrench, BarChart3, Home } from 'lucide-react';
|
||||
|
||||
type TabType = 'home' | 'security' | 'quality' | 'delays' | 'costs' | 'maintenance' | 'charts';
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('home');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">SQDC Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Safety • Quality • Delivery • Cost • Maintenance
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="border-b bg-background sticky top-0 z-10">
|
||||
<div className="container mx-auto px-8">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)}>
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-1">
|
||||
<TabsTrigger
|
||||
value="home"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Safety
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="quality"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
Quality
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="delays"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Delivery
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="costs"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Cost
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="maintenance"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Maintenance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="charts"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="home" className="mt-0">
|
||||
<HomePage />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="mt-0">
|
||||
<DetailPage category="security" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quality" className="mt-0">
|
||||
<DetailPage category="quality" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="delays" className="mt-0">
|
||||
<DetailPage category="delays" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="costs" className="mt-0">
|
||||
<DetailPage category="costs" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="maintenance" className="mt-0">
|
||||
<DetailPage category="maintenance" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="charts" className="mt-0">
|
||||
<div className="container mx-auto p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics & Trends</h1>
|
||||
<p className="text-muted-foreground">Comprehensive performance analysis</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="p-6">
|
||||
<TrendChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<CategoryDistributionChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<StatusChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<CNQChart />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-card mt-12">
|
||||
<div className="container mx-auto px-8 py-6">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<p>SQDC Dashboard - Real-time Performance Monitoring</p>
|
||||
<p>Last updated: {new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
114
dashboard-sqdc/src/components/ChartModal.tsx
Normal file
114
dashboard-sqdc/src/components/ChartModal.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { LineChart } from 'lucide-react';
|
||||
|
||||
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 (!kpi) return null;
|
||||
|
||||
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: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: kpi.unit,
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
display: true,
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LineChart className="h-5 w-5" />
|
||||
{kpi.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Nombre de mesures: {measurements.length} | Période: {labels[0]} à {labels[labels.length - 1]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div style={{ height: '500px', width: '100%' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
274
dashboard-sqdc/src/components/Charts.tsx
Normal file
274
dashboard-sqdc/src/components/Charts.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
import React from 'react';
|
||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { kpiData } from '../data/kpiData';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
const commonOptions = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TrendChart: React.FC = () => {
|
||||
const data = {
|
||||
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Safety (%)',
|
||||
data: [94, 95, 96, 96],
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Quality (%)',
|
||||
data: [75, 76, 77, 78.5],
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Delivery (%)',
|
||||
data: [97, 97.5, 97.8, 98],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Cost (%)',
|
||||
data: [92, 91, 90.5, 89],
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">SQDC Trends</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Line
|
||||
data={data}
|
||||
options={{
|
||||
...commonOptions,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
beginAtZero: true,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CategoryDistributionChart: React.FC = () => {
|
||||
const categoryCounts = {
|
||||
'Safety': kpiData.security.length,
|
||||
'Quality': kpiData.quality.length,
|
||||
'Delivery': kpiData.delays.length,
|
||||
'Cost': kpiData.costs.length,
|
||||
'Maintenance': kpiData.maintenance.length
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: Object.keys(categoryCounts),
|
||||
datasets: [{
|
||||
data: Object.values(categoryCounts),
|
||||
backgroundColor: [
|
||||
'#ef4444',
|
||||
'#3b82f6',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#8b5cf6'
|
||||
],
|
||||
borderColor: '#1e293b',
|
||||
borderWidth: 2
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">KPI Distribution by Category</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Doughnut
|
||||
data={data}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusChart: React.FC = () => {
|
||||
const categories = ['Safety', 'Quality', 'Delivery', 'Cost', 'Maintenance'];
|
||||
const categoryKPIs = [
|
||||
kpiData.security,
|
||||
kpiData.quality,
|
||||
kpiData.delays,
|
||||
kpiData.costs,
|
||||
kpiData.maintenance
|
||||
];
|
||||
|
||||
const data = {
|
||||
labels: categories,
|
||||
datasets: [
|
||||
{
|
||||
label: 'On Target',
|
||||
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'good').length),
|
||||
backgroundColor: '#10b981'
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'warning').length),
|
||||
backgroundColor: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: 'Critical',
|
||||
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'critical').length),
|
||||
backgroundColor: '#ef4444'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">KPI Status by Category</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y' as const,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CNQChart: React.FC = () => {
|
||||
const data = {
|
||||
labels: ['Scrap', 'Rework', 'Returns'],
|
||||
datasets: [{
|
||||
label: 'Cost (€)',
|
||||
data: [8500, 7200, 2800],
|
||||
backgroundColor: [
|
||||
'#ef4444',
|
||||
'#f59e0b',
|
||||
'#3b82f6'
|
||||
],
|
||||
borderColor: '#1e293b',
|
||||
borderWidth: 2
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Non-Quality Costs</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
...commonOptions,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
dashboard-sqdc/src/components/ExportModal.tsx
Normal file
95
dashboard-sqdc/src/components/ExportModal.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Calendar, FileDown } from 'lucide-react';
|
||||
|
||||
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);
|
||||
|
||||
const handleExport = () => {
|
||||
if (selectedRange !== null) {
|
||||
onExport(selectedRange);
|
||||
setSelectedRange(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const ranges = [
|
||||
{ value: 7, label: 'Dernière semaine', sublabel: '7 jours', icon: Calendar },
|
||||
{ value: 30, label: 'Dernier mois', sublabel: '30 jours', icon: Calendar },
|
||||
{ value: 365, label: 'Cette année', sublabel: '365 jours', icon: Calendar },
|
||||
{ value: -1, label: 'Toutes les données', sublabel: 'Sans limite', icon: FileDown },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileDown className="h-5 w-5" />
|
||||
Exporter {kpiName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez la plage de données à exporter en Excel
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-4">
|
||||
{ranges.map((range) => {
|
||||
const Icon = range.icon;
|
||||
return (
|
||||
<button
|
||||
key={range.value}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg border-2 transition-all text-left ${
|
||||
selectedRange === range.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelectedRange(range.value)}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{range.label}</div>
|
||||
<div className="text-sm text-muted-foreground">{range.sublabel}</div>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedRange === range.value
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground'
|
||||
}`}>
|
||||
{selectedRange === range.value && (
|
||||
<div className="w-full h-full rounded-full bg-white scale-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={selectedRange === null}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Exporter
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
78
dashboard-sqdc/src/components/KPICard.tsx
Normal file
78
dashboard-sqdc/src/components/KPICard.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { TrendingUp, Minus } from 'lucide-react';
|
||||
|
||||
interface KPICardProps {
|
||||
kpi: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => {
|
||||
const getStatusVariant = () => {
|
||||
switch (kpi.status) {
|
||||
case 'good':
|
||||
return 'success';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'critical':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = () => {
|
||||
switch (kpi.status) {
|
||||
case 'good':
|
||||
return 'Bon';
|
||||
case 'warning':
|
||||
return 'Attention';
|
||||
case 'critical':
|
||||
return 'Critique';
|
||||
default:
|
||||
return 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = () => {
|
||||
if (!kpi.latest) return <Minus className="h-4 w-4" />;
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 hover:shadow-md transition-shadow" style={{ borderLeftColor: color }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base font-semibold" style={{ color }}>
|
||||
{kpi.name}
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground">
|
||||
{getTrendIcon()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold">{kpi.value}</div>
|
||||
<div className="text-sm text-muted-foreground">{kpi.unit}</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||
{kpi.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<Badge variant={getStatusVariant()}>
|
||||
{getStatusLabel()}
|
||||
</Badge>
|
||||
{kpi.target && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Obj: {kpi.target} {kpi.unit}
|
||||
</span>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
147
dashboard-sqdc/src/components/RangeChartModal.tsx
Normal file
147
dashboard-sqdc/src/components/RangeChartModal.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { LineChart } from 'lucide-react';
|
||||
|
||||
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 (!kpi) return null;
|
||||
|
||||
const filteredMeasurements = getMeasurementsForRange(selectedRange);
|
||||
|
||||
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: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: kpi.unit,
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
display: true,
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ranges = [
|
||||
{ value: 7, label: 'Semaine' },
|
||||
{ value: 30, label: 'Mois' },
|
||||
{ value: 90, label: 'Trimestre' },
|
||||
{ value: 365, label: 'Année' },
|
||||
{ value: -1, label: 'Tout' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LineChart className="h-5 w-5" />
|
||||
{kpi.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Mesures: {filteredMeasurements.length} | Période: {labels[0]} à {labels[labels.length - 1]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 flex-wrap py-2">
|
||||
{ranges.map((range) => (
|
||||
<Button
|
||||
key={range.value}
|
||||
variant={selectedRange === range.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedRange(range.value)}
|
||||
>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
<div style={{ height: '500px', width: '100%' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-red-600 text-white hover:bg-red-700",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-600 text-white hover:bg-emerald-700",
|
||||
warning:
|
||||
"border-transparent bg-amber-500 text-gray-900 hover:bg-amber-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
80
dashboard-sqdc/src/components/ui/card.tsx
Normal file
80
dashboard-sqdc/src/components/ui/card.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line jsx-a11y/heading-has-content
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
dashboard-sqdc/src/components/ui/dialog.tsx
Normal file
120
dashboard-sqdc/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
117
dashboard-sqdc/src/components/ui/table.tsx
Normal file
117
dashboard-sqdc/src/components/ui/table.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
53
dashboard-sqdc/src/components/ui/tabs.tsx
Normal file
53
dashboard-sqdc/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
342
dashboard-sqdc/src/data/kpiData.ts
Normal file
342
dashboard-sqdc/src/data/kpiData.ts
Normal file
@ -0,0 +1,342 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { KPI, CategoryData } from '../types';
|
||||
|
||||
export const kpiData: CategoryData = {
|
||||
security: [
|
||||
{
|
||||
id: 'tf',
|
||||
name: 'Taux de Fréquence',
|
||||
value: 0.5,
|
||||
unit: 'par 1M heures',
|
||||
category: 'security',
|
||||
target: 1.0,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer la fréquence des accidents avec arrêt.',
|
||||
formula: '(Nombre d\'Accidents avec Arrêt / Nombre d\'Heures Travaillées) × 1000000',
|
||||
data: [1.2, 0.9, 0.7, 0.5],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'incidents',
|
||||
name: 'Incidents/Near Miss',
|
||||
value: 12,
|
||||
unit: 'incidents',
|
||||
category: 'security',
|
||||
target: 8,
|
||||
status: 'warning',
|
||||
trend: 'up',
|
||||
description: 'Évaluer la culture de sécurité et la proactivité.',
|
||||
formula: 'Compte des rapports d\'incidents (sans blessure/dommage)',
|
||||
data: [8, 10, 11, 12],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'audit_compliance',
|
||||
name: 'Conformité Audits',
|
||||
value: 96,
|
||||
unit: '%',
|
||||
category: 'security',
|
||||
target: 95,
|
||||
status: 'good',
|
||||
trend: 'up',
|
||||
description: 'Mesurer le respect des procédures de sécurité.',
|
||||
formula: '(Points de Contrôle Conformes / Total Points de Contrôle) × 100',
|
||||
data: [93, 94, 95, 96],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
}
|
||||
],
|
||||
quality: [
|
||||
{
|
||||
id: 'scrap_rate',
|
||||
name: 'Taux de Rebut',
|
||||
value: 2.1,
|
||||
unit: '%',
|
||||
category: 'quality',
|
||||
target: 1.5,
|
||||
status: 'warning',
|
||||
trend: 'down',
|
||||
description: 'Mesurer le pourcentage d\'unités jetées (irréparables).',
|
||||
formula: '(Nombre d\'Unités Rebutées / Nombre Total d\'Unités Produites) × 100',
|
||||
data: [3.2, 2.8, 2.4, 2.1],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'rework_rate',
|
||||
name: 'Taux de Retouche',
|
||||
value: 3.8,
|
||||
unit: '%',
|
||||
category: 'quality',
|
||||
target: 2.0,
|
||||
status: 'warning',
|
||||
trend: 'down',
|
||||
description: 'Mesurer le pourcentage d\'unités nécessitant une reprise.',
|
||||
formula: '(Nombre d\'Unités Retouchées / Nombre Total d\'Unités Produites) × 100',
|
||||
data: [5.2, 4.6, 4.2, 3.8],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'dpu',
|
||||
name: 'Défauts par Unité (DPU)',
|
||||
value: 0.45,
|
||||
unit: 'défauts/unité',
|
||||
category: 'quality',
|
||||
target: 0.5,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer le nombre moyen de défauts par produit.',
|
||||
formula: 'Nombre Total de Défauts Trouvés / Nombre Total d\'Unités Inspectées',
|
||||
data: [0.68, 0.58, 0.52, 0.45],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'return_rate',
|
||||
name: 'Taux de Retours Clients',
|
||||
value: 1.2,
|
||||
unit: '%',
|
||||
category: 'quality',
|
||||
target: 0.8,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer l\'impact de la non-qualité chez le client.',
|
||||
formula: '(Nombre d\'Unités Retournées / Nombre Total d\'Unités Vendues) × 100',
|
||||
data: [2.1, 1.8, 1.5, 1.2],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'trs',
|
||||
name: 'TRS (Rendement Synthétique)',
|
||||
value: 78.5,
|
||||
unit: '%',
|
||||
category: 'quality',
|
||||
target: 85,
|
||||
status: 'warning',
|
||||
trend: 'up',
|
||||
description: 'Rendement global de la ligne de production.',
|
||||
formula: 'Nb pièces bonnes × Temps cycle / Temps d\'ouverture',
|
||||
data: [75.2, 76.5, 77.8, 78.5],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'oee',
|
||||
name: 'OEE (Overall Equipment Effectiveness)',
|
||||
value: 72.3,
|
||||
unit: '%',
|
||||
category: 'quality',
|
||||
target: 80,
|
||||
status: 'warning',
|
||||
trend: 'up',
|
||||
description: 'Efficacité combinée (Disponibilité, Performance, Qualité).',
|
||||
formula: 'Disponibilité × Performance × Qualité',
|
||||
data: [68.5, 70.2, 71.5, 72.3],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
}
|
||||
],
|
||||
delays: [
|
||||
{
|
||||
id: 'schedule_adherence',
|
||||
name: 'Respect du Plan',
|
||||
value: 98,
|
||||
unit: '%',
|
||||
category: 'delays',
|
||||
target: 95,
|
||||
status: 'good',
|
||||
trend: 'up',
|
||||
description: 'Mesurer la capacité à atteindre le volume planifié.',
|
||||
formula: '(Quantité Réellement Produite / Quantité Planifiée) × 100',
|
||||
data: [96.5, 97.2, 97.6, 98.0],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'cycle_time',
|
||||
name: 'Temps de Cycle',
|
||||
value: 45,
|
||||
unit: 'min/unité',
|
||||
category: 'delays',
|
||||
target: 50,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer le temps nécessaire pour assembler une unité.',
|
||||
formula: 'Temps Total de Production / Nombre Total d\'Unités Produites',
|
||||
data: [52, 49, 47, 45],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'tack_time',
|
||||
name: 'Tack Time',
|
||||
value: 50,
|
||||
unit: 'min/unité',
|
||||
category: 'delays',
|
||||
target: 50,
|
||||
status: 'good',
|
||||
trend: 'stable',
|
||||
description: 'Temps de production requis par unité demandée.',
|
||||
formula: 'Temps de production / Nombre de pièces demandées',
|
||||
data: [50, 50, 50, 50],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'downtime',
|
||||
name: 'Temps d\'Arrêt Imprévu',
|
||||
value: 2.5,
|
||||
unit: 'h/jour',
|
||||
category: 'delays',
|
||||
target: 1.5,
|
||||
status: 'warning',
|
||||
trend: 'up',
|
||||
description: 'Mesurer le temps d\'arrêt non planifié de la ligne.',
|
||||
formula: 'Somme des Périodes d\'Arrêt Non Planifié',
|
||||
data: [1.8, 2.1, 2.3, 2.5],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
}
|
||||
],
|
||||
costs: [
|
||||
{
|
||||
id: 'cpu',
|
||||
name: 'Coût par Unité',
|
||||
value: 245,
|
||||
unit: '€',
|
||||
category: 'costs',
|
||||
target: 240,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer l\'efficacité des coûts de production.',
|
||||
formula: 'Coût Total de Production / Nombre Total d\'Unités Produites',
|
||||
data: [258, 252, 248, 245],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'labor_productivity',
|
||||
name: 'Productivité MOD',
|
||||
value: 8.2,
|
||||
unit: 'unités/h',
|
||||
category: 'costs',
|
||||
target: 8.0,
|
||||
status: 'good',
|
||||
trend: 'up',
|
||||
description: 'Mesurer l\'efficacité de l\'équipe d\'assemblage.',
|
||||
formula: 'Nombre d\'Unités Produites / Total Heures Main-d\'œuvre Directe',
|
||||
data: [7.8, 8.0, 8.1, 8.2],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'cnq',
|
||||
name: 'Coût des Non-Qualité',
|
||||
value: 18500,
|
||||
unit: '€',
|
||||
category: 'costs',
|
||||
target: 10000,
|
||||
status: 'critical',
|
||||
trend: 'up',
|
||||
description: 'Mesurer le coût des défauts (retouche, rebut, retours).',
|
||||
formula: 'Coût des Rebuts + Coût des Retouches + Retours clients',
|
||||
data: [15200, 16800, 17800, 18500],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
}
|
||||
],
|
||||
maintenance: [
|
||||
{
|
||||
id: 'mtbf',
|
||||
name: 'MTBF (Temps Moyen Entre Pannes)',
|
||||
value: 450,
|
||||
unit: 'heures',
|
||||
category: 'maintenance',
|
||||
target: 400,
|
||||
status: 'good',
|
||||
trend: 'up',
|
||||
description: 'Mesurer la fiabilité des équipements.',
|
||||
formula: 'Temps Total de Fonctionnement / Nombre Total de Pannes',
|
||||
data: [380, 400, 425, 450],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'mttr',
|
||||
name: 'MTTR (Temps Moyen de Réparation)',
|
||||
value: 2.1,
|
||||
unit: 'heures',
|
||||
category: 'maintenance',
|
||||
target: 2.5,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Mesurer la rapidité des interventions de maintenance.',
|
||||
formula: 'Temps Total de Réparation / Nombre Total de Pannes',
|
||||
data: [2.8, 2.5, 2.3, 2.1],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'pm_cm_ratio',
|
||||
name: 'Ratio Maintenance Préventive/Corrective',
|
||||
value: 65,
|
||||
unit: '%',
|
||||
category: 'maintenance',
|
||||
target: 70,
|
||||
status: 'good',
|
||||
trend: 'up',
|
||||
description: 'Évaluer la stratégie de maintenance (proactif vs réactif).',
|
||||
formula: 'Heures MP / (Heures MP + Heures MC)',
|
||||
data: [58, 61, 63, 65],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'preventive_plan',
|
||||
name: 'Achèvement Plan Préventif',
|
||||
value: 92,
|
||||
unit: '%',
|
||||
category: 'maintenance',
|
||||
target: 95,
|
||||
status: 'good',
|
||||
trend: 'stable',
|
||||
description: 'Mesurer le respect des programmes d\'entretien.',
|
||||
formula: '(Tâches MP Terminées / Tâches MP Planifiées) × 100',
|
||||
data: [92, 92, 92, 92],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
},
|
||||
{
|
||||
id: 'maintenance_cost_per_unit',
|
||||
name: 'Coût Maintenance/Unité',
|
||||
value: 28,
|
||||
unit: '€',
|
||||
category: 'maintenance',
|
||||
target: 30,
|
||||
status: 'good',
|
||||
trend: 'down',
|
||||
description: 'Relier les dépenses de maintenance à la production.',
|
||||
formula: 'Coûts Totaux de Maintenance / Nombre Total d\'Unités Produites',
|
||||
data: [32, 30, 29, 28],
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const getCategoryColor = (category: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
security: '#e74c3c',
|
||||
quality: '#3498db',
|
||||
delays: '#f39c12',
|
||||
costs: '#27ae60',
|
||||
maintenance: '#9b59b6'
|
||||
};
|
||||
return colors[category] || '#95a5a6';
|
||||
};
|
||||
|
||||
export const getCategoryName = (category: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
security: 'Sécurité',
|
||||
quality: 'Qualité',
|
||||
delays: 'Délais & Livraison',
|
||||
costs: 'Coûts',
|
||||
maintenance: 'Maintenance'
|
||||
};
|
||||
return names[category] || category;
|
||||
};
|
||||
|
||||
export const getCategoryEmoji = (category: string): string => {
|
||||
const emojis: Record<string, string> = {
|
||||
security: '🛡️',
|
||||
quality: '🎯',
|
||||
delays: '⏱️',
|
||||
costs: '💰',
|
||||
maintenance: '🔧'
|
||||
};
|
||||
return emojis[category] || '📊';
|
||||
};
|
||||
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
|
||||
};
|
||||
};
|
||||
52
dashboard-sqdc/src/index.css
Normal file
52
dashboard-sqdc/src/index.css
Normal file
@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--ring: 216 34% 17%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
19
dashboard-sqdc/src/index.tsx
Normal file
19
dashboard-sqdc/src/index.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
6
dashboard-sqdc/src/lib/utils.ts
Normal file
6
dashboard-sqdc/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
1
dashboard-sqdc/src/logo.svg
Normal file
1
dashboard-sqdc/src/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
301
dashboard-sqdc/src/pages/DetailPage.tsx
Normal file
301
dashboard-sqdc/src/pages/DetailPage.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getCategoryColor, getCategoryName } from '../data/kpiData';
|
||||
import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
|
||||
import { ChartModal } from '../components/ChartModal';
|
||||
import { ExportModal } from '../components/ExportModal';
|
||||
import { RangeChartModal } from '../components/RangeChartModal';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
||||
import { Download, LineChart } from 'lucide-react';
|
||||
|
||||
interface DetailPageProps {
|
||||
category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance';
|
||||
}
|
||||
|
||||
const categoryMap: Record<string, number> = {
|
||||
security: 1,
|
||||
quality: 2,
|
||||
delays: 3,
|
||||
costs: 4,
|
||||
maintenance: 5
|
||||
};
|
||||
|
||||
export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
|
||||
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);
|
||||
|
||||
const categoryKPIs = db.kpis.filter(kpi => kpi.category_id === categoryId);
|
||||
|
||||
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);
|
||||
|
||||
const exportToExcel = (kpi: any, measurements: any[]) => {
|
||||
if (!kpi || measurements.length === 0) return;
|
||||
|
||||
const data = measurements.map(m => ({
|
||||
Date: new Date(m.measurement_date).toLocaleString('fr-FR'),
|
||||
Valeur: m.value,
|
||||
Statut: m.status,
|
||||
}));
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Mesures');
|
||||
|
||||
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é');
|
||||
|
||||
const filename = `${kpi.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
};
|
||||
|
||||
const handleExportClick = (days: number) => {
|
||||
if (!selectedKPI) return;
|
||||
|
||||
let measurementsToExport = selectedKPIMeasurements;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'good':
|
||||
return <Badge variant="success">On Target</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="warning">Warning</Badge>;
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">N/A</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="pb-4 border-b-2" style={{ borderColor: getCategoryColor(category) }}>
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-2" style={{ color: getCategoryColor(category) }}>
|
||||
{getCategoryName(category)}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">Detailed metric analysis and performance tracking</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* KPI Selector */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Sélectionner un KPI</CardTitle>
|
||||
<CardDescription>Choisissez un indicateur pour voir les détails</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={!selectedKPI}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exporter
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowChartRangeModal(true)}
|
||||
disabled={!selectedKPI}
|
||||
>
|
||||
<LineChart className="h-4 w-4 mr-2" />
|
||||
Graphique
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{categoryKPIs.map(kpi => (
|
||||
<button
|
||||
key={kpi.id}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedKPIId === kpi.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelectedKPIId(kpi.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{kpi.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{kpi.unit}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPI Details */}
|
||||
{selectedKPI && (
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* KPI Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selectedKPI.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Unité</div>
|
||||
<div className="font-medium">{selectedKPI.unit}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Cible</div>
|
||||
<div className="font-medium">{selectedKPI.target}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Fréquence</div>
|
||||
<div className="font-medium">{selectedKPI.frequency || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{selectedKPI.description}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Formule</h4>
|
||||
<p className="text-sm text-muted-foreground">{selectedKPI.formula}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
{selectedKPIStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques (30 derniers jours)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.avg?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Moyenne</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Min</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Max</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.count || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Mesures</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Measurements Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dernières mesures ({selectedKPIMeasurements.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Valeur</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedKPIMeasurements.slice(-10).reverse().map((m: any, idx: number) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{new Date(m.measurement_date).toLocaleString('fr-FR')}</TableCell>
|
||||
<TableCell>{m.value}</TableCell>
|
||||
<TableCell>{getStatusBadge(m.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
301
dashboard-sqdc/src/pages/HomePage.tsx
Normal file
301
dashboard-sqdc/src/pages/HomePage.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { KPICard } from '../components/KPICard';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { getCategoryColor } from '../data/kpiData';
|
||||
import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
|
||||
import { AlertTriangle, TrendingUp, BarChart3, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const db = useSQLiteDatabase();
|
||||
const [timeRange, setTimeRange] = useState<'today' | 'week' | 'last7' | 'month' | 'year'>('today');
|
||||
const [stats, setStats] = useState({ total: 0, good: 0, warning: 0, critical: 0 });
|
||||
const [topKPIs, setTopKPIs] = useState<any>({});
|
||||
const [avgPerformance, setAvgPerformance] = useState(0);
|
||||
const [criticalAlerts, setCriticalAlerts] = useState<any[]>([]);
|
||||
|
||||
const getDaysFromTimeRange = (range: 'today' | 'week' | 'last7' | 'month' | 'year'): number => {
|
||||
switch (range) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (db.loading || db.kpis.length === 0) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
const days = getDaysFromTimeRange(timeRange);
|
||||
const kpisWithStatus: any[] = [];
|
||||
const alertList: any[] = [];
|
||||
|
||||
for (const kpi of db.kpis) {
|
||||
const measurements = await db.getMeasurementsForKPI(kpi.id, days);
|
||||
|
||||
let status = 'good';
|
||||
let value = 0;
|
||||
|
||||
if (measurements && measurements.length > 0) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const topKPIsMap: any = {};
|
||||
[1, 2, 3, 4, 5].forEach(catId => {
|
||||
topKPIsMap[catId] = kpisWithStatus
|
||||
.filter(k => k.category_id === catId)
|
||||
.slice(0, 1);
|
||||
});
|
||||
|
||||
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, 3));
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [db, timeRange]);
|
||||
|
||||
if (db.loading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary"></div>
|
||||
<p className="text-center text-muted-foreground">Loading data...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (db.error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<Card className="border-2 border-destructive">
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive" />
|
||||
<p className="text-center text-destructive text-lg font-semibold">{db.error}</p>
|
||||
<p className="text-center text-sm text-muted-foreground max-w-md">
|
||||
Please ensure the API server is running: <code className="bg-muted px-2 py-1 rounded font-mono">npm run server</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'last7', label: 'Last 7D' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-180px)] overflow-hidden">
|
||||
<div className="container mx-auto p-6 h-full flex flex-col gap-4">
|
||||
{/* Compact Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Performance Overview</h1>
|
||||
<p className="text-sm text-muted-foreground">Real-time SQDC Metrics</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{timeRangeOptions.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={timeRange === option.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(option.value)}
|
||||
className="px-3"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Stats - Single Row */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<Card className="border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Total</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-xs text-muted-foreground">On Target</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-emerald-500">{stats.good}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-amber-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xs text-muted-foreground">Warning</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-500">{stats.warning}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-red-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-muted-foreground">Critical</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-primary/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs text-muted-foreground">Score</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">{avgPerformance}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
|
||||
{/* Left Column - Categories */}
|
||||
<div className="col-span-2 overflow-y-auto pr-2 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(catId => {
|
||||
const categoryKPIs = topKPIs[catId] || [];
|
||||
const category = db.categories.find(c => c.id === catId);
|
||||
|
||||
if (!category || categoryKPIs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={catId}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-1 w-8 rounded" style={{ backgroundColor: getCategoryColor(category.name) }}></div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: getCategoryColor(category.name) }}>
|
||||
{category.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{categoryKPIs.map((kpi: any) => (
|
||||
<KPICard
|
||||
key={kpi.id}
|
||||
kpi={kpi}
|
||||
color={getCategoryColor(category.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Alerts */}
|
||||
<div className="overflow-y-auto">
|
||||
<Card className="border-2 border-amber-500/30 bg-amber-500/5 h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Alerts
|
||||
{criticalAlerts.length > 0 && (
|
||||
<Badge variant="warning" className="ml-auto">
|
||||
{criticalAlerts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{criticalAlerts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">All metrics on target</p>
|
||||
</div>
|
||||
) : (
|
||||
criticalAlerts.map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="p-3 border-2 rounded-lg bg-card"
|
||||
>
|
||||
<Badge variant={alert.status === 'critical' ? 'destructive' : 'warning'} className="mb-2">
|
||||
{alert.status === 'critical' ? 'Critical' : 'Warning'}
|
||||
</Badge>
|
||||
<div className="font-semibold text-sm mb-1">{alert.name}</div>
|
||||
<div className="text-xl font-bold">
|
||||
{alert.value} <span className="text-xs text-muted-foreground font-normal">{alert.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
dashboard-sqdc/src/react-app-env.d.ts
vendored
Normal file
1
dashboard-sqdc/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
dashboard-sqdc/src/reportWebVitals.ts
Normal file
15
dashboard-sqdc/src/reportWebVitals.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
5
dashboard-sqdc/src/setupTests.ts
Normal file
5
dashboard-sqdc/src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
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;
|
||||
}
|
||||
}
|
||||
17
dashboard-sqdc/src/styles/Charts.css
Normal file
17
dashboard-sqdc/src/styles/Charts.css
Normal file
@ -0,0 +1,17 @@
|
||||
.chart-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-container h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
}
|
||||
325
dashboard-sqdc/src/styles/DetailPage.css
Normal file
325
dashboard-sqdc/src/styles/DetailPage.css
Normal file
@ -0,0 +1,325 @@
|
||||
.detail-page {
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* DETAIL HEADER */
|
||||
.detail-header {
|
||||
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 {
|
||||
font-size: 2.2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-header p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* DETAIL CONTENT */
|
||||
.detail-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* KPI SELECTOR */
|
||||
.kpi-selector {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
color: #333;
|
||||
height: fit-content;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.kpi-selector h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 1.2rem 0;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ACTION BUTTONS */
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
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;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.kpi-option:hover {
|
||||
border-color: #6496ff;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.kpi-option.active {
|
||||
background: #6496ff;
|
||||
color: white;
|
||||
border-color: #6496ff;
|
||||
}
|
||||
|
||||
.kpi-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* KPI DETAILS */
|
||||
.kpi-details {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
color: #333;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.details-header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-header h2 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 1.2rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.details-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
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;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* DESCRIPTION SECTION */
|
||||
.details-description {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.details-description h4 {
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
margin: 1.2rem 0 0.6rem 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.details-description p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* STATISTICS SECTION */
|
||||
.details-stats {
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.details-stats h4 {
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
margin: 0 0 1rem 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-box-label {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-box-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* MEASUREMENTS TABLE */
|
||||
.measurements-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.measurements-section h4 {
|
||||
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 */
|
||||
@media (max-width: 1024px) {
|
||||
.detail-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kpi-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.kpi-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
385
dashboard-sqdc/src/styles/HomePage.css
Normal file
385
dashboard-sqdc/src/styles/HomePage.css
Normal file
@ -0,0 +1,385 @@
|
||||
.home-page {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* STATS OVERVIEW */
|
||||
.stats-overview {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.overview-header h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* TIME RANGE SELECTOR */
|
||||
.time-range-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.time-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.time-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.time-btn.active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* CATEGORIES GRID - CÔTE À CÔTE */
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
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 {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ebf0 100%);
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.summary-section h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
border-left: 3px solid #ddd;
|
||||
}
|
||||
|
||||
.alert-item.critical {
|
||||
border-left-color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.05);
|
||||
}
|
||||
|
||||
.alert-item.warning {
|
||||
border-left-color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.05);
|
||||
}
|
||||
|
||||
.alert-item.good {
|
||||
border-left-color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.05);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.alert-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.score-section {
|
||||
text-align: center;
|
||||
padding: 0.8rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.score-circle {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
padding: 1rem;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
|
||||
.category-section h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.kpis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 1200px) {
|
||||
.kpis-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overview-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.overview-header h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.top-kpis-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kpis-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
97
dashboard-sqdc/src/styles/KPICard.css
Normal file
97
dashboard-sqdc/src/styles/KPICard.css
Normal file
@ -0,0 +1,97 @@
|
||||
.kpi-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
border-left: 4px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.kpi-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.8rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.kpi-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.kpi-trend {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.kpi-value-section {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.kpi-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.8rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-status {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-status.status-good {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.kpi-status.status-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.kpi-status.status-critical {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.kpi-target {
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.kpi-description {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
margin: 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;
|
||||
}
|
||||
}
|
||||
31
dashboard-sqdc/src/types/index.ts
Normal file
31
dashboard-sqdc/src/types/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Types pour les KPI et données
|
||||
export interface KPI {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number | string;
|
||||
unit: string;
|
||||
category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance';
|
||||
target?: number;
|
||||
status: 'good' | 'warning' | 'critical';
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
description: string;
|
||||
formula?: string;
|
||||
data?: number[];
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface CategoryData {
|
||||
security: KPI[];
|
||||
quality: KPI[];
|
||||
delays: KPI[];
|
||||
costs: KPI[];
|
||||
maintenance: KPI[];
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalKPIs: number;
|
||||
goodCount: number;
|
||||
warningCount: number;
|
||||
criticalCount: number;
|
||||
avgPerformance: number;
|
||||
}
|
||||
77
dashboard-sqdc/tailwind.config.js
Normal file
77
dashboard-sqdc/tailwind.config.js
Normal file
@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
26
dashboard-sqdc/tsconfig.json
Normal file
26
dashboard-sqdc/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user