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",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@ -21,13 +25,20 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": {
|
||||
"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 { DetailPage } from './pages/DetailPage';
|
||||
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';
|
||||
|
||||
@ -10,112 +12,146 @@ function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('home');
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div className="header-content">
|
||||
<div className="header-title">
|
||||
<h1>📊 Dashboard SQDC</h1>
|
||||
<p>Indicateurs de Performance - Sécurité, Qualité, Délais, Coûts</p>
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">SQDC Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Safety • Quality • Delivery • Cost • Maintenance
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-date">
|
||||
{new Date().toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'home' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('home')}
|
||||
{/* Navigation */}
|
||||
<div className="border-b bg-background sticky top-0 z-10">
|
||||
<div className="container mx-auto px-8">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)}>
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-1">
|
||||
<TabsTrigger
|
||||
value="home"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
🏠 Accueil
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'security' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('security')}
|
||||
<Home className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
🛡️ Sécurité
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'quality' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('quality')}
|
||||
<Shield className="h-4 w-4" />
|
||||
Safety
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="quality"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
🎯 Qualité
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'delays' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('delays')}
|
||||
<Target className="h-4 w-4" />
|
||||
Quality
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="delays"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
⏱️ Délais
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'costs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('costs')}
|
||||
<Clock className="h-4 w-4" />
|
||||
Delivery
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="costs"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
💰 Coûts
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'maintenance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('maintenance')}
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Cost
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="maintenance"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
🔧 Maintenance
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'charts' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('charts')}
|
||||
<Wrench className="h-4 w-4" />
|
||||
Maintenance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="charts"
|
||||
className="data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-t-lg border-b-2 border-transparent data-[state=active]:border-primary gap-2 px-6 py-3"
|
||||
>
|
||||
📈 Analyses
|
||||
</button>
|
||||
</nav>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<main className="app-content">
|
||||
{activeTab === 'home' && (
|
||||
<TabsContent value="home" className="mt-0">
|
||||
<HomePage />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<TabsContent value="security" className="mt-0">
|
||||
<DetailPage category="security" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<TabsContent value="quality" className="mt-0">
|
||||
<DetailPage category="quality" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'delays' && (
|
||||
<TabsContent value="delays" className="mt-0">
|
||||
<DetailPage category="delays" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'costs' && (
|
||||
<TabsContent value="costs" className="mt-0">
|
||||
<DetailPage category="costs" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'maintenance' && (
|
||||
<TabsContent value="maintenance" className="mt-0">
|
||||
<DetailPage category="maintenance" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{activeTab === 'charts' && (
|
||||
<div className="charts-page">
|
||||
<div className="charts-header">
|
||||
<h1>📈 Analyses et Tendances</h1>
|
||||
<p>Vue globale des tendances et des analyses comparatives</p>
|
||||
<TabsContent value="charts" className="mt-0">
|
||||
<div className="container mx-auto p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics & Trends</h1>
|
||||
<p className="text-muted-foreground">Comprehensive performance analysis</p>
|
||||
</div>
|
||||
<div className="charts-grid">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="p-6">
|
||||
<TrendChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<CategoryDistributionChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<StatusChart />
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<CNQChart />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>Dashboard SQDC | Dernière mise à jour: {new Date().toLocaleTimeString('fr-FR')} | Données en temps réel</p>
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-card mt-12">
|
||||
<div className="container mx-auto px-8 py-6">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<p>SQDC Dashboard - Real-time Performance Monitoring</p>
|
||||
<p>Last updated: {new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import '../styles/ChartModal.css';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { LineChart } from 'lucide-react';
|
||||
|
||||
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 }) => {
|
||||
if (!isOpen || !kpi) return null;
|
||||
if (!kpi) return null;
|
||||
|
||||
// Préparer les données pour le graphique
|
||||
const labels = measurements
|
||||
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
|
||||
.reverse();
|
||||
@ -30,13 +36,13 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
|
||||
{
|
||||
label: kpi.name,
|
||||
data: values,
|
||||
borderColor: '#6496ff',
|
||||
backgroundColor: 'rgba(100, 150, 255, 0.1)',
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#6496ff',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
@ -49,11 +55,12 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `Graphique Complet: ${kpi.name}`,
|
||||
font: { size: 16, weight: 'bold' as const },
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
@ -62,31 +69,46 @@ export const ChartModal: React.FC<ChartModalProps> = ({ isOpen, kpi, measurement
|
||||
title: {
|
||||
display: true,
|
||||
text: kpi.unit,
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
display: true,
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart-modal-overlay" onClick={onClose}>
|
||||
<div className="chart-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="chart-modal-header">
|
||||
<h2>📈 {kpi.name}</h2>
|
||||
<button className="chart-modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="chart-modal-body">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LineChart className="h-5 w-5" />
|
||||
{kpi.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Nombre de mesures: {measurements.length} | Période: {labels[0]} à {labels[labels.length - 1]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div style={{ height: '500px', width: '100%' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-modal-footer">
|
||||
<p>Nombre de mesures: <strong>{measurements.length}</strong></p>
|
||||
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { kpiData, getCategoryColor } from '../data/kpiData';
|
||||
import '../styles/Charts.css';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@ -25,60 +24,107 @@ ChartJS.register(
|
||||
Legend
|
||||
);
|
||||
|
||||
const commonOptions = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TrendChart: React.FC = () => {
|
||||
const data = {
|
||||
labels: ['Semaine 1', 'Semaine 2', 'Semaine 3', 'Semaine 4'],
|
||||
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Sécurité (%)',
|
||||
label: 'Safety (%)',
|
||||
data: [94, 95, 96, 96],
|
||||
borderColor: getCategoryColor('security'),
|
||||
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Qualité (%)',
|
||||
label: 'Quality (%)',
|
||||
data: [75, 76, 77, 78.5],
|
||||
borderColor: getCategoryColor('quality'),
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Délais (%)',
|
||||
label: 'Delivery (%)',
|
||||
data: [97, 97.5, 97.8, 98],
|
||||
borderColor: getCategoryColor('delays'),
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.1)',
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Coûts (%)',
|
||||
label: 'Cost (%)',
|
||||
data: [92, 91, 90.5, 89],
|
||||
borderColor: getCategoryColor('costs'),
|
||||
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<h2>📈 Tendances SQDC</h2>
|
||||
<Line data={data} options={{
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">SQDC Trends</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Line
|
||||
data={data}
|
||||
options={{
|
||||
...commonOptions,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' as const } },
|
||||
scales: { y: { beginAtZero: true, max: 100 } }
|
||||
}} />
|
||||
plugins: {
|
||||
...commonOptions.plugins,
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
beginAtZero: true,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CategoryDistributionChart: React.FC = () => {
|
||||
const categoryCounts = {
|
||||
'Sécurité': kpiData.security.length,
|
||||
'Qualité': kpiData.quality.length,
|
||||
'Délais': kpiData.delays.length,
|
||||
'Coûts': kpiData.costs.length,
|
||||
'Safety': kpiData.security.length,
|
||||
'Quality': kpiData.quality.length,
|
||||
'Delivery': kpiData.delays.length,
|
||||
'Cost': kpiData.costs.length,
|
||||
'Maintenance': kpiData.maintenance.length
|
||||
};
|
||||
|
||||
@ -87,40 +133,41 @@ export const CategoryDistributionChart: React.FC = () => {
|
||||
datasets: [{
|
||||
data: Object.values(categoryCounts),
|
||||
backgroundColor: [
|
||||
getCategoryColor('security'),
|
||||
getCategoryColor('quality'),
|
||||
getCategoryColor('delays'),
|
||||
getCategoryColor('costs'),
|
||||
getCategoryColor('maintenance')
|
||||
'#ef4444',
|
||||
'#3b82f6',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#8b5cf6'
|
||||
],
|
||||
borderColor: 'white',
|
||||
borderColor: '#1e293b',
|
||||
borderWidth: 2
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<h2>📊 Répartition KPI par Catégorie</h2>
|
||||
<Doughnut data={data} options={{
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">KPI Distribution by Category</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Doughnut
|
||||
data={data}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' as const } }
|
||||
}} />
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusChart: React.FC = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const allKPIs = [
|
||||
...kpiData.security,
|
||||
...kpiData.quality,
|
||||
...kpiData.delays,
|
||||
...kpiData.costs,
|
||||
...kpiData.maintenance
|
||||
];
|
||||
|
||||
const categories = ['Sécurité', 'Qualité', 'Délais', 'Coûts', 'Maintenance'];
|
||||
const categories = ['Safety', 'Quality', 'Delivery', 'Cost', 'Maintenance'];
|
||||
const categoryKPIs = [
|
||||
kpiData.security,
|
||||
kpiData.quality,
|
||||
@ -133,65 +180,95 @@ export const StatusChart: React.FC = () => {
|
||||
labels: categories,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Bon',
|
||||
label: 'On Target',
|
||||
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),
|
||||
backgroundColor: '#f39c12'
|
||||
backgroundColor: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: 'Critique',
|
||||
label: 'Critical',
|
||||
data: categoryKPIs.map(cat => cat.filter(k => k.status === 'critical').length),
|
||||
backgroundColor: '#e74c3c'
|
||||
backgroundColor: '#ef4444'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<h2>📊 État des KPI par Catégorie</h2>
|
||||
<Bar data={data} options={{
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">KPI Status by Category</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y' as const,
|
||||
plugins: { legend: { position: 'top' as const } },
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true }
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: { color: '#cbd5e1' }
|
||||
}
|
||||
}} />
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CNQChart: React.FC = () => {
|
||||
const data = {
|
||||
labels: ['Rebuts', 'Retouches', 'Retours Clients'],
|
||||
labels: ['Scrap', 'Rework', 'Returns'],
|
||||
datasets: [{
|
||||
label: 'Coût (€)',
|
||||
label: 'Cost (€)',
|
||||
data: [8500, 7200, 2800],
|
||||
backgroundColor: [
|
||||
'#e74c3c',
|
||||
'#f39c12',
|
||||
'#3498db'
|
||||
'#ef4444',
|
||||
'#f59e0b',
|
||||
'#3b82f6'
|
||||
],
|
||||
borderColor: 'white',
|
||||
borderColor: '#1e293b',
|
||||
borderWidth: 2
|
||||
}]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<h2>💔 Coûts des Non-Qualité</h2>
|
||||
<Bar data={data} options={{
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Non-Quality Costs</h3>
|
||||
<div style={{ height: '300px' }}>
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
...commonOptions,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}} />
|
||||
scales: {
|
||||
...commonOptions.scales,
|
||||
y: {
|
||||
...commonOptions.scales.y,
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
@ -11,8 +20,6 @@ interface ExportModalProps {
|
||||
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, kpiName, onExport, onClose }) => {
|
||||
const [selectedRange, setSelectedRange] = useState<number | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleExport = () => {
|
||||
if (selectedRange !== null) {
|
||||
onExport(selectedRange);
|
||||
@ -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 (
|
||||
<div className="export-modal-overlay" onClick={onClose}>
|
||||
<div className="export-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="export-modal-header">
|
||||
<h2>💾 Exporter {kpiName}</h2>
|
||||
<button className="export-modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileDown className="h-5 w-5" />
|
||||
Exporter {kpiName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez la plage de données à exporter en Excel
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="export-modal-body">
|
||||
<p className="export-modal-description">
|
||||
Sélectionnez la plage de données à exporter:
|
||||
</p>
|
||||
|
||||
<div className="export-options">
|
||||
<label className={`export-option ${selectedRange === 7 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="range"
|
||||
value="7"
|
||||
checked={selectedRange === 7}
|
||||
onChange={() => setSelectedRange(7)}
|
||||
/>
|
||||
<span className="option-label">📅 Dernière semaine</span>
|
||||
<span className="option-sublabel">(7 jours)</span>
|
||||
</label>
|
||||
|
||||
<label className={`export-option ${selectedRange === 30 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="range"
|
||||
value="30"
|
||||
checked={selectedRange === 30}
|
||||
onChange={() => setSelectedRange(30)}
|
||||
/>
|
||||
<span className="option-label">📆 Dernier mois</span>
|
||||
<span className="option-sublabel">(30 jours)</span>
|
||||
</label>
|
||||
|
||||
<label className={`export-option ${selectedRange === 365 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="range"
|
||||
value="365"
|
||||
checked={selectedRange === 365}
|
||||
onChange={() => setSelectedRange(365)}
|
||||
/>
|
||||
<span className="option-label">📊 Cette année</span>
|
||||
<span className="option-sublabel">(365 jours)</span>
|
||||
</label>
|
||||
|
||||
<label className={`export-option ${selectedRange === -1 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="range"
|
||||
value="-1"
|
||||
checked={selectedRange === -1}
|
||||
onChange={() => setSelectedRange(-1)}
|
||||
/>
|
||||
<span className="option-label">📈 Toutes les données</span>
|
||||
<span className="option-sublabel">(Sans limite)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-modal-footer">
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
<div className="grid gap-3 py-4">
|
||||
{ranges.map((range) => {
|
||||
const Icon = range.icon;
|
||||
return (
|
||||
<button
|
||||
className="btn-export-confirm"
|
||||
onClick={handleExport}
|
||||
disabled={selectedRange === null}
|
||||
key={range.value}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg border-2 transition-all text-left ${
|
||||
selectedRange === range.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelectedRange(range.value)}
|
||||
>
|
||||
✓ 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>
|
||||
);
|
||||
})}
|
||||
</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 '../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 {
|
||||
kpi: any;
|
||||
@ -7,54 +9,70 @@ interface KPICardProps {
|
||||
}
|
||||
|
||||
export const KPICard: React.FC<KPICardProps> = ({ kpi, color }) => {
|
||||
const getStatusIcon = () => {
|
||||
const getStatusVariant = () => {
|
||||
switch (kpi.status) {
|
||||
case 'good':
|
||||
return '✓';
|
||||
return 'success';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
return 'warning';
|
||||
case 'critical':
|
||||
return '🔴';
|
||||
return 'destructive';
|
||||
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 = () => {
|
||||
if (!kpi.latest) return '•';
|
||||
|
||||
// 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}`;
|
||||
if (!kpi.latest) return <Minus className="h-4 w-4" />;
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="kpi-card" style={{ borderLeftColor: color }}>
|
||||
<div className="kpi-header" style={{ color }}>
|
||||
<h3 className="kpi-title">{kpi.name}</h3>
|
||||
<span className="kpi-trend">{getTrendIcon()}</span>
|
||||
<Card className="border-l-4 hover:shadow-md transition-shadow" style={{ borderLeftColor: color }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base font-semibold" style={{ color }}>
|
||||
{kpi.name}
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground">
|
||||
{getTrendIcon()}
|
||||
</div>
|
||||
|
||||
<div className="kpi-value-section">
|
||||
<div className="kpi-value">{kpi.value}</div>
|
||||
<div className="kpi-unit">{kpi.unit}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="kpi-footer">
|
||||
<span className={`kpi-status ${getStatusClass()}`}>
|
||||
{getStatusIcon()} {kpi.status?.charAt(0).toUpperCase() + kpi.status?.slice(1) || 'N/A'}
|
||||
</span>
|
||||
<CardContent className="pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold">{kpi.value}</div>
|
||||
<div className="text-sm text-muted-foreground">{kpi.unit}</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||
{kpi.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<Badge variant={getStatusVariant()}>
|
||||
{getStatusLabel()}
|
||||
</Badge>
|
||||
{kpi.target && (
|
||||
<span className="kpi-target">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Obj: {kpi.target} {kpi.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="kpi-description">{kpi.description}</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import '../styles/RangeChartModal.css';
|
||||
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);
|
||||
|
||||
@ -22,12 +30,10 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// Préparer les données pour le graphique
|
||||
const labels = filteredMeasurements
|
||||
.map(m => new Date(m.measurement_date).toLocaleDateString('fr-FR'))
|
||||
.reverse();
|
||||
@ -42,13 +48,13 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
|
||||
{
|
||||
label: kpi.name,
|
||||
data: values,
|
||||
borderColor: '#6496ff',
|
||||
backgroundColor: 'rgba(100, 150, 255, 0.1)',
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#6496ff',
|
||||
pointBorderColor: '#fff',
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
@ -61,11 +67,12 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `Graphique Complet: ${kpi.name}`,
|
||||
font: { size: 16, weight: 'bold' as const },
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
@ -74,86 +81,67 @@ export const RangeChartModal: React.FC<RangeChartModalProps> = ({
|
||||
title: {
|
||||
display: true,
|
||||
text: kpi.unit,
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
display: true,
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ranges = [
|
||||
{ value: 7, label: 'Semaine' },
|
||||
{ value: 30, label: 'Mois' },
|
||||
{ value: 90, label: 'Trimestre' },
|
||||
{ value: 365, label: 'Année' },
|
||||
{ value: -1, label: 'Tout' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="range-chart-modal-overlay" onClick={onClose}>
|
||||
<div className="range-chart-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="range-chart-modal-header">
|
||||
<h2>📈 {kpi.name}</h2>
|
||||
<button className="range-chart-modal-close" onClick={onClose}>✕</button>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LineChart className="h-5 w-5" />
|
||||
{kpi.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Mesures: {filteredMeasurements.length} | Période: {labels[0]} à {labels[labels.length - 1]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 flex-wrap py-2">
|
||||
{ranges.map((range) => (
|
||||
<Button
|
||||
key={range.value}
|
||||
variant={selectedRange === range.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedRange(range.value)}
|
||||
>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="range-chart-modal-range-selector">
|
||||
<label className={`range-option ${selectedRange === 7 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="chartRange"
|
||||
value="7"
|
||||
checked={selectedRange === 7}
|
||||
onChange={() => setSelectedRange(7)}
|
||||
/>
|
||||
<span>📅 Semaine</span>
|
||||
</label>
|
||||
<label className={`range-option ${selectedRange === 30 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="chartRange"
|
||||
value="30"
|
||||
checked={selectedRange === 30}
|
||||
onChange={() => setSelectedRange(30)}
|
||||
/>
|
||||
<span>📆 Mois</span>
|
||||
</label>
|
||||
<label className={`range-option ${selectedRange === 90 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="chartRange"
|
||||
value="90"
|
||||
checked={selectedRange === 90}
|
||||
onChange={() => setSelectedRange(90)}
|
||||
/>
|
||||
<span>📊 Trimestre</span>
|
||||
</label>
|
||||
<label className={`range-option ${selectedRange === 365 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="chartRange"
|
||||
value="365"
|
||||
checked={selectedRange === 365}
|
||||
onChange={() => setSelectedRange(365)}
|
||||
/>
|
||||
<span>📈 Année</span>
|
||||
</label>
|
||||
<label className={`range-option ${selectedRange === -1 ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="chartRange"
|
||||
value="-1"
|
||||
checked={selectedRange === -1}
|
||||
onChange={() => setSelectedRange(-1)}
|
||||
/>
|
||||
<span>🔄 Tout</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="range-chart-modal-body">
|
||||
<div className="py-4">
|
||||
<div style={{ height: '500px', width: '100%' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="range-chart-modal-footer">
|
||||
<p>Mesures: <strong>{filteredMeasurements.length}</strong></p>
|
||||
<p>Période: <strong>{labels[0]} à {labels[labels.length - 1]}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
40
dashboard-sqdc/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-red-600 text-white hover:bg-red-700",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-emerald-600 text-white hover:bg-emerald-700",
|
||||
warning:
|
||||
"border-transparent bg-amber-500 text-gray-900 hover:bg-amber-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
56
dashboard-sqdc/src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
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 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
@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%;
|
||||
}
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
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 * as XLSX from 'xlsx';
|
||||
import { getCategoryColor, getCategoryName, getCategoryEmoji } from '../data/kpiData';
|
||||
import { getCategoryColor, getCategoryName } from '../data/kpiData';
|
||||
import { useSQLiteDatabase } from '../database/useSQLiteDatabase';
|
||||
import { ChartModal } from '../components/ChartModal';
|
||||
import { ExportModal } from '../components/ExportModal';
|
||||
import { RangeChartModal } from '../components/RangeChartModal';
|
||||
import '../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 {
|
||||
category: 'security' | 'quality' | 'delays' | 'costs' | 'maintenance';
|
||||
@ -29,10 +33,8 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showChartRangeModal, setShowChartRangeModal] = useState(false);
|
||||
|
||||
// Obtenir les KPI de cette catégorie
|
||||
const categoryKPIs = db.kpis.filter(kpi => kpi.category_id === categoryId);
|
||||
|
||||
// Charger les mesures quand un KPI est sélectionné
|
||||
useEffect(() => {
|
||||
if (!selectedKPIId) {
|
||||
if (categoryKPIs.length > 0) {
|
||||
@ -53,23 +55,19 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
|
||||
|
||||
const selectedKPI = categoryKPIs.find(k => k.id === selectedKPIId);
|
||||
|
||||
// Export to Excel
|
||||
const exportToExcel = (kpi: any, measurements: any[]) => {
|
||||
if (!kpi || measurements.length === 0) return;
|
||||
|
||||
// Préparer les données
|
||||
const data = measurements.map(m => ({
|
||||
Date: new Date(m.measurement_date).toLocaleString('fr-FR'),
|
||||
Valeur: m.value,
|
||||
Statut: m.status,
|
||||
}));
|
||||
|
||||
// Créer un classeur Excel
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Mesures');
|
||||
|
||||
// Ajouter une feuille de résumé
|
||||
const summary = [
|
||||
['KPI', kpi.name],
|
||||
['Unité', kpi.unit],
|
||||
@ -82,18 +80,15 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
|
||||
const summarySheet = XLSX.utils.aoa_to_sheet(summary);
|
||||
XLSX.utils.book_append_sheet(workbook, summarySheet, 'Résumé');
|
||||
|
||||
// Télécharger
|
||||
const filename = `${kpi.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
};
|
||||
|
||||
// Handle export with date range selection
|
||||
const handleExportClick = (days: number) => {
|
||||
if (!selectedKPI) return;
|
||||
|
||||
let measurementsToExport = selectedKPIMeasurements;
|
||||
|
||||
// Si l'utilisateur a sélectionné une plage spécifique (pas -1 pour tous)
|
||||
if (days > 0 && days !== 365) {
|
||||
measurementsToExport = selectedKPIMeasurements.filter((m: any) => {
|
||||
const daysAgo = (Date.now() - new Date(m.measurement_date).getTime()) / (1000 * 60 * 60 * 24);
|
||||
@ -104,7 +99,6 @@ export const DetailPage: React.FC<DetailPageProps> = ({ category }) => {
|
||||
exportToExcel(selectedKPI, measurementsToExport);
|
||||
};
|
||||
|
||||
// Get measurements for chart by date range
|
||||
const getMeasurementsForDateRange = (days: number) => {
|
||||
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 (
|
||||
<div className="detail-page">
|
||||
<div className="detail-header">
|
||||
<h1 style={{ color: getCategoryColor(category) }}>
|
||||
{getCategoryEmoji(category)} {getCategoryName(category)}
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="pb-4 border-b-2" style={{ borderColor: getCategoryColor(category) }}>
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-2" style={{ color: getCategoryColor(category) }}>
|
||||
{getCategoryName(category)}
|
||||
</h1>
|
||||
<p>Analyse détaillée des indicateurs</p>
|
||||
<p className="text-lg text-muted-foreground">Detailed metric analysis and performance tracking</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
<div className="kpi-selector">
|
||||
<h3>Sélectionner un KPI</h3>
|
||||
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn btn-export"
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* KPI Selector */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Sélectionner un KPI</CardTitle>
|
||||
<CardDescription>Choisissez un indicateur pour voir les détails</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={!selectedKPI}
|
||||
>
|
||||
📊 Exporter Excel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-chart"
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exporter
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowChartRangeModal(true)}
|
||||
disabled={!selectedKPI}
|
||||
>
|
||||
📈 Graphique Complet
|
||||
</button>
|
||||
<LineChart className="h-4 w-4 mr-2" />
|
||||
Graphique
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="kpi-list">
|
||||
<div className="space-y-2">
|
||||
{categoryKPIs.map(kpi => (
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<div className="kpi-name">{kpi.name}</div>
|
||||
<div className="kpi-unit">{kpi.unit}</div>
|
||||
<div className="font-medium text-sm">{kpi.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{kpi.unit}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPI Details */}
|
||||
{selectedKPI && (
|
||||
<div className="kpi-details">
|
||||
<div className="details-header">
|
||||
<h2>{selectedKPI.name}</h2>
|
||||
<div className="details-info">
|
||||
<div className="info-item">
|
||||
<div className="info-label">Unité</div>
|
||||
<div className="info-value">{selectedKPI.unit}</div>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* KPI Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selectedKPI.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Unité</div>
|
||||
<div className="font-medium">{selectedKPI.unit}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="info-label">Cible</div>
|
||||
<div className="info-value">{selectedKPI.target}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="info-label">Fréquence</div>
|
||||
<div className="info-value">{selectedKPI.frequency || 'N/A'}</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Cible</div>
|
||||
<div className="font-medium">{selectedKPI.target}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Fréquence</div>
|
||||
<div className="font-medium">{selectedKPI.frequency || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-description">
|
||||
<h4>Description</h4>
|
||||
<p>{selectedKPI.description}</p>
|
||||
<h4>Formule</h4>
|
||||
<p>{selectedKPI.formula}</p>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">{selectedKPI.description}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Formule</h4>
|
||||
<p className="text-sm text-muted-foreground">{selectedKPI.formula}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
{selectedKPIStats && (
|
||||
<div className="details-stats">
|
||||
<h4>Statistiques (30 derniers jours)</h4>
|
||||
<div className="stats-container">
|
||||
<div className="stat-box">
|
||||
<div className="stat-box-label">Moyenne</div>
|
||||
<div className="stat-box-value">{selectedKPIStats.avg?.toFixed(2) || 'N/A'}</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques (30 derniers jours)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.avg?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Moyenne</div>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<div className="stat-box-label">Min</div>
|
||||
<div className="stat-box-value">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.min?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Min</div>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<div className="stat-box-label">Max</div>
|
||||
<div className="stat-box-value">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="stat-box">
|
||||
<div className="stat-box-label">Mesures</div>
|
||||
<div className="stat-box-value">{selectedKPIStats.count || 0}</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.max?.toFixed(2) || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">Max</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{selectedKPIStats.count || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Mesures</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="measurements-section">
|
||||
<h4>Dernières mesures ({selectedKPIMeasurements.length})</h4>
|
||||
<table className="measurements-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Valeur</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Measurements Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dernières mesures ({selectedKPIMeasurements.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Valeur</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedKPIMeasurements.slice(-10).reverse().map((m: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>{new Date(m.measurement_date).toLocaleString('fr-FR')}</td>
|
||||
<td>{m.value}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${m.status}`}>
|
||||
{m.status === 'good' ? '✓ Bon' : m.status === 'warning' ? '⚠️ Attention' : '🔴 Critique'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{new Date(m.measurement_date).toLocaleString('fr-FR')}</TableCell>
|
||||
<TableCell>{m.value}</TableCell>
|
||||
<TableCell>{getStatusBadge(m.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { useSQLiteDatabase } from '../database/useSQLiteDatabase';
|
||||
import '../styles/HomePage.css';
|
||||
import { AlertTriangle, TrendingUp, BarChart3, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const db = useSQLiteDatabase();
|
||||
@ -12,7 +15,6 @@ export const HomePage: React.FC = () => {
|
||||
const [avgPerformance, setAvgPerformance] = useState(0);
|
||||
const [criticalAlerts, setCriticalAlerts] = useState<any[]>([]);
|
||||
|
||||
// Convertir la plage de temps en nombre de jours
|
||||
const getDaysFromTimeRange = (range: 'today' | 'week' | 'last7' | 'month' | 'year'): number => {
|
||||
switch (range) {
|
||||
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(() => {
|
||||
if (db.loading || db.kpis.length === 0) return;
|
||||
|
||||
@ -43,7 +44,6 @@ export const HomePage: React.FC = () => {
|
||||
const kpisWithStatus: any[] = [];
|
||||
const alertList: any[] = [];
|
||||
|
||||
// Charger les mesures pour chaque KPI
|
||||
for (const kpi of db.kpis) {
|
||||
const measurements = await db.getMeasurementsForKPI(kpi.id, days);
|
||||
|
||||
@ -74,7 +74,6 @@ export const HomePage: React.FC = () => {
|
||||
kpisWithStatus.push({ ...kpi, status, value });
|
||||
}
|
||||
|
||||
// Calculer les statistiques
|
||||
const statCounts = {
|
||||
total: kpisWithStatus.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
|
||||
};
|
||||
|
||||
// Grouper par catégorie
|
||||
const topKPIsMap: any = {};
|
||||
[1, 2, 3, 4, 5].forEach(catId => {
|
||||
topKPIsMap[catId] = kpisWithStatus
|
||||
.filter(k => k.category_id === catId)
|
||||
.slice(0, 2);
|
||||
.slice(0, 1);
|
||||
});
|
||||
|
||||
// Performance globale
|
||||
const performance = Math.round(
|
||||
((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;
|
||||
return 0;
|
||||
}).slice(0, 4));
|
||||
}).slice(0, 3));
|
||||
};
|
||||
|
||||
fetchData();
|
||||
@ -110,127 +107,141 @@ export const HomePage: React.FC = () => {
|
||||
|
||||
if (db.loading) {
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="stats-overview">
|
||||
<p style={{ textAlign: 'center', padding: '2rem' }}>⏳ Chargement des données...</p>
|
||||
<div className="container mx-auto p-6">
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary"></div>
|
||||
<p className="text-center text-muted-foreground">Loading data...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (db.error) {
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="stats-overview">
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: 'red' }}>❌ {db.error}</p>
|
||||
<p style={{ textAlign: 'center' }}>Assurez-vous que le serveur API est lancé: <code>npm run server</code></p>
|
||||
<div className="container mx-auto p-6">
|
||||
<Card className="border-2 border-destructive">
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive" />
|
||||
<p className="text-center text-destructive text-lg font-semibold">{db.error}</p>
|
||||
<p className="text-center text-sm text-muted-foreground max-w-md">
|
||||
Please ensure the API server is running: <code className="bg-muted px-2 py-1 rounded font-mono">npm run server</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="stats-overview">
|
||||
<div className="overview-header">
|
||||
<h1>📊 Dashboard SQDC</h1>
|
||||
<div className="time-range-selector">
|
||||
<button
|
||||
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>
|
||||
const timeRangeOptions = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'last7', label: 'Last 7D' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const;
|
||||
|
||||
{criticalAlerts.length > 0 && (
|
||||
<div className="alerts-block">
|
||||
<h3>🚨 Alertes ({criticalAlerts.length})</h3>
|
||||
<div className="alerts-list">
|
||||
{criticalAlerts.map(alert => (
|
||||
<div key={alert.id} className={`alert-item alert-${alert.status}`}>
|
||||
<div className="alert-status">
|
||||
{alert.status === 'critical' ? '🔴' : '⚠️'}
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<div className="alert-name">{alert.name}</div>
|
||||
<div className="alert-value">{alert.value} {alert.unit}</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="h-[calc(100vh-180px)] overflow-hidden">
|
||||
<div className="container mx-auto p-6 h-full flex flex-col gap-4">
|
||||
{/* Compact Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Performance Overview</h1>
|
||||
<p className="text-sm text-muted-foreground">Real-time SQDC Metrics</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{timeRangeOptions.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={timeRange === option.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(option.value)}
|
||||
className="px-3"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="main-grid">
|
||||
<div className="category-section">
|
||||
<div className="category-header">
|
||||
<h2>📊 Statistiques</h2>
|
||||
</div>
|
||||
<div className="stats-grid-inner">
|
||||
<div className="stat-card-inner">
|
||||
<div className="stat-number">{stats.total}</div>
|
||||
<div className="stat-label">KPI Total</div>
|
||||
</div>
|
||||
<div className="stat-card-inner">
|
||||
<div className="stat-number">{stats.good}</div>
|
||||
<div className="stat-label">✅ Bon</div>
|
||||
</div>
|
||||
<div className="stat-card-inner">
|
||||
<div className="stat-number">{stats.warning}</div>
|
||||
<div className="stat-label">⚠️ Attention</div>
|
||||
</div>
|
||||
<div className="stat-card-inner">
|
||||
<div className="stat-number">{stats.critical}</div>
|
||||
<div className="stat-label">🔴 Critique</div>
|
||||
</div>
|
||||
<div className="stat-card-inner">
|
||||
<div className="stat-number">{avgPerformance}%</div>
|
||||
<div className="stat-label">Performance</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Stats - Single Row */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<Card className="border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Total</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-xs text-muted-foreground">On Target</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-emerald-500">{stats.good}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-amber-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xs text-muted-foreground">Warning</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-500">{stats.warning}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-red-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-muted-foreground">Critical</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-primary/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs text-muted-foreground">Score</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">{avgPerformance}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
|
||||
{/* Left Column - Categories */}
|
||||
<div className="col-span-2 overflow-y-auto pr-2 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(catId => {
|
||||
const categoryKPIs = topKPIs[catId] || [];
|
||||
const category = db.categories.find(c => c.id === catId);
|
||||
|
||||
if (!category) return null;
|
||||
if (!category || categoryKPIs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={catId} className="category-section">
|
||||
<div className="category-header">
|
||||
<h2 style={{ color: getCategoryColor(category.name) }}>
|
||||
{category.emoji} {category.name}
|
||||
</h2>
|
||||
<div key={catId}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-1 w-8 rounded" style={{ backgroundColor: getCategoryColor(category.name) }}></div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: getCategoryColor(category.name) }}>
|
||||
{category.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="kpi-grid">
|
||||
<div className="grid gap-3">
|
||||
{categoryKPIs.map((kpi: any) => (
|
||||
<KPICard
|
||||
key={kpi.id}
|
||||
@ -243,6 +254,48 @@ export const HomePage: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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