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>
This commit is contained in:
Alexis Bruneteau 2025-10-22 00:42:32 +02:00
parent ca05e334a7
commit 770c41d5e0
39 changed files with 3759 additions and 699 deletions

View File

@ -0,0 +1,15 @@
node_modules
build
.git
.gitignore
*.md
.env
.vscode
.idea
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
coverage
.eslintcache

View File

@ -0,0 +1,217 @@
# 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 `main` or `dashboard` branches
- **Pull Request** to `main` or `dashboard` branches
### 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 only on push to `main` or `dashboard` branches (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 main/dashboard
- 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:
```yaml
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dashboard'
```
### 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,109 @@
name: Build and Deploy SQDC Dashboard
on:
push:
branches:
- main
- dashboard
pull_request:
branches:
- main
- dashboard
jobs:
build:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --passWithNoTests
- name: Build application
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and Push Docker image
run: |
docker build -t ${{ secrets.REGISTRY_URL }}/sortifal/pfee:${{ github.sha }} .
docker tag ${{ secrets.REGISTRY_URL }}/sortifal/pfee:${{ github.sha }} ${{ secrets.REGISTRY_URL }}/sortifal/pfee:latest
docker push ${{ secrets.REGISTRY_URL }}/sortifal/pfee:${{ github.sha }}
docker push ${{ secrets.REGISTRY_URL }}/sortifal/pfee:latest
deploy:
name: Deploy to Kubernetes
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dashboard'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up kubectl
uses: azure/setup-kubectl@v3
with:
version: 'latest'
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG }}" > $HOME/.kube/config
- name: Create registry credentials secret
run: |
kubectl create secret docker-registry registry-credentials \
--docker-server=${{ secrets.REGISTRY_URL }} \
--docker-username=${{ secrets.REGISTRY_USER }} \
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
-n sqdc-dashboard \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy to Kubernetes
run: |
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
- name: Update deployment with new image
run: |
kubectl set image deployment/sqdc-dashboard dashboard=${{ secrets.REGISTRY_URL }}/sortifal/pfee:${{ github.sha }} -n sqdc-dashboard
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard --timeout=5m
- name: Verify deployment
run: |
kubectl get pods -n sqdc-dashboard
kubectl get svc -n sqdc-dashboard
kubectl get ingress -n sqdc-dashboard
notify:
name: Notify Deployment Status
runs-on: ubuntu-latest
needs: [build, deploy]
if: always()
steps:
- name: Deployment Status
run: |
if [ "${{ needs.deploy.result }}" == "success" ]; then
echo "✅ Deployment successful!"
else
echo "❌ Deployment failed!"
exit 1
fi

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 `main` or `dashboard` branch
- Pull requests to `main` or `dashboard` 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 (only on main/dashboard branch)
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/)

46
dashboard-sqdc/Dockerfile Normal file
View File

@ -0,0 +1,46 @@
# 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 dependencies
RUN npm ci --only=production
# 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
# 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,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,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,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,76 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: sqdc-dashboard
namespace: sqdc-dashboard
labels:
app: sqdc-dashboard
spec:
replicas: 2
selector:
matchLabels:
app: sqdc-dashboard
template:
metadata:
labels:
app: sqdc-dashboard
spec:
imagePullSecrets:
- name: registry-credentials
containers:
- name: dashboard
image: gitea.vidoks.fr/sortifal/pfee:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
protocol: TCP
- containerPort: 3001
name: api
protocol: TCP
env:
- name: NODE_ENV
value: "production"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
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
volumeMounts:
- name: database
mountPath: /app/database
volumes:
- name: database
persistentVolumeClaim:
claimName: sqdc-dashboard-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sqdc-dashboard-pvc
namespace: sqdc-dashboard
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: standard

View File

@ -0,0 +1,27 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sqdc-dashboard-ingress
namespace: sqdc-dashboard
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
rules:
- host: sqdc-dashboard.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sqdc-dashboard-service
port:
number: 80
# Uncomment for HTTPS
# tls:
# - hosts:
# - sqdc-dashboard.local
# secretName: sqdc-dashboard-tls

View File

@ -0,0 +1,19 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sqdc-dashboard
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- configmap.yaml
commonLabels:
app: sqdc-dashboard
managed-by: kustomize
images:
- name: gitea.vidoks.fr/sortifal/pfee
newTag: latest

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

View File

@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: sqdc-dashboard-service
namespace: sqdc-dashboard
labels:
app: sqdc-dashboard
spec:
type: ClusterIP
selector:
app: sqdc-dashboard
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
- name: api
port: 3001
targetPort: 3001
protocol: TCP

45
dashboard-sqdc/nginx.conf Normal file
View File

@ -0,0 +1,45 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 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;
# API proxy
location /api/ {
proxy_pass http://localhost:3001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React app - handle client-side routing
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";
}
# Static assets with caching
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 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;
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "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/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@ -21,13 +25,20 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21" "@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": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

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

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

View File

