Compare commits
5 Commits
ca4c64bbb0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c37cf28d | |||
| 1813603bb3 | |||
| f32e74c713 | |||
| 020f5d11de | |||
| 19af51160a |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.deploy.example
|
||||
coverage
|
||||
.vscode
|
||||
.cursor
|
||||
terminals
|
||||
**/*.log
|
||||
@ -22,6 +22,13 @@ JIRA_API_KEY=
|
||||
# Champ Story Points dans Jira (Administration → Issues → champs personnalisés → ID)
|
||||
# VITE_JIRA_STORY_POINTS_FIELD=customfield_10028
|
||||
|
||||
# Champ Sprint (Scrum) pour la vue Sprint — ID souvent proche de 10020 selon les instances
|
||||
# VITE_JIRA_SPRINT_FIELD=customfield_10020
|
||||
|
||||
# Board logiciel DCC (URL …/boards/1445/) : sprints actifs/futurs via API Agile (sans élargir le JQL).
|
||||
# 0 ou false = désactiver (liste déduite uniquement des tickets chargés).
|
||||
# VITE_JIRA_BOARD_ID=1445
|
||||
|
||||
# Clé de l’épopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI
|
||||
# VITE_JIRA_EPIC_KEY=DCC-5514
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ dist-ssr
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.deploy
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
# Build front Vite (variables VITE_* et JIRA_DOMAIN figées au build)
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Origine Jira pour les liens « ouvrir le ticket » (__JIRA_ORIGIN__ dans vite.config.js)
|
||||
ARG JIRA_DOMAIN=
|
||||
ENV JIRA_DOMAIN=$JIRA_DOMAIN
|
||||
|
||||
# Obligatoire en prod : URL de ton proxy HTTPS vers Jira (même origine ou sous-chemin)
|
||||
ARG VITE_JIRA_BASE_URL=
|
||||
ENV VITE_JIRA_BASE_URL=$VITE_JIRA_BASE_URL
|
||||
|
||||
ARG VITE_JIRA_BROWSE_BASE_URL=
|
||||
ENV VITE_JIRA_BROWSE_BASE_URL=$VITE_JIRA_BROWSE_BASE_URL
|
||||
ARG VITE_JIRA_EPIC_KEY=
|
||||
ENV VITE_JIRA_EPIC_KEY=$VITE_JIRA_EPIC_KEY
|
||||
ARG VITE_JIRA_PAGE_SIZE=
|
||||
ENV VITE_JIRA_PAGE_SIZE=$VITE_JIRA_PAGE_SIZE
|
||||
ARG VITE_JIRA_BOARD_ID=
|
||||
ENV VITE_JIRA_BOARD_ID=$VITE_JIRA_BOARD_ID
|
||||
ARG VITE_JIRA_SPRINT_FIELD=
|
||||
ENV VITE_JIRA_SPRINT_FIELD=$VITE_JIRA_SPRINT_FIELD
|
||||
ARG VITE_JIRA_STORY_POINTS_FIELD=
|
||||
ENV VITE_JIRA_STORY_POINTS_FIELD=$VITE_JIRA_STORY_POINTS_FIELD
|
||||
ARG VITE_MY_JIRA_ACCOUNT_ID=
|
||||
ENV VITE_MY_JIRA_ACCOUNT_ID=$VITE_MY_JIRA_ACCOUNT_ID
|
||||
ARG VITE_MY_JIRA_EMAIL=
|
||||
ENV VITE_MY_JIRA_EMAIL=$VITE_MY_JIRA_EMAIL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
31
deploy/hooks.json.example
Normal file
31
deploy/hooks.json.example
Normal file
@ -0,0 +1,31 @@
|
||||
[
|
||||
{
|
||||
"id": "jira-descours-deploy",
|
||||
"execute-command": "/config/deploy.sh",
|
||||
"command-working-directory": "/",
|
||||
"trigger-rule": {
|
||||
"and": [
|
||||
{
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": "refs/heads/main",
|
||||
"parameter": {
|
||||
"source": "payload",
|
||||
"name": "ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": "CHANGEME_SECRET_TOKEN",
|
||||
"parameter": {
|
||||
"source": "url",
|
||||
"name": "token"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
8
deploy/nas-deploy.sh.example
Normal file
8
deploy/nas-deploy.sh.example
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Copier vers le NAS (ex. /volume1/docker/jira-descours/deploy.sh), chmod +x,
|
||||
# adapter APP_DIR et redémarrer le conteneur « webhook » qui l’invoque.
|
||||
set -e
|
||||
APP_DIR="/volume1/docker/jira-descours/src"
|
||||
cd "$APP_DIR"
|
||||
git pull --ff-only
|
||||
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
||||
# Déploiement NAS : variables dans un fichier `.env.deploy` à côté de ce fichier (non versionné).
|
||||
# Voir docs/DEPLOY_SYNOLOGY_GITEA.md
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
JIRA_DOMAIN: ${JIRA_DOMAIN:-}
|
||||
VITE_JIRA_BASE_URL: ${VITE_JIRA_BASE_URL:-}
|
||||
VITE_JIRA_BROWSE_BASE_URL: ${VITE_JIRA_BROWSE_BASE_URL:-}
|
||||
VITE_JIRA_EPIC_KEY: ${VITE_JIRA_EPIC_KEY:-}
|
||||
VITE_JIRA_PAGE_SIZE: ${VITE_JIRA_PAGE_SIZE:-}
|
||||
VITE_JIRA_BOARD_ID: ${VITE_JIRA_BOARD_ID:-}
|
||||
VITE_JIRA_SPRINT_FIELD: ${VITE_JIRA_SPRINT_FIELD:-}
|
||||
VITE_JIRA_STORY_POINTS_FIELD: ${VITE_JIRA_STORY_POINTS_FIELD:-}
|
||||
VITE_MY_JIRA_ACCOUNT_ID: ${VITE_MY_JIRA_ACCOUNT_ID:-}
|
||||
VITE_MY_JIRA_EMAIL: ${VITE_MY_JIRA_EMAIL:-}
|
||||
image: jira-descours:local
|
||||
ports:
|
||||
- "${HOST_PORT:-8080}:80"
|
||||
restart: unless-stopped
|
||||
18
docker/nginx.conf
Normal file
18
docker/nginx.conf
Normal file
@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
227
docs/DEPLOY_SYNOLOGY_GITEA.md
Normal file
227
docs/DEPLOY_SYNOLOGY_GITEA.md
Normal file
@ -0,0 +1,227 @@
|
||||
# Déploiement automatique : Gitea + Docker sur Synology NAS
|
||||
|
||||
Ce dépôt contient un **`Dockerfile`** et un **`docker-compose.yml`** pour servir le build Vite avec **Nginx**.
|
||||
L’objectif : à chaque **push sur `main`**, le NAS **tire** le code et **reconstruit** le conteneur.
|
||||
|
||||
---
|
||||
|
||||
## Vue d’ensemble
|
||||
|
||||
| Étape | Rôle |
|
||||
|--------|------|
|
||||
| 1–3 | Cloner le dépôt sur le NAS, créer `.env.deploy` |
|
||||
| 4 | Premier déploiement manuel (`docker compose up`) |
|
||||
| 5–7 | Conteneur **webhook** qui exécute un script au push Gitea |
|
||||
| 8 | Configurer le **webhook** dans Gitea (URL + secret en query) |
|
||||
| 9–10 | **Proxy inverse** Synology + HTTPS pour l’accès extérieur |
|
||||
| 11–12 | Vérifications et dépannage |
|
||||
|
||||
Chemins d’exemple : `/volume1/docker/jira-descours/` — à adapter à ton volume DSM.
|
||||
|
||||
---
|
||||
|
||||
## 1. Préparer un dossier sur le NAS
|
||||
|
||||
En **SSH** (utilisateur admin ou compte avec droits `docker`) :
|
||||
|
||||
```sh
|
||||
mkdir -p /volume1/docker/jira-descours
|
||||
cd /volume1/docker/jira-descours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Cloner le dépôt Gitea (une fois)
|
||||
|
||||
Utilise l’URL **SSH** ou **HTTPS** de ton Gitea.
|
||||
|
||||
**Dépôt privé (recommandé)** : créer une **clé SSH** sur le NAS, enregistrer la clé **publique** dans Gitea
|
||||
(*Paramètres du dépôt → Clés de déploiement* ou compte utilisateur → Clés SSH).
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/jira-descours
|
||||
git clone git@GITEA_HOST:UTILISATEUR/jira-descours.git src
|
||||
cd src
|
||||
```
|
||||
|
||||
Le dossier `src` contiendra le `docker-compose.yml` à la racine du dépôt après clone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fichier d’environnement de build `.env.deploy`
|
||||
|
||||
Sur le NAS, dans **`src/`** (racine du clone) :
|
||||
|
||||
```sh
|
||||
cp .env.deploy.example .env.deploy
|
||||
nano .env.deploy
|
||||
```
|
||||
|
||||
Renseigne au minimum :
|
||||
|
||||
- **`JIRA_DOMAIN`** — sous-domaine Atlassian (comme en dev).
|
||||
- **`VITE_JIRA_BASE_URL`** — URL **HTTPS** de ton **proxy Jira** (obligatoire en prod : le navigateur n’utilise pas le proxy Vite du `npm run dev`).
|
||||
|
||||
Optionnel : `VITE_JIRA_*` comme dans `.env.example`.
|
||||
|
||||
**`HOST_PORT`** : port sur lequel le NAS écoute (ex. `8080`). Tu le brancheras ensuite sur le **proxy inverse** DSM.
|
||||
|
||||
Ne **commit pas** `.env.deploy` (déjà ignoré par `.gitignore`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Premier build manuel
|
||||
|
||||
Toujours dans `src/` :
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy up -d --build
|
||||
```
|
||||
|
||||
Teste en local (LAN) : `http://IP_DU_NAS:8080` (ou le port choisi).
|
||||
|
||||
---
|
||||
|
||||
## 5. Script de déploiement appelé par le webhook
|
||||
|
||||
Toujours sur le NAS, crée un script **hors** du dépôt (pour ne pas l’écraser au `git pull`), par ex. :
|
||||
|
||||
```sh
|
||||
sudo tee /volume1/docker/jira-descours/deploy.sh <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
APP_DIR="/volume1/docker/jira-descours/src"
|
||||
cd "$APP_DIR"
|
||||
git pull --ff-only
|
||||
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build
|
||||
EOF
|
||||
sudo chmod +x /volume1/docker/jira-descours/deploy.sh
|
||||
```
|
||||
|
||||
Adapte **`APP_DIR`** si ton chemin diffère.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fichier `hooks.json` pour [adnanh/webhook](https://github.com/adnanh/webhook)
|
||||
|
||||
Copie `deploy/hooks.json.example` vers le NAS, par ex. :
|
||||
|
||||
`/volume1/docker/jira-descours/hooks.json`
|
||||
|
||||
1. Remplace **`CHANGEME_SECRET_TOKEN`** par un **secret long** (génère-en un et garde-le pour Gitea).
|
||||
2. Vérifie que **`refs/heads/main`** correspond à ta branche de prod (sinon change la valeur).
|
||||
|
||||
---
|
||||
|
||||
## 7. Lancer le récepteur webhook (Docker)
|
||||
|
||||
Le conteneur webhook doit pouvoir exécuter **`docker`** : montage du socket Docker.
|
||||
|
||||
**Choisir une IP joignable depuis le conteneur Gitea** :
|
||||
|
||||
- Gitea **hors Docker** sur le NAS → souvent `http://127.0.0.1:PORT` depuis le NAS… mais le webhook doit écouter sur une interface que Gitea peut appeler.
|
||||
- Gitea **dans Docker** sur le même hôte → souvent `http://172.17.0.1:9888` (bridge Docker vers l’hôte) ou **l’IP LAN du NAS** (`http://192.168.x.x:9888`).
|
||||
|
||||
Exemple (adapter chemins et tag d’image) :
|
||||
|
||||
```sh
|
||||
docker run -d --name gitea-deploy-hook --restart unless-stopped \
|
||||
-p 127.0.0.1:9888:9000 \
|
||||
-v /volume1/docker/jira-descours/hooks.json:/etc/webhook/hooks.json:ro \
|
||||
-v /volume1/docker/jira-descours/deploy.sh:/config/deploy.sh:ro \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
ghcr.io/adnanh/webhook:2.8.2 \
|
||||
-verbose -hooks=/etc/webhook/hooks.json
|
||||
```
|
||||
|
||||
- **`-p 127.0.0.1:9888:9000`** : le hook n’est pas exposé sur toute la carte réseau (plus sûr). Gitea sur le **même hôte** doit joindre `127.0.0.1:9888` **depuis l’hôte** ; si Gitea est **dans un autre conteneur**, utilise plutôt l’IP LAN du NAS et `-p 9888:9000` **avec pare-feu** ou réseau Docker partagé.
|
||||
|
||||
Vérifie la doc de ton image `webhook` pour le chemin des hooks (`-hooks=...`).
|
||||
|
||||
Test manuel (depuis le NAS) :
|
||||
|
||||
```sh
|
||||
curl "http://127.0.0.1:9888/hooks/jira-descours-deploy?token=CHANGEME_SECRET_TOKEN" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d '{"ref":"refs/heads/main"}'
|
||||
```
|
||||
|
||||
Le hook doit s’exécuter (voir logs du conteneur `gitea-deploy-hook`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Webhook dans Gitea
|
||||
|
||||
1. Ouvre le dépôt → **Paramètres** → **Webhooks** → **Ajouter un webhook** → **Gitea**.
|
||||
2. **URL cible** (exemple si hook sur le NAS, port 9888, secret en query) :
|
||||
|
||||
`http://IP_LAN_DU_NAS:9888/hooks/jira-descours-deploy?token=TON_SECRET_LONG`
|
||||
|
||||
Si Gitea et le hook sont sur le **même** OS Docker, teste d’abord avec l’IP LAN ; ajuste selon ce qui fonctionne chez toi.
|
||||
|
||||
3. **Déclencher sur** : « Push » (événements de push).
|
||||
4. Branche : si l’UI le permet, limite à **`main`** ; sinon le filtre est déjà dans `hooks.json` (`ref`).
|
||||
|
||||
Enregistre, puis **« Test de livraison »** ou un **push** sur `main` : le site doit se reconstruire après quelques minutes.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accès depuis l’extérieur (HTTPS)
|
||||
|
||||
1. **Nom de domaine** ou **DDNS Synology** pointant vers l’IP publique de ta box.
|
||||
2. **Box internet** : redirection **TCP 443** (et éventuellement **80**) vers l’**IP du NAS** (même ports ou ceux de ton reverse proxy).
|
||||
3. Sur DSM : **Panneau de configuration** → **Portail de connexion** → **Proxy inverse** :
|
||||
- **Nom d’hôte** : ex. `jira-descours.mondomaine.fr`
|
||||
- **Destination** : `http://127.0.0.1:8080` (ou le `HOST_PORT` défini dans `.env.deploy`)
|
||||
- **HTTPS** activé, certificat Let’s Encrypt pour ce nom d’hôte.
|
||||
|
||||
4. Accès : `https://jira-descours.mondomaine.fr`
|
||||
|
||||
**Alternative sans ouvrir les ports** : [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) sur le NAS (très courant en homelab).
|
||||
|
||||
---
|
||||
|
||||
## 10. Sécurité (rappel court)
|
||||
|
||||
- Secret **long** dans l’URL du webhook ; ne le commite pas.
|
||||
- Préférer le hook en **127.0.0.1** si Gitea appelle depuis la même machine ; sinon **pare-feu** DSM limitant le port du hook aux IP de confiance.
|
||||
- Mettre à jour DSM, Gitea et les images Docker régulièrement.
|
||||
|
||||
---
|
||||
|
||||
## 11. Check-list après un push
|
||||
|
||||
- Gitea → **Paramètres du dépôt** → **Webhooks** : dernière livraison en vert.
|
||||
- Sur le NAS : `docker logs gitea-deploy-hook` puis `docker ps` : conteneur `web` à jour.
|
||||
- Site public : hard refresh (Ctrl+F5) pour éviter le cache du navigateur.
|
||||
|
||||
---
|
||||
|
||||
## 12. Dépannage
|
||||
|
||||
| Problème | Piste |
|
||||
|----------|--------|
|
||||
| `git pull` échoue | Clé SSH / droits dépôt ; URL du `origin`. |
|
||||
| Build Docker OOM | NAS peu de RAM : build sur une machine CI puis `docker pull` (évolution). |
|
||||
| Page blanche / erreur Jira | `VITE_JIRA_BASE_URL` absent ou incorrect au **build** ; refaire `up --build` après correction de `.env.deploy`. |
|
||||
| Webhook jamais reçu | URL depuis le conteneur Gitea (test `curl` depuis **dedans** le conteneur Gitea vers l’URL du hook). |
|
||||
| 403 Let’s Encrypt | Ports 80/443 bien redirigés ; nom DNS correct. |
|
||||
|
||||
---
|
||||
|
||||
## Fichiers utiles dans ce dépôt
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `Dockerfile` | Build Node + image Nginx |
|
||||
| `docker-compose.yml` | Service `web` + args de build |
|
||||
| `docker/nginx.conf` | SPA `try_files` → `index.html` |
|
||||
| `.env.deploy.example` | Modèle pour le NAS |
|
||||
| `deploy/hooks.json.example` | Modèle webhook |
|
||||
| `deploy/nas-deploy.sh.example` | Ancienne variante (script dans le dépôt) ; le guide privilégie `deploy.sh` **sur le NAS** |
|
||||
|
||||
---
|
||||
|
||||
## Variante : Gitea Actions + runner sur le NAS
|
||||
|
||||
Si tu préfères tout voir dans l’UI Gitea : active **Actions**, installe **act_runner** sur le NAS, enregistre-le sur ton instance, puis ajoute un workflow `.gitea/workflows/deploy.yml` qui exécute les mêmes commandes que `deploy.sh`. C’est plus lourd à configurer (dont l’accès sécurisé à `docker.sock`) mais très propre à long terme.
|
||||
187
src/App.tsx
187
src/App.tsx
@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { StoryGroup } from './types/jira'
|
||||
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient'
|
||||
import {
|
||||
fetchAllIssuesByJql,
|
||||
fetchBoardSprints,
|
||||
MIGRATION_EPIC_KEY,
|
||||
MIGRATION_JQL,
|
||||
jiraClient,
|
||||
} from './api/jiraClient'
|
||||
import { groupSubtasksUnderStories } from './lib/groupIssues'
|
||||
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
|
||||
import { isIssueDone } from './lib/statusBuckets'
|
||||
@ -15,9 +21,12 @@ import {
|
||||
} from './lib/executiveHealth'
|
||||
import { countSubtasksByPhase } from './lib/phaseAggregate'
|
||||
import {
|
||||
exportSynologyBackupJson,
|
||||
loadDashboardConfig,
|
||||
sanitizeGanttSprintRowMetric,
|
||||
saveDashboardConfig,
|
||||
type DashboardConfig,
|
||||
type GanttSprintRowMetric,
|
||||
} from './lib/dashboardConfig'
|
||||
import { assigneeMatchesMyView } from './lib/assigneeMatch'
|
||||
import { isAxiosError } from 'axios'
|
||||
@ -31,12 +40,18 @@ import { DashboardSettingsModal } from './components/DashboardSettingsModal'
|
||||
import { ManagementOverview } from './components/ManagementOverview'
|
||||
import { PhaseDistributionChart } from './components/PhaseDistributionChart'
|
||||
import { ExportDashboardButton } from './components/ExportDashboardButton'
|
||||
import { MacroCockpitStrip } from './components/MacroCockpitStrip'
|
||||
import { StatusBucketProvider } from './context/StatusBucketContext'
|
||||
import { LaneLabelsProvider } from './context/LaneLabelsContext'
|
||||
import { PipelineOverview } from './components/PipelineOverview'
|
||||
import { LaneTicketsListView } from './components/LaneTicketsListView'
|
||||
import { ProjectTimelineView } from './components/ProjectTimelineView'
|
||||
import { SprintGanttView } from './components/SprintGanttView'
|
||||
import { SprintView } from './components/SprintView'
|
||||
import { resolveJiraBoardId, resolveSprintFieldId } from './lib/jiraSprintField'
|
||||
import type { JiraSprintSnapshot } from './lib/sprintExtract'
|
||||
|
||||
type ViewMode = 'list' | 'board'
|
||||
type ViewMode = 'list' | 'board' | 'project' | 'gantt' | 'sprint'
|
||||
|
||||
export default function App() {
|
||||
const dashboardRef = useRef<HTMLDivElement>(null)
|
||||
@ -48,12 +63,18 @@ export default function App() {
|
||||
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
|
||||
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [boardSprints, setBoardSprints] = useState<JiraSprintSnapshot[]>([])
|
||||
|
||||
const statusBucketsRef = useRef(dashboardCfg.statusBuckets)
|
||||
statusBucketsRef.current = dashboardCfg.statusBuckets
|
||||
|
||||
const myViewActive = Boolean(dashboardCfg.myViewActive)
|
||||
|
||||
const sprintFieldResolved = useMemo(
|
||||
() => resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId }),
|
||||
[dashboardCfg.sprintFieldId],
|
||||
)
|
||||
|
||||
const displayGroups = useMemo(() => {
|
||||
if (!myViewActive) return groups
|
||||
return groups.filter((g) =>
|
||||
@ -120,7 +141,24 @@ export default function App() {
|
||||
],
|
||||
)
|
||||
|
||||
const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups])
|
||||
const phaseCounts = useMemo(
|
||||
() => countSubtasksByPhase(groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels),
|
||||
[groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels],
|
||||
)
|
||||
|
||||
const boardSprintsVisible = useMemo(() => {
|
||||
const ex = new Set(dashboardCfg.excludedSprintIds)
|
||||
return boardSprints.filter((s) => !ex.has(s.id))
|
||||
}, [boardSprints, dashboardCfg.excludedSprintIds])
|
||||
|
||||
const setGanttSprintRowMetric = useCallback((metric: GanttSprintRowMetric) => {
|
||||
const m = sanitizeGanttSprintRowMetric(metric)
|
||||
setDashboardCfg((prev) => {
|
||||
const next: DashboardConfig = { ...prev, ganttSprintRowMetric: m }
|
||||
saveDashboardConfig(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleMyView = () => {
|
||||
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
|
||||
@ -137,7 +175,23 @@ export default function App() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal)
|
||||
const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId })
|
||||
const boardId = resolveJiraBoardId()
|
||||
const sprintListPromise =
|
||||
boardId != null
|
||||
? fetchBoardSprints(boardId, signal, ['active', 'future']).catch((err) => {
|
||||
console.warn('[jira] Sprints board Agile (active/future) :', err)
|
||||
return [] as JiraSprintSnapshot[]
|
||||
})
|
||||
: Promise.resolve([] as JiraSprintSnapshot[])
|
||||
|
||||
const [issues, sprintsFromBoard] = await Promise.all([
|
||||
fetchAllIssuesByJql(MIGRATION_JQL, signal, {
|
||||
additionalFields: sprintField ? [sprintField] : [],
|
||||
}),
|
||||
sprintListPromise,
|
||||
])
|
||||
setBoardSprints(sprintsFromBoard)
|
||||
const grouped = groupSubtasksUnderStories(issues)
|
||||
setGroups(grouped)
|
||||
setUpdatedAt(new Date())
|
||||
@ -166,10 +220,11 @@ export default function App() {
|
||||
setError(e instanceof Error ? e.message : 'Erreur inconnue')
|
||||
}
|
||||
setGroups([])
|
||||
setBoardSprints([])
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [dashboardCfg.sprintFieldId])
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
@ -243,10 +298,56 @@ export default function App() {
|
||||
>
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('project')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
view === 'project'
|
||||
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Frise calendaire, types de jalons et actions attendues"
|
||||
>
|
||||
Projet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('gantt')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
view === 'gantt'
|
||||
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Gantt sprints (dates Jira) + jalons, avancement"
|
||||
>
|
||||
Gantt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('sprint')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
view === 'sprint'
|
||||
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Filtrer les stories par sprint Jira"
|
||||
>
|
||||
Sprint
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!loading && groups.length > 0 && (
|
||||
<>
|
||||
<ExportDashboardButton targetRef={dashboardRef} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportSynologyBackupJson(dashboardCfg)}
|
||||
className="rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-100 transition hover:border-emerald-400/50 hover:bg-emerald-500/15"
|
||||
title="JSON avec horodatage pour sauvegarde sur NAS Synology (volume Docker)."
|
||||
>
|
||||
Backup JSON NAS
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{updatedAt && !loading && (
|
||||
<span className="text-xs text-slate-500">
|
||||
@ -293,9 +394,69 @@ export default function App() {
|
||||
|
||||
{!loading && !error && groups.length > 0 && (
|
||||
<div ref={dashboardRef} className="space-y-10">
|
||||
{view === 'project' ? (
|
||||
<div className="space-y-10">
|
||||
<MacroCockpitStrip
|
||||
groups={groups}
|
||||
dashboardCfg={dashboardCfg}
|
||||
landing={landing}
|
||||
finalMilestoneIso={finalMilestoneIso}
|
||||
/>
|
||||
<ProjectTimelineView
|
||||
milestones={dashboardCfg.milestones}
|
||||
groups={groups}
|
||||
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : view === 'gantt' ? (
|
||||
<div className="space-y-10">
|
||||
<MacroCockpitStrip
|
||||
groups={groups}
|
||||
dashboardCfg={dashboardCfg}
|
||||
landing={landing}
|
||||
finalMilestoneIso={finalMilestoneIso}
|
||||
/>
|
||||
<SprintGanttView
|
||||
sprints={boardSprintsVisible}
|
||||
milestones={dashboardCfg.milestones}
|
||||
groups={groups}
|
||||
sprintFieldId={sprintFieldResolved}
|
||||
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
|
||||
ganttNonWorkingDates={dashboardCfg.ganttNonWorkingDates}
|
||||
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : view === 'sprint' ? (
|
||||
<div className="space-y-10">
|
||||
<MacroCockpitStrip
|
||||
groups={groups}
|
||||
dashboardCfg={dashboardCfg}
|
||||
landing={landing}
|
||||
finalMilestoneIso={finalMilestoneIso}
|
||||
/>
|
||||
<SprintView
|
||||
groups={displayGroups}
|
||||
sprintFieldId={sprintFieldResolved}
|
||||
boardSprintsFromApi={boardSprintsVisible}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
gapBadges={dashboardCfg.functionalGaps}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MacroCockpitStrip
|
||||
groups={groups}
|
||||
dashboardCfg={dashboardCfg}
|
||||
landing={landing}
|
||||
finalMilestoneIso={finalMilestoneIso}
|
||||
/>
|
||||
|
||||
<MilestonesTimeline
|
||||
milestones={dashboardCfg.milestones}
|
||||
groups={groups}
|
||||
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
impactMessages={impactMessages}
|
||||
/>
|
||||
@ -344,13 +505,24 @@ export default function App() {
|
||||
) : view === 'list' ? (
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
{displayGroups.map((g) => (
|
||||
<StoryCard key={g.story.key} group={g} />
|
||||
<StoryCard
|
||||
key={g.story.key}
|
||||
group={g}
|
||||
sprintFieldId={sprintFieldResolved}
|
||||
gapBadges={dashboardCfg.functionalGaps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<BoardView groups={displayGroups} />
|
||||
<BoardView
|
||||
groups={displayGroups}
|
||||
sprintFieldId={sprintFieldResolved}
|
||||
gapBadges={dashboardCfg.functionalGaps}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@ -360,6 +532,7 @@ export default function App() {
|
||||
config={dashboardCfg}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSave={saveSettings}
|
||||
boardSprints={boardSprints}
|
||||
/>
|
||||
</div>
|
||||
</LaneLabelsProvider>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira'
|
||||
import { coerceSprintObject, type JiraSprintSnapshot } from '../lib/sprintExtract'
|
||||
|
||||
/**
|
||||
* Même périmètre qu’un filtre Jira type filter=25111 : tout ce qui est sous l’épopée
|
||||
@ -63,6 +64,7 @@ export async function fetchJqlApproximateCount(
|
||||
export async function fetchAllIssuesByJql(
|
||||
jql: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { additionalFields?: string[] },
|
||||
): Promise<JiraIssue[]> {
|
||||
const base = clientBaseUrl()
|
||||
if (!base) {
|
||||
@ -74,7 +76,7 @@ export async function fetchAllIssuesByJql(
|
||||
const maxResults = pageSize()
|
||||
const storyPointsField =
|
||||
import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028'
|
||||
const fields = [
|
||||
const baseFields = [
|
||||
'summary',
|
||||
'status',
|
||||
'issuetype',
|
||||
@ -86,7 +88,9 @@ export async function fetchAllIssuesByJql(
|
||||
'timetracking',
|
||||
'labels',
|
||||
storyPointsField,
|
||||
] as const
|
||||
]
|
||||
const extra = (options?.additionalFields ?? []).map((f) => f.trim()).filter(Boolean)
|
||||
const fields = [...new Set([...baseFields, ...extra])]
|
||||
|
||||
const collected: JiraIssue[] = []
|
||||
let nextPageToken: string | undefined
|
||||
@ -96,7 +100,7 @@ export async function fetchAllIssuesByJql(
|
||||
for (let page = 0; page < MAX_PAGES; page += 1) {
|
||||
const body: Record<string, unknown> = {
|
||||
jql,
|
||||
fields: [...fields],
|
||||
fields,
|
||||
maxResults,
|
||||
...(nextPageToken ? { nextPageToken } : {}),
|
||||
}
|
||||
@ -152,3 +156,88 @@ export async function fetchAllIssuesByJql(
|
||||
|
||||
return collected
|
||||
}
|
||||
|
||||
type AgileSprintPage = {
|
||||
values?: Record<string, unknown>[]
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprints d’un board logiciel (API Agile Jira), avec pagination.
|
||||
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get
|
||||
*/
|
||||
export async function fetchBoardSprints(
|
||||
boardId: number,
|
||||
signal: AbortSignal | undefined,
|
||||
states: ('active' | 'future' | 'closed')[],
|
||||
): Promise<JiraSprintSnapshot[]> {
|
||||
const base = clientBaseUrl()
|
||||
if (!base || !Number.isFinite(boardId) || boardId <= 0) return []
|
||||
|
||||
const stateParam = states.join(',')
|
||||
const all: JiraSprintSnapshot[] = []
|
||||
let startAt = 0
|
||||
const maxResults = 50
|
||||
for (let page = 0; page < 40; page += 1) {
|
||||
const { data } = await jiraClient.get<AgileSprintPage>(
|
||||
`/rest/agile/1.0/board/${boardId}/sprint`,
|
||||
{
|
||||
params: { state: stateParam, startAt, maxResults },
|
||||
signal,
|
||||
},
|
||||
)
|
||||
const values = data.values ?? []
|
||||
for (const row of values) {
|
||||
if (row && typeof row === 'object') {
|
||||
const s = coerceSprintObject(row as Record<string, unknown>)
|
||||
if (s) all.push(s)
|
||||
}
|
||||
}
|
||||
if (values.length === 0) break
|
||||
if (data.isLast === true) break
|
||||
if (values.length < maxResults) break
|
||||
startAt += values.length
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
type AgileSprintIssuesPage = {
|
||||
issues?: { key?: string }[]
|
||||
isLast?: boolean
|
||||
maxResults?: number
|
||||
startAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Toutes les clés d’issues d’un sprint (API Agile), pour filtrer sans `customfield` Sprint.
|
||||
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-sprint/#api-rest-agile-1-0-sprint-sprintid-issue-get
|
||||
*/
|
||||
export async function fetchAllIssueKeysInSprint(
|
||||
sprintId: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Set<string>> {
|
||||
const base = clientBaseUrl()
|
||||
if (!base || !Number.isFinite(sprintId) || sprintId <= 0) return new Set()
|
||||
|
||||
const keys = new Set<string>()
|
||||
let startAt = 0
|
||||
const maxResults = 100
|
||||
for (let page = 0; page < 100; page += 1) {
|
||||
const { data } = await jiraClient.get<AgileSprintIssuesPage>(
|
||||
`/rest/agile/1.0/sprint/${sprintId}/issue`,
|
||||
{
|
||||
params: { startAt, maxResults, fields: 'key' },
|
||||
signal,
|
||||
},
|
||||
)
|
||||
const issues = data.issues ?? []
|
||||
for (const row of issues) {
|
||||
if (row?.key && typeof row.key === 'string') keys.add(row.key)
|
||||
}
|
||||
if (issues.length === 0) break
|
||||
if (data.isLast === true) break
|
||||
if (issues.length < maxResults) break
|
||||
startAt += issues.length
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
|
||||
import { groupStoriesByComponent } from '../lib/boardGrouping'
|
||||
import { StoryCard } from './StoryCard'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
sprintFieldId?: string | null
|
||||
gapBadges?: FunctionalGapBadge[]
|
||||
}
|
||||
|
||||
export function BoardView({ groups }: Props) {
|
||||
export function BoardView({ groups, sprintFieldId = null, gapBadges }: Props) {
|
||||
const columns = groupStoriesByComponent(groups)
|
||||
|
||||
return (
|
||||
@ -24,7 +27,13 @@ export function BoardView({ groups }: Props) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{col.map((g) => (
|
||||
<StoryCard key={g.story.key} group={g} variant="board" />
|
||||
<StoryCard
|
||||
key={g.story.key}
|
||||
group={g}
|
||||
variant="board"
|
||||
sprintFieldId={sprintFieldId}
|
||||
gapBadges={gapBadges}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
|
||||
import {
|
||||
exportConfigJson,
|
||||
exportSynologyBackupJson,
|
||||
GANTT_SPRINT_METRIC_OPTIONS,
|
||||
mergeImportedConfig,
|
||||
normalizeFunctionalGapsForSave,
|
||||
parseGanttNonWorkingDatesFromText,
|
||||
sanitizeExcludedSprintIds,
|
||||
type DashboardConfig,
|
||||
type FunctionalGapBadge,
|
||||
type GanttSprintRowMetric,
|
||||
type LaneLabelsConfig,
|
||||
type Milestone,
|
||||
type MilestoneKind,
|
||||
type StatusBucketConfig,
|
||||
} from '../lib/dashboardConfig'
|
||||
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
|
||||
import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds'
|
||||
|
||||
function parseBucketLines(raw: string): string[] {
|
||||
return raw
|
||||
@ -59,6 +69,8 @@ type Props = {
|
||||
config: DashboardConfig
|
||||
onClose: () => void
|
||||
onSave: (next: DashboardConfig) => void
|
||||
/** Sprints board (API) pour masquage sélectif ; optionnel si pas encore chargés. */
|
||||
boardSprints?: JiraSprintSnapshot[]
|
||||
}
|
||||
|
||||
function newMilestone(): Milestone {
|
||||
@ -68,19 +80,47 @@ function newMilestone(): Milestone {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
linkedStoryKeys: [],
|
||||
critical: false,
|
||||
kind: 'generic',
|
||||
expectedActions: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) {
|
||||
function newGapBadge(): FunctionalGapBadge {
|
||||
return {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? `gap-${crypto.randomUUID().slice(0, 10)}`
|
||||
: `gap-${Date.now()}`,
|
||||
label: '',
|
||||
terms: [''],
|
||||
criticalFlow: false,
|
||||
}
|
||||
}
|
||||
|
||||
function parseGapTerms(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function DashboardSettingsModal({ open, config, onClose, onSave, boardSprints }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
const [draft, setDraft] = useState<DashboardConfig>(config)
|
||||
const [ganttNonWorkInput, setGanttNonWorkInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(config)
|
||||
}, [open, config])
|
||||
|
||||
const configNonWorkKey = config.ganttNonWorkingDates.join('|')
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setGanttNonWorkInput(config.ganttNonWorkingDates.join('\n'))
|
||||
}, [open, configNonWorkKey])
|
||||
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current
|
||||
if (!el) return
|
||||
@ -114,8 +154,10 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result)) as unknown
|
||||
const merged = mergeImportedConfig(draft, parsed)
|
||||
if (merged) setDraft(merged)
|
||||
else alert('Fichier JSON invalide (version 1 attendue).')
|
||||
if (merged) {
|
||||
setDraft(merged)
|
||||
setGanttNonWorkInput(merged.ganttNonWorkingDates.join('\n'))
|
||||
} else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
|
||||
} catch {
|
||||
alert('Impossible de lire ce fichier JSON.')
|
||||
}
|
||||
@ -199,6 +241,26 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-[10px] uppercase text-slate-500">
|
||||
Champ Sprint Jira (ID customfield)
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-xs outline-none"
|
||||
value={draft.sprintFieldId ?? ''}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
sprintFieldId: e.target.value.trim() || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="ex. customfield_10020 (vide = utiliser VITE_JIRA_SPRINT_FIELD ou désactiver)"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-slate-600">
|
||||
Laissez vide pour n’utiliser que la variable d’environnement, ou saisissez l’ID exact du
|
||||
champ Sprint de votre projet Scrum.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-slate-500">
|
||||
La date d’atterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
|
||||
nominaux pour la jauge « Ressources ».
|
||||
@ -298,6 +360,174 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-indigo-500/25 bg-indigo-500/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-200/90">
|
||||
Gantt & vue Sprint
|
||||
</p>
|
||||
<label className="mt-2 block text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Infos sous les barres (Gantt)
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-2 text-sm text-slate-200 outline-none"
|
||||
value={draft.ganttSprintRowMetric}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
ganttSprintRowMetric: e.target.value as GanttSprintRowMetric,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="mt-3 block text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
|
||||
Jours non travaillés (Gantt)
|
||||
</label>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
|
||||
Une date par ligne au format <span className="font-mono text-slate-400">AAAA-MM-JJ</span> (fuseau
|
||||
local du navigateur). Même style que sam. / dim. : fériés, ponts, fermeture.
|
||||
</p>
|
||||
<textarea
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder={'2026-05-01\n2026-05-08'}
|
||||
className="mt-1.5 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-indigo-500/20 focus:ring-1"
|
||||
value={ganttNonWorkInput}
|
||||
onChange={(e) => setGanttNonWorkInput(e.target.value)}
|
||||
/>
|
||||
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
|
||||
Sprints à masquer
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
|
||||
Sprints cochés : retirés du Gantt et du menu de la vue Sprint. Rechargez les données si la
|
||||
liste est vide.
|
||||
</p>
|
||||
{boardSprints && boardSprints.length > 0 ? (
|
||||
<ul className="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{boardSprints.map((sp) => (
|
||||
<li key={sp.id} className="flex items-start gap-2 text-xs text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`ex-sp-${sp.id}`}
|
||||
checked={draft.excludedSprintIds.includes(sp.id)}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => {
|
||||
const next = new Set(d.excludedSprintIds)
|
||||
if (e.target.checked) next.add(sp.id)
|
||||
else next.delete(sp.id)
|
||||
return { ...d, excludedSprintIds: [...next] }
|
||||
})
|
||||
}
|
||||
className="mt-0.5 rounded border-indigo-400/50"
|
||||
/>
|
||||
<label htmlFor={`ex-sp-${sp.id}`} className="cursor-pointer leading-snug">
|
||||
<span className="font-medium">{sp.name}</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-500">#{sp.id}</span>
|
||||
{sp.state ? (
|
||||
<span className="ml-2 text-[10px] uppercase text-slate-500">{sp.state}</span>
|
||||
) : null}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
Aucun sprint en mémoire : actualisez le cockpit puis rouvrez les réglages.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/[0.06] p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-rose-100/90">
|
||||
Badges « gaps » (PO)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((d) => ({ ...d, functionalGaps: [...d.functionalGaps, newGapBadge()] }))
|
||||
}
|
||||
className="text-xs font-medium text-rose-200/90 hover:text-rose-100"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-slate-500">
|
||||
Termes cherchés dans clés, résumés et étiquettes (insensible casse / accents). Cochez
|
||||
« flux critique » pour renforcer le feu rouge macro (Panier, Checkout…).
|
||||
</p>
|
||||
<ul className="mt-3 max-h-48 space-y-3 overflow-y-auto pr-1">
|
||||
{draft.functionalGaps.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="rounded-lg border border-white/10 bg-black/25 p-2 text-xs text-slate-200"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="min-w-[100px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1"
|
||||
value={g.label}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
functionalGaps: d.functionalGaps.map((x) =>
|
||||
x.id === g.id ? { ...x, label: e.target.value } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="Libellé (ex. Panier)"
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[11px] text-rose-100/90">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(g.criticalFlow)}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
functionalGaps: d.functionalGaps.map((x) =>
|
||||
x.id === g.id ? { ...x, criticalFlow: e.target.checked } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
className="rounded border-rose-400/50"
|
||||
/>
|
||||
Flux critique
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
functionalGaps: d.functionalGaps.filter((x) => x.id !== g.id),
|
||||
}))
|
||||
}
|
||||
className="text-[11px] text-rose-400 hover:text-rose-300"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
className="mt-2 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 font-mono text-[11px] outline-none"
|
||||
value={g.terms.join(', ')}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
functionalGaps: d.functionalGaps.map((x) =>
|
||||
x.id === g.id ? { ...x, terms: parseGapTerms(e.target.value) } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="panier, cart, basket…"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
@ -323,6 +553,37 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
|
||||
placeholder="ex. Fin design"
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<label className="text-[10px] uppercase text-slate-500">Type de jalon</label>
|
||||
<select
|
||||
className="mt-1 w-full rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
|
||||
value={m.kind ?? 'generic'}
|
||||
onChange={(e) =>
|
||||
updateMilestone(m.id, { kind: e.target.value as MilestoneKind })
|
||||
}
|
||||
>
|
||||
{MILESTONE_KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} — {opt.hint}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="text-[10px] uppercase text-slate-500">Actions attendues à cette date</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
className="mt-1 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
|
||||
value={m.expectedActions ?? ''}
|
||||
onChange={(e) =>
|
||||
updateMilestone(m.id, {
|
||||
expectedActions: e.target.value.trim() ? e.target.value : undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ex. Recette signée, doc runbook, passage en prod…"
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -350,7 +611,7 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="Stories liées (DCC-1, DCC-2) — vide = toutes"
|
||||
placeholder="Stories (DCC-1, DCC-2) — vide = toutes les stories chargées"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@ -373,6 +634,14 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
>
|
||||
Exporter configuration (JSON)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportSynologyBackupJson(draft)}
|
||||
className="rounded-lg border border-emerald-400/35 bg-emerald-950/40 px-3 py-2 text-xs font-semibold text-emerald-50"
|
||||
title="Enveloppe bundleVersion + exportedAt pour sauvegarde NAS / Docker Synology."
|
||||
>
|
||||
Bundle Synology (JSON)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
@ -401,7 +670,12 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(draft)
|
||||
onSave({
|
||||
...draft,
|
||||
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
|
||||
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
|
||||
ganttNonWorkingDates: parseGanttNonWorkingDatesFromText(ganttNonWorkInput),
|
||||
})
|
||||
onClose()
|
||||
}}
|
||||
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||
|
||||
198
src/components/MacroCockpitStrip.tsx
Normal file
198
src/components/MacroCockpitStrip.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { DashboardConfig } from '../lib/dashboardConfig'
|
||||
import type { LandingEstimate } from '../lib/executiveLanding'
|
||||
import { computeMacroPipelineHealth } from '../lib/macroTrafficLight'
|
||||
import { assigneeOpenLoadRadar } from '../lib/assigneeRadar'
|
||||
import { countOpenGapsByBadge } from '../lib/functionalGaps'
|
||||
import { calendarDelayVsLastMilestone } from '../lib/scheduleDelay'
|
||||
import { resolveWorkBucketFromIssue } from '../lib/statusBuckets'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
dashboardCfg: DashboardConfig
|
||||
landing: LandingEstimate
|
||||
finalMilestoneIso: string | null
|
||||
}
|
||||
|
||||
function trafficLightClasses(light: 'green' | 'amber' | 'red'): { ring: string; bg: string; dot: string } {
|
||||
switch (light) {
|
||||
case 'green':
|
||||
return {
|
||||
ring: 'ring-emerald-400/50',
|
||||
bg: 'bg-emerald-500/20',
|
||||
dot: 'bg-emerald-400 shadow-[0_0_14px_rgba(52,211,153,0.7)]',
|
||||
}
|
||||
case 'amber':
|
||||
return {
|
||||
ring: 'ring-amber-400/55',
|
||||
bg: 'bg-amber-500/20',
|
||||
dot: 'bg-amber-400 shadow-[0_0_14px_rgba(251,191,36,0.65)]',
|
||||
}
|
||||
case 'red':
|
||||
return {
|
||||
ring: 'ring-rose-500/60',
|
||||
bg: 'bg-rose-600/25',
|
||||
dot: 'bg-rose-500 shadow-[0_0_16px_rgba(244,63,94,0.75)]',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function MacroCockpitStrip({ groups, dashboardCfg, landing, finalMilestoneIso }: Props) {
|
||||
const macro = computeMacroPipelineHealth(
|
||||
groups,
|
||||
dashboardCfg.statusBuckets,
|
||||
dashboardCfg.laneLabels,
|
||||
dashboardCfg.functionalGaps,
|
||||
)
|
||||
const delay = calendarDelayVsLastMilestone(landing, finalMilestoneIso)
|
||||
const radar = assigneeOpenLoadRadar(
|
||||
groups,
|
||||
dashboardCfg.statusBuckets,
|
||||
dashboardCfg.wipSlotsPerDev,
|
||||
).slice(0, 6)
|
||||
const gaps = countOpenGapsByBadge(groups, dashboardCfg.functionalGaps, dashboardCfg.statusBuckets)
|
||||
|
||||
const openSamples = groups
|
||||
.flatMap((g) =>
|
||||
g.subtasks
|
||||
.filter((st) => {
|
||||
const b = resolveWorkBucketFromIssue(st, dashboardCfg.statusBuckets)
|
||||
return b === 'in_progress' || b === 'blocked'
|
||||
})
|
||||
.map((st) => ({
|
||||
key: st.key,
|
||||
who: st.fields.assignee?.displayName ?? '—',
|
||||
summary: st.fields.summary,
|
||||
})),
|
||||
)
|
||||
.slice(0, 5)
|
||||
|
||||
const cls = trafficLightClasses(macro.light)
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.08] bg-gradient-to-br from-slate-950/90 to-slate-900/40 p-4 shadow-[0_12px_48px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
Cockpit macro — DSI · Projet · PO · Exécution
|
||||
</h2>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Données = instantané Jira au dernier chargement (Actualiser).
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-12">
|
||||
<div
|
||||
className={`lg:col-span-4 rounded-xl border border-white/10 p-4 ring-2 ring-inset ${cls.ring} ${cls.bg}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Feux phases (DSI)
|
||||
</p>
|
||||
<div className="mt-2 flex items-start gap-3">
|
||||
<span
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full ${cls.dot}`}
|
||||
title={macro.title}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">{macro.title}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-300">{macro.detail}</p>
|
||||
{macro.violatingStoryKeys.length > 0 && (
|
||||
<p className="mt-2 font-mono text-[11px] text-rose-200/90">
|
||||
{macro.violatingStoryKeys.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Jalons & vélocité (Directeur de projet)
|
||||
</p>
|
||||
{delay ? (
|
||||
<p className="mt-2 text-sm font-medium text-amber-100">{delay.message}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
Aucun retard calendaire détecté par rapport au dernier jalon (ou date / vélocité
|
||||
indisponible).
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
{landing.businessDaysToFinish != null
|
||||
? `~${landing.businessDaysToFinish} j. ouvrés restants (sous-tâches), vélocité ajustée effectif / baseline.`
|
||||
: 'Vélocité nulle ou données insuffisantes pour estimer la fin.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Écarts fonctionnels (PO)
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{gaps.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ring-inset ${
|
||||
g.criticalFlow
|
||||
? 'bg-rose-500/15 text-rose-100 ring-rose-400/40'
|
||||
: 'bg-slate-600/30 text-slate-200 ring-slate-500/35'
|
||||
}`}
|
||||
title="Sous-tâches encore ouvertes sur les stories correspondant aux termes configurés."
|
||||
>
|
||||
{g.label}
|
||||
<span className="ml-1 tabular-nums opacity-90">({g.openCount})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Radar de charge (Chef de projet)
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-slate-500">
|
||||
Plafond WIP : {dashboardCfg.wipSlotsPerDev} sous-tâches ouvertes / personne.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-xs">
|
||||
{radar.length === 0 ? (
|
||||
<li className="text-slate-500">Aucune sous-tâche ouverte.</li>
|
||||
) : (
|
||||
radar.map((r) => (
|
||||
<li
|
||||
key={r.name}
|
||||
className={`flex justify-between gap-2 rounded-lg px-2 py-1 ${
|
||||
r.overload ? 'bg-rose-500/15 text-rose-100' : 'bg-white/[0.04] text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="shrink-0 tabular-nums font-semibold">
|
||||
{r.openCount}
|
||||
{r.overload ? ' ⚠' : ''}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
En cours (aperçu)
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-[11px] text-slate-300">
|
||||
{openSamples.length === 0 ? (
|
||||
<li className="text-slate-500">Aucun ticket « en cours » ou « bloqué ».</li>
|
||||
) : (
|
||||
openSamples.map((x) => (
|
||||
<li key={x.key} className="truncate" title={x.summary}>
|
||||
<span className="font-mono text-cyan-300/90">{x.key}</span>{' '}
|
||||
<span className="text-slate-500">·</span> {x.who}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -9,12 +9,16 @@ import {
|
||||
milestoneLinkedGroups,
|
||||
milestoneOpenRemainingUnits,
|
||||
} from '../lib/milestoneStatus'
|
||||
import { milestoneVelocityRisk } from '../lib/milestoneLoadRisk'
|
||||
import { milestoneKindLabel, milestoneKindMarkerClass } from '../lib/milestoneKinds'
|
||||
import { ProjectRoadmapBar } from './ProjectRoadmapBar'
|
||||
|
||||
type Props = {
|
||||
milestones: Milestone[]
|
||||
groups: StoryGroup[]
|
||||
onOpenSettings: () => void
|
||||
/** Vélocité sous-tâches terminées / jour calendaire (burn-up), pour l’indicateur charge. */
|
||||
velocityPerCalendarDay: number
|
||||
/** Alertes d’impact (ex. jalons critiques en retard). */
|
||||
impactMessages?: string[]
|
||||
}
|
||||
@ -50,10 +54,30 @@ function delaySummary(
|
||||
return { text: '—', className: 'text-slate-500' }
|
||||
}
|
||||
|
||||
function chargeLabel(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
v: number,
|
||||
): { text: string; className: string } {
|
||||
const r = milestoneVelocityRisk(m, groups, cfg, v)
|
||||
if (r.level === 'tight' && r.daysNeeded != null) {
|
||||
return {
|
||||
text: `Serré (~${r.daysNeeded}j / ${r.calendarDaysLeft}j)`,
|
||||
className: 'text-amber-200',
|
||||
}
|
||||
}
|
||||
if (r.level === 'unknown') {
|
||||
return { text: 'N/D', className: 'text-slate-500' }
|
||||
}
|
||||
return { text: 'OK', className: 'text-slate-500' }
|
||||
}
|
||||
|
||||
export function MilestonesTimeline({
|
||||
milestones,
|
||||
groups,
|
||||
onOpenSettings,
|
||||
velocityPerCalendarDay,
|
||||
impactMessages = [],
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
@ -90,10 +114,10 @@ export function MilestonesTimeline({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">
|
||||
Chaque jalon regarde un périmètre : les stories saisies dans « Stories liées », ou toutes les
|
||||
stories chargées si ce champ est vide. L’avancement est la moyenne des pourcentages de
|
||||
sous-tâches terminées (même règle que le retard). Le RAF est la somme du temps restant Jira
|
||||
(unités ÷ 27 000) sur les sous-tâches encore actives de ce périmètre.
|
||||
Périmètre : stories liées ou toutes si vide. Types (livrable, gouvernance, …) et actions
|
||||
attendues se configurent dans les réglages — voir aussi la vue Projet pour la frise. La
|
||||
colonne « Charge » compare les sous-tâches ouvertes du périmètre à la vélocité globale
|
||||
(burn-up, jours calendaires restants).
|
||||
</p>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
@ -117,12 +141,12 @@ export function MilestonesTimeline({
|
||||
className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${
|
||||
late
|
||||
? 'bg-rose-500 shadow-[0_0_14px_rgba(244,63,94,0.7)]'
|
||||
: 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.5)]'
|
||||
: milestoneKindMarkerClass(m.kind)
|
||||
}`}
|
||||
title={
|
||||
late
|
||||
? 'Retard : date dépassée et périmètre non à 100 % (sous-tâches).'
|
||||
: 'À jour ou échéance future.'
|
||||
: `${milestoneKindLabel(m.kind)} — à jour ou échéance future.`
|
||||
}
|
||||
/>
|
||||
<span className="max-w-[140px] text-center text-xs font-medium text-white">
|
||||
@ -151,15 +175,18 @@ export function MilestonesTimeline({
|
||||
Synthèse par jalon
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
|
||||
<table className="w-full min-w-[720px] border-collapse text-left text-xs">
|
||||
<table className="w-full min-w-[960px] border-collapse text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
|
||||
<th className="px-3 py-2 font-medium">Jalon</th>
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Date</th>
|
||||
<th className="px-3 py-2 font-medium">Périmètre</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Avancement</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RAF (u.)</th>
|
||||
<th className="px-3 py-2 font-medium">Charge</th>
|
||||
<th className="px-3 py-2 font-medium">Échéance</th>
|
||||
<th className="px-3 py-2 font-medium">Actions attendues</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -169,10 +196,12 @@ export function MilestonesTimeline({
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
const raf = milestoneOpenRemainingUnits(m, groups, cfg)
|
||||
const del = delaySummary(m, groups, cfg)
|
||||
const ch = chargeLabel(m, groups, cfg, velocityPerCalendarDay)
|
||||
const scopeHint =
|
||||
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
|
||||
? `${nStories} story(s) liée(s)`
|
||||
: `Toutes (${nStories})`
|
||||
const actions = m.expectedActions?.trim() ?? ''
|
||||
return (
|
||||
<tr
|
||||
key={m.id}
|
||||
@ -186,10 +215,13 @@ export function MilestonesTimeline({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
|
||||
{milestoneKindLabel(m.kind)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
|
||||
{formatFr(m.date)}
|
||||
</td>
|
||||
<td className="max-w-[200px] px-3 py-2 align-top text-slate-500" title={scopeHint}>
|
||||
<td className="max-w-[160px] px-3 py-2 align-top text-slate-500" title={scopeHint}>
|
||||
{scopeHint}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right font-mono text-slate-300">
|
||||
@ -198,9 +230,21 @@ export function MilestonesTimeline({
|
||||
<td className="px-3 py-2 align-top text-right font-mono text-slate-400">
|
||||
{raf.toFixed(2)}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${ch.className}`}>
|
||||
{ch.text}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${del.className}`}>
|
||||
{del.text}
|
||||
</td>
|
||||
<td className="max-w-[220px] px-3 py-2 align-top text-[11px] text-slate-500">
|
||||
{actions ? (
|
||||
<span className="line-clamp-3" title={actions}>
|
||||
{actions}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
302
src/components/ProjectTimelineView.tsx
Normal file
302
src/components/ProjectTimelineView.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { Milestone } from '../lib/dashboardConfig'
|
||||
import type { StatusBucketConfig } from '../lib/statusBuckets'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import {
|
||||
isMilestoneLate,
|
||||
milestoneAverageCompletionPercent,
|
||||
milestoneCalendarDaysUntil,
|
||||
milestoneLinkedGroups,
|
||||
milestoneOpenRemainingUnits,
|
||||
} from '../lib/milestoneStatus'
|
||||
import { milestoneVelocityRisk } from '../lib/milestoneLoadRisk'
|
||||
import {
|
||||
milestoneKindChipClass,
|
||||
milestoneKindLabel,
|
||||
milestoneKindMarkerClass,
|
||||
} from '../lib/milestoneKinds'
|
||||
|
||||
type Props = {
|
||||
milestones: Milestone[]
|
||||
groups: StoryGroup[]
|
||||
/** Vélocité globale sous-tâches terminées / jour calendaire (burn-up). */
|
||||
velocityPerCalendarDay: number
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
function toNoonMs(iso: string): number {
|
||||
return new Date(iso + 'T12:00:00').getTime()
|
||||
}
|
||||
|
||||
function formatShort(iso: string): string {
|
||||
try {
|
||||
return new Date(iso + 'T12:00:00').toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function delaySummary(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): { text: string; className: string } {
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
if (pct >= 100) return { text: 'Terminé', className: 'text-emerald-400' }
|
||||
if (isMilestoneLate(m, groups, cfg)) return { text: 'Retard', className: 'text-rose-400' }
|
||||
const d = milestoneCalendarDaysUntil(m)
|
||||
if (d > 1) return { text: `Dans ${d} j`, className: d <= 7 ? 'text-amber-200' : 'text-slate-400' }
|
||||
if (d === 1) return { text: 'Demain', className: 'text-amber-200' }
|
||||
if (d === 0) return { text: "Aujourd'hui", className: 'text-amber-300' }
|
||||
return { text: '—', className: 'text-slate-500' }
|
||||
}
|
||||
|
||||
function loadHint(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
v: number,
|
||||
): string {
|
||||
const r = milestoneVelocityRisk(m, groups, cfg, v)
|
||||
if (r.level === 'tight' && r.daysNeeded != null) {
|
||||
return `~${r.daysNeeded} j à la vélocité actuelle pour fermer les sous-tâches ouvertes, ${r.calendarDaysLeft} j cal. avant le jalon.`
|
||||
}
|
||||
if (r.level === 'unknown') return 'Vélocité nulle ou historique insuffisant — impossible de comparer la charge.'
|
||||
return 'Charge compatible avec la marge calendaire (ordre de grandeur).'
|
||||
}
|
||||
|
||||
export function ProjectTimelineView({
|
||||
milestones,
|
||||
groups,
|
||||
velocityPerCalendarDay,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const sorted = useMemo(
|
||||
() => [...milestones].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
[milestones],
|
||||
)
|
||||
|
||||
const { startMs, endMs, todayPct } = useMemo(() => {
|
||||
const today = Date.now()
|
||||
const dates = sorted.map((m) => m.date).filter(Boolean)
|
||||
if (dates.length === 0) {
|
||||
const s = today - 7 * 86400000
|
||||
const e = today + 30 * 86400000
|
||||
return {
|
||||
startMs: s,
|
||||
endMs: e,
|
||||
todayPct: 50,
|
||||
}
|
||||
}
|
||||
let s = toNoonMs(dates[0]!)
|
||||
let e = toNoonMs(dates[dates.length - 1]!)
|
||||
s = Math.min(s, today - 7 * 86400000)
|
||||
e = Math.max(e, today + 21 * 86400000)
|
||||
const span = Math.max(1, e - s)
|
||||
const tp = ((today - s) / span) * 100
|
||||
return { startMs: s, endMs: e, todayPct: Math.max(0, Math.min(100, tp)) }
|
||||
}, [sorted])
|
||||
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
|
||||
const monthTicks = useMemo(() => {
|
||||
const ticks: { ms: number; label: string }[] = []
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
while (d.getTime() <= endMs) {
|
||||
if (d.getTime() >= startMs) {
|
||||
ticks.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return ticks
|
||||
}, [startMs, endMs])
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-8 text-center backdrop-blur-xl sm:px-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Vue projet — frise
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Aucun jalon configuré. Ajoutez des dates, types et actions attendues dans les réglages.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="mt-4 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-100"
|
||||
>
|
||||
Ouvrir la configuration
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-5 backdrop-blur-xl sm:px-6">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Vue projet — frise & jalons
|
||||
</h2>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
|
||||
Frise calendaire : chaque point correspond à un jalon (couleur = type). La ligne
|
||||
verticale blanche indique aujourd’hui. En dessous, l’agenda liste les actions attendues
|
||||
et un indicateur de charge vs vélocité globale (sous-tâches / jour, comme le burn-up).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="shrink-0 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-500/20"
|
||||
>
|
||||
Réglages jalons
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-10 select-none">
|
||||
<div className="mb-1 flex justify-between text-[10px] text-slate-600">
|
||||
{monthTicks.map((t) => {
|
||||
const pct = ((t.ms - startMs) / span) * 100
|
||||
return (
|
||||
<span
|
||||
key={t.ms}
|
||||
className="absolute -translate-x-1/2 whitespace-nowrap"
|
||||
style={{ left: `${pct}%` }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mt-6 h-14 w-full rounded-lg bg-slate-900/80 ring-1 ring-inset ring-white/[0.06]">
|
||||
{monthTicks.map((t) => {
|
||||
const pct = ((t.ms - startMs) / span) * 100
|
||||
return (
|
||||
<div
|
||||
key={`g-${t.ms}`}
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-px bg-slate-700/60"
|
||||
style={{ left: `${pct}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white shadow-[0_0_12px_rgba(255,255,255,0.5)]"
|
||||
style={{ left: `${todayPct}%` }}
|
||||
title="Aujourd'hui"
|
||||
/>
|
||||
{sorted.map((m) => {
|
||||
const pct = ((toNoonMs(m.date) - startMs) / span) * 100
|
||||
const clamped = Math.max(1.5, Math.min(98.5, pct))
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
title={`${m.title} — ${m.date}`}
|
||||
className={`absolute top-1/2 z-10 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-slate-950 ${milestoneKindMarkerClass(m.kind)}`}
|
||||
style={{ left: `${clamped}%` }}
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`milestone-card-${m.id}`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mt-2 h-8 text-[10px] text-slate-500">
|
||||
{sorted.map((m) => {
|
||||
const pct = ((toNoonMs(m.date) - startMs) / span) * 100
|
||||
const clamped = Math.max(2, Math.min(98, pct))
|
||||
return (
|
||||
<span
|
||||
key={`l-${m.id}`}
|
||||
className="absolute -translate-x-1/2 truncate text-center"
|
||||
style={{ left: `${clamped}%`, maxWidth: '14%' }}
|
||||
title={m.title}
|
||||
>
|
||||
{m.title || '(Sans titre)'}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Agenda (ordre chronologique)
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{sorted.map((m) => {
|
||||
const linked = milestoneLinkedGroups(m, groups)
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
const raf = milestoneOpenRemainingUnits(m, groups, cfg)
|
||||
const del = delaySummary(m, groups, cfg)
|
||||
const risk = milestoneVelocityRisk(m, groups, cfg, velocityPerCalendarDay)
|
||||
const scopeHint =
|
||||
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
|
||||
? `${linked.length} story(s) liée(s)`
|
||||
: `Toutes (${linked.length})`
|
||||
return (
|
||||
<li
|
||||
id={`milestone-card-${m.id}`}
|
||||
key={m.id}
|
||||
className="scroll-mt-24 rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-400">{formatShort(m.date)}</span>
|
||||
<span className={milestoneKindChipClass(m.kind)}>{milestoneKindLabel(m.kind)}</span>
|
||||
{m.critical && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase text-amber-200 ring-1 ring-amber-500/40">
|
||||
Critique
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{m.title || 'Sans titre'}</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">{scopeHint}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs">
|
||||
<p className={`font-medium ${del.className}`}>{del.text}</p>
|
||||
<p className="mt-0.5 font-mono text-slate-500">{pct}% · RAF {raf.toFixed(2)} u.</p>
|
||||
</div>
|
||||
</div>
|
||||
{m.expectedActions ? (
|
||||
<div className="mt-2 rounded-lg border border-white/[0.04] bg-slate-950/50 px-3 py-2 text-xs leading-relaxed text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Actions attendues —{' '}
|
||||
</span>
|
||||
{m.expectedActions}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] italic text-slate-600">
|
||||
Aucune action attendue renseignée — complétez le champ dans les réglages pour le
|
||||
suivi de réunion / livrable.
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={`mt-2 text-[11px] ${
|
||||
risk.level === 'tight'
|
||||
? 'text-amber-200/90'
|
||||
: risk.level === 'unknown'
|
||||
? 'text-slate-500'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{loadHint(m, groups, cfg, velocityPerCalendarDay)}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
517
src/components/SprintGanttView.tsx
Normal file
517
src/components/SprintGanttView.tsx
Normal file
@ -0,0 +1,517 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import { GANTT_SPRINT_METRIC_OPTIONS, type GanttSprintRowMetric, type Milestone } from '../lib/dashboardConfig'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
|
||||
import { milestoneKindMarkerClass } from '../lib/milestoneKinds'
|
||||
import {
|
||||
GANTT_ZOOM_FACTORS,
|
||||
epicScopeSprintProgress,
|
||||
formatGanttSprintSubtitleLines,
|
||||
formatSprintRangeFr,
|
||||
ganttDayColumns,
|
||||
ganttMonthBands,
|
||||
ganttRangeFromSprintsAndMilestones,
|
||||
ganttSubheaderTicks,
|
||||
ganttVerticalGuideXs,
|
||||
milestoneTooltipText,
|
||||
msToX,
|
||||
parseIsoMs,
|
||||
pixelsPerDay,
|
||||
timelineWidthPx,
|
||||
type GanttDayColumn,
|
||||
type GanttTimeScale,
|
||||
sprintBarBounds,
|
||||
sprintBarFillPercent,
|
||||
} from '../lib/sprintGantt'
|
||||
|
||||
type Props = {
|
||||
sprints: JiraSprintSnapshot[]
|
||||
milestones: Milestone[]
|
||||
groups: StoryGroup[]
|
||||
sprintFieldId: string | null
|
||||
ganttSprintRowMetric: GanttSprintRowMetric
|
||||
/** Dates yyyy-mm-dd (local) en plus des week-ends pour le fond du Gantt. */
|
||||
ganttNonWorkingDates: string[]
|
||||
onGanttSprintRowMetricChange: (m: GanttSprintRowMetric) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
function sprintOrderRank(state?: string): number {
|
||||
const s = (state ?? '').toLowerCase()
|
||||
if (s === 'active') return 0
|
||||
if (s === 'future') return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function IconLoupeMinus({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<path d="M15 15l5 5" />
|
||||
<path d="M7.5 10h5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconLoupePlus({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<path d="M15 15l5 5" />
|
||||
<path d="M10 7.5v5M7.5 10h5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const SCALE_LABELS: Record<GanttTimeScale, string> = {
|
||||
day: 'Jour',
|
||||
week: 'Semaine',
|
||||
month: 'Mois',
|
||||
}
|
||||
|
||||
function GanttTimelineBackdrop({
|
||||
dayColumns,
|
||||
guideXs,
|
||||
todayX,
|
||||
todayClamped,
|
||||
compact,
|
||||
}: {
|
||||
dayColumns: GanttDayColumn[]
|
||||
guideXs: number[]
|
||||
todayX: number
|
||||
todayClamped: 'before' | 'inside' | 'after'
|
||||
compact?: boolean
|
||||
}) {
|
||||
const insetY = compact ? 'bottom-1 top-1' : 'inset-y-0'
|
||||
return (
|
||||
<>
|
||||
{dayColumns
|
||||
.filter((c) => c.isGanttNonWork)
|
||||
.map((c) => (
|
||||
<div
|
||||
key={c.dayStartMs}
|
||||
className={`pointer-events-none absolute z-0 bg-slate-800/[0.38] ${insetY}`}
|
||||
style={{ left: `${c.x0}px`, width: `${Math.max(0, c.x1 - c.x0)}px` }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
{guideXs.map((gx, i) => (
|
||||
<div
|
||||
key={`vg-${i}-${Math.round(gx * 10)}`}
|
||||
className={`pointer-events-none absolute z-[1] w-px bg-slate-600/50 ${insetY}`}
|
||||
style={{ left: `${gx}px` }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`pointer-events-none absolute z-[2] w-0 border-l-2 border-dashed border-emerald-400/95 shadow-[0_0_10px_rgba(52,211,153,0.35)] ${insetY} ${
|
||||
todayClamped !== 'inside' ? 'opacity-45' : ''
|
||||
}`}
|
||||
style={{ left: `${todayX}px` }}
|
||||
title={
|
||||
todayClamped === 'inside'
|
||||
? 'Aujourd’hui'
|
||||
: todayClamped === 'before'
|
||||
? 'Aujourd’hui (avant la période affichée)'
|
||||
: 'Aujourd’hui (après la période affichée)'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SprintGanttView({
|
||||
sprints,
|
||||
milestones,
|
||||
groups,
|
||||
sprintFieldId,
|
||||
ganttSprintRowMetric,
|
||||
ganttNonWorkingDates,
|
||||
onGanttSprintRowMetricChange,
|
||||
onOpenSettings,
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [timeScale, setTimeScale] = useState<GanttTimeScale>('week')
|
||||
const [zoomIndex, setZoomIndex] = useState(3)
|
||||
|
||||
const { datedSprints, undatedSprints } = useMemo(() => {
|
||||
const dated: JiraSprintSnapshot[] = []
|
||||
const undated: JiraSprintSnapshot[] = []
|
||||
for (const s of sprints) {
|
||||
if (sprintBarBounds(s)) dated.push(s)
|
||||
else undated.push(s)
|
||||
}
|
||||
dated.sort((a, b) => {
|
||||
const ra = sprintOrderRank(a.state)
|
||||
const rb = sprintOrderRank(b.state)
|
||||
if (ra !== rb) return ra - rb
|
||||
const ba = sprintBarBounds(a)!
|
||||
const bb = sprintBarBounds(b)!
|
||||
return ba.startMs - bb.startMs
|
||||
})
|
||||
return { datedSprints: dated, undatedSprints: undated }
|
||||
}, [sprints])
|
||||
|
||||
const sortedMilestones = useMemo(
|
||||
() => [...milestones].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
[milestones],
|
||||
)
|
||||
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ganttRangeFromSprintsAndMilestones(datedSprints, sortedMilestones),
|
||||
[datedSprints, sortedMilestones],
|
||||
)
|
||||
|
||||
const ppd = useMemo(() => pixelsPerDay(timeScale, zoomIndex), [timeScale, zoomIndex])
|
||||
const widthPx = useMemo(
|
||||
() => timelineWidthPx(startMs, endMs, ppd),
|
||||
[startMs, endMs, ppd],
|
||||
)
|
||||
|
||||
const nonWorkingSet = useMemo(() => new Set(ganttNonWorkingDates), [ganttNonWorkingDates])
|
||||
const dayColumns = useMemo(
|
||||
() => ganttDayColumns(startMs, endMs, widthPx, nonWorkingSet),
|
||||
[startMs, endMs, widthPx, nonWorkingSet],
|
||||
)
|
||||
const monthBands = useMemo(
|
||||
() => ganttMonthBands(startMs, endMs, widthPx),
|
||||
[startMs, endMs, widthPx],
|
||||
)
|
||||
const subheaderTicks = useMemo(
|
||||
() => ganttSubheaderTicks(timeScale, startMs, endMs, widthPx),
|
||||
[timeScale, startMs, endMs, widthPx],
|
||||
)
|
||||
const guideXs = useMemo(
|
||||
() => ganttVerticalGuideXs(timeScale, startMs, endMs, widthPx),
|
||||
[timeScale, startMs, endMs, widthPx],
|
||||
)
|
||||
|
||||
const todayLine = useMemo(() => {
|
||||
const now = Date.now()
|
||||
if (now < startMs) return { x: 0, clamped: 'before' as const }
|
||||
if (now > endMs) return { x: widthPx, clamped: 'after' as const }
|
||||
return { x: msToX(now, startMs, endMs, widthPx), clamped: 'inside' as const }
|
||||
}, [startMs, endMs, widthPx])
|
||||
|
||||
const scrollToToday = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const now = Date.now()
|
||||
const clamped = Math.min(Math.max(now, startMs), endMs)
|
||||
const x = msToX(clamped, startMs, endMs, widthPx)
|
||||
el.scrollLeft = Math.max(0, x - el.clientWidth / 2 + 110)
|
||||
}, [startMs, endMs, widthPx])
|
||||
|
||||
if (sprints.length === 0) {
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Gantt sprints
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
Aucun sprint actif ou futur renvoyé par le board Jira. Vérifiez{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code> et
|
||||
actualisez.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (datedSprints.length === 0) {
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 backdrop-blur-xl sm:px-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
|
||||
Gantt sprints — dates manquantes
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm text-amber-100/90">
|
||||
Jira n’a pas renvoyé de <code className="rounded bg-black/30 px-1 font-mono text-xs">startDate</code> /{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono text-xs">endDate</code> pour ces sprints. Le
|
||||
Gantt nécessite ces champs (sprints Scrum classiques).
|
||||
</p>
|
||||
{undatedSprints.length > 0 && (
|
||||
<ul className="mx-auto mt-4 max-w-lg list-inside list-disc text-left text-xs text-amber-200/80">
|
||||
{undatedSprints.map((s) => (
|
||||
<li key={s.id}>
|
||||
{s.name} ({s.state ?? '?'})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const zoomFactor = GANTT_ZOOM_FACTORS[Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))]!
|
||||
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-5 backdrop-blur-xl sm:px-6">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Gantt — sprints & jalons
|
||||
</h2>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
|
||||
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
|
||||
timeline s’étire en pixels par jour — faites défiler horizontalement. En-tête : mois puis
|
||||
jours (sans répéter le mois) ; week-ends et jours configurés en fond plus sombre. Remplissage
|
||||
des barres = % de sous-tâches terminées (champ Sprint). Losanges = jalons (survol pour le
|
||||
détail).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="shrink-0 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-500/20"
|
||||
>
|
||||
Réglages jalons / Sprint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!sprintFieldId && (
|
||||
<p className="mb-3 rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-xs text-sky-100/90">
|
||||
Sans champ Sprint, le <span className="font-medium text-sky-50">remplissage des barres reste à 0 %</span>{' '}
|
||||
(aucune sous-tâche rattachée au sprint). Ajoutez{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono">customfield_…</code> pour agréger les sous-tâches
|
||||
par sprint.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-white/[0.08] pb-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Échelle
|
||||
</span>
|
||||
{(['day', 'week', 'month'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setTimeScale(s)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||||
timeScale === s
|
||||
? 'bg-cyan-500/25 text-cyan-100 ring-1 ring-cyan-400/50'
|
||||
: 'bg-slate-900/80 text-slate-400 ring-1 ring-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{SCALE_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 hidden h-6 w-px bg-white/15 sm:inline-block" aria-hidden />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Zoom</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom arrière — voir plus de période"
|
||||
title="Zoom arrière"
|
||||
disabled={zoomIndex <= 0}
|
||||
onClick={() => setZoomIndex((z) => Math.max(0, z - 1))}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<IconLoupeMinus />
|
||||
</button>
|
||||
<span
|
||||
className="min-w-[3rem] text-center font-mono text-[11px] text-slate-400"
|
||||
title="Facteur de zoom sur la densité horizontale"
|
||||
>
|
||||
×{zoomFactor.toFixed(2)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom avant — agrandir le détail"
|
||||
title="Zoom avant"
|
||||
disabled={zoomIndex >= GANTT_ZOOM_FACTORS.length - 1}
|
||||
onClick={() => setZoomIndex((z) => Math.min(GANTT_ZOOM_FACTORS.length - 1, z + 1))}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<IconLoupePlus />
|
||||
</button>
|
||||
<span className="ml-auto hidden text-[10px] text-slate-600 sm:inline">
|
||||
≈ {ppd.toFixed(1)} px/j · {Math.round(widthPx)} px
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToToday}
|
||||
className="rounded-lg border border-emerald-500/35 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-emerald-100/95 hover:bg-emerald-500/20"
|
||||
title="Fait défiler la timeline pour centrer la date du jour"
|
||||
>
|
||||
Centrer sur aujourd’hui
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<span className="hidden sm:inline">Sous les barres</span>
|
||||
<select
|
||||
className="max-w-[11rem] rounded-lg border border-white/10 bg-slate-900/90 px-2 py-1 text-[11px] font-medium normal-case text-slate-200 outline-none"
|
||||
value={ganttSprintRowMetric}
|
||||
onChange={(e) => onGanttSprintRowMetricChange(e.target.value as GanttSprintRowMetric)}
|
||||
title="Identique à l’option dans Réglages"
|
||||
>
|
||||
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-w-full overflow-x-auto overflow-y-visible rounded-xl border border-white/[0.08] bg-slate-950/50"
|
||||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `220px ${widthPx}px`,
|
||||
width: `${220 + widthPx}px`,
|
||||
}}
|
||||
>
|
||||
<div className="sticky left-0 z-40 flex min-h-[4.25rem] flex-col justify-center border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
|
||||
Piste
|
||||
</div>
|
||||
<div
|
||||
className="relative min-h-[4.25rem] border-b border-white/[0.08] bg-slate-900/55"
|
||||
style={{ width: `${widthPx}px` }}
|
||||
>
|
||||
<div className="relative h-8 border-b border-white/15">
|
||||
{monthBands.map((b) => (
|
||||
<div
|
||||
key={b.monthStartMs}
|
||||
className="absolute inset-y-0 flex items-center justify-center border-r border-white/20 bg-slate-900/80 text-[11px] font-semibold capitalize tracking-tight text-slate-200"
|
||||
style={{ left: `${b.x0}px`, width: `${Math.max(0, b.x1 - b.x0)}px` }}
|
||||
>
|
||||
<span className="truncate px-1.5 text-center" title={b.label}>
|
||||
{b.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative h-7">
|
||||
{subheaderTicks.map((t, idx) => (
|
||||
<span
|
||||
key={`${timeScale}-${t.ms}-${idx}`}
|
||||
className={`absolute top-1 -translate-x-1/2 select-none whitespace-nowrap text-[10px] ${
|
||||
t.major ? 'font-semibold text-slate-300' : 'text-slate-500'
|
||||
}`}
|
||||
style={{ left: `${t.x}px` }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky left-0 z-30 flex items-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-violet-300/90 backdrop-blur-sm">
|
||||
Jalons
|
||||
</div>
|
||||
<div className="relative h-12 border-b border-white/[0.06] bg-slate-900/50">
|
||||
<GanttTimelineBackdrop
|
||||
dayColumns={dayColumns}
|
||||
guideXs={guideXs}
|
||||
todayX={todayLine.x}
|
||||
todayClamped={todayLine.clamped}
|
||||
/>
|
||||
{sortedMilestones.map((m) => {
|
||||
const ms = parseIsoMs(`${m.date}T12:00:00`)
|
||||
if (ms == null || ms < startMs || ms > endMs) return null
|
||||
const x = msToX(ms, startMs, endMs, widthPx)
|
||||
const left = Math.max(6, Math.min(widthPx - 6, x))
|
||||
return (
|
||||
<span
|
||||
key={m.id}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
title={milestoneTooltipText(m)}
|
||||
className={`absolute top-1/2 z-20 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 cursor-help rotate-45 ring-2 ring-slate-950 ${milestoneKindMarkerClass(m.kind)}`}
|
||||
style={{ left: `${left}px` }}
|
||||
aria-label={m.title}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{datedSprints.map((s) => {
|
||||
const b = sprintBarBounds(s)!
|
||||
const x0 = msToX(b.startMs, startMs, endMs, widthPx)
|
||||
const x1 = msToX(b.endMs, startMs, endMs, widthPx)
|
||||
const barW = Math.max(10, x1 - x0)
|
||||
const fill = sprintBarFillPercent(s, groups, sprintFieldId, cfg)
|
||||
const delivery = epicScopeSprintProgress(groups, s.id, sprintFieldId, cfg)
|
||||
const subtitleLines = formatGanttSprintSubtitleLines(
|
||||
ganttSprintRowMetric,
|
||||
s,
|
||||
groups,
|
||||
sprintFieldId,
|
||||
cfg,
|
||||
)
|
||||
const barTitle =
|
||||
sprintFieldId && delivery.total > 0
|
||||
? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : ${delivery.percent} % des sous-tâches terminées (${delivery.done} / ${delivery.total})\n${subtitleLines.join('\n')}`
|
||||
: sprintFieldId
|
||||
? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — aucune sous-tâche (non annulée) dans ce sprint avec le champ Sprint.\n${subtitleLines.join('\n')}`
|
||||
: `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — configurez le champ Sprint dans les réglages.\n${subtitleLines.join('\n')}`
|
||||
|
||||
return (
|
||||
<div key={s.id} className="contents">
|
||||
<div className="sticky left-0 z-30 flex flex-col justify-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-xs backdrop-blur-sm">
|
||||
<span className="font-medium text-slate-200">{s.name}</span>
|
||||
<span className="mt-0.5 text-[10px] uppercase text-slate-500">{s.state ?? '—'}</span>
|
||||
<span className="mt-0.5 font-mono text-[10px] text-slate-500">{formatSprintRangeFr(s)}</span>
|
||||
{subtitleLines.map((line, i) => (
|
||||
<span key={i} className="mt-0.5 font-mono text-[10px] leading-snug text-sky-200/85">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative min-h-[48px] border-b border-white/[0.06] bg-slate-900/30 py-2">
|
||||
<div className="relative mx-0 h-10">
|
||||
<GanttTimelineBackdrop
|
||||
dayColumns={dayColumns}
|
||||
guideXs={guideXs}
|
||||
todayX={todayLine.x}
|
||||
todayClamped={todayLine.clamped}
|
||||
compact
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1 top-1 overflow-hidden rounded-full bg-gradient-to-r from-slate-700/90 to-sky-950/50 ring-1 ring-sky-500/25"
|
||||
style={{ left: `${x0}px`, width: `${barW}px` }}
|
||||
title={barTitle}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-l-full bg-gradient-to-r from-sky-600 via-sky-500 to-sky-400/90 shadow-[inset_0_0_12px_rgba(255,255,255,0.12)]"
|
||||
style={{ width: `${fill}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{undatedSprints.length > 0 && (
|
||||
<p className="mt-4 text-[11px] text-slate-500">
|
||||
Sprints sans dates affichables : {undatedSprints.map((u) => u.name).join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
239
src/components/SprintView.tsx
Normal file
239
src/components/SprintView.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
|
||||
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
|
||||
import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
|
||||
import {
|
||||
collectSprintOptions,
|
||||
filterGroupsBySprint,
|
||||
filterGroupsBySprintIssueKeys,
|
||||
sprintOptionsFromBoardAndGroups,
|
||||
sprintOptionsFromBoardOnly,
|
||||
} from '../lib/sprintExtract'
|
||||
import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
|
||||
import { StoryCard } from './StoryCard'
|
||||
|
||||
type Props = {
|
||||
groups: StoryGroup[]
|
||||
sprintFieldId: string | null
|
||||
/** Sprints actifs/futurs depuis l’API Agile du board (sans élargir le périmètre JQL). */
|
||||
boardSprintsFromApi?: JiraSprintSnapshot[]
|
||||
onOpenSettings: () => void
|
||||
gapBadges?: FunctionalGapBadge[]
|
||||
}
|
||||
|
||||
export function SprintView({
|
||||
groups,
|
||||
sprintFieldId,
|
||||
boardSprintsFromApi = [],
|
||||
onOpenSettings,
|
||||
gapBadges,
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const [focusId, setFocusId] = useState<number | 'all'>('all')
|
||||
const [sprintIssueKeys, setSprintIssueKeys] = useState<Set<string>>(new Set())
|
||||
const [sprintIssuesLoading, setSprintIssuesLoading] = useState(false)
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (boardSprintsFromApi.length > 0) {
|
||||
if (sprintFieldId) {
|
||||
const fromBoard = sprintOptionsFromBoardAndGroups(
|
||||
boardSprintsFromApi,
|
||||
groups,
|
||||
sprintFieldId,
|
||||
)
|
||||
if (fromBoard.length > 0) return fromBoard
|
||||
} else {
|
||||
return sprintOptionsFromBoardOnly(boardSprintsFromApi)
|
||||
}
|
||||
}
|
||||
if (sprintFieldId) return collectSprintOptions(groups, sprintFieldId)
|
||||
return []
|
||||
}, [boardSprintsFromApi, groups, sprintFieldId])
|
||||
|
||||
useEffect(() => {
|
||||
if (sprintFieldId || focusId === 'all') {
|
||||
setSprintIssueKeys(new Set())
|
||||
setSprintIssuesLoading(false)
|
||||
return
|
||||
}
|
||||
const sprintId = focusId as number
|
||||
const ac = new AbortController()
|
||||
setSprintIssuesLoading(true)
|
||||
setSprintIssueKeys(new Set())
|
||||
void fetchAllIssueKeysInSprint(sprintId, ac.signal)
|
||||
.then((set) => {
|
||||
if (!ac.signal.aborted) setSprintIssueKeys(set)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!ac.signal.aborted) setSprintIssueKeys(new Set())
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ac.signal.aborted) setSprintIssuesLoading(false)
|
||||
})
|
||||
return () => ac.abort()
|
||||
}, [focusId, sprintFieldId])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (focusId === 'all') return groups
|
||||
if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
|
||||
if (sprintIssuesLoading) return []
|
||||
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
|
||||
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
let subs = 0
|
||||
let sumPct = 0
|
||||
let nWithSubs = 0
|
||||
for (const g of filtered) {
|
||||
subs += g.subtasks.length
|
||||
if (g.subtasks.length > 0) {
|
||||
sumPct += subtaskDoneRatioPercent(g.subtasks, cfg)
|
||||
nWithSubs += 1
|
||||
}
|
||||
}
|
||||
const avgPct = nWithSubs > 0 ? Math.round(sumPct / nWithSubs) : 0
|
||||
return { stories: filtered.length, subtasks: subs, avgPct }
|
||||
}, [filtered, cfg])
|
||||
|
||||
/** Aucune liste possible : ni board Agile, ni champ Sprint sur les tickets. */
|
||||
const mustConfigureSprintField = options.length === 0 && !sprintFieldId && boardSprintsFromApi.length === 0
|
||||
|
||||
if (mustConfigureSprintField) {
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
|
||||
Vue Sprint — configuration requise
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90">
|
||||
Aucun sprint actif/futur n’a été reçu du board Agile, et le champ Sprint n’est pas
|
||||
renseigné. Indiquez l’identifiant du champ personnalisé Sprint (souvent{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono text-xs">customfield_10020</code>) dans
|
||||
les réglages ou via{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
|
||||
ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
|
||||
) puis actualisez.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="mt-5 rounded-lg border border-cyan-500/50 bg-cyan-500/15 px-4 py-2 text-sm font-medium text-cyan-100"
|
||||
>
|
||||
Ouvrir les réglages
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 backdrop-blur-xl sm:px-8">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Vue Sprint
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
Aucun sprint actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
|
||||
|
||||
return (
|
||||
<section className="mb-10 space-y-6">
|
||||
{filterModeAgile && (
|
||||
<div className="rounded-xl border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs leading-relaxed text-sky-100/95">
|
||||
Filtre sprint via l’API Agile Jira (issues du sprint). Pour afficher les pastilles sprint
|
||||
sur chaque carte, renseignez aussi le champ Sprint (ID{' '}
|
||||
<code className="rounded bg-black/30 px-1 font-mono text-[11px]">customfield_…</code>) dans
|
||||
les réglages.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Vue Sprint
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-slate-500">
|
||||
{sprintFieldId ? (
|
||||
<>
|
||||
Sprints actifs/futurs du board Jira Agile ; le nombre de stories compte le
|
||||
périmètre chargé (épopée) avec le champ Sprint sur les tickets.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sprints actifs/futurs du board (API Agile). Le filtre par sprint interroge les
|
||||
issues du sprint côté Jira ; le périmètre affiché reste celui de l’épopée Golden
|
||||
Carbon.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
|
||||
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-500">
|
||||
Sprint ciblé
|
||||
</label>
|
||||
<select
|
||||
value={focusId === 'all' ? 'all' : String(focusId)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setFocusId(v === 'all' ? 'all' : Number(v))
|
||||
}}
|
||||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 outline-none ring-cyan-500/20 focus:ring-2"
|
||||
>
|
||||
<option value="all">Tous les sprints ({groups.length} stories)</option>
|
||||
{options.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
{s.state ? ` (${s.state})` : ''}
|
||||
{sprintFieldId ? ` — ${s.storyCount} stories` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 border-t border-white/[0.06] pt-4 text-sm text-slate-400">
|
||||
<span>
|
||||
Stories :{' '}
|
||||
<span className="font-mono font-semibold text-slate-200">{stats.stories}</span>
|
||||
</span>
|
||||
<span>
|
||||
Sous-tâches :{' '}
|
||||
<span className="font-mono font-semibold text-slate-200">{stats.subtasks}</span>
|
||||
</span>
|
||||
<span title="Moyenne des % sous-tâches terminées sur les stories qui ont des sous-tâches">
|
||||
Avancement moy. :{' '}
|
||||
<span className="font-mono font-semibold text-emerald-300">{stats.avgPct}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sprintIssuesLoading && focusId !== 'all' && !sprintFieldId ? (
|
||||
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
|
||||
Chargement des tickets du sprint…
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
|
||||
Aucune story du périmètre dans ce sprint (filtre « Ma vue » inclus), ou erreur lors du
|
||||
chargement des issues du sprint.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
{filtered.map((g) => (
|
||||
<StoryCard
|
||||
key={g.story.key}
|
||||
group={g}
|
||||
sprintFieldId={sprintFieldId}
|
||||
gapBadges={gapBadges}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
|
||||
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
|
||||
import { storyMatchesGapBadge } from '../lib/functionalGaps'
|
||||
import { effectivePipelinePhase } from '../lib/pipelinePhase'
|
||||
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
|
||||
import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
|
||||
import { PhaseStepper } from './PhaseStepper'
|
||||
@ -10,6 +13,7 @@ import { useStatusBuckets } from '../context/StatusBucketContext'
|
||||
import { useLaneLabels } from '../context/LaneLabelsContext'
|
||||
import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors'
|
||||
import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
|
||||
import { mergeSprintsForGroup } from '../lib/sprintExtract'
|
||||
|
||||
function IssueKeyLink({
|
||||
issueKey,
|
||||
@ -42,6 +46,18 @@ type Props = {
|
||||
group: StoryGroup
|
||||
/** Board : icônes A/D/I par piste + carte un peu plus compacte. */
|
||||
variant?: 'default' | 'board'
|
||||
/** Champ Sprint Jira : affiche les pastilles sur la carte (vue Sprint ou debug). */
|
||||
sprintFieldId?: string | null
|
||||
/** Badges d’écarts fonctionnels (Panier, Checkout…) selon les réglages. */
|
||||
gapBadges?: FunctionalGapBadge[]
|
||||
}
|
||||
|
||||
function sprintChipClass(state?: string): string {
|
||||
const s = (state ?? '').toLowerCase()
|
||||
if (s === 'active') return 'bg-emerald-500/20 text-emerald-100 ring-emerald-500/40'
|
||||
if (s === 'future') return 'bg-sky-500/20 text-sky-100 ring-sky-500/40'
|
||||
if (s === 'closed') return 'bg-slate-600/30 text-slate-300 ring-slate-500/30'
|
||||
return 'bg-slate-600/25 text-slate-200 ring-slate-500/35'
|
||||
}
|
||||
|
||||
function phaseChipClass(phase: PhaseId): string {
|
||||
@ -56,13 +72,18 @@ function phaseChipClass(phase: PhaseId): string {
|
||||
return map[phase] ?? `${base} bg-slate-500/15 text-slate-200 ring-slate-500/35`
|
||||
}
|
||||
|
||||
export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
export function StoryCard({
|
||||
group,
|
||||
variant = 'default',
|
||||
sprintFieldId = null,
|
||||
gapBadges,
|
||||
}: Props) {
|
||||
const cfg = useStatusBuckets()
|
||||
const laneCfg = useLaneLabels()
|
||||
const { story, subtasks } = group
|
||||
const [subsOpen, setSubsOpen] = useState(true)
|
||||
const progress = subtaskDoneRatioPercent(subtasks, cfg)
|
||||
const steps = stepperStates(subtasks, cfg)
|
||||
const steps = stepperStates(subtasks, cfg, laneCfg)
|
||||
const band = priorityBand(story)
|
||||
const assignee = story.fields.assignee?.displayName
|
||||
const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
|
||||
@ -71,6 +92,8 @@ export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0)
|
||||
const remStory = getRemainingEstimateUnits(story)
|
||||
const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0)
|
||||
const sprintChips = sprintFieldId ? mergeSprintsForGroup(group, sprintFieldId) : []
|
||||
const matchedGaps = (gapBadges ?? []).filter((b) => storyMatchesGapBadge(group, b))
|
||||
|
||||
return (
|
||||
<article className="group flex flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-gradient-to-br from-white/[0.07] to-white/[0.02] shadow-[0_8px_40px_rgba(0,0,0,0.35)] backdrop-blur-xl transition hover:border-cyan-400/25 hover:shadow-[0_0_28px_rgba(34,211,238,0.08)]">
|
||||
@ -91,6 +114,36 @@ export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
<h3 className="mt-1.5 text-base font-semibold leading-snug text-white sm:text-lg">
|
||||
{story.fields.summary}
|
||||
</h3>
|
||||
{matchedGaps.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{matchedGaps.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${
|
||||
b.criticalFlow
|
||||
? 'bg-rose-500/20 text-rose-100 ring-rose-500/45'
|
||||
: 'bg-amber-500/15 text-amber-100 ring-amber-500/40'
|
||||
}`}
|
||||
title={`Écart fonctionnel (termes : ${b.terms.join(', ')})`}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sprintChips.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{sprintChips.map((sp) => (
|
||||
<span
|
||||
key={sp.id}
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset ${sprintChipClass(sp.state)}`}
|
||||
title={sp.goal ? `${sp.name} — ${sp.goal}` : sp.name}
|
||||
>
|
||||
{sp.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
<span title="Story Points (champ configurable, défaut customfield_10028)">
|
||||
SP story : <span className="font-mono text-slate-300">{spStory}</span>
|
||||
@ -165,7 +218,7 @@ export function StoryCard({ group, variant = 'default' }: Props) {
|
||||
}`}
|
||||
>
|
||||
{subtasks.map((st) => {
|
||||
const ph = statusToPhase(st.fields.status.name)
|
||||
const ph = effectivePipelinePhase(st, cfg, laneCfg)
|
||||
return (
|
||||
<li
|
||||
key={st.key}
|
||||
|
||||
33
src/lib/assigneeRadar.ts
Normal file
33
src/lib/assigneeRadar.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
export type AssigneeLoadRow = {
|
||||
name: string
|
||||
openCount: number
|
||||
overload: boolean
|
||||
}
|
||||
|
||||
/** Radar de charge : sous-tâches ouvertes par assigné vs plafond WIP (réglages). */
|
||||
export function assigneeOpenLoadRadar(
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
wipCap: number,
|
||||
): AssigneeLoadRow[] {
|
||||
const map = new Map<string, number>()
|
||||
for (const g of groups) {
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b === 'done' || b === 'cancel') continue
|
||||
const name = st.fields.assignee?.displayName?.trim() || 'Non assigné'
|
||||
map.set(name, (map.get(name) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([name, openCount]) => ({
|
||||
name,
|
||||
openCount,
|
||||
overload: openCount > wipCap,
|
||||
}))
|
||||
.sort((a, b) => b.openCount - a.openCount)
|
||||
}
|
||||
@ -1,3 +1,14 @@
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
|
||||
import type { MilestoneKind } from './milestoneKinds'
|
||||
import { normalizeMilestoneKind } from './milestoneKinds'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
|
||||
|
||||
export type { LaneLabelsConfig } from './laneDetection'
|
||||
export type { MilestoneKind } from './milestoneKinds'
|
||||
export type { StatusBucketConfig } from './statusBuckets'
|
||||
|
||||
export type Milestone = {
|
||||
id: string
|
||||
title: string
|
||||
@ -7,15 +18,158 @@ export type Milestone = {
|
||||
linkedStoryKeys?: string[]
|
||||
/** Jalon critique : alerte d’impact si retard après la date. */
|
||||
critical?: boolean
|
||||
/** Nature du jalon (couleur frise, libellés). */
|
||||
kind?: MilestoneKind
|
||||
/** Actions ou livrables attendus à cette date (vue projet & synthèse). */
|
||||
expectedActions?: string
|
||||
}
|
||||
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
|
||||
export function sanitizeMilestone(raw: unknown): Milestone | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const o = raw as Partial<Milestone>
|
||||
if (!o.id || typeof o.id !== 'string') return null
|
||||
const dateStr =
|
||||
typeof o.date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(o.date)
|
||||
? o.date.slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10)
|
||||
return {
|
||||
id: o.id,
|
||||
title: typeof o.title === 'string' ? o.title : '',
|
||||
date: dateStr,
|
||||
linkedStoryKeys: Array.isArray(o.linkedStoryKeys)
|
||||
? o.linkedStoryKeys.filter((k): k is string => typeof k === 'string' && k.trim().length > 0)
|
||||
: [],
|
||||
critical: Boolean(o.critical),
|
||||
kind: normalizeMilestoneKind(o.kind),
|
||||
expectedActions:
|
||||
typeof o.expectedActions === 'string' && o.expectedActions.trim()
|
||||
? o.expectedActions.trim()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export type { StatusBucketConfig } from './statusBuckets'
|
||||
export type { LaneLabelsConfig } from './laneDetection'
|
||||
export function sanitizeMilestonesArray(arr: unknown): Milestone[] {
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr.map(sanitizeMilestone).filter((m): m is Milestone => m != null)
|
||||
}
|
||||
|
||||
/** Badges « Golden Carbon » : écarts fonctionnels (Panier, Checkout…) par mots-clés configurables. */
|
||||
export type FunctionalGapBadge = {
|
||||
id: string
|
||||
label: string
|
||||
/** Termes cherchés dans clés, résumés et étiquettes (insensible à la casse / accents). */
|
||||
terms: string[]
|
||||
/** Flux critique : renforce le feu rouge macro si conflit de phases. */
|
||||
criticalFlow?: boolean
|
||||
}
|
||||
|
||||
export function sanitizeFunctionalGapBadge(raw: unknown): FunctionalGapBadge | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const o = raw as Partial<FunctionalGapBadge>
|
||||
if (!o.id || typeof o.id !== 'string' || !o.label || typeof o.label !== 'string') return null
|
||||
const terms = Array.isArray(o.terms)
|
||||
? o.terms
|
||||
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
|
||||
.map((t) => t.trim())
|
||||
: []
|
||||
if (terms.length === 0) return null
|
||||
return {
|
||||
id: o.id.trim(),
|
||||
label: o.label.trim(),
|
||||
terms,
|
||||
criticalFlow: Boolean(o.criticalFlow),
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultFunctionalGaps(): FunctionalGapBadge[] {
|
||||
return [
|
||||
{ id: 'cart', label: 'Panier', terms: ['panier', 'cart', 'basket', 'caddie'], criticalFlow: true },
|
||||
{ id: 'checkout', label: 'Checkout', terms: ['checkout', 'caisse', 'commande'], criticalFlow: true },
|
||||
{ id: 'payment', label: 'Paiement', terms: ['paiement', 'payment', 'stripe', 'psp'], criticalFlow: false },
|
||||
]
|
||||
}
|
||||
|
||||
function sanitizeFunctionalGapsArray(
|
||||
arr: unknown,
|
||||
fallback: FunctionalGapBadge[],
|
||||
): FunctionalGapBadge[] {
|
||||
if (!Array.isArray(arr)) return fallback
|
||||
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
|
||||
return out.length > 0 ? out : fallback
|
||||
}
|
||||
|
||||
/** À l’enregistrement des réglages : retire les badges invalides, garde au moins les défauts. */
|
||||
export function normalizeFunctionalGapsForSave(arr: FunctionalGapBadge[]): FunctionalGapBadge[] {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return defaultFunctionalGaps()
|
||||
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
|
||||
return out.length > 0 ? out : defaultFunctionalGaps()
|
||||
}
|
||||
|
||||
/** Infos affichées sous chaque sprint sur le Gantt. */
|
||||
export type GanttSprintRowMetric =
|
||||
| 'none'
|
||||
| 'time_remaining'
|
||||
| 'subtasks_done_count'
|
||||
| 'subtasks_done_percent'
|
||||
| 'combined'
|
||||
|
||||
export function defaultGanttSprintRowMetric(): GanttSprintRowMetric {
|
||||
return 'combined'
|
||||
}
|
||||
|
||||
export const GANTT_SPRINT_METRIC_OPTIONS: { value: GanttSprintRowMetric; label: string }[] = [
|
||||
{ value: 'combined', label: 'Temps restant + sous-tâches' },
|
||||
{ value: 'time_remaining', label: 'Temps restant (dates sprint)' },
|
||||
{ value: 'subtasks_done_count', label: 'Sous-tâches terminées (nombre)' },
|
||||
{ value: 'subtasks_done_percent', label: 'Sous-tâches terminées (%)' },
|
||||
{ value: 'none', label: 'Masquer' },
|
||||
]
|
||||
|
||||
export function sanitizeGanttSprintRowMetric(raw: unknown): GanttSprintRowMetric {
|
||||
const v = typeof raw === 'string' ? raw : ''
|
||||
if (
|
||||
v === 'none' ||
|
||||
v === 'time_remaining' ||
|
||||
v === 'subtasks_done_count' ||
|
||||
v === 'subtasks_done_percent' ||
|
||||
v === 'combined'
|
||||
) {
|
||||
return v
|
||||
}
|
||||
return defaultGanttSprintRowMetric()
|
||||
}
|
||||
|
||||
export function sanitizeExcludedSprintIds(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: number[] = []
|
||||
for (const x of raw) {
|
||||
const n = typeof x === 'number' ? x : Number(x)
|
||||
if (Number.isFinite(n) && n > 0) out.push(Math.floor(n))
|
||||
}
|
||||
return [...new Set(out)]
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/** Jours fériés / ponts (clé locale yyyy-mm-dd) — même fond atténué que les week-ends sur le Gantt. */
|
||||
export function sanitizeGanttNonWorkingDates(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: string[] = []
|
||||
for (const x of raw) {
|
||||
const s = typeof x === 'string' ? x.trim().slice(0, 10) : ''
|
||||
if (ISO_DATE_RE.test(s)) out.push(s)
|
||||
}
|
||||
return [...new Set(out)].sort()
|
||||
}
|
||||
|
||||
/** Parse une zone de texte (lignes ou séparateurs virgule / point-virgule). */
|
||||
export function parseGanttNonWorkingDatesFromText(raw: string): string[] {
|
||||
const parts = raw
|
||||
.split(/[\n,;\t]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
return sanitizeGanttNonWorkingDates(parts)
|
||||
}
|
||||
|
||||
export type DashboardConfig = {
|
||||
version: 1
|
||||
@ -34,6 +188,16 @@ export type DashboardConfig = {
|
||||
myJiraEmail?: string
|
||||
/** Filtre « Ma vue » (sous-tâches me concernant). */
|
||||
myViewActive?: boolean
|
||||
/** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */
|
||||
sprintFieldId?: string
|
||||
/** Badges d’écarts fonctionnels (vue produit / pages critiques). */
|
||||
functionalGaps: FunctionalGapBadge[]
|
||||
/** IDs sprint Jira (API Agile) masqués dans le Gantt et la vue Sprint. */
|
||||
excludedSprintIds: number[]
|
||||
/** Lignes d’info sous les barres de sprint (Gantt). */
|
||||
ganttSprintRowMetric: GanttSprintRowMetric
|
||||
/** Dates non travaillées (yyyy-mm-dd, fuseau local) — fond Gantt comme week-end. */
|
||||
ganttNonWorkingDates: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
||||
@ -46,8 +210,26 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
|
||||
teamCapacity: 3,
|
||||
baselineCapacity: 3,
|
||||
wipSlotsPerDev: 5,
|
||||
functionalGaps: defaultFunctionalGaps(),
|
||||
excludedSprintIds: [],
|
||||
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
|
||||
ganttNonWorkingDates: [],
|
||||
})
|
||||
|
||||
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
|
||||
export function extractDashboardPayload(imported: unknown): Partial<DashboardConfig> | null {
|
||||
if (!imported || typeof imported !== 'object') return null
|
||||
const o = imported as Record<string, unknown>
|
||||
if (o.bundleVersion === 1 && o.dashboard && typeof o.dashboard === 'object') {
|
||||
const inner = o.dashboard as Partial<DashboardConfig>
|
||||
if (inner.version !== 1) return null
|
||||
return inner
|
||||
}
|
||||
const flat = imported as Partial<DashboardConfig>
|
||||
if (flat.version !== 1) return null
|
||||
return flat
|
||||
}
|
||||
|
||||
export function loadDashboardConfig(): DashboardConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
@ -56,7 +238,7 @@ export function loadDashboardConfig(): DashboardConfig {
|
||||
if (!parsed || parsed.version !== 1) return defaultDashboardConfig()
|
||||
return {
|
||||
version: 1,
|
||||
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
|
||||
milestones: sanitizeMilestonesArray(parsed.milestones),
|
||||
statusBuckets: mergeStatusBucketConfig(
|
||||
parsed.statusBuckets && typeof parsed.statusBuckets === 'object'
|
||||
? (parsed.statusBuckets as Partial<StatusBucketConfig>)
|
||||
@ -82,6 +264,14 @@ export function loadDashboardConfig(): DashboardConfig {
|
||||
myJiraAccountId: parsed.myJiraAccountId,
|
||||
myJiraEmail: parsed.myJiraEmail,
|
||||
myViewActive: parsed.myViewActive,
|
||||
sprintFieldId:
|
||||
typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim()
|
||||
? parsed.sprintFieldId.trim()
|
||||
: undefined,
|
||||
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
|
||||
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
|
||||
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
|
||||
ganttNonWorkingDates: sanitizeGanttNonWorkingDates(parsed.ganttNonWorkingDates),
|
||||
}
|
||||
} catch {
|
||||
return defaultDashboardConfig()
|
||||
@ -102,16 +292,33 @@ export function exportConfigJson(cfg: DashboardConfig): void {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** Sauvegarde JSON enrichie pour NAS Synology (Docker / volume monté) : horodatage + enveloppe versionnée. */
|
||||
export function exportSynologyBackupJson(cfg: DashboardConfig): void {
|
||||
const bundle = {
|
||||
bundleVersion: 1 as const,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: 'dcc-migration-cockpit' as const,
|
||||
dashboard: cfg,
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
a.download = `dcc-synology-backup-${stamp}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function mergeImportedConfig(
|
||||
current: DashboardConfig,
|
||||
imported: unknown,
|
||||
): DashboardConfig | null {
|
||||
if (!imported || typeof imported !== 'object') return null
|
||||
const o = imported as Partial<DashboardConfig>
|
||||
if (o.version !== 1) return null
|
||||
const o = extractDashboardPayload(imported)
|
||||
if (!o) return null
|
||||
return {
|
||||
version: 1,
|
||||
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
|
||||
milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones,
|
||||
statusBuckets:
|
||||
o.statusBuckets && typeof o.statusBuckets === 'object'
|
||||
? mergeStatusBucketConfig(o.statusBuckets as Partial<StatusBucketConfig>)
|
||||
@ -135,5 +342,22 @@ export function mergeImportedConfig(
|
||||
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
|
||||
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
|
||||
myViewActive: o.myViewActive ?? current.myViewActive,
|
||||
sprintFieldId:
|
||||
o.sprintFieldId !== undefined
|
||||
? typeof o.sprintFieldId === 'string' && o.sprintFieldId.trim()
|
||||
? o.sprintFieldId.trim()
|
||||
: undefined
|
||||
: current.sprintFieldId,
|
||||
functionalGaps: sanitizeFunctionalGapsArray(o.functionalGaps, current.functionalGaps),
|
||||
excludedSprintIds:
|
||||
o.excludedSprintIds !== undefined ? sanitizeExcludedSprintIds(o.excludedSprintIds) : current.excludedSprintIds,
|
||||
ganttSprintRowMetric:
|
||||
o.ganttSprintRowMetric !== undefined
|
||||
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
|
||||
: current.ganttSprintRowMetric,
|
||||
ganttNonWorkingDates:
|
||||
o.ganttNonWorkingDates !== undefined
|
||||
? sanitizeGanttNonWorkingDates(o.ganttNonWorkingDates)
|
||||
: current.ganttNonWorkingDates,
|
||||
}
|
||||
}
|
||||
|
||||
60
src/lib/functionalGaps.ts
Normal file
60
src/lib/functionalGaps.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from './dashboardConfig'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
}
|
||||
|
||||
function groupHaystack(group: StoryGroup): string {
|
||||
const { story, subtasks } = group
|
||||
const parts: string[] = [
|
||||
story.key,
|
||||
story.fields.summary,
|
||||
...(story.fields.labels ?? []),
|
||||
]
|
||||
for (const st of subtasks) {
|
||||
parts.push(st.key, st.fields.summary, ...(st.fields.labels ?? []))
|
||||
}
|
||||
return norm(parts.join(' '))
|
||||
}
|
||||
|
||||
function haystackMatchesTerms(hay: string, terms: string[]): boolean {
|
||||
return terms.some((t) => {
|
||||
const n = norm(t)
|
||||
return n.length > 0 && hay.includes(n)
|
||||
})
|
||||
}
|
||||
|
||||
export function storyMatchesGapBadge(group: StoryGroup, badge: FunctionalGapBadge): boolean {
|
||||
return haystackMatchesTerms(groupHaystack(group), badge.terms)
|
||||
}
|
||||
|
||||
/** Sous-tâches encore ouvertes sur les stories qui matchent chaque badge (vue écarts). */
|
||||
export function countOpenGapsByBadge(
|
||||
groups: StoryGroup[],
|
||||
badges: FunctionalGapBadge[],
|
||||
cfg: StatusBucketConfig,
|
||||
): { id: string; label: string; openCount: number; criticalFlow: boolean }[] {
|
||||
return badges.map((badge) => {
|
||||
let openCount = 0
|
||||
for (const g of groups) {
|
||||
if (!storyMatchesGapBadge(g, badge)) continue
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b !== 'done' && b !== 'cancel') openCount += 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: badge.id,
|
||||
label: badge.label,
|
||||
openCount,
|
||||
criticalFlow: Boolean(badge.criticalFlow),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -19,11 +19,18 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
|
||||
'assignee',
|
||||
'timetracking',
|
||||
'subtasks',
|
||||
'labels',
|
||||
])
|
||||
const extras: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(f)) {
|
||||
if (!skip.has(k)) extras[k] = v
|
||||
}
|
||||
const labelsRaw = f.labels
|
||||
const labels =
|
||||
Array.isArray(labelsRaw) && labelsRaw.length > 0
|
||||
? labelsRaw.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: emb.id,
|
||||
key: emb.key,
|
||||
@ -35,6 +42,7 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
|
||||
? issuetype
|
||||
: { name: 'Sous-tâche', subtask: true },
|
||||
parent: { key: parentKey },
|
||||
labels,
|
||||
components: f.components as JiraIssue['fields']['components'],
|
||||
priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
|
||||
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,
|
||||
|
||||
28
src/lib/jiraSprintField.ts
Normal file
28
src/lib/jiraSprintField.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { DashboardConfig } from './dashboardConfig'
|
||||
|
||||
/**
|
||||
* Identifiant du champ personnalisé « Sprint » dans Jira (ex. customfield_10020).
|
||||
* Priorité : réglages dashboard → variable d’environnement.
|
||||
*/
|
||||
export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'> | null | undefined): string | null {
|
||||
const fromCfg = cfg?.sprintFieldId?.trim()
|
||||
if (fromCfg) return fromCfg
|
||||
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Board logiciel Jira (ex. URL `.../boards/1445/`) pour l’API Agile `.../board/{id}/sprint`.
|
||||
* Sert à lister les sprints **actifs** et **futurs** sans élargir le JQL du cockpit.
|
||||
* `0` ou `false` = désactiver l’appel (repli sur les sprints déduits des tickets chargés).
|
||||
* Variable absente ou vide → **1445** (board DCC).
|
||||
*/
|
||||
export function resolveJiraBoardId(): number | null {
|
||||
const rawOpt = import.meta.env.VITE_JIRA_BOARD_ID
|
||||
if (rawOpt === undefined || rawOpt === null) return 1445
|
||||
const raw = String(rawOpt).trim().toLowerCase()
|
||||
if (raw === '' || raw === '0' || raw === 'false') return null
|
||||
const n = Number(String(rawOpt).trim())
|
||||
if (Number.isFinite(n) && n > 0) return Math.floor(n)
|
||||
return 1445
|
||||
}
|
||||
68
src/lib/macroTrafficLight.ts
Normal file
68
src/lib/macroTrafficLight.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from './dashboardConfig'
|
||||
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { storyMatchesGapBadge } from './functionalGaps'
|
||||
import { stepperStates } from './storyMetrics'
|
||||
|
||||
export type MacroTrafficLight = 'green' | 'amber' | 'red'
|
||||
|
||||
export type MacroPipelineHealth = {
|
||||
light: MacroTrafficLight
|
||||
title: string
|
||||
detail: string
|
||||
violatingStoryKeys: string[]
|
||||
hasCriticalViolation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Feux macro (DSI) : rouge / orange si l’intégration (piste I) est encore ouverte
|
||||
* alors que l’étape Design du stepper n’est pas validée à 100 %.
|
||||
*/
|
||||
export function computeMacroPipelineHealth(
|
||||
groups: StoryGroup[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
gapBadges: FunctionalGapBadge[] | undefined,
|
||||
): MacroPipelineHealth {
|
||||
const violating: { key: string; critical: boolean }[] = []
|
||||
const badges = gapBadges ?? []
|
||||
|
||||
for (const g of groups) {
|
||||
const { story, subtasks } = g
|
||||
if (subtasks.length === 0) continue
|
||||
const steps = stepperStates(subtasks, bucketCfg, laneCfg)
|
||||
const designValidated = steps.design
|
||||
const integrationStillOpen = subtasks.some((st) => {
|
||||
if (isIssueDone(st, bucketCfg) || isIssueCanceled(st, bucketCfg)) return false
|
||||
return detectWorkLane(st, laneCfg) === 'integration'
|
||||
})
|
||||
if (integrationStillOpen && !designValidated) {
|
||||
const critical = badges.some((b) => b.criticalFlow && storyMatchesGapBadge(g, b))
|
||||
violating.push({ key: story.key, critical })
|
||||
}
|
||||
}
|
||||
|
||||
if (violating.length === 0) {
|
||||
return {
|
||||
light: 'green',
|
||||
title: 'Séquence A → D → I',
|
||||
detail:
|
||||
'Aucun conflit détecté : pas d’intégration active tant que le design n’est pas validé sur une même story.',
|
||||
violatingStoryKeys: [],
|
||||
hasCriticalViolation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const hasCriticalViolation = violating.some((v) => v.critical)
|
||||
const light: MacroTrafficLight = hasCriticalViolation ? 'red' : 'amber'
|
||||
|
||||
return {
|
||||
light,
|
||||
title: hasCriticalViolation ? 'Alerte — flux critique' : 'Attention — séquence',
|
||||
detail: `${violating.length} story(s) avec intégration ouverte sans validation design complète.`,
|
||||
violatingStoryKeys: violating.map((v) => v.key),
|
||||
hasCriticalViolation,
|
||||
}
|
||||
}
|
||||
58
src/lib/milestoneKinds.ts
Normal file
58
src/lib/milestoneKinds.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export type MilestoneKind = 'deliverable' | 'governance' | 'dependency' | 'generic'
|
||||
|
||||
export const MILESTONE_KIND_OPTIONS: { value: MilestoneKind; label: string; hint: string }[] = [
|
||||
{
|
||||
value: 'deliverable',
|
||||
label: 'Livrable',
|
||||
hint: 'Recette, MEP, lot fonctionnel — suivi fin de réalisation.',
|
||||
},
|
||||
{
|
||||
value: 'governance',
|
||||
label: 'Gouvernance',
|
||||
hint: 'Comité, GO/NO-GO, cadrage — date de décision ou de pilotage.',
|
||||
},
|
||||
{
|
||||
value: 'dependency',
|
||||
label: 'Dépendance',
|
||||
hint: 'Autre équipe, infra, lot externe.',
|
||||
},
|
||||
{ value: 'generic', label: 'Générique', hint: 'Repère calendaire simple.' },
|
||||
]
|
||||
|
||||
export function normalizeMilestoneKind(k: unknown): MilestoneKind {
|
||||
if (k === 'deliverable' || k === 'governance' || k === 'dependency' || k === 'generic') return k
|
||||
return 'generic'
|
||||
}
|
||||
|
||||
export function milestoneKindLabel(kind: MilestoneKind | undefined): string {
|
||||
const k = kind ?? 'generic'
|
||||
return MILESTONE_KIND_OPTIONS.find((o) => o.value === k)?.label ?? 'Générique'
|
||||
}
|
||||
|
||||
/** Classes pour pastilles / marqueurs sur la frise. */
|
||||
export function milestoneKindChipClass(kind: MilestoneKind | undefined): string {
|
||||
const base = 'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset'
|
||||
switch (kind ?? 'generic') {
|
||||
case 'deliverable':
|
||||
return `${base} bg-emerald-500/15 text-emerald-200 ring-emerald-500/35`
|
||||
case 'governance':
|
||||
return `${base} bg-violet-500/15 text-violet-200 ring-violet-500/35`
|
||||
case 'dependency':
|
||||
return `${base} bg-amber-500/15 text-amber-100 ring-amber-500/35`
|
||||
default:
|
||||
return `${base} bg-slate-600/40 text-slate-200 ring-slate-500/30`
|
||||
}
|
||||
}
|
||||
|
||||
export function milestoneKindMarkerClass(kind: MilestoneKind | undefined): string {
|
||||
switch (kind ?? 'generic') {
|
||||
case 'deliverable':
|
||||
return 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]'
|
||||
case 'governance':
|
||||
return 'bg-violet-400 shadow-[0_0_12px_rgba(167,139,250,0.45)]'
|
||||
case 'dependency':
|
||||
return 'bg-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.45)]'
|
||||
default:
|
||||
return 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.45)]'
|
||||
}
|
||||
}
|
||||
74
src/lib/milestoneLoadRisk.ts
Normal file
74
src/lib/milestoneLoadRisk.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { Milestone } from './dashboardConfig'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
import {
|
||||
milestoneAverageCompletionPercent,
|
||||
milestoneLinkedGroups,
|
||||
} from './milestoneStatus'
|
||||
|
||||
/** Jours calendaires restants jusqu’à la fin du jour du jalon (0 si déjà passé). */
|
||||
export function calendarDaysInclusiveUntil(isoDate: string): number {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(isoDate + 'T23:59:59')
|
||||
const diff = (end.getTime() - start.getTime()) / 86400000
|
||||
return Math.max(0, Math.ceil(diff))
|
||||
}
|
||||
|
||||
/** Sous-tâches encore actives (hors terminé / annulé) dans le périmètre du jalon. */
|
||||
export function milestoneOpenSubtaskCount(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
let n = 0
|
||||
for (const g of milestoneLinkedGroups(m, groups)) {
|
||||
for (const s of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||
if (b !== 'done' && b !== 'cancel') n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export type MilestoneVelocityRisk = 'ok' | 'tight' | 'unknown'
|
||||
|
||||
/**
|
||||
* Compare le volume de sous-tâches ouvertes du périmètre à la vélocité globale (sous-tâches / jour
|
||||
* calendaire, comme le burn-up). Si les jours « nécessaires » dépassent les jours calendaires
|
||||
* restants avant le jalon → charge serrée (heuristique).
|
||||
*/
|
||||
export function milestoneVelocityRisk(
|
||||
m: Milestone,
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
velocitySubtasksPerCalendarDay: number,
|
||||
): {
|
||||
level: MilestoneVelocityRisk
|
||||
openSubtasks: number
|
||||
daysNeeded: number | null
|
||||
calendarDaysLeft: number
|
||||
} {
|
||||
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||
const open = milestoneOpenSubtaskCount(m, groups, cfg)
|
||||
const calendarDaysLeft = calendarDaysInclusiveUntil(m.date)
|
||||
|
||||
if (pct >= 100) {
|
||||
return { level: 'ok', openSubtasks: open, daysNeeded: 0, calendarDaysLeft }
|
||||
}
|
||||
if (open === 0) {
|
||||
return { level: 'ok', openSubtasks: 0, daysNeeded: 0, calendarDaysLeft }
|
||||
}
|
||||
if (velocitySubtasksPerCalendarDay <= 0.001) {
|
||||
return { level: 'unknown', openSubtasks: open, daysNeeded: null, calendarDaysLeft }
|
||||
}
|
||||
const daysNeeded = Math.ceil(open / velocitySubtasksPerCalendarDay)
|
||||
const tight = calendarDaysLeft > 0 && daysNeeded > calendarDaysLeft
|
||||
return {
|
||||
level: tight ? 'tight' : 'ok',
|
||||
openSubtasks: open,
|
||||
daysNeeded,
|
||||
calendarDaysLeft,
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { effectivePipelinePhase } from './pipelinePhase'
|
||||
|
||||
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
|
||||
export function countSubtasksByPhase(
|
||||
groups: StoryGroup[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): Record<PhaseId, number> {
|
||||
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
|
||||
for (const g of groups) {
|
||||
for (const s of g.subtasks) {
|
||||
const p = statusToPhase(s.fields.status.name)
|
||||
const p = effectivePipelinePhase(s, bucketCfg, laneCfg)
|
||||
acc[p] += 1
|
||||
}
|
||||
}
|
||||
|
||||
19
src/lib/pipelinePhase.ts
Normal file
19
src/lib/pipelinePhase.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
|
||||
/**
|
||||
* Phase « pipeline » pour une sous-tâche : cartographie statuts, ou piste A/D/I
|
||||
* (étiquettes + heuristique) lorsque `laneCfg` est fourni — aligné sur `PhaseLaneIcons`.
|
||||
*/
|
||||
export function effectivePipelinePhase(
|
||||
issue: JiraIssue,
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): PhaseId {
|
||||
if (isIssueCanceled(issue, bucketCfg) || isIssueDone(issue, bucketCfg)) return 'done'
|
||||
if (laneCfg) return detectWorkLane(issue, laneCfg) as PhaseId
|
||||
return statusToPhase(issue.fields.status.name)
|
||||
}
|
||||
19
src/lib/scheduleDelay.ts
Normal file
19
src/lib/scheduleDelay.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { LandingEstimate } from './executiveLanding'
|
||||
|
||||
/** Compare l’atterrissage projeté au dernier jalon (calendrier civil). */
|
||||
export function calendarDelayVsLastMilestone(
|
||||
landing: LandingEstimate,
|
||||
milestoneIso: string | null,
|
||||
): { delayDays: number; message: string } | null {
|
||||
if (!landing.estimatedLanding || !milestoneIso || !/^\d{4}-\d{2}-\d{2}/.test(milestoneIso)) {
|
||||
return null
|
||||
}
|
||||
const end = new Date(milestoneIso.slice(0, 10) + 'T12:00:00').getTime()
|
||||
const land = landing.estimatedLanding.getTime()
|
||||
const delayDays = Math.ceil((land - end) / 86400000)
|
||||
if (delayDays <= 0) return null
|
||||
return {
|
||||
delayDays,
|
||||
message: `Retard estimé : +${delayDays} jour(s) calendaire(s) vs dernier jalon (vélocité actuelle).`,
|
||||
}
|
||||
}
|
||||
196
src/lib/sprintExtract.ts
Normal file
196
src/lib/sprintExtract.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import type { JiraIssue, StoryGroup } from '../types/jira'
|
||||
|
||||
/** Snapshot sprint tel que renvoyé par le champ Sprint Jira (souvent `customfield_10020`). */
|
||||
export type JiraSprintSnapshot = {
|
||||
id: number
|
||||
name: string
|
||||
state?: string
|
||||
boardId?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
goal?: string
|
||||
}
|
||||
|
||||
export function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
|
||||
const id = Number(o.id)
|
||||
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
||||
if (!Number.isFinite(id) || !name) return null
|
||||
const boardIdRaw = o.boardId ?? o.originBoardId
|
||||
const boardId =
|
||||
typeof boardIdRaw === 'number' && Number.isFinite(boardIdRaw)
|
||||
? boardIdRaw
|
||||
: typeof boardIdRaw === 'string' && Number.isFinite(Number(boardIdRaw))
|
||||
? Number(boardIdRaw)
|
||||
: undefined
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
state: typeof o.state === 'string' ? o.state : undefined,
|
||||
boardId,
|
||||
startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
|
||||
endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
|
||||
goal: typeof o.goal === 'string' ? o.goal : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse la valeur brute du champ Sprint (tableau d’objets ou de chaînes JSON historiques). */
|
||||
export function parseSprintFieldRaw(raw: unknown): JiraSprintSnapshot[] {
|
||||
if (raw == null) return []
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: JiraSprintSnapshot[] = []
|
||||
for (const item of raw) {
|
||||
if (typeof item === 'string') {
|
||||
try {
|
||||
const o = JSON.parse(item) as Record<string, unknown>
|
||||
const s = coerceSprintObject(o)
|
||||
if (s) out.push(s)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
const s = coerceSprintObject(item as Record<string, unknown>)
|
||||
if (s) out.push(s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getSprintsOnIssue(issue: JiraIssue, fieldId: string | null): JiraSprintSnapshot[] {
|
||||
if (!fieldId) return []
|
||||
const raw = (issue.fields as Record<string, unknown>)[fieldId]
|
||||
return parseSprintFieldRaw(raw)
|
||||
}
|
||||
|
||||
/** Sprints distincts sur la story et ses sous-tâches (par id). */
|
||||
export function mergeSprintsForGroup(group: StoryGroup, fieldId: string | null): JiraSprintSnapshot[] {
|
||||
if (!fieldId) return []
|
||||
const map = new Map<number, JiraSprintSnapshot>()
|
||||
for (const issue of [group.story, ...group.subtasks]) {
|
||||
for (const sp of getSprintsOnIssue(issue, fieldId)) {
|
||||
map.set(sp.id, sp)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => {
|
||||
const ae = a.endDate ?? ''
|
||||
const be = b.endDate ?? ''
|
||||
if (ae && be) return be.localeCompare(ae)
|
||||
return a.name.localeCompare(b.name, 'fr')
|
||||
})
|
||||
}
|
||||
|
||||
/** La story ou une sous-tâche est dans le sprint `sprintId`. */
|
||||
export function groupInSprint(group: StoryGroup, sprintId: number, fieldId: string | null): boolean {
|
||||
if (!fieldId) return false
|
||||
return mergeSprintsForGroup(group, fieldId).some((s) => s.id === sprintId)
|
||||
}
|
||||
|
||||
export type SprintOption = JiraSprintSnapshot & { storyCount: number }
|
||||
|
||||
/** Sprints distincts sur tout le périmètre, avec nombre de stories touchées. */
|
||||
export function collectSprintOptions(groups: StoryGroup[], fieldId: string | null): SprintOption[] {
|
||||
if (!fieldId) return []
|
||||
const acc = new Map<number, { sprint: JiraSprintSnapshot; keys: Set<string> }>()
|
||||
for (const g of groups) {
|
||||
const seen = new Set<number>()
|
||||
for (const issue of [g.story, ...g.subtasks]) {
|
||||
for (const sp of getSprintsOnIssue(issue, fieldId)) {
|
||||
seen.add(sp.id)
|
||||
const cur = acc.get(sp.id)
|
||||
if (cur) {
|
||||
cur.keys.add(g.story.key)
|
||||
} else {
|
||||
acc.set(sp.id, { sprint: sp, keys: new Set([g.story.key]) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...acc.values()]
|
||||
.map(({ sprint, keys }) => ({ ...sprint, storyCount: keys.size }))
|
||||
.sort((a, b) => {
|
||||
const ae = a.endDate ?? ''
|
||||
const be = b.endDate ?? ''
|
||||
if (ae && be) return be.localeCompare(ae)
|
||||
return b.name.localeCompare(a.name, 'fr')
|
||||
})
|
||||
}
|
||||
|
||||
export function filterGroupsBySprint(
|
||||
groups: StoryGroup[],
|
||||
sprintId: number | null,
|
||||
fieldId: string | null,
|
||||
): StoryGroup[] {
|
||||
if (!fieldId || sprintId == null) return groups
|
||||
return groups.filter((g) => groupInSprint(g, sprintId, fieldId))
|
||||
}
|
||||
|
||||
/** Filtre par clés issues (ex. issues renvoyées par `GET .../sprint/{id}/issue`). */
|
||||
export function filterGroupsBySprintIssueKeys(groups: StoryGroup[], issueKeys: Set<string>): StoryGroup[] {
|
||||
if (issueKeys.size === 0) return []
|
||||
return groups.filter(
|
||||
(g) =>
|
||||
issueKeys.has(g.story.key) || g.subtasks.some((st) => issueKeys.has(st.key)),
|
||||
)
|
||||
}
|
||||
|
||||
function sprintOrderRank(state?: string): number {
|
||||
const s = (state ?? '').toLowerCase()
|
||||
if (s === 'active') return 0
|
||||
if (s === 'future') return 1
|
||||
if (s === 'closed') return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
/** Sprints board uniquement (`storyCount` = 0 tant que le champ Sprint n’est pas configuré). */
|
||||
export function sprintOptionsFromBoardOnly(boardSprints: JiraSprintSnapshot[]): SprintOption[] {
|
||||
if (boardSprints.length === 0) return []
|
||||
return boardSprints
|
||||
.map((s) => ({ ...s, storyCount: 0 }))
|
||||
.sort((a, b) => {
|
||||
const ra = sprintOrderRank(a.state)
|
||||
const rb = sprintOrderRank(b.state)
|
||||
if (ra !== rb) return ra - rb
|
||||
const as = a.startDate ?? ''
|
||||
const bs = b.startDate ?? ''
|
||||
if (as && bs) return as.localeCompare(bs)
|
||||
return a.name.localeCompare(b.name, 'fr')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprints renvoyés par l’API Agile du board (actifs / futurs), enrichis du nombre de **stories**
|
||||
* du périmètre chargé présentes dans chaque sprint (via le champ Sprint sur les issues).
|
||||
*/
|
||||
export function sprintOptionsFromBoardAndGroups(
|
||||
boardSprints: JiraSprintSnapshot[],
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
): SprintOption[] {
|
||||
if (!fieldId || boardSprints.length === 0) return []
|
||||
const boardIds = new Set(boardSprints.map((s) => s.id))
|
||||
const keysBySprintId = new Map<number, Set<string>>()
|
||||
for (const g of groups) {
|
||||
const seen = new Set<number>()
|
||||
for (const issue of [g.story, ...g.subtasks]) {
|
||||
for (const sp of getSprintsOnIssue(issue, fieldId)) {
|
||||
if (!boardIds.has(sp.id) || seen.has(sp.id)) continue
|
||||
seen.add(sp.id)
|
||||
if (!keysBySprintId.has(sp.id)) keysBySprintId.set(sp.id, new Set())
|
||||
keysBySprintId.get(sp.id)!.add(g.story.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return boardSprints
|
||||
.map((sprint) => ({
|
||||
...sprint,
|
||||
storyCount: keysBySprintId.get(sprint.id)?.size ?? 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const ra = sprintOrderRank(a.state)
|
||||
const rb = sprintOrderRank(b.state)
|
||||
if (ra !== rb) return ra - rb
|
||||
const as = a.startDate ?? ''
|
||||
const bs = b.startDate ?? ''
|
||||
if (as && bs) return as.localeCompare(bs)
|
||||
return a.name.localeCompare(b.name, 'fr')
|
||||
})
|
||||
}
|
||||
527
src/lib/sprintGantt.ts
Normal file
527
src/lib/sprintGantt.ts
Normal file
@ -0,0 +1,527 @@
|
||||
import type { GanttSprintRowMetric, Milestone } from './dashboardConfig'
|
||||
import { milestoneKindLabel } from './milestoneKinds'
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
import type { JiraSprintSnapshot } from './sprintExtract'
|
||||
import { groupInSprint } from './sprintExtract'
|
||||
|
||||
export const MS_DAY = 86400000
|
||||
|
||||
/** Échelle d’affichage du Gantt (densité des graduations). */
|
||||
export type GanttTimeScale = 'day' | 'week' | 'month'
|
||||
|
||||
export type GanttTick = { ms: number; label: string; major?: boolean }
|
||||
|
||||
/** Facteurs de zoom (indice ↑ = plus de pixels par jour = loupe « + »). */
|
||||
export const GANTT_ZOOM_FACTORS = [0.35, 0.5, 0.68, 0.9, 1.2, 1.6, 2.1, 2.8] as const
|
||||
|
||||
const BASE_PPD: Record<GanttTimeScale, number> = {
|
||||
/** ~2,5 semaines lisibles à zoom neutre sur un écran large */
|
||||
day: 22,
|
||||
week: 8,
|
||||
month: 2.4,
|
||||
}
|
||||
|
||||
export function pixelsPerDay(scale: GanttTimeScale, zoomIndex: number): number {
|
||||
const i = Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))
|
||||
return BASE_PPD[scale] * GANTT_ZOOM_FACTORS[i]!
|
||||
}
|
||||
|
||||
export function timelineWidthPx(startMs: number, endMs: number, ppd: number): number {
|
||||
const days = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
|
||||
return Math.max(720, Math.ceil(days * ppd))
|
||||
}
|
||||
|
||||
export function msToX(ms: number, startMs: number, endMs: number, widthPx: number): number {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
return ((ms - startMs) / span) * widthPx
|
||||
}
|
||||
|
||||
/**
|
||||
* Graduations selon l’échelle ; `maxTicks` limite le nombre de lignes / libellés.
|
||||
*/
|
||||
export function timelineTicks(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
maxTicks: number,
|
||||
): GanttTick[] {
|
||||
const cap = Math.max(8, Math.min(72, maxTicks))
|
||||
const ticks: GanttTick[] = []
|
||||
|
||||
if (scale === 'month') {
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
while (d.getTime() <= endMs) {
|
||||
if (d.getTime() >= startMs - MS_DAY / 2) {
|
||||
ticks.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
|
||||
major: true,
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
if (ticks.length > 120) break
|
||||
}
|
||||
return thinTicks(ticks, cap)
|
||||
}
|
||||
|
||||
if (scale === 'week') {
|
||||
const stepWeeks = Math.max(1, Math.ceil(((endMs - startMs) / MS_DAY / 7) / cap))
|
||||
let t = startOfWeekMondayLocal(startMs)
|
||||
let back = 0
|
||||
while (t + 6 * MS_DAY < startMs && back++ < 104) {
|
||||
t += stepWeeks * 7 * MS_DAY
|
||||
}
|
||||
let guard = 0
|
||||
while (t <= endMs && guard++ < 200) {
|
||||
if (t + 6 * MS_DAY >= startMs) {
|
||||
ticks.push({
|
||||
ms: t,
|
||||
label: formatWeekRangeFr(t),
|
||||
major: true,
|
||||
})
|
||||
}
|
||||
t += stepWeeks * 7 * MS_DAY
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
/* day */
|
||||
const totalDays = (endMs - startMs) / MS_DAY
|
||||
const stepDays = totalDays > cap ? Math.max(1, Math.ceil(totalDays / cap)) : 1
|
||||
let cur = startMs
|
||||
let g = 0
|
||||
while (cur <= endMs && g++ < 500) {
|
||||
const dd = new Date(cur)
|
||||
ticks.push({
|
||||
ms: cur,
|
||||
label:
|
||||
stepDays === 1
|
||||
? dd.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
: dd.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' }),
|
||||
major: dd.getDay() === 1,
|
||||
})
|
||||
cur += stepDays * MS_DAY
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
/** Premier instant (midi local) du lundi de la semaine calendaire contenant `ms`. */
|
||||
export function startOfWeekMondayLocal(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
const day = d.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day
|
||||
d.setDate(d.getDate() + diff)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function formatWeekRangeFr(weekStartMs: number): string {
|
||||
const a = new Date(weekStartMs)
|
||||
const b = new Date(weekStartMs + 6 * MS_DAY)
|
||||
const sameMonth = a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()
|
||||
if (sameMonth) {
|
||||
return `${a.getDate()}–${b.getDate()} ${a.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })}`
|
||||
}
|
||||
return `${a.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} – ${b.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: '2-digit' })}`
|
||||
}
|
||||
|
||||
function thinTicks(ticks: GanttTick[], max: number): GanttTick[] {
|
||||
if (ticks.length <= max) return ticks
|
||||
const step = Math.ceil(ticks.length / max)
|
||||
return ticks.filter((_, i) => i % step === 0)
|
||||
}
|
||||
|
||||
export function parseIsoMs(iso?: string | null): number | null {
|
||||
if (!iso || typeof iso !== 'string') return null
|
||||
const t = Date.parse(iso)
|
||||
return Number.isFinite(t) ? t : null
|
||||
}
|
||||
|
||||
/** Fenêtre calendaire du sprint à partir des dates Jira Agile (`startDate` / `endDate`). */
|
||||
export function sprintBarBounds(s: JiraSprintSnapshot): { startMs: number; endMs: number } | null {
|
||||
const startRaw = parseIsoMs(s.startDate)
|
||||
const endRaw = parseIsoMs(s.endDate)
|
||||
if (startRaw == null && endRaw == null) return null
|
||||
let startMs = startRaw ?? endRaw! - 14 * MS_DAY
|
||||
let endMs = endRaw ?? startRaw! + 14 * MS_DAY
|
||||
if (endMs <= startMs) endMs = startMs + MS_DAY
|
||||
return { startMs, endMs }
|
||||
}
|
||||
|
||||
export function ganttRangeFromSprintsAndMilestones(
|
||||
sprints: JiraSprintSnapshot[],
|
||||
milestones: Milestone[],
|
||||
): { startMs: number; endMs: number } {
|
||||
const now = Date.now()
|
||||
let sMin = now
|
||||
let sMax = now + 30 * MS_DAY
|
||||
let has = false
|
||||
for (const sp of sprints) {
|
||||
const b = sprintBarBounds(sp)
|
||||
if (!b) continue
|
||||
has = true
|
||||
sMin = Math.min(sMin, b.startMs)
|
||||
sMax = Math.max(sMax, b.endMs)
|
||||
}
|
||||
for (const m of milestones) {
|
||||
const t = parseIsoMs(`${m.date}T12:00:00`)
|
||||
if (t != null) {
|
||||
has = true
|
||||
sMin = Math.min(sMin, t)
|
||||
sMax = Math.max(sMax, t)
|
||||
}
|
||||
}
|
||||
if (!has) {
|
||||
return { startMs: now - 14 * MS_DAY, endMs: now + 90 * MS_DAY }
|
||||
}
|
||||
sMin = Math.min(sMin, now - 7 * MS_DAY)
|
||||
sMax = Math.max(sMax, now + 28 * MS_DAY)
|
||||
return { startMs: sMin, endMs: sMax }
|
||||
}
|
||||
|
||||
export function monthTicksBetween(startMs: number, endMs: number): { ms: number; label: string }[] {
|
||||
const ticks: { ms: number; label: string }[] = []
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
while (d.getTime() <= endMs) {
|
||||
if (d.getTime() >= startMs) {
|
||||
ticks.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
/** Colonne calendaire (jour) projetée sur la timeline — week-ends, jours configurés, grille. */
|
||||
export type GanttDayColumn = {
|
||||
dayStartMs: number
|
||||
weekday: number
|
||||
x0: number
|
||||
x1: number
|
||||
/** Fond atténué : samedi / dimanche ou date listée dans les réglages Gantt. */
|
||||
isGanttNonWork: boolean
|
||||
}
|
||||
|
||||
function localIsoDateKey(dayStartMs: number): string {
|
||||
const d = new Date(dayStartMs)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Une entrée par jour local qui intersecte [startMs, endMs] (bornes en pixels pour le fond).
|
||||
*/
|
||||
export function ganttDayColumns(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
widthPx: number,
|
||||
nonWorkingKeys?: ReadonlySet<string>,
|
||||
): GanttDayColumn[] {
|
||||
const cols: GanttDayColumn[] = []
|
||||
const iter = new Date(startMs)
|
||||
iter.setHours(0, 0, 0, 0)
|
||||
if (iter.getTime() > startMs) iter.setDate(iter.getDate() - 1)
|
||||
|
||||
let guard = 0
|
||||
while (iter.getTime() < endMs + MS_DAY && guard++ < 800) {
|
||||
const dayStart = iter.getTime()
|
||||
const wd = iter.getDay()
|
||||
const next = new Date(iter)
|
||||
next.setDate(next.getDate() + 1)
|
||||
const dayEnd = next.getTime()
|
||||
if (dayEnd > startMs && dayStart < endMs) {
|
||||
const x0 = msToX(Math.max(dayStart, startMs), startMs, endMs, widthPx)
|
||||
const x1 = msToX(Math.min(dayEnd, endMs), startMs, endMs, widthPx)
|
||||
if (x1 > x0 + 0.02) {
|
||||
const key = localIsoDateKey(dayStart)
|
||||
const isWeekend = wd === 0 || wd === 6
|
||||
const isConfigured = nonWorkingKeys?.has(key) ?? false
|
||||
cols.push({
|
||||
dayStartMs: dayStart,
|
||||
weekday: wd,
|
||||
x0,
|
||||
x1,
|
||||
isGanttNonWork: isWeekend || isConfigured,
|
||||
})
|
||||
}
|
||||
}
|
||||
iter.setDate(iter.getDate() + 1)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
/** Bandeau « mois + année » en tête de Gantt (aligné sur les mois calendaires). */
|
||||
export type GanttMonthBand = {
|
||||
label: string
|
||||
x0: number
|
||||
x1: number
|
||||
monthStartMs: number
|
||||
}
|
||||
|
||||
export function ganttMonthBands(startMs: number, endMs: number, widthPx: number): GanttMonthBand[] {
|
||||
const bands: GanttMonthBand[] = []
|
||||
const cur = new Date(startMs)
|
||||
cur.setDate(1)
|
||||
cur.setHours(0, 0, 0, 0)
|
||||
if (cur.getTime() > startMs) cur.setMonth(cur.getMonth() - 1)
|
||||
|
||||
let guard = 0
|
||||
while (cur.getTime() < endMs && guard++ < 48) {
|
||||
const monthStart = cur.getTime()
|
||||
const next = new Date(cur)
|
||||
next.setMonth(next.getMonth() + 1)
|
||||
const monthEnd = next.getTime()
|
||||
const x0 = msToX(Math.max(monthStart, startMs), startMs, endMs, widthPx)
|
||||
const x1 = msToX(Math.min(monthEnd, endMs), startMs, endMs, widthPx)
|
||||
if (x1 > x0 + 2) {
|
||||
const label = new Date(monthStart).toLocaleDateString('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
bands.push({ label, x0, x1, monthStartMs: monthStart })
|
||||
}
|
||||
cur.setMonth(cur.getMonth() + 1)
|
||||
}
|
||||
return bands
|
||||
}
|
||||
|
||||
export type GanttSubheaderTick = { ms: number; label: string; x: number; major: boolean }
|
||||
|
||||
function thinSubheaderTicks(ticks: GanttSubheaderTick[], max: number): GanttSubheaderTick[] {
|
||||
if (ticks.length <= max) return ticks
|
||||
const step = Math.ceil(ticks.length / max)
|
||||
return ticks.filter((_, i) => i % step === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellés de la rangée inférieure (jours / semaines / repères mois) sous les bandeaux mensuels.
|
||||
*/
|
||||
export function ganttSubheaderTicks(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
widthPx: number,
|
||||
): GanttSubheaderTick[] {
|
||||
const spanDays = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
|
||||
const maxLabels = Math.max(10, Math.min(64, Math.floor(widthPx / 48)))
|
||||
|
||||
if (scale === 'day') {
|
||||
const cols = ganttDayColumns(startMs, endMs, widthPx)
|
||||
if (cols.length === 0) return []
|
||||
const avgW = widthPx / spanDays
|
||||
const step =
|
||||
avgW >= 24 ? 1 : avgW >= 15 ? 2 : avgW >= 10 ? 3 : Math.max(1, Math.ceil(cols.length / maxLabels))
|
||||
const out: GanttSubheaderTick[] = []
|
||||
for (let i = 0; i < cols.length; i += step) {
|
||||
const c = cols[i]!
|
||||
const d = new Date(c.dayStartMs)
|
||||
const wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
|
||||
const dom = d.getDate()
|
||||
const label =
|
||||
step === 1 && avgW >= 26 ? `${wdShort} ${dom}` : `${dom}`
|
||||
const x = (c.x0 + c.x1) / 2
|
||||
const major = d.getDate() === 1 || d.getDay() === 1
|
||||
out.push({ ms: c.dayStartMs, label, x, major })
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
if (scale === 'week') {
|
||||
const out: GanttSubheaderTick[] = []
|
||||
let t = startOfWeekMondayLocal(startMs - 8 * MS_DAY)
|
||||
let guard = 0
|
||||
while (t <= endMs + MS_DAY && guard++ < 140) {
|
||||
if (t + 6 * MS_DAY >= startMs) {
|
||||
const d = new Date(t)
|
||||
const wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
|
||||
const label = `${wdShort} ${d.getDate()}`
|
||||
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
|
||||
out.push({ ms: t, label, x, major: true })
|
||||
}
|
||||
t += 7 * MS_DAY
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
/* month */
|
||||
const out: GanttSubheaderTick[] = []
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
if (d.getTime() > startMs) d.setMonth(d.getMonth() - 1)
|
||||
let g = 0
|
||||
while (d.getTime() < endMs && g++ < 40) {
|
||||
const y = d.getFullYear()
|
||||
const m = d.getMonth()
|
||||
for (const dom of [1, 8, 15, 22]) {
|
||||
const cur = new Date(y, m, dom, 12, 0, 0, 0)
|
||||
const ms = cur.getTime()
|
||||
if (ms < startMs || ms > endMs) continue
|
||||
const x = msToX(ms, startMs, endMs, widthPx)
|
||||
out.push({
|
||||
ms,
|
||||
label: String(dom),
|
||||
x,
|
||||
major: dom === 1,
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
/** Positions X des guides verticaux (limiter le nombre quand le zoom est faible). */
|
||||
export function ganttVerticalGuideXs(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
widthPx: number,
|
||||
): number[] {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
const ppdEff = widthPx / (span / MS_DAY)
|
||||
|
||||
if (ppdEff >= 2.4) {
|
||||
return ganttDayColumns(startMs, endMs, widthPx).map((c) => c.x0)
|
||||
}
|
||||
if (ppdEff >= 0.95 || scale === 'week') {
|
||||
const xs: number[] = []
|
||||
let t = startOfWeekMondayLocal(startMs - 3 * MS_DAY)
|
||||
let guard = 0
|
||||
while (t <= endMs + MS_DAY && guard++ < 220) {
|
||||
xs.push(msToX(t, startMs, endMs, widthPx))
|
||||
t += 7 * MS_DAY
|
||||
}
|
||||
return xs
|
||||
}
|
||||
return ganttMonthBands(startMs, endMs, widthPx).map((b) => b.x0)
|
||||
}
|
||||
|
||||
export function pctOnSpan(ms: number, startMs: number, endMs: number): number {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
return ((ms - startMs) / span) * 100
|
||||
}
|
||||
|
||||
/** Avancement livraison : sous-tâches Done / (non annulées) pour le périmètre épique dans ce sprint. */
|
||||
export function epicScopeSprintProgress(
|
||||
groups: StoryGroup[],
|
||||
sprintId: number,
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): { done: number; total: number; percent: number } {
|
||||
if (!fieldId) return { done: 0, total: 0, percent: 0 }
|
||||
let done = 0
|
||||
let total = 0
|
||||
for (const g of groups) {
|
||||
if (!groupInSprint(g, sprintId, fieldId)) continue
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b === 'cancel') continue
|
||||
total += 1
|
||||
if (b === 'done') done += 1
|
||||
}
|
||||
}
|
||||
const percent = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
return { done, total, percent }
|
||||
}
|
||||
|
||||
/** Avancement temporel du sprint (début → fin vs aujourd’hui). */
|
||||
export function sprintTimeElapsedPercent(s: JiraSprintSnapshot, nowMs = Date.now()): number {
|
||||
const b = sprintBarBounds(s)
|
||||
if (!b) return 0
|
||||
const { startMs, endMs } = b
|
||||
if (nowMs <= startMs) return 0
|
||||
if (nowMs >= endMs) return 100
|
||||
return Math.round(((nowMs - startMs) / (endMs - startMs)) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplissage de la barre : uniquement le % de sous-tâches terminées (périmètre + champ Sprint).
|
||||
* Pas d’avancement calendaire : sans données, la barre reste à 0 %.
|
||||
*/
|
||||
export function sprintBarFillPercent(
|
||||
s: JiraSprintSnapshot,
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
||||
if (!fieldId || delivery.total === 0) return 0
|
||||
return Math.min(100, delivery.percent)
|
||||
}
|
||||
|
||||
export function milestoneTooltipText(m: Milestone): string {
|
||||
const lines: string[] = [m.title || 'Jalon', `Date : ${m.date}`]
|
||||
if (m.expectedActions?.trim()) lines.push(`Actions attendues :\n${m.expectedActions.trim()}`)
|
||||
if (m.critical) lines.push('Jalon critique')
|
||||
lines.push(`Type : ${milestoneKindLabel(m.kind)}`)
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatSprintRangeFr(s: JiraSprintSnapshot): string {
|
||||
const fmt = (iso?: string) => {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
return `${fmt(s.startDate)} → ${fmt(s.endDate)}`
|
||||
}
|
||||
|
||||
export function formatSprintTimeRemaining(s: JiraSprintSnapshot): string | null {
|
||||
const b = sprintBarBounds(s)
|
||||
if (!b) return null
|
||||
const now = Date.now()
|
||||
if (now >= b.endMs) return 'Sprint terminé (dates)'
|
||||
if (now < b.startMs) {
|
||||
const j = Math.ceil((b.startMs - now) / MS_DAY)
|
||||
if (j <= 1) return 'Démarre demain ou aujourd’hui'
|
||||
return `Début dans ${j} j`
|
||||
}
|
||||
const j = Math.ceil((b.endMs - now) / MS_DAY)
|
||||
if (j <= 1) return '< 1 j restant'
|
||||
return j === 1 ? '1 jour restant' : `${j} jours restants`
|
||||
}
|
||||
|
||||
export function formatGanttSprintSubtitleLines(
|
||||
metric: GanttSprintRowMetric,
|
||||
s: JiraSprintSnapshot,
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): string[] {
|
||||
const time = formatSprintTimeRemaining(s)
|
||||
const prog = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
||||
switch (metric) {
|
||||
case 'none':
|
||||
return []
|
||||
case 'time_remaining':
|
||||
return time ? [time] : []
|
||||
case 'subtasks_done_count':
|
||||
if (!fieldId) return ['Champ Sprint requis pour compter les sous-tâches']
|
||||
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint (périmètre chargé)']
|
||||
return [`${prog.done} / ${prog.total} sous-tâches terminées`]
|
||||
case 'subtasks_done_percent':
|
||||
if (!fieldId) return ['Champ Sprint requis pour le %']
|
||||
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint']
|
||||
return [`${prog.percent} % sous-tâches terminées`]
|
||||
case 'combined':
|
||||
default: {
|
||||
const lines: string[] = []
|
||||
if (time) lines.push(time)
|
||||
if (fieldId && prog.total > 0) lines.push(`${prog.done} / ${prog.total} st · ${prog.percent} %`)
|
||||
return lines.length > 0 ? lines : [time ?? '—']
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||
import { PHASE_ORDER, statusToPhase } from './statusPhase'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { PHASE_ORDER } from './statusPhase'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { effectivePipelinePhase } from './pipelinePhase'
|
||||
|
||||
function phaseRank(p: PhaseId): number {
|
||||
const i = PHASE_ORDER.indexOf(p)
|
||||
@ -14,10 +16,14 @@ const MAX_PHASE_RANK = PHASE_ORDER.length - 1
|
||||
* % d’avancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé
|
||||
* (0 % = tout en analyse, 100 % = tout terminé).
|
||||
*/
|
||||
export function storyProgressPercent(subtasks: JiraIssue[]): number {
|
||||
export function storyProgressPercent(
|
||||
subtasks: JiraIssue[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): number {
|
||||
if (subtasks.length === 0) return 0
|
||||
const sum = subtasks.reduce(
|
||||
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)),
|
||||
(acc, st) => acc + phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)),
|
||||
0,
|
||||
)
|
||||
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
|
||||
@ -27,10 +33,17 @@ export function storyProgressPercent(subtasks: JiraIssue[]): number {
|
||||
* Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase
|
||||
* (évite les barres vertes alors que tout est encore en analyse / ouvert).
|
||||
*/
|
||||
export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boolean {
|
||||
export function isStepComplete(
|
||||
subtasks: JiraIssue[],
|
||||
stepPhase: PhaseId,
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): boolean {
|
||||
if (subtasks.length === 0) return false
|
||||
const stepIdx = phaseRank(stepPhase)
|
||||
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
|
||||
return subtasks.every(
|
||||
(st) => phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)) > stepIdx,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,11 +63,12 @@ export function subtaskDoneRatioPercent(
|
||||
export function stepperStates(
|
||||
subtasks: JiraIssue[],
|
||||
cfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): Record<PhaseId, boolean> {
|
||||
return {
|
||||
analyse: isStepComplete(subtasks, 'analyse'),
|
||||
design: isStepComplete(subtasks, 'design'),
|
||||
integration: isStepComplete(subtasks, 'integration'),
|
||||
analyse: isStepComplete(subtasks, 'analyse', cfg, laneCfg),
|
||||
design: isStepComplete(subtasks, 'design', cfg, laneCfg),
|
||||
integration: isStepComplete(subtasks, 'integration', cfg, laneCfg),
|
||||
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
|
||||
}
|
||||
}
|
||||
|
||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@ -14,6 +14,10 @@ interface ImportMetaEnv {
|
||||
readonly VITE_JIRA_EPIC_KEY?: string
|
||||
/** Taille de page `/search/jql` (1–100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */
|
||||
readonly VITE_JIRA_PAGE_SIZE?: string
|
||||
/** ID du board logiciel Jira pour lister les sprints actifs/futurs (API Agile). 0 = désactiver. */
|
||||
readonly VITE_JIRA_BOARD_ID?: string
|
||||
/** Champ Sprint Scrum (ex. customfield_10020). */
|
||||
readonly VITE_JIRA_SPRINT_FIELD?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user