Compare commits

...

18 Commits

Author SHA1 Message Date
Alexis Bruneteau
0c9f5b58e0 fix: Correct Ingress routing configuration for API and frontend
Some checks failed
Build and Deploy SQDC Dashboard / Build Docker Image (push) Successful in 1m59s
Build and Deploy SQDC Dashboard / Deploy to Kubernetes (push) Failing after 10s
- Reorder paths: /api must come before / (more specific first)
- Add path regex pattern /api(/|$)(.*) to match API requests
- Add rewrite-target annotation to strip /api prefix from backend requests
- Enable regex matching with use-regex: "true"
- Change API pathType to ImplementationSpecific for proper regex support

This fixes the 404 errors by ensuring:
1. /api/* requests are correctly routed to the API service
2. The /api prefix is stripped before reaching the Express backend
3. Frontend /* requests fall through to the React SPA

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 23:27:30 +02:00
Alexis Bruneteau
567245a5b0 fix: Resolve Kubernetes deployment issues with proper persistence and authentication
- Replace non-persistent emptyDir with PersistentVolumeClaim for database storage
- Add imagePullSecrets to both API and frontend deployments for private registry access
- Implement database initialization Job that creates schema and populates fake data
- Fix incomplete frontend-deployment.yaml YAML structure
- Add database initialization ServiceAccount with minimal privileges
- Ensure idempotent initialization (checks if DB exists before creating)
- Update kustomization.yaml to include all new resources in correct order

These changes ensure the deployment:
1. Persists database across pod restarts
2. Authenticates with private container registry
3. Automatically initializes the database with schema and sample KPI data
4. Follows DRY and KISS principles with single reusable init job

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 23:18:09 +02:00
Alexis Bruneteau
0493a7ef70 refactor: Split monolithic container into separate API and frontend deployments
ARCHITECTURE CHANGES:
- API service: Node.js server on port 3001 (Dockerfile.api)
- Frontend service: Nginx serving React on port 80 (Dockerfile.frontend)
- Each service has its own deployment, service, and replicas
- Ingress routes / to frontend and /api/ to API

KUBERNETES MANIFESTS:
- api-deployment.yaml: 2 replicas of Node.js API server
- api-service.yaml: ClusterIP service for API
- frontend-deployment.yaml: 2 replicas of Nginx frontend
- frontend-service.yaml: ClusterIP service for frontend
- Updated ingress.yaml: Routes traffic based on paths
- Updated kustomization.yaml: References new deployments

DOCKER IMAGES:
- Dockerfile.api: Minimal Node.js image for API (~200MB)
- Dockerfile.frontend: Nginx + React build (~50MB)
- Separate builds in workflow for independent versioning

NGINX CONFIGURATION:
- Removed API proxy (separate service now)
- Simplified config for static file serving only

BENEFITS:
- Independent scaling (can scale frontend/API separately)
- Smaller images with minimal base images
- API errors don't affect frontend availability
- Easier to update one service without affecting the other

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:28:26 +02:00
Alexis Bruneteau
7ae458f768 fix: Improve nginx configuration for React SPA routing
- Change server_name to '_' (catch-all for any domain)
- Move /static/ location before / to ensure static assets are matched first
- Move root fallback to last location block (must be last for proper priority)
- Change proxy_pass from 'localhost' to '127.0.0.1' for better container networking
- Add index.htm to index directive

These changes ensure:
1. Static assets are served with proper caching before catching with /index.html
2. Root fallback correctly handles React client-side routing
3. Proper nginx location block precedence

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:16:59 +02:00
Alexis Bruneteau
59e270e3ca fix: Remove 'build' from .dockerignore
The Dockerfile needs access to build/ directory during the COPY step. The .dockerignore was excluding it, causing nginx to have no static files to serve, resulting in 404 errors on /.

The Dockerfile correctly builds the React app in the build stage, but the exclusion prevented it from being used.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:11:29 +02:00
Alexis Bruneteau
02c41b9f4e fix: Replace PVC with emptyDir to resolve pod pending issue
- Remove PersistentVolumeClaim definition from deployment.yaml
- Replace persistentVolumeClaim volume with emptyDir for temporary storage
- Database files will be stored in pod memory/disk (ephemeral)
- Pods can now schedule without waiting for storage provisioning

Note: Data is not persistent across pod restarts. For production, implement proper storage class or migrate to persistent storage solution.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:59:13 +02:00
Alexis Bruneteau
9420975f76 fix: Install kustomize directly from GitHub instead of using external action
Replace imranismail/setup-kustomize action with direct kustomize installation from kubernetes-sigs GitHub repository. This avoids authentication issues with Gitea.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:50:50 +02:00
Alexis Bruneteau
00331c5d95 refactor: Use Kustomize for Kubernetes deployment management
- Update kustomization.yaml with newName field for proper image replacement
- Replace individual kubectl apply commands with kustomize build
- Use kustomize edit to dynamically set image tag from CI/CD pipeline
- Simplify deployment to single 'kubectl apply -k' command
- Consolidate verification steps

Benefits:
- Single source of truth for manifest configuration
- Declarative image tag management
- Easier to manage overlays for different environments
- Reduced template duplication

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:42:33 +02:00
Alexis Bruneteau
b98075df82 fix: Fix kubectl apply syntax for multiple files
kubectl apply -f does not accept multiple files as separate arguments on the same line. Changed to apply each manifest individually.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:42:00 +02:00
Alexis Bruneteau
b555203dc7 fix: Resolve Docker build failure by installing dev dependencies
- Change npm ci to install all dependencies (including devDependencies) during build stage
- Run npm ci --only=production after build to minimize final image size
- Fix CMD instruction to use JSON format (resolves Docker warning)

class-variance-authority, tailwindcss, postcss, and other build tools are required during the build process and were missing when using --only=production flag initially.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:20:36 +02:00
Alexis Bruneteau
20d0993b06 fix: Resolve ESLint errors to enable production build
- Remove unused getCategoryColor import from Charts.tsx (no-unused-vars)
- Remove unused CardDescription import from HomePage.tsx (no-unused-vars)
- Fix CardTitle type annotation (HTMLParagraphElement -> HTMLHeadingElement)
- Add eslint-disable-next-line for CardTitle heading-has-content (intentional generic wrapper component)

Build now compiles without errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:11:54 +02:00
Alexis Bruneteau
a918615eb4 fix: Resolve dependency conflict and optimize Gitea workflow
- Fix package-lock.json sync issue by installing yaml@2.8.1 to satisfy tailwindcss dependency requirements
- Remove redundant test step (--passWithNoTests defeats purpose)
- Consolidate kubectl apply commands into single operation
- Combine kubectl verification with deployment update step
- Remove separate notify job (redundant; use Gitea status checks)
- Add env variables at workflow level for DRY principle
- Improve Docker build efficiency with multi-tag in single command
- Add proper permissions to kubeconfig file
- Simplify action syntax (uses: vs name:)

This reduces workflow execution time and complexity while fixing npm ci errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:06:06 +02:00
Alexis Bruneteau
35614903d6 feat: Update k8s ingress configuration
- Change host to diwii.sortifal.dev
- Remove HTTPS/TLS configuration (handled upstream)
- Simplify ingress rules for HTTP-only routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 01:29:54 +02:00
Alexis Bruneteau
af378e0d0e fix: Configure Gitea workflow for monorepo structure
- Add working-directory default to build and deploy jobs
- Fix npm cache lookup to point to dashboard-sqdc/package-lock.json
- Ensure all Node.js and kubectl operations execute from correct directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 01:29:01 +02:00
Alexis Bruneteau
1ca02412a9 tag 2025-10-22 00:44:02 +02:00
Alexis Bruneteau
770c41d5e0 feat: Add Shadcn UI, dark theme, and complete Docker/K8s deployment setup
Major Changes:
- Migrate UI to Shadcn components with Tailwind CSS v3
- Implement dark theme as default with improved color scheme
- Optimize homepage layout to fit single screen without scrolling
- Fix chart visibility with explicit colors for dark mode

Deployment Infrastructure:
- Add Docker multi-stage build with Nginx + Node.js
- Create Kubernetes manifests (deployment, service, ingress, PVC)
- Configure Gitea CI/CD workflow with registry integration
- Add deployment scripts with registry support

CI/CD Configuration:
- Registry: gitea.vidoks.fr/sortifal/pfee
- Automatic build and push on commits
- Kubernetes deployment with image pull secrets
- Three-stage pipeline: build, deploy, notify

Documentation:
- Add DEPLOYMENT.md with comprehensive deployment guide
- Add SETUP-REGISTRY.md with step-by-step registry setup
- Add workflow README with troubleshooting guide
- Include configuration examples and best practices

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 00:42:32 +02:00
paul.roost
ca05e334a7 feat: Implement server-side API for categories, KPIs, and measurements
- Added Express server with SQLite database connection.
- Created API endpoints to fetch categories, KPIs, measurements, and statistics.
- Implemented error handling for database operations.

feat: Create ChartModal component for visualizing KPI data

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

feat: Add ExportModal component for exporting KPI data

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

feat: Introduce RangeChartModal for dynamic range selection

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

refactor: Create useSQLiteDatabase hook for data fetching

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

style: Add CSS styles for modals and charts

- Created styles for ChartModal, ExportModal, and RangeChartModal.
- Ensured responsive design for various screen sizes.
2025-10-21 13:31:14 +02:00
paul.roost
5ecda7eef7 feat: Add DetailPage and HomePage components with KPI visualization
- Implemented DetailPage for detailed KPI analysis including charts and status badges.
- Created HomePage to display an overview of KPIs categorized by security, quality, delays, costs, and maintenance.
- Introduced KPI types and data structures for better type safety.
- Added styles for DetailPage, HomePage, KPICard, and charts for improved UI.
- Integrated web vitals reporting and setup tests for better performance tracking and testing.
- Included a CSV file with mathematical formulas for KPI calculations.
2025-10-21 12:23:45 +02:00
77 changed files with 26265 additions and 0 deletions

221
.gitea/workflows/README.md Normal file
View 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).

View 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

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

View File

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

View File

@ -0,0 +1,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
View 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;'"]

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

View 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
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

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

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

View File

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

View File

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

View File

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

Binary file not shown.

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: sqdc-db-init
namespace: sqdc-dashboard
labels:
app: sqdc-api

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: sqdc-dashboard
labels:
name: sqdc-dashboard
environment: production

View 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

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

File diff suppressed because it is too large Load Diff

View 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"
]
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

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

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

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

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

145
dashboard-sqdc/src/App.css Normal file
View 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;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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] || '📊';
};

View File

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

View File

@ -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%;
}
}

View 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();

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

View 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

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

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

@ -0,0 +1 @@
/// <reference types="react-scripts" />

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

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

View File

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

View File

@ -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;
}

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

View File

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

View File

@ -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);
}
}

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

View File

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

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

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

View 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"
]
}