@ -2,7 +2,9 @@ import React, { useState } from 'react';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { DetailPage } from './pages/DetailPage'; import { DetailPage } from './pages/DetailPage';
import { TrendChart, CategoryDistributionChart, StatusChart, CNQChart } from './components/Charts'; import { TrendChart, CategoryDistributionChart, StatusChart, CNQChart } from './components/Charts';
import './App.css'; 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'; type TabType = 'home' | 'security' | 'quality' | 'delays' | 'costs' | 'maintenance' | 'charts';
@ -10,112 +12,146 @@ function App() {
const [activeTab, setActiveTab] = useState<TabType>('home'); const [activeTab, setActiveTab] = useState<TabType>('home');
return ( return (
<div className="app"> <div className="min-h-screen bg-background">
<header className="app-header"> {/* Header */}
<div className="header-content"> <header className="border-b bg-card">
<div className="header-title"> <div className="container mx-auto px-8 py-6">
<h1>📊 Dashboard SQDC</h1> <div className="flex items-center justify-between">
<p>Indicateurs de Performance - Sécurité, Qualité, Délais, Coûts</p> <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>
<div className="header-date"> <div className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('fr-FR', { {new Date().toLocaleDateString('en-US', {
weekday: 'long', weekday: 'short',
year: 'numeric', year: 'numeric',
month: 'long', month: 'short',
day: 'numeric' day: 'numeric'
})} })}
</div> </div>
</div> </div>
</div>
</header> </header>
<nav className="app-nav"> {/* Navigation */}
<button <div className="border-b bg-background sticky top-0 z-10">
className={`nav-btn ${activeTab === 'home' ? 'active' : ''}`} <div className="container mx-auto px-8">
onClick={() => setActiveTab('home')} <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"
> >
🏠 Accueil <Home className="h-4 w-4" />
</button> Overview
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'security' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('security')} 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"
> >
🛡 Sécurité <Shield className="h-4 w-4" />
</button> Safety
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'quality' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('quality')} 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"
> >
🎯 Qualité <Target className="h-4 w-4" />
</button> Quality
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'delays' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('delays')} 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"
> >
Délais <Clock className="h-4 w-4" />
</button> Delivery
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'costs' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('costs')} 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"
> >
💰 Coûts <DollarSign className="h-4 w-4" />
</button> Cost
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'maintenance' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('maintenance')} 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"
> >
🔧 Maintenance <Wrench className="h-4 w-4" />
</button> Maintenance
<button </TabsTrigger>
className={`nav-btn ${activeTab === 'charts' ? 'active' : ''}`} <TabsTrigger
onClick={() => setActiveTab('charts')} 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"
> >
📈 Analyses <BarChart3 className="h-4 w-4" />
</button> Analytics
</nav> </TabsTrigger>
</TabsList>
<main className="app-content"> <TabsContent value="home" className="mt-0">
{activeTab === 'home' && (
<HomePage /> <HomePage />
)} </TabsContent>
{activeTab === 'security' && ( <TabsContent value="security" className="mt-0">
<DetailPage category="security" /> <DetailPage category="security" />
)} </TabsContent>
{activeTab === 'quality' && ( <TabsContent value="quality" className="mt-0">
<DetailPage category="quality" /> <DetailPage category="quality" />
)} </TabsContent>
{activeTab === 'delays' && ( <TabsContent value="delays" className="mt-0">
<DetailPage category="delays" /> <DetailPage category="delays" />
)} </TabsContent>
{activeTab === 'costs' && ( <TabsContent value="costs" className="mt-0">
<DetailPage category="costs" /> <DetailPage category="costs" />
)} </TabsContent>
{activeTab === 'maintenance' && ( <TabsContent value="maintenance" className="mt-0">
<DetailPage category="maintenance" /> <DetailPage category="maintenance" />
)} </TabsContent>
{activeTab === 'charts' && ( <TabsContent value="charts" className="mt-0">
<div className="charts-page"> <div className="container mx-auto p-8 space-y-6">
<div className="charts-header"> <div>
<h1>📈 Analyses et Tendances</h1> <h1 className="text-3xl font-bold tracking-tight">Analytics & Trends</h1>
<p>Vue globale des tendances et des analyses comparatives</p> <p className="text-muted-foreground">Comprehensive performance analysis</p>
</div> </div>
<div className="charts-grid"> <div className="grid gap-6 md:grid-cols-2">
<Card className="p-6">
<TrendChart /> <TrendChart />
</Card>
<Card className="p-6">
<CategoryDistributionChart /> <CategoryDistributionChart />
</Card>
<Card className="p-6">
<StatusChart /> <StatusChart />
</Card>
<Card className="p-6">
<CNQChart /> <CNQChart />
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</div> </div>
</div> </div>
)}
</main>
<footer className="app-footer"> {/* Footer */}
<p>Dashboard SQDC | Dernière mise à jour: {new Date().toLocaleTimeString('fr-FR')} | Données en temps réel</p> <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> </footer>
</div> </div>
); );

View File

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import '../styles/ChartModal.css'; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { LineChart } from 'lucide-react';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
@ -13,9 +20,8 @@ interface ChartModalProps {
} }
export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurements, onClose }) => { export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurements, onClose }) => {
if (!isOpen || !kpi) return null; if (!kpi) return null;
// Préparer les données pour le graphique
const labels = measurements const labels = measurements
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR')) .map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
.reverse(); .reverse();
@ -30,13 +36,13 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
{ {
label: kpi.name, label: kpi.name,
data: values, data: values,
borderColor: '#6496ff', borderColor: '#60a5fa',
backgroundColor: 'rgba(100, 150, 255, 0.1)', backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.4, tension: 0.4,
fill: true, fill: true,
pointRadius: 4, pointRadius: 4,
pointBackgroundColor: '#6496ff', pointBackgroundColor: '#60a5fa',
pointBorderColor: '#fff', pointBorderColor: '#1e293b',
pointBorderWidth: 2, pointBorderWidth: 2,
}, },
], ],
@ -49,11 +55,12 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
legend: { legend: {
display: true, display: true,
position: 'top' as const, position: 'top' as const,
labels: {
color: '#cbd5e1',
},
}, },
title: { title: {
display: true, display: false,
text: `Graphique Complet: ${kpi.name}`,
font: { size: 16, weight: 'bold' as const },
}, },
}, },
scales: { scales: {
@ -62,31 +69,46 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
title: { title: {
display: true, display: true,
text: kpi.unit, text: kpi.unit,
color: '#cbd5e1',
},
ticks: {
color: '#94a3b8',
},
grid: {
color: 'rgba(148, 163, 184, 0.1)',
}, },
}, },
x: { x: {
display: true, display: true,
ticks: {
color: '#94a3b8',
},
grid: {
color: 'rgba(148, 163, 184, 0.1)',
},
}, },
}, },
}; };
return ( return (
<div className="chart-modal-overlay" onClick={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<div className="chart-modal" onClick={(e) => e.stopPropagation()}> <DialogContent className="sm:max-w-[800px] max-h-[90vh]">
<div className="chart-modal-header"> <DialogHeader>
<h2>📈 {kpi.name}</h2> <DialogTitle className="flex items-center gap-2">
<button className="chart-modal-close" onClick={onClose}></button> <LineChart className="h-5 w-5" />
</div> {kpi.name}
<div className="chart-modal-body"> </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%' }}> <div style={{ height: '500px', width: '100%' }}>
<Line data={chartData} options={chartOptions} /> <Line data={chartData} options={chartOptions} />
</div> </div>
</div> </div>
<div className="chart-modal-footer"> </DialogContent>
<p>Nombre de mesures: <strong>{measurements.length}</strong></p> </Dialog>
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
</div>
</div>
</div>
); );
}; };

View File

