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:
parent
ca05e334a7
commit
770c41d5e0
15
dashboard-sqdc/.dockerignore
Normal file
15
dashboard-sqdc/.dockerignore
Normal 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
|
||||||
217
dashboard-sqdc/.gitea/workflows/README.md
Normal file
217
dashboard-sqdc/.gitea/workflows/README.md
Normal 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).
|
||||||
109
dashboard-sqdc/.gitea/workflows/build-deploy.yml
Normal file
109
dashboard-sqdc/.gitea/workflows/build-deploy.yml
Normal 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
|
||||||
446
dashboard-sqdc/DEPLOYMENT.md
Normal file
446
dashboard-sqdc/DEPLOYMENT.md
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
# SQDC Dashboard - Deployment Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Quick Start with Registry](#quick-start-with-registry)
|
||||||
|
- [Docker Deployment](#docker-deployment)
|
||||||
|
- [Kubernetes Deployment](#kubernetes-deployment)
|
||||||
|
- [Gitea CI/CD Setup](#gitea-cicd-setup)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start with Registry
|
||||||
|
|
||||||
|
For a step-by-step guide to set up the container registry and CI/CD pipeline, see [SETUP-REGISTRY.md](SETUP-REGISTRY.md).
|
||||||
|
|
||||||
|
**Quick Summary:**
|
||||||
|
- Registry: `gitea.vidoks.fr`
|
||||||
|
- Image: `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||||
|
- Required Secrets: `KUBE_CONFIG`, `REGISTRY_URL`, `REGISTRY_USER`, `REGISTRY_PASSWORD`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Local Development with Docker Compose
|
||||||
|
|
||||||
|
1. **Build and run the application:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Access the application:**
|
||||||
|
- Frontend: http://localhost:8080
|
||||||
|
- API: http://localhost:3001
|
||||||
|
|
||||||
|
3. **View logs:**
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Stop the application:**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Docker Build
|
||||||
|
|
||||||
|
1. **Build the image:**
|
||||||
|
```bash
|
||||||
|
docker build -t sqdc-dashboard:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the container:**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name sqdc-dashboard \
|
||||||
|
-p 8080:80 \
|
||||||
|
-p 3001:3001 \
|
||||||
|
-v $(pwd)/database:/app/database \
|
||||||
|
sqdc-dashboard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check container status:**
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
docker logs sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes cluster (v1.20+)
|
||||||
|
- kubectl configured
|
||||||
|
- Nginx Ingress Controller installed
|
||||||
|
- (Optional) Cert-Manager for HTTPS
|
||||||
|
|
||||||
|
### Quick Deployment
|
||||||
|
|
||||||
|
1. **Create registry credentials secret:**
|
||||||
|
```bash
|
||||||
|
kubectl create secret docker-registry registry-credentials \
|
||||||
|
--docker-server=gitea.vidoks.fr \
|
||||||
|
--docker-username=<your-username> \
|
||||||
|
--docker-password=<your-password> \
|
||||||
|
-n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Using the deployment script:**
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh gitea.vidoks.fr <registry-user> <registry-password> sortifal/pfee
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Manual deployment:**
|
||||||
|
```bash
|
||||||
|
# Apply all manifests
|
||||||
|
kubectl apply -f k8s/
|
||||||
|
|
||||||
|
# Or use kustomize
|
||||||
|
kubectl apply -k k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify deployment:**
|
||||||
|
```bash
|
||||||
|
kubectl get all -n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detailed Steps
|
||||||
|
|
||||||
|
#### 1. Create Namespace
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Deploy Application
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/deployment.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Create Service
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/service.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Setup Ingress
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Verify Deployment
|
||||||
|
```bash
|
||||||
|
# Check pods
|
||||||
|
kubectl get pods -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Check service
|
||||||
|
kubectl get svc -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Check ingress
|
||||||
|
kubectl get ingress -n sqdc-dashboard
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale to 3 replicas
|
||||||
|
kubectl scale deployment/sqdc-dashboard --replicas=3 -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Auto-scaling (optional)
|
||||||
|
kubectl autoscale deployment/sqdc-dashboard \
|
||||||
|
--cpu-percent=70 \
|
||||||
|
--min=2 \
|
||||||
|
--max=10 \
|
||||||
|
-n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the rollback script
|
||||||
|
./scripts/rollback.sh
|
||||||
|
|
||||||
|
# Or manually
|
||||||
|
kubectl rollout undo deployment/sqdc-dashboard -n sqdc-dashboard
|
||||||
|
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea CI/CD Setup
|
||||||
|
|
||||||
|
### 1. Configure Gitea Actions
|
||||||
|
|
||||||
|
Ensure Gitea Actions is enabled in your Gitea instance:
|
||||||
|
- Go to Site Administration → Configuration
|
||||||
|
- Enable Actions Runner
|
||||||
|
|
||||||
|
### 2. Set up Secrets
|
||||||
|
|
||||||
|
In your Gitea repository, add the following secrets:
|
||||||
|
|
||||||
|
**Settings → Secrets → Actions**
|
||||||
|
|
||||||
|
- `KUBE_CONFIG`: Plain text kubeconfig file for k3s
|
||||||
|
```bash
|
||||||
|
cat ~/.kube/config
|
||||||
|
```
|
||||||
|
|
||||||
|
- `REGISTRY_URL`: Container registry URL (e.g., `gitea.vidoks.fr`)
|
||||||
|
|
||||||
|
- `REGISTRY_USER`: Registry username
|
||||||
|
|
||||||
|
- `REGISTRY_PASSWORD`: Registry password or access token
|
||||||
|
|
||||||
|
### 3. Configure Workflow
|
||||||
|
|
||||||
|
The workflow file is already created at `.gitea/workflows/build-deploy.yml`
|
||||||
|
|
||||||
|
**Workflow triggers:**
|
||||||
|
- Push to `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
46
dashboard-sqdc/Dockerfile
Normal 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;'"
|
||||||
205
dashboard-sqdc/SETUP-REGISTRY.md
Normal file
205
dashboard-sqdc/SETUP-REGISTRY.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# Registry and CI/CD Setup Guide
|
||||||
|
|
||||||
|
This guide walks you through setting up the container registry and CI/CD pipeline for the SQDC Dashboard.
|
||||||
|
|
||||||
|
## Registry Information
|
||||||
|
|
||||||
|
- **Registry URL**: `gitea.vidoks.fr`
|
||||||
|
- **Repository**: `sortifal/pfee`
|
||||||
|
- **Full Image Path**: `gitea.vidoks.fr/sortifal/pfee:latest`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Gitea account with access to `sortifal/pfee` repository
|
||||||
|
2. Kubernetes cluster (k3s) with kubectl configured
|
||||||
|
3. Registry credentials (username and password/token)
|
||||||
|
|
||||||
|
## Step 1: Configure Gitea Secrets
|
||||||
|
|
||||||
|
Go to your Gitea repository: **Settings → Secrets → Actions**
|
||||||
|
|
||||||
|
Add the following secrets:
|
||||||
|
|
||||||
|
### Required Secrets
|
||||||
|
|
||||||
|
| Secret Name | Description | Example Value |
|
||||||
|
|------------|-------------|---------------|
|
||||||
|
| `KUBE_CONFIG` | Plain text kubeconfig for k3s | Contents of `~/.kube/config` |
|
||||||
|
| `REGISTRY_URL` | Container registry URL | `gitea.vidoks.fr` |
|
||||||
|
| `REGISTRY_USER` | Registry username | Your Gitea username |
|
||||||
|
| `REGISTRY_PASSWORD` | Registry password or token | Your Gitea password/token |
|
||||||
|
|
||||||
|
### How to get KUBE_CONFIG
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Display your kubeconfig
|
||||||
|
cat ~/.kube/config
|
||||||
|
|
||||||
|
# Copy the entire output and paste it as the KUBE_CONFIG secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to create a Gitea Token
|
||||||
|
|
||||||
|
1. Go to your Gitea profile → Settings → Applications
|
||||||
|
2. Create a new token with `write:package` permission
|
||||||
|
3. Use this token as `REGISTRY_PASSWORD`
|
||||||
|
|
||||||
|
## Step 2: Verify Workflow Configuration
|
||||||
|
|
||||||
|
The workflow file at [.gitea/workflows/build-deploy.yml](.gitea/workflows/build-deploy.yml) is already configured with:
|
||||||
|
|
||||||
|
- Image path: `gitea.vidoks.fr/sortifal/pfee`
|
||||||
|
- Triggers: Push to `main` or `dashboard` branches
|
||||||
|
- Build, deploy, and notify jobs
|
||||||
|
|
||||||
|
## Step 3: Test Local Build (Optional)
|
||||||
|
|
||||||
|
Before pushing, you can test the Docker build locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t gitea.vidoks.fr/sortifal/pfee:test .
|
||||||
|
|
||||||
|
# Test run locally
|
||||||
|
docker run -p 8080:80 -p 3001:3001 gitea.vidoks.fr/sortifal/pfee:test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Manual Registry Push (Optional)
|
||||||
|
|
||||||
|
If you want to manually push to the registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login to registry
|
||||||
|
docker login gitea.vidoks.fr -u <username>
|
||||||
|
|
||||||
|
# Build and tag
|
||||||
|
docker build -t gitea.vidoks.fr/sortifal/pfee:latest .
|
||||||
|
|
||||||
|
# Push to registry
|
||||||
|
docker push gitea.vidoks.fr/sortifal/pfee:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Deploy to Kubernetes
|
||||||
|
|
||||||
|
### Option A: Using the Deployment Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh gitea.vidoks.fr <username> <password> sortifal/pfee
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Manual Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
|
||||||
|
# Create registry credentials secret
|
||||||
|
kubectl create secret docker-registry registry-credentials \
|
||||||
|
--docker-server=gitea.vidoks.fr \
|
||||||
|
--docker-username=<your-username> \
|
||||||
|
--docker-password=<your-password> \
|
||||||
|
-n sqdc-dashboard
|
||||||
|
|
||||||
|
# Apply manifests
|
||||||
|
kubectl apply -f k8s/deployment.yaml
|
||||||
|
kubectl apply -f k8s/service.yaml
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
|
||||||
|
# Wait for rollout
|
||||||
|
kubectl rollout status deployment/sqdc-dashboard -n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Trigger CI/CD Pipeline
|
||||||
|
|
||||||
|
Once secrets are configured, simply push to trigger the pipeline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Configure CI/CD with registry"
|
||||||
|
git push origin dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow will:
|
||||||
|
1. Install dependencies and run tests
|
||||||
|
2. Build the React application
|
||||||
|
3. Build Docker image and push to `gitea.vidoks.fr/sortifal/pfee`
|
||||||
|
4. Deploy to Kubernetes cluster
|
||||||
|
5. Update deployment with the new image
|
||||||
|
6. Verify deployment status
|
||||||
|
|
||||||
|
## Monitoring Deployment
|
||||||
|
|
||||||
|
### View Workflow Logs
|
||||||
|
|
||||||
|
1. Go to your Gitea repository
|
||||||
|
2. Click on "Actions" tab
|
||||||
|
3. Select the workflow run
|
||||||
|
4. View logs for each job (build, deploy, notify)
|
||||||
|
|
||||||
|
### Check Kubernetes Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check pods
|
||||||
|
kubectl get pods -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Check deployment
|
||||||
|
kubectl get deployment -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Check service
|
||||||
|
kubectl get svc -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Check ingress
|
||||||
|
kubectl get ingress -n sqdc-dashboard
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
kubectl logs -f deployment/sqdc-dashboard -n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Image Pull Errors
|
||||||
|
|
||||||
|
If pods show `ImagePullBackOff`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if secret exists
|
||||||
|
kubectl get secret registry-credentials -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Describe the secret
|
||||||
|
kubectl describe secret registry-credentials -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Recreate the secret
|
||||||
|
kubectl delete secret registry-credentials -n sqdc-dashboard
|
||||||
|
kubectl create secret docker-registry registry-credentials \
|
||||||
|
--docker-server=gitea.vidoks.fr \
|
||||||
|
--docker-username=<username> \
|
||||||
|
--docker-password=<password> \
|
||||||
|
-n sqdc-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow Authentication Errors
|
||||||
|
|
||||||
|
If the workflow fails during image push:
|
||||||
|
|
||||||
|
1. Verify `REGISTRY_USER` and `REGISTRY_PASSWORD` secrets are correct
|
||||||
|
2. Ensure the token has `write:package` permission
|
||||||
|
3. Check registry URL matches exactly: `gitea.vidoks.fr`
|
||||||
|
|
||||||
|
### Kubectl Connection Errors
|
||||||
|
|
||||||
|
If deployment step fails:
|
||||||
|
|
||||||
|
1. Verify `KUBE_CONFIG` secret contains valid kubeconfig
|
||||||
|
2. Ensure the config is in plain text (not base64 encoded)
|
||||||
|
3. Check cluster is accessible from Gitea Actions runner
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Once deployment is successful:
|
||||||
|
|
||||||
|
1. Access the application via the ingress URL
|
||||||
|
2. Set up monitoring and alerts
|
||||||
|
3. Configure backup procedures for the database
|
||||||
|
4. Review and adjust resource limits based on usage
|
||||||
|
|
||||||
|
For detailed documentation, see [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||||
17
dashboard-sqdc/components.json
Normal file
17
dashboard-sqdc/components.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
dashboard-sqdc/docker-compose.yml
Normal file
27
dashboard-sqdc/docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
dashboard:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sqdc-dashboard
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
# Mount database directory for persistence
|
||||||
|
- ./database:/app/database
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: sqdc-network
|
||||||
9
dashboard-sqdc/k8s/configmap.yaml
Normal file
9
dashboard-sqdc/k8s/configmap.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: sqdc-dashboard-config
|
||||||
|
namespace: sqdc-dashboard
|
||||||
|
data:
|
||||||
|
NODE_ENV: "production"
|
||||||
|
API_PORT: "3001"
|
||||||
|
# Add other environment variables as needed
|
||||||
76
dashboard-sqdc/k8s/deployment.yaml
Normal file
76
dashboard-sqdc/k8s/deployment.yaml
Normal 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
|
||||||
27
dashboard-sqdc/k8s/ingress.yaml
Normal file
27
dashboard-sqdc/k8s/ingress.yaml
Normal 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
|
||||||
19
dashboard-sqdc/k8s/kustomization.yaml
Normal file
19
dashboard-sqdc/k8s/kustomization.yaml
Normal 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
|
||||||
7
dashboard-sqdc/k8s/namespace.yaml
Normal file
7
dashboard-sqdc/k8s/namespace.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: sqdc-dashboard
|
||||||
|
labels:
|
||||||
|
name: sqdc-dashboard
|
||||||
|
environment: production
|
||||||
23
dashboard-sqdc/k8s/registry-secret.yaml
Normal file
23
dashboard-sqdc/k8s/registry-secret.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Registry credentials secret
|
||||||
|
# This is a template - create the actual secret using:
|
||||||
|
# kubectl create secret docker-registry registry-credentials \
|
||||||
|
# --docker-server=<REGISTRY_URL> \
|
||||||
|
# --docker-username=<REGISTRY_USER> \
|
||||||
|
# --docker-password=<REGISTRY_PASSWORD> \
|
||||||
|
# -n sqdc-dashboard
|
||||||
|
|
||||||
|
# Or use this manifest (replace values):
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: registry-credentials
|
||||||
|
namespace: sqdc-dashboard
|
||||||
|
type: kubernetes.io/dockerconfigjson
|
||||||
|
data:
|
||||||
|
.dockerconfigjson: <base64-encoded-docker-config>
|
||||||
|
# To generate the base64 encoded value:
|
||||||
|
# kubectl create secret docker-registry registry-credentials \
|
||||||
|
# --docker-server=REGISTRY_URL \
|
||||||
|
# --docker-username=REGISTRY_USER \
|
||||||
|
# --docker-password=REGISTRY_PASSWORD \
|
||||||
|
# --dry-run=client -o yaml | grep .dockerconfigjson
|
||||||
20
dashboard-sqdc/k8s/service.yaml
Normal file
20
dashboard-sqdc/k8s/service.yaml
Normal 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
45
dashboard-sqdc/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
892
dashboard-sqdc/package-lock.json
generated
892
dashboard-sqdc/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
6
dashboard-sqdc/postcss.config.js
Normal file
6
dashboard-sqdc/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
79
dashboard-sqdc/scripts/deploy.sh
Executable file
79
dashboard-sqdc/scripts/deploy.sh
Executable file
@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# SQDC Dashboard Deployment Script
|
||||||
|
# Usage: ./scripts/deploy.sh [registry_url] [registry_user] [registry_password] [repository_path]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REGISTRY_URL=${1:-"gitea.vidoks.fr"}
|
||||||
|
REGISTRY_USER=${2}
|
||||||
|
REGISTRY_PASSWORD=${3}
|
||||||
|
REPOSITORY_PATH=${4:-"sortifal/pfee"}
|
||||||
|
NAMESPACE="sqdc-dashboard"
|
||||||
|
|
||||||
|
echo "🚀 Deploying SQDC Dashboard..."
|
||||||
|
|
||||||
|
# Check if registry credentials are provided
|
||||||
|
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then
|
||||||
|
echo "⚠️ Warning: Registry credentials not provided. Using local image."
|
||||||
|
REGISTRY_URL="local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
echo "📦 Building Docker image..."
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
if [ "$REGISTRY_URL" != "local" ]; then
|
||||||
|
# Login to registry
|
||||||
|
echo "🔐 Logging in to registry..."
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login $REGISTRY_URL -u $REGISTRY_USER --password-stdin
|
||||||
|
|
||||||
|
# Build and push to registry
|
||||||
|
IMAGE_NAME="$REGISTRY_URL/$REPOSITORY_PATH"
|
||||||
|
docker build -t $IMAGE_NAME:$TIMESTAMP .
|
||||||
|
docker tag $IMAGE_NAME:$TIMESTAMP $IMAGE_NAME:latest
|
||||||
|
docker push $IMAGE_NAME:$TIMESTAMP
|
||||||
|
docker push $IMAGE_NAME:latest
|
||||||
|
echo "✅ Docker image pushed: $IMAGE_NAME:$TIMESTAMP"
|
||||||
|
else
|
||||||
|
# Build local image
|
||||||
|
docker build -t sqdc-dashboard:latest .
|
||||||
|
docker tag sqdc-dashboard:latest sqdc-dashboard:$TIMESTAMP
|
||||||
|
echo "✅ Docker image built: sqdc-dashboard:$TIMESTAMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply Kubernetes manifests
|
||||||
|
echo "☸️ Applying Kubernetes manifests..."
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
|
||||||
|
# Create registry secret if credentials provided
|
||||||
|
if [ "$REGISTRY_URL" != "local" ]; then
|
||||||
|
echo "🔑 Creating registry credentials secret..."
|
||||||
|
kubectl create secret docker-registry registry-credentials \
|
||||||
|
--docker-server=$REGISTRY_URL \
|
||||||
|
--docker-username=$REGISTRY_USER \
|
||||||
|
--docker-password=$REGISTRY_PASSWORD \
|
||||||
|
-n $NAMESPACE \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
fi
|
||||||
|
|
||||||
|
kubectl apply -f k8s/deployment.yaml
|
||||||
|
kubectl apply -f k8s/service.yaml
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
|
||||||
|
# Wait for rollout
|
||||||
|
echo "⏳ Waiting for deployment to complete..."
|
||||||
|
kubectl rollout status deployment/sqdc-dashboard -n $NAMESPACE --timeout=5m
|
||||||
|
|
||||||
|
# Display deployment info
|
||||||
|
echo ""
|
||||||
|
echo "📊 Deployment Status:"
|
||||||
|
kubectl get pods -n $NAMESPACE
|
||||||
|
echo ""
|
||||||
|
kubectl get svc -n $NAMESPACE
|
||||||
|
echo ""
|
||||||
|
kubectl get ingress -n $NAMESPACE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo "🌐 Access the dashboard at: http://sqdc-dashboard.local"
|
||||||
18
dashboard-sqdc/scripts/rollback.sh
Executable file
18
dashboard-sqdc/scripts/rollback.sh
Executable file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# SQDC Dashboard Rollback Script
|
||||||
|
# Usage: ./scripts/rollback.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NAMESPACE="sqdc-dashboard"
|
||||||
|
|
||||||
|
echo "⏪ Rolling back SQDC Dashboard deployment..."
|
||||||
|
|
||||||
|
kubectl rollout undo deployment/sqdc-dashboard -n $NAMESPACE
|
||||||
|
|
||||||
|
echo "⏳ Waiting for rollback to complete..."
|
||||||
|
kubectl rollout status deployment/sqdc-dashboard -n $NAMESPACE --timeout=5m
|
||||||
|
|
||||||
|
echo "✅ Rollback complete!"
|
||||||
|
kubectl get pods -n $NAMESPACE
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-red-600 text-white hover:bg-red-700",
|
||||||
|
outline: "text-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-emerald-600 text-white hover:bg-emerald-700",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-amber-500 text-gray-900 hover:bg-amber-600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
dashboard-sqdc/src/components/ui/card.tsx
Normal file
79
dashboard-sqdc/src/components/ui/card.tsx
Normal 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 }
|
||||||
120
dashboard-sqdc/src/components/ui/dialog.tsx
Normal file
120
dashboard-sqdc/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
117
dashboard-sqdc/src/components/ui/table.tsx
Normal file
117
dashboard-sqdc/src/components/ui/table.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
53
dashboard-sqdc/src/components/ui/tabs.tsx
Normal file
53
dashboard-sqdc/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
dashboard-sqdc/src/lib/utils.ts
Normal file
6
dashboard-sqdc/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@ -1,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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
77
dashboard-sqdc/tailwind.config.js
Normal file
77
dashboard-sqdc/tailwind.config.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
prefix: "",
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
@ -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 d’Accidents avec Arret/Nombre d’Heures Travaillees)×1000000
|
|
||||||
SÉCURITÉ,Nombre d'Incidents/Near Miss,Évaluer la culture de sécurité et la proactivité.,Compte des rapports d’incidents (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 d’Unites Rebutees/Nombre Total d’Unites Produites)×100
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
QUALITÉ,Taux de rendement synthétique (TRS),,Nb pièces bonnes x Temps cycle / Temps d’ouverture
|
|
||||||
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 d’Unites 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 d’Arret Non Planifie
|
|
||||||
COÛT,Coût par Unité (CPU),Mesurer l'efficacité des coûts de production.,Cout Total de Production/Nombre Total d’Unites Produites
|
|
||||||
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
|
|
||||||
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 d’Unites Produites
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user