@ -12,7 +12,6 @@ import {
Legend Legend
} from 'chart.js'; } from 'chart.js';
import { kpiData, getCategoryColor } from '../data/kpiData'; import { kpiData, getCategoryColor } from '../data/kpiData';
import '../styles/Charts.css';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@ -25,60 +24,107 @@ ChartJS.register(
Legend 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 = () => { export const TrendChart: React.FC = () => {
const data = { const data = {
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4'], labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
datasets: [ datasets: [
{ {
label: 'Sécurité (%)', label: 'Safety (%)',
data: [94, 95, 96, 96], data: [94, 95, 96, 96],
borderColor: getCategoryColor('security'), borderColor: '#ef4444',
backgroundColor: 'rgba(231, 76, 60, 0.1)', backgroundColor: 'rgba(239, 68, 68, 0.1)',
tension: 0.4 tension: 0.4
}, },
{ {
label: 'Qualité (%)', label: 'Quality (%)',
data: [75, 76, 77, 78.5], data: [75, 76, 77, 78.5],
borderColor: getCategoryColor('quality'), borderColor: '#3b82f6',
backgroundColor: 'rgba(52, 152, 219, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4 tension: 0.4
}, },
{ {
label: 'Délais (%)', label: 'Delivery (%)',
data: [97, 97.5, 97.8, 98], data: [97, 97.5, 97.8, 98],
borderColor: getCategoryColor('delays'), borderColor: '#f59e0b',
backgroundColor: 'rgba(243, 156, 18, 0.1)', backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.4 tension: 0.4
}, },
{ {
label: 'Coûts (%)', label: 'Cost (%)',
data: [92, 91, 90.5, 89], data: [92, 91, 90.5, 89],
borderColor: getCategoryColor('costs'), borderColor: '#10b981',
backgroundColor: 'rgba(39, 174, 96, 0.1)', backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4 tension: 0.4
} }
] ]
}; };
return ( return (
<div className="chart-container"> <div>
<h2>📈 Tendances SQDC</h2> <h3 className="text-lg font-semibold mb-4">SQDC Trends</h3>
<Line data={data} options={{ <div style={{ height: '300px' }}>
<Line
data={data}
options={{
...commonOptions,
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: 'top' as const } }, plugins: {
scales: { y: { beginAtZero: true, max: 100 } } ...commonOptions.plugins,
}} /> legend: {
position: 'top' as const,
labels: { color: '#cbd5e1' }
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
beginAtZero: true,
max: 100
}
}
}}
/>
</div>
</div> </div>
); );
}; };
export const CategoryDistributionChart: React.FC = () => { export const CategoryDistributionChart: React.FC = () => {
const categoryCounts = { const categoryCounts = {
'Sécurité': kpiData.security.length, 'Safety': kpiData.security.length,
'Qualité': kpiData.quality.length, 'Quality': kpiData.quality.length,
'Délais': kpiData.delays.length, 'Delivery': kpiData.delays.length,
'Coûts': kpiData.costs.length, 'Cost': kpiData.costs.length,
'Maintenance': kpiData.maintenance.length 'Maintenance': kpiData.maintenance.length
}; };
@ -87,40 +133,41 @@ export const CategoryDistributionChart: React.FC = () => {
datasets: [{ datasets: [{
data: Object.values(categoryCounts), data: Object.values(categoryCounts),
backgroundColor: [ backgroundColor: [
getCategoryColor('security'), '#ef4444',
getCategoryColor('quality'), '#3b82f6',
getCategoryColor('delays'), '#f59e0b',
getCategoryColor('costs'), '#10b981',
getCategoryColor('maintenance') '#8b5cf6'
], ],
borderColor: 'white', borderColor: '#1e293b',
borderWidth: 2 borderWidth: 2
}] }]
}; };
return ( return (
<div className="chart-container"> <div>
<h2>📊 Répartition KPI par Catégorie</h2> <h3 className="text-lg font-semibold mb-4">KPI Distribution by Category</h3>
<Doughnut data={data} options={{ <div style={{ height: '300px' }}>
<Doughnut
data={data}
options={{
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' as const } } plugins: {
}} /> legend: {
position: 'bottom' as const,
labels: { color: '#cbd5e1' }
}
}
}}
/>
</div>
</div> </div>
); );
}; };
export const StatusChart: React.FC = () => { export const StatusChart: React.FC = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const categories = ['Safety', 'Quality', 'Delivery', 'Cost', 'Maintenance'];
const allKPIs = [
...kpiData.security,
...kpiData.quality,
...kpiData.delays,
...kpiData.costs,
...kpiData.maintenance
];
const categories = ['Sécurité', 'Qualité', 'Délais', 'Coûts', 'Maintenance'];
const categoryKPIs = [ const categoryKPIs = [
kpiData.security, kpiData.security,
kpiData.quality, kpiData.quality,
@ -133,65 +180,95 @@ export const StatusChart: React.FC = () => {
labels: categories, labels: categories,
datasets: [ datasets: [
{ {
label: 'Bon', label: 'On Target',
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'good').length), data: categoryKPIs.map(cat => cat.filter(k => k.status === 'good').length),
backgroundColor: '#27ae60' backgroundColor: '#10b981'
}, },
{ {
label: 'À améliorer', label: 'Warning',
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'warning').length), data: categoryKPIs.map(cat => cat.filter(k => k.status === 'warning').length),
backgroundColor: '#f39c12' backgroundColor: '#f59e0b'
}, },
{ {
label: 'Critique', label: 'Critical',
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'critical').length), data: categoryKPIs.map(cat => cat.filter(k => k.status === 'critical').length),
backgroundColor: '#e74c3c' backgroundColor: '#ef4444'
} }
] ]
}; };
return ( return (
<div className="chart-container"> <div>
<h2>📊 État des KPI par Catégorie</h2> <h3 className="text-lg font-semibold mb-4">KPI Status by Category</h3>
<Bar data={data} options={{ <div style={{ height: '300px' }}>
<Bar
data={data}
options={{
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
indexAxis: 'y' as const, indexAxis: 'y' as const,
plugins: { legend: { position: 'top' as const } }, plugins: {
scales: { legend: {
x: { stacked: true }, position: 'top' as const,
y: { stacked: true } 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> </div>
); );
}; };
export const CNQChart: React.FC = () => { export const CNQChart: React.FC = () => {
const data = { const data = {
labels: ['Rebuts', 'Retouches', 'Retours Clients'], labels: ['Scrap', 'Rework', 'Returns'],
datasets: [{ datasets: [{
label: 'Coût (€)', label: 'Cost (€)',
data: [8500, 7200, 2800], data: [8500, 7200, 2800],
backgroundColor: [ backgroundColor: [
'#e74c3c', '#ef4444',
'#f39c12', '#f59e0b',
'#3498db' '#3b82f6'
], ],
borderColor: 'white', borderColor: '#1e293b',
borderWidth: 2 borderWidth: 2
}] }]
}; };
return ( return (
<div className="chart-container"> <div>
<h2>💔 Coûts des Non-Qualité</h2> <h3 className="text-lg font-semibold mb-4">Non-Quality Costs</h3>
<Bar data={data} options={{ <div style={{ height: '300px' }}>
<Bar
data={data}
options={{
...commonOptions,
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } } scales: {
}} /> ...commonOptions.scales,
y: {
...commonOptions.scales.y,
beginAtZero: true
}
}
}}
/>
</div>
</div> </div>
); );
}; };

View File

@ -1,5 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import '../styles/ExportModal.css'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Calendar, FileDown } from 'lucide-react';
interface ExportModalProps { interface ExportModalProps {
isOpen: boolean; isOpen: boolean;
@ -11,8 +20,6 @@ interface ExportModalProps {
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, kpiName, onExport, onClose }) => { export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, kpiName, onExport, onClose }) => {
const [selectedRange, setSelectedRange] = useState<number | null>(null); const [selectedRange, setSelectedRange] = useState<number | null>(null);
if (!isOpen) return null;
const handleExport = () => { const handleExport = () => {
if (selectedRange !== null) { if (selectedRange !== null) {
onExport(selectedRange); onExport(selectedRange);
@ -21,83 +28,68 @@ export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, kpiName, onExp
} }
}; };
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 ( return (
<div className="export-modal-overlay" onClick={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<div className="export-modal" onClick={(e) => e.stopPropagation()}> <DialogContent className="sm:max-w-[500px]">
<div className="export-modal-header"> <DialogHeader>
<h2>💾 Exporter {kpiName}</h2> <DialogTitle className="flex items-center gap-2">
<button className="export-modal-close" onClick={onClose}></button> <FileDown className="h-5 w-5" />
</div> Exporter {kpiName}
</DialogTitle>
<DialogDescription>
Sélectionnez la plage de données à exporter en Excel
</DialogDescription>
</DialogHeader>
<div className="export-modal-body"> <div className="grid gap-3 py-4">
<p className="export-modal-description"> {ranges.map((range) => {
Sélectionnez la plage de données à exporter: const Icon = range.icon;
</p> return (
<div className="export-options">
<label className={`export-option ${selectedRange === 7 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="7"
checked={selectedRange === 7}
onChange={() => setSelectedRange(7)}
/>
<span className="option-label">📅 Dernière semaine</span>
<span className="option-sublabel">(7 jours)</span>
</label>
<label className={`export-option ${selectedRange === 30 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="30"
checked={selectedRange === 30}
onChange={() => setSelectedRange(30)}
/>
<span className="option-label">📆 Dernier mois</span>
<span className="option-sublabel">(30 jours)</span>
</label>
<label className={`export-option ${selectedRange === 365 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="365"
checked={selectedRange === 365}
onChange={() => setSelectedRange(365)}
/>
<span className="option-label">📊 Cette année</span>
<span className="option-sublabel">(365 jours)</span>
</label>
<label className={`export-option ${selectedRange === -1 ? 'active' : ''}`}>
<input
type="radio"
name="range"
value="-1"
checked={selectedRange === -1}
onChange={() => setSelectedRange(-1)}
/>
<span className="option-label">📈 Toutes les données</span>
<span className="option-sublabel">(Sans limite)</span>
</label>
</div>
</div>
<div className="export-modal-footer">
<button className="btn-cancel" onClick={onClose}>
Annuler
</button>
<button <button
className="btn-export-confirm" key={range.value}
onClick={handleExport} className={`flex items-center gap-3 p-4 rounded-lg border-2 transition-all text-left ${
disabled={selectedRange === null} selectedRange === range.value
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-accent'
}`}
onClick={() => setSelectedRange(range.value)}
> >
Exporter <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> </button>
);
})}
</div> </div>
</div>
</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

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import '../styles/KPICard.css'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { TrendingUp, Minus } from 'lucide-react';
interface KPICardProps { interface KPICardProps {
kpi: any; kpi: any;
@ -7,54 +9,70 @@ interface KPICardProps {
} }
export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => { export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => {
const getStatusIcon = () => { const getStatusVariant = () => {
switch (kpi.status) { switch (kpi.status) {
case 'good': case 'good':
return ''; return 'success';
case 'warning': case 'warning':
return ''; return 'warning';
case 'critical': case 'critical':
return '🔴'; return 'destructive';
default: default:
return '•'; 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 = () => { const getTrendIcon = () => {
if (!kpi.latest) return '•'; if (!kpi.latest) return <Minus className="h-4 w-4" />;
return <TrendingUp className="h-4 w-4" />;
// Déterminer la tendance basée sur les données de la dernière semaine
return '📊'; // Placeholder pour l'instant
};
const getStatusClass = () => {
return `status-${kpi.status}`;
}; };
return ( return (
<div className="kpi-card" style={{ borderLeftColor: color }}> <Card className="border-l-4 hover:shadow-md transition-shadow" style={{ borderLeftColor: color }}>
<div className="kpi-header" style={{ color }}> <CardHeader className="pb-3">
<h3 className="kpi-title">{kpi.name}</h3> <div className="flex items-start justify-between">
<span className="kpi-trend">{getTrendIcon()}</span> <CardTitle className="text-base font-semibold" style={{ color }}>
{kpi.name}
</CardTitle>
<div className="text-muted-foreground">
{getTrendIcon()}
</div> </div>
<div className="kpi-value-section">
<div className="kpi-value">{kpi.value}</div>
<div className="kpi-unit">{kpi.unit}</div>
</div> </div>
</CardHeader>
<div className="kpi-footer"> <CardContent className="pb-3">
<span className={`kpi-status ${getStatusClass()}`}> <div className="flex items-baseline gap-2">
{getStatusIcon()} {kpi.status?.charAt(0).toUpperCase() + kpi.status?.slice(1) || 'N/A'} <div className="text-3xl font-bold">{kpi.value}</div>
</span> <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 && ( {kpi.target && (
<span className="kpi-target"> <span className="text-xs text-muted-foreground">
Obj: {kpi.target} {kpi.unit} Obj: {kpi.target} {kpi.unit}
</span> </span>
)} )}
</div> </CardFooter>
</Card>
<p className="kpi-description">{kpi.description}</p>
</div>
); );
}; };

View File

@ -1,7 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import '../styles/RangeChartModal.css'; 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); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
@ -22,12 +30,10 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
}) => { }) => {
const [selectedRange, setSelectedRange] = useState<number>(30); const [selectedRange, setSelectedRange] = useState<number>(30);
if (!isOpen || !kpi) return null; if (!kpi) return null;
// Obtenir les mesures pour la plage sélectionnée
const filteredMeasurements = getMeasurementsForRange(selectedRange); const filteredMeasurements = getMeasurementsForRange(selectedRange);
// Préparer les données pour le graphique
const labels = filteredMeasurements const labels = filteredMeasurements
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR')) .map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
.reverse(); .reverse();
@ -42,13 +48,13 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
{ {
label: kpi.name, label: kpi.name,
data: values, data: values,
borderColor: '#6496ff', borderColor: '#60a5fa',
backgroundColor: 'rgba(100, 150, 255, 0.1)', backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.4, tension: 0.4,
fill: true, fill: true,
pointRadius: 4, pointRadius: 4,
pointBackgroundColor: '#6496ff', pointBackgroundColor: '#60a5fa',
pointBorderColor: '#fff', pointBorderColor: '#1e293b',
pointBorderWidth: 2, pointBorderWidth: 2,
}, },
], ],
@ -61,11 +67,12 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
legend: { legend: {
display: true, display: true,
position: 'top' as const, position: 'top' as const,
labels: {
color: '#cbd5e1',
},
}, },
title: { title: {
display: true, display: false,
text: `Graphique Complet: ${kpi.name}`,
font: { size: 16, weight: 'bold' as const },
}, },
}, },
scales: { scales: {
@ -74,86 +81,67 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
title: { title: {
display: true, display: true,
text: kpi.unit, text: kpi.unit,
color: '#cbd5e1',
},
ticks: {
color: '#94a3b8',
},
grid: {
color: 'rgba(148, 163, 184, 0.1)',
}, },
}, },
x: { x: {
display: true, 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 ( return (
<div className="range-chart-modal-overlay" onClick={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<div className="range-chart-modal" onClick={(e) => e.stopPropagation()}> <DialogContent className="sm:max-w-[900px] max-h-[90vh]">
<div className="range-chart-modal-header"> <DialogHeader>
<h2>📈 {kpi.name}</h2> <DialogTitle className="flex items-center gap-2">
<button className="range-chart-modal-close" onClick={onClose}></button> <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>
<div className="range-chart-modal-range-selector"> <div className="py-4">
<label className={`range-option ${selectedRange === 7 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="7"
checked={selectedRange === 7}
onChange={() => setSelectedRange(7)}
/>
<span>📅 Semaine</span>
</label>
<label className={`range-option ${selectedRange === 30 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="30"
checked={selectedRange === 30}
onChange={() => setSelectedRange(30)}
/>
<span>📆 Mois</span>
</label>
<label className={`range-option ${selectedRange === 90 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="90"
checked={selectedRange === 90}
onChange={() => setSelectedRange(90)}
/>
<span>📊 Trimestre</span>
</label>
<label className={`range-option ${selectedRange === 365 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="365"
checked={selectedRange === 365}
onChange={() => setSelectedRange(365)}
/>
<span>📈 Année</span>
</label>
<label className={`range-option ${selectedRange === -1 ? 'active' : ''}`}>
<input
type="radio"
name="chartRange"
value="-1"
checked={selectedRange === -1}
onChange={() => setSelectedRange(-1)}
/>
<span>🔄 Tout</span>
</label>
</div>
<div className="range-chart-modal-body">
<div style={{ height: '500px', width: '100%' }}> <div style={{ height: '500px', width: '100%' }}>
<Line data={chartData} options={chartOptions} /> <Line data={chartData} options={chartOptions} />
</div> </div>
</div> </div>
</DialogContent>
<div className="range-chart-modal-footer"> </Dialog>
<p>Mesures: <strong>{filteredMeasurements.length}</strong></p>
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
</div>
</div>
</div>
); );
}; };

View File

@ -0,0 +1,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,79 @@
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<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<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

@ -1,25 +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 {
* { * {
margin: 0; @apply border-border;
padding: 0; }
box-sizing: border-box; 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 { html, body, #root {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
body {
margin: 0;
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;
background: #f5f7fa;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
} }

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

@ -1,11 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { getCategoryColor, getCategoryName, getCategoryEmoji } from '../data/kpiData'; import { getCategoryColor, getCategoryName } from '../data/kpiData';
import { useSQLiteDatabase } from '../database/useSQLiteDatabase'; import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
import { ChartModal } from '../components/ChartModal'; import { ChartModal } from '../components/ChartModal';
import { ExportModal } from '../components/ExportModal'; import { ExportModal } from '../components/ExportModal';
import { RangeChartModal } from '../components/RangeChartModal'; import { RangeChartModal } from '../components/RangeChartModal';
import '../styles/DetailPage.css'; 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 { interface DetailPageProps {
category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance'; category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance';
@ -29,10 +33,8 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [showChartRangeModal, setShowChartRangeModal] = useState(false); const [showChartRangeModal, setShowChartRangeModal] = useState(false);
// Obtenir les KPI de cette catégorie
const categoryKPIs = db.kpis.filter(kpi => kpi.category_id === categoryId); const categoryKPIs = db.kpis.filter(kpi => kpi.category_id === categoryId);
// Charger les mesures quand un KPI est sélectionné
useEffect(() => { useEffect(() => {
if (!selectedKPIId) { if (!selectedKPIId) {
if (categoryKPIs.length > 0) { if (categoryKPIs.length > 0) {
@ -53,23 +55,19 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
const selectedKPI = categoryKPIs.find(k => k.id === selectedKPIId); const selectedKPI = categoryKPIs.find(k => k.id === selectedKPIId);
// Export to Excel
const exportToExcel = (kpi: any, measurements: any[]) => { const exportToExcel = (kpi: any, measurements: any[]) => {
if (!kpi || measurements.length === 0) return; if (!kpi || measurements.length === 0) return;
// Préparer les données
const data = measurements.map(m => ({ const data = measurements.map(m => ({
Date: new Date(m.measurement_date).toLocaleString('fr-FR'), Date: new Date(m.measurement_date).toLocaleString('fr-FR'),
Valeur: m.value, Valeur: m.value,
Statut: m.status, Statut: m.status,
})); }));
// Créer un classeur Excel
const worksheet = XLSX.utils.json_to_sheet(data); const worksheet = XLSX.utils.json_to_sheet(data);
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Mesures'); XLSX.utils.book_append_sheet(workbook, worksheet, 'Mesures');
// Ajouter une feuille de résumé
const summary = [ const summary = [
['KPI', kpi.name], ['KPI', kpi.name],
['Unité', kpi.unit], ['Unité', kpi.unit],
@ -82,18 +80,15 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
const summarySheet = XLSX.utils.aoa_to_sheet(summary); const summarySheet = XLSX.utils.aoa_to_sheet(summary);
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Résumé'); XLSX.utils.book_append_sheet(workbook, summarySheet, 'Résumé');
// Télécharger
const filename = `${kpi.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`; const filename = `${kpi.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename); XLSX.writeFile(workbook, filename);
}; };
// Handle export with date range selection
const handleExportClick = (days: number) => { const handleExportClick = (days: number) => {
if (!selectedKPI) return; if (!selectedKPI) return;
let measurementsToExport = selectedKPIMeasurements; let measurementsToExport = selectedKPIMeasurements;
// Si l'utilisateur a sélectionné une plage spécifique (pas -1 pour tous)
if (days > 0 && days !== 365) { if (days > 0 && days !== 365) {
measurementsToExport = selectedKPIMeasurements.filter((m: any) => { measurementsToExport = selectedKPIMeasurements.filter((m: any) => {
const daysAgo = (Date.now() - new Date(m.measurement_date).getTime()) / (1000 * 60 * 60 * 24); const daysAgo = (Date.now() - new Date(m.measurement_date).getTime()) / (1000 * 60 * 60 * 24);
@ -104,7 +99,6 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
exportToExcel(selectedKPI, measurementsToExport); exportToExcel(selectedKPI, measurementsToExport);
}; };
// Get measurements for chart by date range
const getMeasurementsForDateRange = (days: number) => { const getMeasurementsForDateRange = (days: number) => {
if (days === -1) return selectedKPIMeasurements; if (days === -1) return selectedKPIMeasurements;
@ -114,126 +108,169 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
}); });
}; };
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 ( return (
<div className="detail-page"> <div className="container mx-auto p-6 space-y-6">
<div className="detail-header"> {/* Header */}
<h1 style={{ color: getCategoryColor(category) }}> <div className="pb-4 border-b-2" style={{ borderColor: getCategoryColor(category) }}>
{getCategoryEmoji(category)} {getCategoryName(category)} <h1 className="text-4xl font-bold tracking-tight mb-2" style={{ color: getCategoryColor(category) }}>
{getCategoryName(category)}
</h1> </h1>
<p>Analyse détaillée des indicateurs</p> <p className="text-lg text-muted-foreground">Detailed metric analysis and performance tracking</p>
</div> </div>
<div className="detail-content"> <div className="grid gap-6 lg:grid-cols-3">
<div className="kpi-selector"> {/* KPI Selector */}
<h3>Sélectionner un KPI</h3> <Card className="lg:col-span-1">
<CardHeader>
<div className="action-buttons"> <CardTitle>Sélectionner un KPI</CardTitle>
<button <CardDescription>Choisissez un indicateur pour voir les détails</CardDescription>
className="btn btn-export" </CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={() => setShowExportModal(true)} onClick={() => setShowExportModal(true)}
disabled={!selectedKPI} disabled={!selectedKPI}
> >
📊 Exporter Excel <Download className="h-4 w-4 mr-2" />
</button> Exporter
<button </Button>
className="btn btn-chart" <Button
className="flex-1"
size="sm"
variant="outline"
onClick={() => setShowChartRangeModal(true)} onClick={() => setShowChartRangeModal(true)}
disabled={!selectedKPI} disabled={!selectedKPI}
> >
📈 Graphique Complet <LineChart className="h-4 w-4 mr-2" />
</button> Graphique
</Button>
</div> </div>
<div className="kpi-list"> <div className="space-y-2">
{categoryKPIs.map(kpi => ( {categoryKPIs.map(kpi => (
<button <button
key={kpi.id} key={kpi.id}
className={`kpi-option ${selectedKPIId === kpi.id ? 'active' : ''}`} 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)} onClick={() => setSelectedKPIId(kpi.id)}
> >
<div className="kpi-name">{kpi.name}</div> <div className="font-medium text-sm">{kpi.name}</div>
<div className="kpi-unit">{kpi.unit}</div> <div className="text-xs text-muted-foreground">{kpi.unit}</div>
</button> </button>
))} ))}
</div> </div>
</div> </CardContent>
</Card>
{/* KPI Details */}
{selectedKPI && ( {selectedKPI && (
<div className="kpi-details"> <div className="lg:col-span-2 space-y-6">
<div className="details-header"> {/* KPI Info */}
<h2>{selectedKPI.name}</h2> <Card>
<div className="details-info"> <CardHeader>
<div className="info-item"> <CardTitle>{selectedKPI.name}</CardTitle>
<div className="info-label">Unité</div> </CardHeader>
<div className="info-value">{selectedKPI.unit}</div> <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="info-item"> <div>
<div className="info-label">Cible</div> <div className="text-sm text-muted-foreground">Cible</div>
<div className="info-value">{selectedKPI.target}</div> <div className="font-medium">{selectedKPI.target}</div>
</div>
<div className="info-item">
<div className="info-label">Fréquence</div>
<div className="info-value">{selectedKPI.frequency || 'N/A'}</div>
</div> </div>
<div>
<div className="text-sm text-muted-foreground">Fréquence</div>
<div className="font-medium">{selectedKPI.frequency || 'N/A'}</div>
</div> </div>
</div> </div>
<div className="details-description"> <div>
<h4>Description</h4> <h4 className="font-semibold mb-2">Description</h4>
<p>{selectedKPI.description}</p> <p className="text-sm text-muted-foreground">{selectedKPI.description}</p>
<h4>Formule</h4>
<p>{selectedKPI.formula}</p>
</div> </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 && ( {selectedKPIStats && (
<div className="details-stats"> <Card>
<h4>Statistiques (30 derniers jours)</h4> <CardHeader>
<div className="stats-container"> <CardTitle>Statistiques (30 derniers jours)</CardTitle>
<div className="stat-box"> </CardHeader>
<div className="stat-box-label">Moyenne</div> <CardContent>
<div className="stat-box-value">{selectedKPIStats.avg?.toFixed(2) || 'N/A'}</div> <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>
<div className="stat-box"> <div className="text-center">
<div className="stat-box-label">Min</div> <div className="text-2xl font-bold">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div>
<div className="stat-box-value">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div> <div className="text-sm text-muted-foreground">Min</div>
</div> </div>
<div className="stat-box"> <div className="text-center">
<div className="stat-box-label">Max</div> <div className="text-2xl font-bold">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div>
<div className="stat-box-value">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div> <div className="text-sm text-muted-foreground">Max</div>
</div>
<div className="stat-box">
<div className="stat-box-label">Mesures</div>
<div className="stat-box-value">{selectedKPIStats.count || 0}</div>
</div> </div>
<div className="text-center">
<div className="text-2xl font-bold">{selectedKPIStats.count || 0}</div>
<div className="text-sm text-muted-foreground">Mesures</div>
</div> </div>
</div> </div>
</CardContent>
</Card>
)} )}
<div className="measurements-section"> {/* Measurements Table */}
<h4>Dernières mesures ({selectedKPIMeasurements.length})</h4> <Card>
<table className="measurements-table"> <CardHeader>
<thead> <CardTitle>Dernières mesures ({selectedKPIMeasurements.length})</CardTitle>
<tr> </CardHeader>
<th>Date</th> <CardContent>
<th>Valeur</th> <Table>
<th>Statut</th> <TableHeader>
</tr> <TableRow>
</thead> <TableHead>Date</TableHead>
<tbody> <TableHead>Valeur</TableHead>
<TableHead>Statut</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedKPIMeasurements.slice(-10).reverse().map((m: any, idx: number) => ( {selectedKPIMeasurements.slice(-10).reverse().map((m: any, idx: number) => (
<tr key={idx}> <TableRow key={idx}>
<td>{new Date(m.measurement_date).toLocaleString('fr-FR')}</td> <TableCell>{new Date(m.measurement_date).toLocaleString('fr-FR')}</TableCell>
<td>{m.value}</td> <TableCell>{m.value}</TableCell>
<td> <TableCell>{getStatusBadge(m.status)}</TableCell>
<span className={`status-badge status-${m.status}`}> </TableRow>
{m.status === 'good' ? '✓ Bon' : m.status === 'warning' ? '⚠️ Attention' : '🔴 Critique'}
</span>
</td>
</tr>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </CardContent>
</Card>
</div> </div>
)} )}
</div> </div>

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { KPICard } from '../components/KPICard'; import { KPICard } from '../components/KPICard';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { getCategoryColor } from '../data/kpiData'; import { getCategoryColor } from '../data/kpiData';
import { useSQLiteDatabase } from '../database/useSQLiteDatabase'; import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
import '../styles/HomePage.css'; import { AlertTriangle, TrendingUp, BarChart3, CheckCircle2 } from 'lucide-react';
export const HomePage: React.FC = () => { export const HomePage: React.FC = () => {
const db = useSQLiteDatabase(); const db = useSQLiteDatabase();
@ -12,7 +15,6 @@ export const HomePage: React.FC = () => {
const [avgPerformance, setAvgPerformance] = useState(0); const [avgPerformance, setAvgPerformance] = useState(0);
const [criticalAlerts, setCriticalAlerts] = useState<any[]>([]); const [criticalAlerts, setCriticalAlerts] = useState<any[]>([]);
// Convertir la plage de temps en nombre de jours
const getDaysFromTimeRange = (range: 'today' | 'week' | 'last7' | 'month' | 'year'): number => { const getDaysFromTimeRange = (range: 'today' | 'week' | 'last7' | 'month' | 'year'): number => {
switch (range) { switch (range) {
case 'today': case 'today':
@ -34,7 +36,6 @@ export const HomePage: React.FC = () => {
} }
}; };
// Charger les données quand les KPI changent ou que la plage change
useEffect(() => { useEffect(() => {
if (db.loading || db.kpis.length === 0) return; if (db.loading || db.kpis.length === 0) return;
@ -43,7 +44,6 @@ export const HomePage: React.FC = () => {
const kpisWithStatus: any[] = []; const kpisWithStatus: any[] = [];
const alertList: any[] = []; const alertList: any[] = [];
// Charger les mesures pour chaque KPI
for (const kpi of db.kpis) { for (const kpi of db.kpis) {
const measurements = await db.getMeasurementsForKPI(kpi.id, days); const measurements = await db.getMeasurementsForKPI(kpi.id, days);
@ -74,7 +74,6 @@ export const HomePage: React.FC = () => {
kpisWithStatus.push({ ...kpi, status, value }); kpisWithStatus.push({ ...kpi, status, value });
} }
// Calculer les statistiques
const statCounts = { const statCounts = {
total: kpisWithStatus.length, total: kpisWithStatus.length,
good: kpisWithStatus.filter(k => k.status === 'good').length, good: kpisWithStatus.filter(k => k.status === 'good').length,
@ -82,15 +81,13 @@ export const HomePage: React.FC = () => {
critical: kpisWithStatus.filter(k => k.status === 'critical').length critical: kpisWithStatus.filter(k => k.status === 'critical').length
}; };
// Grouper par catégorie
const topKPIsMap: any = {}; const topKPIsMap: any = {};
[1, 2, 3, 4, 5].forEach(catId => { [1, 2, 3, 4, 5].forEach(catId => {
topKPIsMap[catId] = kpisWithStatus topKPIsMap[catId] = kpisWithStatus
.filter(k => k.category_id === catId) .filter(k => k.category_id === catId)
.slice(0, 2); .slice(0, 1);
}); });
// Performance globale
const performance = Math.round( const performance = Math.round(
((statCounts.good * 100 + statCounts.warning * 50) / (statCounts.total * 100)) * 100 ((statCounts.good * 100 + statCounts.warning * 50) / (statCounts.total * 100)) * 100
); );
@ -102,7 +99,7 @@ export const HomePage: React.FC = () => {
if (a.status === 'critical' && b.status !== 'critical') return -1; if (a.status === 'critical' && b.status !== 'critical') return -1;
if (a.status !== 'critical' && b.status === 'critical') return 1; if (a.status !== 'critical' && b.status === 'critical') return 1;
return 0; return 0;
}).slice(0, 4)); }).slice(0, 3));
}; };
fetchData(); fetchData();
@ -110,127 +107,141 @@ export const HomePage: React.FC = () => {
if (db.loading) { if (db.loading) {
return ( return (
<div className="home-page"> <div className="container mx-auto p-6">
<div className="stats-overview"> <Card className="border-2">
<p style={{ textAlign: 'center', padding: '2rem' }}> Chargement des données...</p> <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> </div>
</CardContent>
</Card>
</div> </div>
); );
} }
if (db.error) { if (db.error) {
return ( return (
<div className="home-page"> <div className="container mx-auto p-6">
<div className="stats-overview"> <Card className="border-2 border-destructive">
<p style={{ textAlign: 'center', padding: '2rem', color: 'red' }}> {db.error}</p> <CardContent className="py-12">
<p style={{ textAlign: 'center' }}>Assurez-vous que le serveur API est lancé: <code>npm run server</code></p> <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> </div>
</CardContent>
</Card>
</div> </div>
); );
} }
return ( const timeRangeOptions = [
<div className="home-page"> { value: 'today', label: 'Today' },
<div className="stats-overview"> { value: 'week', label: 'Week' },
<div className="overview-header"> { value: 'last7', label: 'Last 7D' },
<h1>📊 Dashboard SQDC</h1> { value: 'month', label: 'Month' },
<div className="time-range-selector"> { value: 'year', label: 'Year' },
<button ] as const;
className={`time-btn ${timeRange === 'today' ? 'active' : ''}`}
onClick={() => setTimeRange('today')}
>
Aujourd'hui
</button>
<button
className={`time-btn ${timeRange === 'week' ? 'active' : ''}`}
onClick={() => setTimeRange('week')}
>
Cette semaine
</button>
<button
className={`time-btn ${timeRange === 'last7' ? 'active' : ''}`}
onClick={() => setTimeRange('last7')}
>
7 derniers jours
</button>
<button
className={`time-btn ${timeRange === 'month' ? 'active' : ''}`}
onClick={() => setTimeRange('month')}
>
Ce mois
</button>
<button
className={`time-btn ${timeRange === 'year' ? 'active' : ''}`}
onClick={() => setTimeRange('year')}
>
Cette année
</button>
</div>
</div>
{criticalAlerts.length > 0 && ( return (
<div className="alerts-block"> <div className="h-[calc(100vh-180px)] overflow-hidden">
<h3>🚨 Alertes ({criticalAlerts.length})</h3> <div className="container mx-auto p-6 h-full flex flex-col gap-4">
<div className="alerts-list"> {/* Compact Header */}
{criticalAlerts.map(alert => ( <div className="flex items-center justify-between">
<div key={alert.id} className={`alert-item alert-${alert.status}`}> <div>
<div className="alert-status"> <h1 className="text-2xl font-bold">Performance Overview</h1>
{alert.status === 'critical' ? '🔴' : '⚠️'} <p className="text-sm text-muted-foreground">Real-time SQDC Metrics</p>
</div>
<div className="alert-info">
<div className="alert-name">{alert.name}</div>
<div className="alert-value">{alert.value} {alert.unit}</div>
</div>
</div> </div>
<div 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>
</div> </div>
)}
</div> {/* Compact Stats - Single Row */}
<div className="grid grid-cols-5 gap-3">
<div className="main-grid"> <Card className="border">
<div className="category-section"> <CardContent className="p-4">
<div className="category-header"> <div className="flex items-center gap-2 mb-1">
<h2>📊 Statistiques</h2> <BarChart3 className="h-4 w-4 text-muted-foreground" />
</div> <span className="text-xs text-muted-foreground">Total</span>
<div className="stats-grid-inner"> </div>
<div className="stat-card-inner"> <div className="text-2xl font-bold">{stats.total}</div>
<div className="stat-number">{stats.total}</div> </CardContent>
<div className="stat-label">KPI Total</div> </Card>
</div>
<div className="stat-card-inner"> <Card className="border border-emerald-500/30">
<div className="stat-number">{stats.good}</div> <CardContent className="p-4">
<div className="stat-label"> Bon</div> <div className="flex items-center gap-2 mb-1">
</div> <CheckCircle2 className="h-4 w-4 text-emerald-500" />
<div className="stat-card-inner"> <span className="text-xs text-muted-foreground">On Target</span>
<div className="stat-number">{stats.warning}</div> </div>
<div className="stat-label"> Attention</div> <div className="text-2xl font-bold text-emerald-500">{stats.good}</div>
</div> </CardContent>
<div className="stat-card-inner"> </Card>
<div className="stat-number">{stats.critical}</div>
<div className="stat-label">🔴 Critique</div> <Card className="border border-amber-500/30">
</div> <CardContent className="p-4">
<div className="stat-card-inner"> <div className="flex items-center gap-2 mb-1">
<div className="stat-number">{avgPerformance}%</div> <AlertTriangle className="h-4 w-4 text-amber-500" />
<div className="stat-label">Performance</div> <span className="text-xs text-muted-foreground">Warning</span>
</div> </div>
</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> </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 => { {[1, 2, 3, 4, 5].map(catId => {
const categoryKPIs = topKPIs[catId] || []; const categoryKPIs = topKPIs[catId] || [];
const category = db.categories.find(c => c.id === catId); const category = db.categories.find(c => c.id === catId);
if (!category) return null; if (!category || categoryKPIs.length === 0) return null;
return ( return (
<div key={catId} className="category-section"> <div key={catId}>
<div className="category-header"> <div className="flex items-center gap-2 mb-2">
<h2 style={{ color: getCategoryColor(category.name) }}> <div className="h-1 w-8 rounded" style={{ backgroundColor: getCategoryColor(category.name) }}></div>
{category.emoji} {category.name} <h3 className="text-sm font-semibold" style={{ color: getCategoryColor(category.name) }}>
</h2> {category.name}
</h3>
</div> </div>
<div className="kpi-grid"> <div className="grid gap-3">
{categoryKPIs.map((kpi: any) => ( {categoryKPIs.map((kpi: any) => (
<KPICard <KPICard
key={kpi.id} key={kpi.id}
@ -243,6 +254,48 @@ export const HomePage: React.FC = () => {
); );
})} })}
</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> </div>
); );
}; };

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

@ -1,22 +0,0 @@
Catégorie,KPI (Indicateur),Objectif,Formule de Calcul Ajustée
SÉCURITÉ,Taux de Fréquence (TF),Mesurer la fréquence des accidents avec arrêt.,(Nombre dAccidents avec Arret/Nombre dHeures Travaillees)×1000000
SÉCURITÉ,Nombre d'Incidents/Near Miss,Évaluer la culture de sécurité et la proactivité.,Compte des rapports dincidents (sans blessure/dommage)
SÉCURITÉ,Taux de Conformité aux Audits,Mesurer le respect des procédures de sécurité.,(Points de Controle Conformites/Total Points de Controle)×100
QUALITÉ,Taux de Rebut (Scrap Rate),Mesurer le pourcentage d'unités jetées (irréparables).,(Nombre dUnites Rebutees/Nombre Total dUnites Produites)×100
QUALITÉ,Taux de Retouche (Rework Rate),Mesurer le pourcentage d'unités nécessitant une reprise.,(Nombre dUnites Retouchees/Nombre Total dUnites Produites)×100
QUALITÉ,Nombre de Défauts par Unité (DPU),Mesurer le nombre moyen de défauts par produit.,Nombre Total de Defauts Trouves/Nombre Total dUnites Inspectees
QUALITÉ,Taux de Retours Clients,Mesurer l'impact de la non-qualité chez le client.,(Nombre dUnites Retournees/Nombre Total dUnites Vendues)×100
QUALITÉ,Taux de rendement synthétique (TRS),,Nb pièces bonnes x Temps cycle / Temps douverture
DÉLAIS / LIVRAISON,Efficacité Globale de l'Équipement (OEE),"Mesurer l'efficacité combinée (Disponibilité, Performance, Qualité).",Disponibilite×Performance×Qualite
DÉLAIS / LIVRAISON,Taux de Respect du Plan (Schedule Adherence),Mesurer la capacité à atteindre le volume planifié.,(Quantite Reellement Produite/Quantite Planifiee)×100
DÉLAIS / LIVRAISON,"Temps de Cycle (Cycle Time, TC)",Mesurer le temps nécessaire pour assembler une unité.,Temps Total de Production/Nombre Total dUnites Produites
DÉLAIS / LIVRAISON,Tack Time (TT),,Temps de production / Nombre de pièces demandées
DÉLAIS / LIVRAISON,Temps d'Arrêt Imprévu (Downtime),Mesurer le temps d'arrêt non planifié de la ligne.,Somme des Periodes dArret Non Planifie
COÛT,Coût par Unité (CPU),Mesurer l'efficacité des coûts de production.,Cout Total de Production/Nombre Total dUnites Produites
COÛT,Productivité de la Main-d'œuvre,Mesurer l'efficacité de l'équipe d'assemblage.,Nombre dUnites Produites/Total Heures Main-dœuvre Directe
COÛT,Coût des Non-Qualité (CNQ),"Mesurer le coût des défauts (retouche, rebut, retours).",Cout des Rebuts+Cout des Retouches
MAINTENANCE,Temps Moyen Entre Pannes (MTBF),Mesurer la fiabilité des équipements.,Temps Total de Fonctionnement/Nombre Total de Pannes
MAINTENANCE,Temps Moyen de Réparation (MTTR),Mesurer la rapidité des interventions de maintenance.,Temps Total de Reparation/Nombre Total de Pannes
MAINTENANCE,Ratio Maintenance Préventive / Corrective,Évaluer la stratégie de maintenance (proactif vs réactif).,Heures MP/Heures MC
MAINTENANCE,Taux d'Achèvement du Plan Préventif,Mesurer le respect des programmes d'entretien.,(Taches MP Terminees/Taches MP Planifiees)×100
MAINTENANCE,Coût de Maintenance par Unité Produite,Relier les dépenses de maintenance à la production.,Couts Totaux de Maintenance/Nombre Total dUnites Produites
1 Catégorie KPI (Indicateur) Objectif Formule de Calcul Ajustée
2 SÉCURITÉ Taux de Fréquence (TF) Mesurer la fréquence des accidents avec arrêt. (Nombre d’Accidents avec Arret/Nombre d’Heures Travaillees)×1000000
3 SÉCURITÉ Nombre d'Incidents/Near Miss Évaluer la culture de sécurité et la proactivité. Compte des rapports d’incidents (sans blessure/dommage)
4 SÉCURITÉ Taux de Conformité aux Audits Mesurer le respect des procédures de sécurité. (Points de Controle Conformites/Total Points de Controle)×100
5 QUALITÉ Taux de Rebut (Scrap Rate) Mesurer le pourcentage d'unités jetées (irréparables). (Nombre d’Unites Rebutees/Nombre Total d’Unites Produites)×100
6 QUALITÉ Taux de Retouche (Rework Rate) Mesurer le pourcentage d'unités nécessitant une reprise. (Nombre d’Unites Retouchees/Nombre Total d’Unites Produites)×100
7 QUALITÉ Nombre de Défauts par Unité (DPU) Mesurer le nombre moyen de défauts par produit. Nombre Total de Defauts Trouves/Nombre Total d’Unites Inspectees
8 QUALITÉ Taux de Retours Clients Mesurer l'impact de la non-qualité chez le client. (Nombre d’Unites Retournees/Nombre Total d’Unites Vendues)×100
9 QUALITÉ Taux de rendement synthétique (TRS) Nb pièces bonnes x Temps cycle / Temps d’ouverture
10 DÉLAIS / LIVRAISON Efficacité Globale de l'Équipement (OEE) Mesurer l'efficacité combinée (Disponibilité, Performance, Qualité). Disponibilite×Performance×Qualite
11 DÉLAIS / LIVRAISON Taux de Respect du Plan (Schedule Adherence) Mesurer la capacité à atteindre le volume planifié. (Quantite Reellement Produite/Quantite Planifiee)×100
12 DÉLAIS / LIVRAISON Temps de Cycle (Cycle Time, TC) Mesurer le temps nécessaire pour assembler une unité. Temps Total de Production/Nombre Total d’Unites Produites
13 DÉLAIS / LIVRAISON Tack Time (TT) Temps de production / Nombre de pièces demandées
14 DÉLAIS / LIVRAISON Temps d'Arrêt Imprévu (Downtime) Mesurer le temps d'arrêt non planifié de la ligne. Somme des Periodes d’Arret Non Planifie
15 COÛT Coût par Unité (CPU) Mesurer l'efficacité des coûts de production. Cout Total de Production/Nombre Total d’Unites Produites
16 COÛT Productivité de la Main-d'œuvre Mesurer l'efficacité de l'équipe d'assemblage. Nombre d’Unites Produites/Total Heures Main-d’œuvre Directe
17 COÛT Coût des Non-Qualité (CNQ) Mesurer le coût des défauts (retouche, rebut, retours). Cout des Rebuts+Cout des Retouches
18 MAINTENANCE Temps Moyen Entre Pannes (MTBF) Mesurer la fiabilité des équipements. Temps Total de Fonctionnement/Nombre Total de Pannes
19 MAINTENANCE Temps Moyen de Réparation (MTTR) Mesurer la rapidité des interventions de maintenance. Temps Total de Reparation/Nombre Total de Pannes
20 MAINTENANCE Ratio Maintenance Préventive / Corrective Évaluer la stratégie de maintenance (proactif vs réactif). Heures MP/Heures MC
21 MAINTENANCE Taux d'Achèvement du Plan Préventif Mesurer le respect des programmes d'entretien. (Taches MP Terminees/Taches MP Planifiees)×100
22 MAINTENANCE Coût de Maintenance par Unité Produite Relier les dépenses de maintenance à la production. Couts Totaux de Maintenance/Nombre Total d’Unites Produites