init
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Sous-domaine Jira Cloud (sans https), ex. mon-entreprise pour mon-entreprise.atlassian.net
|
||||||
|
JIRA_DOMAIN=mon-entreprise
|
||||||
|
|
||||||
|
# Optionnel : URL complète du site Jira pour les liens « ouvrir le ticket » en prod (npm run build).
|
||||||
|
# En dev, les liens utilisent aussi JIRA_DOMAIN via Vite si cette variable est vide.
|
||||||
|
# VITE_JIRA_BROWSE_BASE_URL=https://mon-entreprise.atlassian.net
|
||||||
|
|
||||||
|
# Compte Atlassian (e-mail lié à Jira Cloud)
|
||||||
|
JIRA_EMAIL=vous@exemple.com
|
||||||
|
|
||||||
|
# Jeton API : https://id.atlassian.com/manage-profile/security/api-tokens
|
||||||
|
JIRA_API_KEY=
|
||||||
|
|
||||||
|
# Optionnel : en build de production uniquement, URL d’un proxy HTTPS vers Jira
|
||||||
|
# (le dev server injecte déjà l’auth via vite.config.js)
|
||||||
|
# VITE_JIRA_BASE_URL=https://votre-backend.example.com/jira-proxy
|
||||||
|
|
||||||
|
# Optionnel (client) : identité pour le filtre « Ma vue » — accountId prioritaire
|
||||||
|
# VITE_MY_JIRA_ACCOUNT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
# VITE_MY_JIRA_EMAIL=vous@exemple.com
|
||||||
|
|
||||||
|
# Champ Story Points dans Jira (Administration → Issues → champs personnalisés → ID)
|
||||||
|
# VITE_JIRA_STORY_POINTS_FIELD=customfield_10028
|
||||||
|
|
||||||
|
# Clé de l’épopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI
|
||||||
|
# VITE_JIRA_EPIC_KEY=DCC-5514
|
||||||
|
|
||||||
|
# Taille de page pour POST /rest/api/3/search/jql (défaut 100, max 100)
|
||||||
|
# VITE_JIRA_PAGE_SIZE=100
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Migration OroCommerce · Jira</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2057
package-lock.json
generated
Normal file
2057
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "jira-descours",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.15.2",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
263
src/App.tsx
Normal file
263
src/App.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { StoryGroup } from './types/jira'
|
||||||
|
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient'
|
||||||
|
import { groupSubtasksUnderStories } from './lib/groupIssues'
|
||||||
|
import { statusToPhase } from './lib/statusPhase'
|
||||||
|
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
|
||||||
|
import {
|
||||||
|
loadDashboardConfig,
|
||||||
|
saveDashboardConfig,
|
||||||
|
type DashboardConfig,
|
||||||
|
} from './lib/dashboardConfig'
|
||||||
|
import { assigneeMatchesMyView } from './lib/assigneeMatch'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { ExecutiveSummary } from './components/ExecutiveSummary'
|
||||||
|
import { BurnupChart } from './components/BurnupChart'
|
||||||
|
import { StoryCard } from './components/StoryCard'
|
||||||
|
import { BoardView } from './components/BoardView'
|
||||||
|
import { DashboardSkeleton } from './components/DashboardSkeleton'
|
||||||
|
import { MilestonesTimeline } from './components/MilestonesTimeline'
|
||||||
|
import { DashboardSettingsModal } from './components/DashboardSettingsModal'
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'board'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [groups, setGroups] = useState<StoryGroup[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
|
||||||
|
const [view, setView] = useState<ViewMode>('list')
|
||||||
|
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
|
||||||
|
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
|
const myViewActive = Boolean(dashboardCfg.myViewActive)
|
||||||
|
|
||||||
|
const displayGroups = useMemo(() => {
|
||||||
|
if (!myViewActive) return groups
|
||||||
|
return groups.filter((g) =>
|
||||||
|
g.subtasks.some((st) => assigneeMatchesMyView(st, dashboardCfg)),
|
||||||
|
)
|
||||||
|
}, [groups, myViewActive, dashboardCfg])
|
||||||
|
|
||||||
|
const toggleMyView = () => {
|
||||||
|
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
|
||||||
|
setDashboardCfg(next)
|
||||||
|
saveDashboardConfig(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSettings = (next: DashboardConfig) => {
|
||||||
|
setDashboardCfg(next)
|
||||||
|
saveDashboardConfig(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal)
|
||||||
|
const grouped = groupSubtasksUnderStories(issues)
|
||||||
|
setGroups(grouped)
|
||||||
|
setUpdatedAt(new Date())
|
||||||
|
|
||||||
|
const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0)
|
||||||
|
const doneSubs = grouped.reduce(
|
||||||
|
(acc, g) =>
|
||||||
|
acc +
|
||||||
|
g.subtasks.filter((s) => statusToPhase(s.fields.status.name) === 'done').length,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs))
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted || (isAxiosError(e) && e.code === 'ERR_CANCELED')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isAxiosError(e)) {
|
||||||
|
const msg =
|
||||||
|
typeof e.response?.data === 'object' &&
|
||||||
|
e.response.data !== null &&
|
||||||
|
'errorMessages' in e.response.data &&
|
||||||
|
Array.isArray((e.response.data as { errorMessages: string[] }).errorMessages)
|
||||||
|
? (e.response.data as { errorMessages: string[] }).errorMessages.join(' ')
|
||||||
|
: e.message
|
||||||
|
setError(msg || 'Erreur réseau Jira')
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur inconnue')
|
||||||
|
}
|
||||||
|
setGroups([])
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController()
|
||||||
|
void load(ac.signal)
|
||||||
|
return () => ac.abort()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-4 pb-20 pt-8 sm:px-6 md:px-8 md:pt-10">
|
||||||
|
<header className="mx-auto mb-6 flex max-w-7xl flex-col gap-4 lg:mb-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-cyan-300/90">
|
||||||
|
OroCommerce · Migration exécutive
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold tracking-tight text-white md:text-4xl">
|
||||||
|
Pilotage migration
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-slate-400">
|
||||||
|
Périmètre DCC sous l’épopée{' '}
|
||||||
|
<span className="font-mono text-slate-200">{MIGRATION_EPIC_KEY}</span> (JQL{' '}
|
||||||
|
<span className="font-mono text-slate-400">parentEpic</span>, sous-tâches incluses),
|
||||||
|
parent résolu (clé ou id), jalons, export JSON pour Synology.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMyView}
|
||||||
|
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition ${
|
||||||
|
myViewActive
|
||||||
|
? 'border-cyan-400/60 bg-cyan-500/20 text-cyan-50 shadow-[0_0_16px_rgba(34,211,238,0.25)]'
|
||||||
|
: 'border-white/10 bg-slate-950/50 text-slate-300 hover:border-white/20 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Stories avec au moins une sous-tâche qui vous est assignée (accountId ou e-mail configurés)."
|
||||||
|
>
|
||||||
|
Ma vue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
className="rounded-xl border border-white/10 bg-slate-950/50 px-4 py-2.5 text-sm font-medium text-slate-300 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
Réglages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-xl border border-white/10 bg-slate-950/50 p-1 backdrop-blur-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||||
|
view === 'list'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView('board')}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||||
|
view === 'board'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{updatedAt && !loading && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Mis à jour {updatedAt.toLocaleTimeString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center rounded-xl bg-cyan-500 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/25 transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Chargement…' : 'Actualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-7xl">
|
||||||
|
{!baseOk && (
|
||||||
|
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||||
|
Configurez <code className="rounded bg-black/30 px-1">VITE_JIRA_BASE_URL</code> pour
|
||||||
|
un build de production pointant vers votre proxy HTTPS (le proxy Vite ne s’applique
|
||||||
|
qu’en <code className="rounded bg-black/30 px-1">npm run dev</code>).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mb-6 rounded-xl border border-rose-500/40 bg-rose-950/40 px-4 py-3 text-sm text-rose-100"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <DashboardSkeleton />}
|
||||||
|
|
||||||
|
{!loading && !error && groups.length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-10 text-center text-slate-400 backdrop-blur-md">
|
||||||
|
Aucun ticket ne correspond au JQL actuel.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<MilestonesTimeline
|
||||||
|
milestones={dashboardCfg.milestones}
|
||||||
|
groups={groups}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutiveSummary groups={displayGroups} />
|
||||||
|
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Burnup
|
||||||
|
</h2>
|
||||||
|
<BurnupChart data={burnupData} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-4 flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
{view === 'list' ? 'Stories (liste)' : 'Stories par composant'}
|
||||||
|
</h2>
|
||||||
|
{myViewActive && (
|
||||||
|
<span className="text-xs text-cyan-300/90">
|
||||||
|
Filtre actif : {displayGroups.length} / {groups.length} stories
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{displayGroups.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 backdrop-blur-md">
|
||||||
|
Aucune story ne correspond à « Ma vue ». Vérifiez vos assignations ou configurez
|
||||||
|
votre accountId / e-mail dans les réglages.
|
||||||
|
</p>
|
||||||
|
) : view === 'list' ? (
|
||||||
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||||
|
{displayGroups.map((g) => (
|
||||||
|
<StoryCard key={g.story.key} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<BoardView groups={displayGroups} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<DashboardSettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
config={dashboardCfg}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSave={saveSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/api/jiraClient.ts
Normal file
153
src/api/jiraClient.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Même périmètre qu’un filtre Jira type filter=25111 : tout ce qui est sous l’épopée
|
||||||
|
* (`parentEpic`), **y compris les sous-tâches** — un JQL sur `parent` / `parent.parent` ne
|
||||||
|
* reprend pas toute la hiérarchie épique.
|
||||||
|
*/
|
||||||
|
export const MIGRATION_EPIC_KEY =
|
||||||
|
import.meta.env.VITE_JIRA_EPIC_KEY?.trim() || 'DCC-5514'
|
||||||
|
|
||||||
|
export const MIGRATION_JQL = `project IN (DCC) AND parentEpic IN (${MIGRATION_EPIC_KEY}) AND key NOT IN (${MIGRATION_EPIC_KEY}) ORDER BY Rank ASC`
|
||||||
|
|
||||||
|
function clientBaseUrl(): string {
|
||||||
|
if (import.meta.env.DEV) return '/jira-api'
|
||||||
|
const fromEnv = import.meta.env.VITE_JIRA_BASE_URL?.replace(/\/$/, '')
|
||||||
|
return fromEnv ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jiraClient = axios.create({
|
||||||
|
baseURL: clientBaseUrl(),
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
timeout: 120_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MAX_PAGES = 250
|
||||||
|
|
||||||
|
function pageSize(): number {
|
||||||
|
const parsedSize = Number(import.meta.env.VITE_JIRA_PAGE_SIZE)
|
||||||
|
if (Number.isFinite(parsedSize) && parsedSize >= 1 && parsedSize <= 100) {
|
||||||
|
return Math.floor(parsedSize)
|
||||||
|
}
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre total (approximatif) d’issues pour le JQL (optionnel : KPI, titres, etc.).
|
||||||
|
* La pagination complète de `fetchAllIssuesByJql` repose sur `nextPageToken`, pas sur ce count.
|
||||||
|
* @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-approximate-count-post
|
||||||
|
*/
|
||||||
|
export async function fetchJqlApproximateCount(
|
||||||
|
jql: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const { data } = await jiraClient.post<{ count?: number }>(
|
||||||
|
'/rest/api/3/search/approximate-count',
|
||||||
|
{ jql },
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
return typeof data?.count === 'number' && Number.isFinite(data.count) ? data.count : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge toutes les issues via POST `/rest/api/3/search/jql` (seul endpoint supporté après
|
||||||
|
* suppression de `/rest/api/3/search` — CHANGE-2046).
|
||||||
|
* Pagination par `nextPageToken` ; garde-fous si Jira renvoie un token cyclique (bugs signalés en prod).
|
||||||
|
* @see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-post
|
||||||
|
*/
|
||||||
|
export async function fetchAllIssuesByJql(
|
||||||
|
jql: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<JiraIssue[]> {
|
||||||
|
const base = clientBaseUrl()
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(
|
||||||
|
'URL Jira absente : en dev, définissez JIRA_DOMAIN dans .env et utilisez le proxy /jira-api. En prod, définissez VITE_JIRA_BASE_URL vers un proxy HTTPS.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxResults = pageSize()
|
||||||
|
const storyPointsField =
|
||||||
|
import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028'
|
||||||
|
const fields = [
|
||||||
|
'summary',
|
||||||
|
'status',
|
||||||
|
'issuetype',
|
||||||
|
'parent',
|
||||||
|
'subtasks',
|
||||||
|
'components',
|
||||||
|
'priority',
|
||||||
|
'assignee',
|
||||||
|
'timetracking',
|
||||||
|
storyPointsField,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const collected: JiraIssue[] = []
|
||||||
|
let nextPageToken: string | undefined
|
||||||
|
const seenTokens = new Set<string>()
|
||||||
|
let lastPageFingerprint = ''
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page += 1) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
jql,
|
||||||
|
fields: [...fields],
|
||||||
|
maxResults,
|
||||||
|
...(nextPageToken ? { nextPageToken } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await jiraClient.post<JiraSearchJqlResponse>(
|
||||||
|
'/rest/api/3/search/jql',
|
||||||
|
body,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const batch = data.issues ?? []
|
||||||
|
const fingerprint = batch.map((i) => i.key).join('\0')
|
||||||
|
if (fingerprint && fingerprint === lastPageFingerprint) {
|
||||||
|
console.warn(
|
||||||
|
'[jira] Pagination search/jql : même jeu de clés qu’à la page précédente — arrêt pour éviter une boucle.',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastPageFingerprint = fingerprint
|
||||||
|
|
||||||
|
collected.push(...batch)
|
||||||
|
if (batch.length === 0) break
|
||||||
|
|
||||||
|
const token =
|
||||||
|
typeof data.nextPageToken === 'string' && data.nextPageToken.length > 0
|
||||||
|
? data.nextPageToken
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!token) break
|
||||||
|
if (seenTokens.has(token)) {
|
||||||
|
console.warn('[jira] Pagination search/jql : nextPageToken déjà vu — arrêt.')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seenTokens.add(token)
|
||||||
|
nextPageToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
let approx: number | undefined
|
||||||
|
try {
|
||||||
|
approx = await fetchJqlApproximateCount(jql, signal)
|
||||||
|
} catch {
|
||||||
|
approx = undefined
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
approx !== undefined &&
|
||||||
|
approx > collected.length &&
|
||||||
|
collected.length > 0
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`[jira] Décompte approximatif Jira ≈ ${approx} issue(s), mais seulement ${collected.length} chargée(s). Vérifiez les droits, le JQL, ou un bug du endpoint search/jql sur votre site.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
34
src/components/BoardView.tsx
Normal file
34
src/components/BoardView.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
import { groupStoriesByComponent } from '../lib/boardGrouping'
|
||||||
|
import { StoryCard } from './StoryCard'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
groups: StoryGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardView({ groups }: Props) {
|
||||||
|
const columns = groupStoriesByComponent(groups)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:thin] sm:gap-5 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-slate-600">
|
||||||
|
{[...columns.entries()].map(([name, col]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex w-[min(100%,320px)] shrink-0 flex-col rounded-2xl border border-white/[0.06] bg-slate-950/40 p-3 backdrop-blur-md sm:w-[300px]"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2 border-b border-white/[0.06] pb-2">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-white">{name}</h3>
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-800 px-2 py-0.5 text-[10px] text-slate-400">
|
||||||
|
{col.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{col.map((g) => (
|
||||||
|
<StoryCard key={g.story.key} group={g} variant="board" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/BurnupChart.tsx
Normal file
68
src/components/BurnupChart.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { BurnupPoint } from '../lib/burnupHistory'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: BurnupPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BurnupChart({ data }: Props) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-4 py-10 text-center text-sm text-slate-500 backdrop-blur-md">
|
||||||
|
Le graphique burnup se remplira au fil des actualisations (historique stocké localement).
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[280px] w-full rounded-2xl border border-white/10 bg-slate-950/30 p-3 backdrop-blur-md sm:p-4">
|
||||||
|
<p className="mb-2 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Burnup — terminés vs périmètre
|
||||||
|
</p>
|
||||||
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
|
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.12)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||||
|
tickFormatter={(d) => d.slice(5)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} allowDecimals={false} width={28} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'rgba(15,23,42,0.95)',
|
||||||
|
border: '1px solid rgba(148,163,184,0.25)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelFormatter={(l) => `Date : ${l}`}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
name="Périmètre (sous-tâches)"
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="done"
|
||||||
|
name="Terminées"
|
||||||
|
stroke="#34d399"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: '#34d399' }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/components/DashboardSettingsModal.tsx
Normal file
227
src/components/DashboardSettingsModal.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
|
||||||
|
import {
|
||||||
|
exportConfigJson,
|
||||||
|
mergeImportedConfig,
|
||||||
|
type DashboardConfig,
|
||||||
|
type Milestone,
|
||||||
|
} from '../lib/dashboardConfig'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
config: DashboardConfig
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (next: DashboardConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function newMilestone(): Milestone {
|
||||||
|
return {
|
||||||
|
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `m-${Date.now()}`,
|
||||||
|
title: '',
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
linkedStoryKeys: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const titleId = useId()
|
||||||
|
const [draft, setDraft] = useState<DashboardConfig>(config)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(config)
|
||||||
|
}, [open, config])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current
|
||||||
|
if (!el) return
|
||||||
|
if (open) {
|
||||||
|
if (!el.open) el.showModal()
|
||||||
|
} else if (el.open) {
|
||||||
|
el.close()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const updateMilestone = (id: string, patch: Partial<Milestone>) => {
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
milestones: d.milestones.map((m) => (m.id === id ? { ...m, ...patch } : m)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMilestone = (id: string) => {
|
||||||
|
setDraft((d) => ({ ...d, milestones: d.milestones.filter((m) => m.id !== id) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMilestone = () => {
|
||||||
|
setDraft((d) => ({ ...d, milestones: [...d.milestones, newMilestone()] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImportFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
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).')
|
||||||
|
} catch {
|
||||||
|
alert('Impossible de lire ce fichier JSON.')
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="max-h-[90vh] w-[min(100%,520px)] overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/95 p-0 text-slate-200 shadow-2xl backdrop:bg-black/70"
|
||||||
|
onClose={onClose}
|
||||||
|
onCancel={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="border-b border-white/10 px-5 py-4">
|
||||||
|
<h2 id={titleId} className="text-lg font-semibold text-white">
|
||||||
|
Configuration dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Sauvegarde locale (navigateur). Exportez le JSON pour le versionner sur votre Synology.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Mon accountId Jira (recommandé pour « Ma vue »)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none ring-cyan-500/30 focus:ring-2"
|
||||||
|
value={draft.myJiraAccountId ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, myJiraAccountId: e.target.value || undefined }))}
|
||||||
|
placeholder="ex. 5b10a2844c20165700ede21g"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Mon e-mail Jira (alternative si visible dans l’API)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none ring-cyan-500/30 focus:ring-2"
|
||||||
|
value={draft.myJiraEmail ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, myJiraEmail: e.target.value || undefined }))}
|
||||||
|
placeholder="vous@entreprise.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Jalons
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMilestone}
|
||||||
|
className="text-xs font-medium text-cyan-400 hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
+ Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-52 space-y-3 overflow-y-auto pr-1">
|
||||||
|
{draft.milestones.map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className="rounded-xl border border-white/10 bg-black/25 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="mb-2 w-full rounded border border-white/10 bg-transparent px-2 py-1 text-sm text-white"
|
||||||
|
value={m.title}
|
||||||
|
onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
|
||||||
|
placeholder="ex. Fin design"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="rounded border border-white/10 bg-transparent px-2 py-1 text-xs"
|
||||||
|
value={m.date}
|
||||||
|
onChange={(e) => updateMilestone(m.id, { date: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="min-w-[120px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1 text-xs"
|
||||||
|
value={(m.linkedStoryKeys ?? []).join(', ')}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMilestone(m.id, {
|
||||||
|
linkedStoryKeys: e.target.value
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Stories liées (DCC-1, DCC-2) — vide = toutes"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMilestone(m.id)}
|
||||||
|
className="text-xs text-rose-400 hover:text-rose-300"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-white/10 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportConfigJson(draft)}
|
||||||
|
className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-100"
|
||||||
|
>
|
||||||
|
Exporter configuration (JSON)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-xs font-semibold text-slate-200"
|
||||||
|
>
|
||||||
|
Importer JSON…
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onImportFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSave(draft)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/components/DashboardSkeleton.tsx
Normal file
39
src/components/DashboardSkeleton.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
function Shimmer({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden rounded-lg bg-slate-800/70 ${className}`}>
|
||||||
|
<div className="animate-shimmer absolute inset-0 bg-gradient-to-r from-transparent via-white/12 to-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-2xl border border-white/10 bg-slate-950/40 p-5 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<Shimmer className="mb-4 h-3 w-24" />
|
||||||
|
<Shimmer className="mx-auto h-28 w-28 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Shimmer className="h-48 w-full rounded-2xl" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-2xl border border-white/10 bg-slate-950/35 p-5 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<Shimmer className="mb-3 h-4 w-[66%]" />
|
||||||
|
<Shimmer className="mb-2 h-3 w-full" />
|
||||||
|
<Shimmer className="mb-4 h-3 w-[80%]" />
|
||||||
|
<Shimmer className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/components/ExecutiveSummary.tsx
Normal file
133
src/components/ExecutiveSummary.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
import {
|
||||||
|
blockingTicketsCount,
|
||||||
|
designHealthPercent,
|
||||||
|
globalProgressPercent,
|
||||||
|
goldenCarbonHealthPercent,
|
||||||
|
goldenCarbonSubtasks,
|
||||||
|
maquetteRelatedSubtasks,
|
||||||
|
} from '../lib/executiveKpis'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
groups: StoryGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const neonCard =
|
||||||
|
'rounded-2xl border bg-slate-950/35 p-4 backdrop-blur-xl transition hover:bg-slate-950/45 sm:p-5'
|
||||||
|
|
||||||
|
function DonutGlobal({ pct }: { pct: number }) {
|
||||||
|
const rest = Math.max(0, 100 - pct)
|
||||||
|
const data = [
|
||||||
|
{ name: 'Terminé', value: pct },
|
||||||
|
{ name: 'Reste', value: rest },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto h-[140px] w-[140px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={48}
|
||||||
|
outerRadius={62}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
<Cell fill="rgb(52 211 153)" />
|
||||||
|
<Cell fill="rgb(30 41 59 / 0.85)" />
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [`${value ?? 0}%`, '']}
|
||||||
|
contentStyle={{
|
||||||
|
background: 'rgba(15,23,42,0.95)',
|
||||||
|
border: '1px solid rgba(148,163,184,0.25)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-2xl font-semibold tabular-nums text-white">{pct}%</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-slate-500">global</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutiveSummary({ groups }: Props) {
|
||||||
|
const total = globalProgressPercent(groups)
|
||||||
|
const design = designHealthPercent(groups)
|
||||||
|
const golden = goldenCarbonHealthPercent(groups)
|
||||||
|
const blockers = blockingTicketsCount(groups)
|
||||||
|
|
||||||
|
const maquetteCount = maquetteRelatedSubtasks(groups).length
|
||||||
|
const gcCount = goldenCarbonSubtasks(groups).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Executive summary
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div
|
||||||
|
className={`${neonCard} border-cyan-400/45 shadow-[0_0_24px_rgba(34,211,238,0.18)]`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-cyan-200/90">
|
||||||
|
Progression totale
|
||||||
|
</p>
|
||||||
|
<DonutGlobal pct={total} />
|
||||||
|
<p className="mt-1 text-center text-[11px] text-slate-500">
|
||||||
|
Sous-tâches terminées / sous-tâches liées aux stories
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${neonCard} border-fuchsia-500/40 shadow-[0_0_22px_rgba(217,70,239,0.16)]`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-fuchsia-200/90">
|
||||||
|
Santé du design
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-4xl font-semibold tabular-nums text-white">
|
||||||
|
{maquetteCount === 0 ? '—' : `${design}%`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-slate-400">
|
||||||
|
Maquettes validées (sous-tâches dont le résumé évoque maquette / Figma / wireframe).
|
||||||
|
{maquetteCount === 0 && ' Aucune sous-tâche détectée avec ces mots-clés.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${neonCard} border-emerald-400/40 shadow-[0_0_22px_rgba(52,211,153,0.18)]`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-emerald-200/90">
|
||||||
|
Santé du dev
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-4xl font-semibold tabular-nums text-white">
|
||||||
|
{gcCount === 0 ? '—' : `${golden}%`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-slate-400">
|
||||||
|
Intégration Golden Carbon (mot-clé « golden carbon » dans résumé ou composants).
|
||||||
|
{gcCount === 0 && ' Aucun ticket correspondant — ajustez les filtres dans le code si besoin.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${neonCard} border-rose-400/45 shadow-[0_0_22px_rgba(251,113,133,0.2)]`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-rose-200/90">
|
||||||
|
Points de blocage
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-4xl font-semibold tabular-nums text-white">{blockers}</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-slate-400">
|
||||||
|
Tickets en statut « Bloqué », « Recette KO » ou assimilés (stories + sous-tâches).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/components/MilestonesTimeline.tsx
Normal file
83
src/components/MilestonesTimeline.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
import type { Milestone } from '../lib/dashboardConfig'
|
||||||
|
import { isMilestoneLate } from '../lib/milestoneStatus'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
milestones: Milestone[]
|
||||||
|
groups: StoryGroup[]
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFr(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso + 'T12:00:00').toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MilestonesTimeline({ milestones, groups, onOpenSettings }: Props) {
|
||||||
|
const sorted = [...milestones].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Timeline de jalons
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
Configurer les jalons
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Aucun jalon défini — ouvrez la configuration pour ajouter des dates clés (ex. fin design,
|
||||||
|
recette, mise en ligne).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative pt-2 pb-6">
|
||||||
|
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-slate-600 to-transparent" />
|
||||||
|
<ul className="relative flex flex-wrap gap-6 sm:flex-nowrap sm:justify-between">
|
||||||
|
{sorted.map((m) => {
|
||||||
|
const late = isMilestoneLate(m, groups)
|
||||||
|
return (
|
||||||
|
<li key={m.id} className="flex min-w-[100px] flex-1 flex-col items-center sm:flex-none">
|
||||||
|
<span
|
||||||
|
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)]'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
late
|
||||||
|
? 'Retard : date dépassée et stories liées non terminées (sous-tâches).'
|
||||||
|
: 'À jour ou échéance future.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="max-w-[140px] text-center text-xs font-medium text-white">
|
||||||
|
{m.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500">{formatFr(m.date)}</span>
|
||||||
|
{late && (
|
||||||
|
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-rose-400">
|
||||||
|
Retard
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/components/PhaseLaneIcons.tsx
Normal file
44
src/components/PhaseLaneIcons.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
import { laneAggregateState, type WorkLane } from '../lib/laneDetection'
|
||||||
|
|
||||||
|
const LANES: { lane: WorkLane; label: string; hint: string }[] = [
|
||||||
|
{ lane: 'analyse', label: 'A', hint: 'Piste Analyse' },
|
||||||
|
{ lane: 'design', label: 'D', hint: 'Piste Design' },
|
||||||
|
{ lane: 'integration', label: 'I', hint: 'Piste Intégration' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function stateClasses(state: ReturnType<typeof laneAggregateState>): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'empty':
|
||||||
|
return 'bg-slate-800/80 text-slate-600 ring-slate-700/50'
|
||||||
|
case 'grey':
|
||||||
|
return 'bg-slate-700 text-slate-300 ring-slate-500/40'
|
||||||
|
case 'blue':
|
||||||
|
return 'bg-sky-600 text-white ring-sky-400/60 shadow-[0_0_12px_rgba(56,189,248,0.45)]'
|
||||||
|
case 'green':
|
||||||
|
return 'bg-emerald-500 text-white ring-emerald-400/50 shadow-[0_0_12px_rgba(52,211,153,0.45)]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subtasks: JiraIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseLaneIcons({ subtasks }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title="État par piste (sous-tâches détectées par libellé)">
|
||||||
|
{LANES.map(({ lane, label, hint }) => {
|
||||||
|
const st = laneAggregateState(subtasks, lane)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={lane}
|
||||||
|
title={`${hint} — ${st === 'empty' ? 'aucune sous-tâche détectée' : st}`}
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-bold ring-1 ring-inset transition ${stateClasses(st)}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/PhaseStepper.tsx
Normal file
35
src/components/PhaseStepper.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { PhaseId } from '../types/jira'
|
||||||
|
import { PHASE_LABELS } from '../lib/statusPhase'
|
||||||
|
|
||||||
|
const STEPS: PhaseId[] = ['analyse', 'design', 'integration']
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stepDone: Record<PhaseId, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseStepper({ stepDone }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full gap-1.5">
|
||||||
|
{STEPS.map((phase, idx) => {
|
||||||
|
const done = stepDone[phase]
|
||||||
|
return (
|
||||||
|
<div key={phase} className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`relative h-2 flex-1 overflow-hidden rounded-full transition-all duration-500 ${
|
||||||
|
done
|
||||||
|
? 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]'
|
||||||
|
: 'bg-slate-700/80'
|
||||||
|
}`}
|
||||||
|
title={PHASE_LABELS[phase]}
|
||||||
|
/>
|
||||||
|
{idx < STEPS.length - 1 && (
|
||||||
|
<span className="shrink-0 text-[9px] text-slate-600" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
src/components/StoryCard.tsx
Normal file
210
src/components/StoryCard.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||||
|
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
|
||||||
|
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
|
||||||
|
import { storyProgressPercent, stepperStates } from '../lib/storyMetrics'
|
||||||
|
import { PhaseStepper } from './PhaseStepper'
|
||||||
|
import { PhaseLaneIcons } from './PhaseLaneIcons'
|
||||||
|
import { blockingSummaryForTooltip } from '../lib/executiveKpis'
|
||||||
|
import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors'
|
||||||
|
import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
|
||||||
|
|
||||||
|
function IssueKeyLink({
|
||||||
|
issueKey,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
issueKey: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const href = jiraBrowseIssueUrl(issueKey)
|
||||||
|
const base =
|
||||||
|
className ??
|
||||||
|
'font-mono text-xs text-cyan-300/95 decoration-cyan-400/40 hover:text-cyan-200 hover:decoration-cyan-300/60'
|
||||||
|
if (!href) {
|
||||||
|
return <span className={base}>{issueKey}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Ouvrir ${issueKey} dans Jira`}
|
||||||
|
className={`${base} underline-offset-2 hover:underline`}
|
||||||
|
>
|
||||||
|
{issueKey}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: StoryGroup
|
||||||
|
/** Board : icônes A/D/I par piste + carte un peu plus compacte. */
|
||||||
|
variant?: 'default' | 'board'
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseChipClass(phase: PhaseId): string {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset'
|
||||||
|
const map: Record<PhaseId, string> = {
|
||||||
|
analyse: `${base} bg-violet-500/15 text-violet-200 ring-violet-500/35`,
|
||||||
|
design: `${base} bg-amber-500/15 text-amber-100 ring-amber-500/35`,
|
||||||
|
integration: `${base} bg-sky-500/15 text-sky-100 ring-sky-500/35`,
|
||||||
|
done: `${base} bg-emerald-500/15 text-emerald-100 ring-emerald-500/35`,
|
||||||
|
}
|
||||||
|
return map[phase] ?? `${base} bg-slate-500/15 text-slate-200 ring-slate-500/35`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryCard({ group, variant = 'default' }: Props) {
|
||||||
|
const { story, subtasks } = group
|
||||||
|
const [subsOpen, setSubsOpen] = useState(true)
|
||||||
|
const progress = storyProgressPercent(subtasks)
|
||||||
|
const steps = stepperStates(subtasks)
|
||||||
|
const band = priorityBand(story)
|
||||||
|
const assignee = story.fields.assignee?.displayName
|
||||||
|
const blockerHint = blockingSummaryForTooltip(group)
|
||||||
|
const isBoard = variant === 'board'
|
||||||
|
const spStory = getStoryPoints(story)
|
||||||
|
const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0)
|
||||||
|
const remStory = getRemainingEstimateUnits(story)
|
||||||
|
const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0)
|
||||||
|
|
||||||
|
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)]">
|
||||||
|
<div className="border-b border-white/[0.06] px-4 py-4 sm:px-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<IssueKeyLink issueKey={story.key} />
|
||||||
|
{band && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${priorityBadgeClass(band)}`}
|
||||||
|
>
|
||||||
|
{band}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isBoard && subtasks.length > 0 && <PhaseLaneIcons subtasks={subtasks} />}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-1.5 text-base font-semibold leading-snug text-white sm:text-lg">
|
||||||
|
{story.fields.summary}
|
||||||
|
</h3>
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
SP sous-tâches :{' '}
|
||||||
|
<span className="font-mono text-slate-300">{spSubs}</span>
|
||||||
|
</span>
|
||||||
|
<span title="Reste = remainingEstimateSeconds / 27 000 (comme ton export)">
|
||||||
|
Reste (Σ) :{' '}
|
||||||
|
<span className="font-mono text-slate-300">
|
||||||
|
{(remStory + remSubs).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className={phaseChipClass(statusToPhase(story.fields.status.name))}>
|
||||||
|
{PHASE_LABELS[statusToPhase(story.fields.status.name)]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-500">
|
||||||
|
Jira : <span className="text-slate-300">{story.fields.status.name}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{assignee && (
|
||||||
|
<div
|
||||||
|
className="max-w-[140px] truncate rounded-full border border-white/10 bg-slate-900/60 px-3 py-1 text-[11px] text-slate-200"
|
||||||
|
title={assignee}
|
||||||
|
>
|
||||||
|
{assignee}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-[10px] uppercase tracking-wider text-slate-500">
|
||||||
|
<span>Analyse</span>
|
||||||
|
<span>Design</span>
|
||||||
|
<span>Intégration</span>
|
||||||
|
</div>
|
||||||
|
<PhaseStepper stepDone={steps} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Progression (sous-tâches)</span>
|
||||||
|
<span className="font-semibold tabular-nums text-emerald-300">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex-1 px-4 py-3 sm:px-5 ${isBoard ? 'max-sm:py-2' : ''}`}>
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubsOpen((v) => !v)}
|
||||||
|
className="rounded-lg border border-white/10 bg-slate-950/40 px-2.5 py-1 text-[11px] font-medium text-slate-300 transition hover:border-cyan-500/30 hover:text-white"
|
||||||
|
>
|
||||||
|
{subsOpen
|
||||||
|
? 'Masquer les sous-tâches'
|
||||||
|
: `Afficher les sous-tâches (${subtasks.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{subsOpen && (
|
||||||
|
<ul
|
||||||
|
className={`space-y-2 overflow-y-auto pr-1 text-sm ${
|
||||||
|
isBoard ? 'max-h-36' : 'max-h-48 sm:max-h-56'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subtasks.map((st) => {
|
||||||
|
const ph = statusToPhase(st.fields.status.name)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={st.key}
|
||||||
|
className="flex flex-col gap-1 rounded-lg border border-white/[0.04] bg-black/20 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<IssueKeyLink
|
||||||
|
issueKey={st.key}
|
||||||
|
className="font-mono text-[10px] text-slate-400 decoration-slate-500/50 hover:text-cyan-200/90 hover:decoration-cyan-400/50"
|
||||||
|
/>
|
||||||
|
<p className="truncate text-slate-200">{st.fields.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||||
|
{getStoryPoints(st) > 0 && (
|
||||||
|
<span className="font-mono text-[10px] text-slate-500" title="Story Points">
|
||||||
|
SP {getStoryPoints(st)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-slate-500">{st.fields.status.name}</span>
|
||||||
|
<span className={phaseChipClass(ph)}>{PHASE_LABELS[ph]}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtasks.length > 0 && (
|
||||||
|
<div className="mt-auto border-t border-white/[0.05] px-4 pb-3 pt-2 sm:px-5">
|
||||||
|
<div
|
||||||
|
className="h-1 cursor-help overflow-hidden rounded-full bg-slate-800/90"
|
||||||
|
title={blockerHint}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-cyan-400 transition-all duration-700 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/global.d.ts
vendored
Normal file
2
src/global.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/** Renseigné par Vite (`vite.config.js`) à partir de `JIRA_DOMAIN` pour les liens navigateur. */
|
||||||
|
declare const __JIRA_ORIGIN__: string
|
||||||
28
src/index.css
Normal file
28
src/index.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: radial-gradient(1200px 800px at 10% -10%, #1e1b4b 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 600px at 100% 0%, #0c4a6e 0%, transparent 50%),
|
||||||
|
#020617;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer-slide 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
18
src/lib/assigneeMatch.ts
Normal file
18
src/lib/assigneeMatch.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
import type { DashboardConfig } from './dashboardConfig'
|
||||||
|
|
||||||
|
/** Correspondance utilisateur « Ma vue » : compte Atlassian ou e-mail. */
|
||||||
|
export function assigneeMatchesMyView(issue: JiraIssue, cfg: DashboardConfig): boolean {
|
||||||
|
const a = issue.fields.assignee
|
||||||
|
if (!a) return false
|
||||||
|
const cfgId = cfg.myJiraAccountId?.trim()
|
||||||
|
const cfgEmail = cfg.myJiraEmail?.trim().toLowerCase()
|
||||||
|
const envId = import.meta.env.VITE_MY_JIRA_ACCOUNT_ID?.trim()
|
||||||
|
const envEmail = import.meta.env.VITE_MY_JIRA_EMAIL?.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (cfgId && a.accountId && a.accountId === cfgId) return true
|
||||||
|
if (envId && a.accountId && a.accountId === envId) return true
|
||||||
|
if (cfgEmail && a.emailAddress && a.emailAddress.toLowerCase() === cfgEmail) return true
|
||||||
|
if (envEmail && a.emailAddress && a.emailAddress.toLowerCase() === envEmail) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
17
src/lib/boardGrouping.ts
Normal file
17
src/lib/boardGrouping.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
|
||||||
|
/** Regroupe les stories par premier composant Jira, sinon « Autres ». */
|
||||||
|
export function groupStoriesByComponent(groups: StoryGroup[]): Map<string, StoryGroup[]> {
|
||||||
|
const map = new Map<string, StoryGroup[]>()
|
||||||
|
for (const g of groups) {
|
||||||
|
const comps = g.story.fields.components
|
||||||
|
const label =
|
||||||
|
comps && comps.length > 0 ? comps[0]!.name : 'Autres'
|
||||||
|
if (!map.has(label)) map.set(label, [])
|
||||||
|
map.get(label)!.push(g)
|
||||||
|
}
|
||||||
|
const keys = [...map.keys()].sort((a, b) => a.localeCompare(b, 'fr'))
|
||||||
|
const sorted = new Map<string, StoryGroup[]>()
|
||||||
|
for (const k of keys) sorted.set(k, map.get(k)!)
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
36
src/lib/burnupHistory.ts
Normal file
36
src/lib/burnupHistory.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const STORAGE_KEY = 'jira-descours-burnup-v1'
|
||||||
|
|
||||||
|
export type BurnupPoint = {
|
||||||
|
date: string
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayISO(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBurnupHistory(): BurnupPoint[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw) as BurnupPoint[]
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre le snapshot du jour (remplace le point s’il existe déjà pour cette date). */
|
||||||
|
export function appendBurnupSnapshot(done: number, total: number): BurnupPoint[] {
|
||||||
|
const day = todayISO()
|
||||||
|
const prev = loadBurnupHistory()
|
||||||
|
const filtered = prev.filter((p) => p.date !== day)
|
||||||
|
const next = [...filtered, { date: day, done, total }].sort((a, b) =>
|
||||||
|
a.date.localeCompare(b.date),
|
||||||
|
)
|
||||||
|
const trimmed = next.slice(-45)
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
72
src/lib/dashboardConfig.ts
Normal file
72
src/lib/dashboardConfig.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export type Milestone = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
/** ISO date (yyyy-mm-dd) */
|
||||||
|
date: string
|
||||||
|
/** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */
|
||||||
|
linkedStoryKeys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardConfig = {
|
||||||
|
version: 1
|
||||||
|
milestones: Milestone[]
|
||||||
|
myJiraAccountId?: string
|
||||||
|
myJiraEmail?: string
|
||||||
|
/** Filtre « Ma vue » (sous-tâches me concernant). */
|
||||||
|
myViewActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
||||||
|
|
||||||
|
export const defaultDashboardConfig = (): DashboardConfig => ({
|
||||||
|
version: 1,
|
||||||
|
milestones: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function loadDashboardConfig(): DashboardConfig {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return defaultDashboardConfig()
|
||||||
|
const parsed = JSON.parse(raw) as Partial<DashboardConfig>
|
||||||
|
if (!parsed || parsed.version !== 1) return defaultDashboardConfig()
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
|
||||||
|
myJiraAccountId: parsed.myJiraAccountId,
|
||||||
|
myJiraEmail: parsed.myJiraEmail,
|
||||||
|
myViewActive: parsed.myViewActive,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return defaultDashboardConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDashboardConfig(cfg: DashboardConfig): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportConfigJson(cfg: DashboardConfig): void {
|
||||||
|
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `dcc-dashboard-config-${new Date().toISOString().slice(0, 10)}.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
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
|
||||||
|
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
|
||||||
|
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
|
||||||
|
myViewActive: o.myViewActive ?? current.myViewActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/lib/executiveKpis.ts
Normal file
96
src/lib/executiveKpis.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { JiraIssue, StoryGroup } from '../types/jira'
|
||||||
|
import { statusToPhase } from './statusPhase'
|
||||||
|
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{M}/gu, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockingStatus(statusName: string): boolean {
|
||||||
|
const n = norm(statusName)
|
||||||
|
return (
|
||||||
|
n.includes('bloque') ||
|
||||||
|
n.includes('blocked') ||
|
||||||
|
n.includes('recette ko') ||
|
||||||
|
n.includes('recetteko') ||
|
||||||
|
n.includes('recette nok')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function allSubtasks(groups: StoryGroup[]): JiraIssue[] {
|
||||||
|
return groups.flatMap((g) => g.subtasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maquetteRelatedSubtasks(groups: StoryGroup[]): JiraIssue[] {
|
||||||
|
return allSubtasks(groups).filter(isMaquetteRelated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] {
|
||||||
|
return groups.flatMap((g) =>
|
||||||
|
g.subtasks.filter((st) => isGoldenCarbonRelated(st, g.story)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progression globale : sous-tâches terminées / sous-tâches totales. */
|
||||||
|
export function globalProgressPercent(groups: StoryGroup[]): number {
|
||||||
|
const subs = allSubtasks(groups)
|
||||||
|
if (subs.length === 0) return 0
|
||||||
|
const done = subs.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||||
|
return Math.round((done / subs.length) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */
|
||||||
|
export function isMaquetteRelated(st: JiraIssue): boolean {
|
||||||
|
const t = `${st.fields.summary} ${st.key}`.toLowerCase()
|
||||||
|
return /maquette|mockup|figma|wireframe|zoning|ui\s*design/i.test(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */
|
||||||
|
export function designHealthPercent(groups: StoryGroup[]): number {
|
||||||
|
const candidates = maquetteRelatedSubtasks(groups)
|
||||||
|
if (candidates.length === 0) return 0
|
||||||
|
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||||
|
return Math.round((ok / candidates.length) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function textWithComponents(st: JiraIssue, story: JiraIssue): string {
|
||||||
|
const comps = [...(st.fields.components ?? []), ...(story.fields.components ?? [])]
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(' ')
|
||||||
|
return `${st.fields.summary} ${comps}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Intégration « Golden Carbon » : filtre par mot-clé ou composant. */
|
||||||
|
function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean {
|
||||||
|
return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goldenCarbonHealthPercent(groups: StoryGroup[]): number {
|
||||||
|
const candidates = goldenCarbonSubtasks(groups)
|
||||||
|
if (candidates.length === 0) return 0
|
||||||
|
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
|
||||||
|
return Math.round((ok / candidates.length) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockingTicketsCount(groups: StoryGroup[]): number {
|
||||||
|
const issues: JiraIssue[] = [
|
||||||
|
...groups.map((g) => g.story),
|
||||||
|
...allSubtasks(groups),
|
||||||
|
]
|
||||||
|
return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] {
|
||||||
|
return [group.story, ...group.subtasks].filter((i) =>
|
||||||
|
isBlockingStatus(i.fields.status.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockingSummaryForTooltip(group: StoryGroup): string {
|
||||||
|
const list = blockingIssuesInGroup(group)
|
||||||
|
if (list.length === 0) return 'Aucun ticket bloquant sur cette story.'
|
||||||
|
return list.map((i) => `${i.key} — ${i.fields.status.name}: ${i.fields.summary}`).join('\n')
|
||||||
|
}
|
||||||
144
src/lib/groupIssues.ts
Normal file
144
src/lib/groupIssues.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import type { JiraEmbeddedChildIssue, JiraIssue, StoryGroup } from '../types/jira'
|
||||||
|
import { MIGRATION_EPIC_KEY } from '../api/jiraClient'
|
||||||
|
import { getStoryPoints } from './jiraFieldExtractors'
|
||||||
|
import { isJiraSubtask } from './subtaskUtils'
|
||||||
|
import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve'
|
||||||
|
|
||||||
|
/** Certaines réponses ne listent les sous-tâches que sous `fields.subtasks` du parent. */
|
||||||
|
function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): JiraIssue {
|
||||||
|
const f = emb.fields ?? {}
|
||||||
|
const status = f.status as JiraIssue['fields']['status'] | undefined
|
||||||
|
const issuetype = f.issuetype as JiraIssue['fields']['issuetype'] | undefined
|
||||||
|
const skip = new Set([
|
||||||
|
'summary',
|
||||||
|
'status',
|
||||||
|
'issuetype',
|
||||||
|
'parent',
|
||||||
|
'components',
|
||||||
|
'priority',
|
||||||
|
'assignee',
|
||||||
|
'timetracking',
|
||||||
|
'subtasks',
|
||||||
|
])
|
||||||
|
const extras: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(f)) {
|
||||||
|
if (!skip.has(k)) extras[k] = v
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: emb.id,
|
||||||
|
key: emb.key,
|
||||||
|
fields: {
|
||||||
|
summary: typeof f.summary === 'string' ? f.summary : '—',
|
||||||
|
status: status && typeof status.name === 'string' ? status : { name: '—' },
|
||||||
|
issuetype:
|
||||||
|
issuetype && typeof issuetype.name === 'string'
|
||||||
|
? issuetype
|
||||||
|
: { name: 'Sous-tâche', subtask: true },
|
||||||
|
parent: { key: parentKey },
|
||||||
|
components: f.components as JiraIssue['fields']['components'],
|
||||||
|
priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
|
||||||
|
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,
|
||||||
|
timetracking: f.timetracking as JiraIssue['fields']['timetracking'],
|
||||||
|
...extras,
|
||||||
|
} as JiraIssue['fields'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEmbeddedSubtasksFromParents(issues: JiraIssue[]): JiraIssue[] {
|
||||||
|
const byKey = new Map(issues.map((i) => [i.key, i]))
|
||||||
|
const merged = [...issues]
|
||||||
|
for (const parent of issues) {
|
||||||
|
const subs = parent.fields.subtasks
|
||||||
|
if (!Array.isArray(subs) || subs.length === 0) continue
|
||||||
|
for (const emb of subs) {
|
||||||
|
if (!emb?.key || byKey.has(emb.key)) continue
|
||||||
|
const row = embeddedChildToIssue(parent.key, emb)
|
||||||
|
byKey.set(emb.key, row)
|
||||||
|
merged.push(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En cas de doublon de clé, garde l’entrée la plus riche (SP + nombre de champs) pour éviter un double comptage des SP. */
|
||||||
|
function dedupeIssuesByKey(list: JiraIssue[]): JiraIssue[] {
|
||||||
|
const map = new Map<string, JiraIssue>()
|
||||||
|
const score = (x: JiraIssue) => {
|
||||||
|
const n = Object.keys(x.fields as object).length
|
||||||
|
return getStoryPoints(x) * 10 + n
|
||||||
|
}
|
||||||
|
for (const i of list) {
|
||||||
|
const prev = map.get(i.key)
|
||||||
|
if (!prev || score(i) >= score(prev)) map.set(i.key, i)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderStory(key: string, parent?: JiraIssue['fields']['parent']): JiraIssue {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
fields: {
|
||||||
|
summary: parent?.fields?.summary ?? `Story ${key}`,
|
||||||
|
status: { name: '—' },
|
||||||
|
issuetype: { name: 'Story', subtask: false },
|
||||||
|
parent: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattache les tickets « enfants » au parent présent dans le lot :
|
||||||
|
* - **Sous-tâche Jira** (`Sub-task` / `Sous-tâche`…) → parent (même hors lot : placeholder).
|
||||||
|
* - **Tâche, Bug, autre** dont le `parent` est une **autre issue du même résultat** et **≠ épopée** → rangée sous ce parent (ex. tâche liée à un Récit).
|
||||||
|
* Les tickets dont le seul parent est l’épopée (`MIGRATION_EPIC_KEY`) restent des cartes racine (un groupe = une ligne métier).
|
||||||
|
*/
|
||||||
|
export function groupSubtasksUnderStories(issues: JiraIssue[]): StoryGroup[] {
|
||||||
|
issues = dedupeIssuesByKey(mergeEmbeddedSubtasksFromParents(issues))
|
||||||
|
const idToKey = buildIssueIdToKeyMap(issues)
|
||||||
|
const byKey = new Map(issues.map((i) => [i.key, i]))
|
||||||
|
const epicKey = MIGRATION_EPIC_KEY
|
||||||
|
|
||||||
|
const childrenByParent = new Map<string, JiraIssue[]>()
|
||||||
|
const nestedIssueKeys = new Set<string>()
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const parentKey = resolveParentIssueKey(issue, idToKey)
|
||||||
|
if (!parentKey) continue
|
||||||
|
|
||||||
|
const parentInBatch = byKey.has(parentKey)
|
||||||
|
const parentIsEpic = parentKey === epicKey
|
||||||
|
const sub = isJiraSubtask(issue)
|
||||||
|
|
||||||
|
const nestUnderParentInBatch = parentInBatch && !parentIsEpic
|
||||||
|
/** Sous-tâche Jira dont le parent n’est pas dans ce lot (ex. story hors JQL) : groupe placeholder. */
|
||||||
|
const nestSubtaskPlaceholder = sub && !parentInBatch
|
||||||
|
|
||||||
|
if (!nestUnderParentInBatch && !nestSubtaskPlaceholder) continue
|
||||||
|
|
||||||
|
nestedIssueKeys.add(issue.key)
|
||||||
|
if (!childrenByParent.has(parentKey)) childrenByParent.set(parentKey, [])
|
||||||
|
childrenByParent.get(parentKey)!.push(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = issues.filter((i) => !nestedIssueKeys.has(i.key))
|
||||||
|
|
||||||
|
const groups = new Map<string, StoryGroup>()
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
groups.set(root.key, {
|
||||||
|
story: root,
|
||||||
|
subtasks: dedupeIssuesByKey([...(childrenByParent.get(root.key) ?? [])]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [parentKey, list] of childrenByParent) {
|
||||||
|
if (!groups.has(parentKey)) {
|
||||||
|
groups.set(parentKey, {
|
||||||
|
story: placeholderStory(parentKey, list[0]?.fields.parent),
|
||||||
|
subtasks: dedupeIssuesByKey([...list]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
g.subtasks = dedupeIssu
|
||||||
64
src/lib/jiraFieldExtractors.ts
Normal file
64
src/lib/jiraFieldExtractors.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve'
|
||||||
|
|
||||||
|
/** ID du champ Story Points (souvent `customfield_10028` — à vérifier dans Jira). */
|
||||||
|
export function getStoryPointsFieldId(): string {
|
||||||
|
return import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028'
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceNumber(v: unknown): number | null {
|
||||||
|
if (v == null || v === '') return null
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
if (typeof v === 'object' && v !== null && 'value' in v) {
|
||||||
|
return coerceNumber((v as { value: unknown }).value)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Story Points bruts depuis le champ custom Jira (nombre, chaîne, ou `{ value }`). */
|
||||||
|
export function getStoryPoints(issue: JiraIssue): number {
|
||||||
|
const id = getStoryPointsFieldId()
|
||||||
|
const raw = (issue.fields as Record<string, unknown>)[id]
|
||||||
|
const n = coerceNumber(raw)
|
||||||
|
return n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reste « en unités » comme dans ton export : secondes / 27 000
|
||||||
|
* (27 000 s ≈ 7,5 h — une journée-type Atlassian).
|
||||||
|
*/
|
||||||
|
export function getRemainingEstimateUnits(issue: JiraIssue): number {
|
||||||
|
const sec = issue.fields.timetracking?.remainingEstimateSeconds
|
||||||
|
if (sec == null || !Number.isFinite(sec)) return 0
|
||||||
|
const v = sec / 27000
|
||||||
|
return Number.isFinite(v) ? v : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Objet plat proche de ton ancien `issues.map` + clé parent résolue pour le debug. */
|
||||||
|
export function toTicketRow(issue: JiraIssue, allIssues: JiraIssue[]): {
|
||||||
|
key: string
|
||||||
|
summary: string
|
||||||
|
sp: number
|
||||||
|
remaining: number
|
||||||
|
status: string
|
||||||
|
issuetype: string
|
||||||
|
assignee: string
|
||||||
|
parentKey?: string
|
||||||
|
} {
|
||||||
|
const idToKey = buildIssueIdToKeyMap(allIssues)
|
||||||
|
const parentKey = resolveParentIssueKey(issue, idToKey)
|
||||||
|
return {
|
||||||
|
key: issue.key,
|
||||||
|
summary: issue.fields.summary,
|
||||||
|
sp: getStoryPoints(issue),
|
||||||
|
remaining: getRemainingEstimateUnits(issue),
|
||||||
|
status: issue.fields.status?.name ?? 'Inconnu',
|
||||||
|
issuetype: issue.fields.issuetype?.name ?? 'Inconnu',
|
||||||
|
assignee: issue.fields.assignee?.displayName ?? 'Inconnu',
|
||||||
|
...(parentKey ? { parentKey } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/lib/jiraLinks.ts
Normal file
7
src/lib/jiraLinks.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** URL « Ouvrir dans Jira » : `VITE_JIRA_BROWSE_BASE_URL` en priorité, sinon `JIRA_DOMAIN` via Vite (`__JIRA_ORIGIN__`). */
|
||||||
|
export function jiraBrowseIssueUrl(issueKey: string): string | null {
|
||||||
|
const explicit = import.meta.env.VITE_JIRA_BROWSE_BASE_URL?.trim().replace(/\/$/, '')
|
||||||
|
const fromEnv = explicit || __JIRA_ORIGIN__.trim().replace(/\/$/, '')
|
||||||
|
if (!fromEnv) return null
|
||||||
|
return `${fromEnv}/browse/${encodeURIComponent(issueKey)}`
|
||||||
|
}
|
||||||
53
src/lib/laneDetection.ts
Normal file
53
src/lib/laneDetection.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { JiraIssue, JiraStatus } from '../types/jira'
|
||||||
|
|
||||||
|
export type WorkLane = 'analyse' | 'design' | 'integration'
|
||||||
|
|
||||||
|
/** Regroupe les sous-tâches par « piste » Analyse / Design / Intégration (mots-clés + repli sur le statut). */
|
||||||
|
export function detectWorkLane(subtask: JiraIssue): WorkLane {
|
||||||
|
const s = subtask.fields.summary.toLowerCase()
|
||||||
|
if (
|
||||||
|
/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)
|
||||||
|
) {
|
||||||
|
return 'analyse'
|
||||||
|
}
|
||||||
|
if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) {
|
||||||
|
return 'design'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\bintégration\b|\bintegration\b|recette|développement|developpement|dev\b|golden|carbon|déploiement|deploy/i.test(s)
|
||||||
|
) {
|
||||||
|
return 'integration'
|
||||||
|
}
|
||||||
|
const st = subtask.fields.status.name.toLowerCase()
|
||||||
|
if (/analyse|spec|backlog|nouveau|à faire|todo|open/i.test(st)) return 'analyse'
|
||||||
|
if (/design|maquette|mockup/i.test(st)) return 'design'
|
||||||
|
return 'integration'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' {
|
||||||
|
const k = status.statusCategory?.key
|
||||||
|
if (k === 'new') return 'new'
|
||||||
|
if (k === 'done') return 'done'
|
||||||
|
if (k === 'indeterminate') return 'indeterminate'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */
|
||||||
|
export function laneAggregateState(
|
||||||
|
subtasks: JiraIssue[],
|
||||||
|
lane: WorkLane,
|
||||||
|
): 'empty' | 'grey' | 'blue' | 'green' {
|
||||||
|
const inLane = subtasks.filter((st) => detectWorkLane(st) === lane)
|
||||||
|
if (inLane.length === 0) return 'empty'
|
||||||
|
const cats = inLane.map((st) => statusCategoryKey(st.fields.status))
|
||||||
|
if (cats.every((c) => c === 'done')) return 'green'
|
||||||
|
if (cats.some((c) => c === 'indeterminate')) return 'blue'
|
||||||
|
if (cats.some((c) => c === 'done') && cats.some((c) => c !== 'done')) return 'blue'
|
||||||
|
const names = inLane.map((st) => st.fields.status.name.toLowerCase()).join(' ')
|
||||||
|
if (
|
||||||
|
/en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names)
|
||||||
|
) {
|
||||||
|
return 'blue'
|
||||||
|
}
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
32
src/lib/milestoneStatus.ts
Normal file
32
src/lib/milestoneStatus.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
import type { Milestone } from './dashboardConfig'
|
||||||
|
import { storyProgressPercent } from './storyMetrics'
|
||||||
|
|
||||||
|
/** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */
|
||||||
|
function storyCompletionForMilestone(g: StoryGroup): number {
|
||||||
|
if (g.subtasks.length === 0) return 100
|
||||||
|
return storyProgressPercent(g.subtasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(isoDate: string): Date {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00')
|
||||||
|
d.setHours(23, 59, 59, 999)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jalon en retard : date dépassée et au moins une story concernée n’est pas à 100 %. */
|
||||||
|
export function isMilestoneLate(m: Milestone, groups: StoryGroup[]): boolean {
|
||||||
|
if (groups.length === 0) return false
|
||||||
|
const deadline = endOfDay(m.date)
|
||||||
|
if (new Date() <= deadline) return false
|
||||||
|
const keys =
|
||||||
|
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
|
||||||
|
? m.linkedStoryKeys
|
||||||
|
: groups.map((g) => g.story.key)
|
||||||
|
for (const key of keys) {
|
||||||
|
const g = groups.find((x) => x.story.key === key)
|
||||||
|
if (!g) continue
|
||||||
|
if (storyCompletionForMilestone(g) < 100) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
39
src/lib/parentResolve.ts
Normal file
39
src/lib/parentResolve.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
|
||||||
|
/** Construit la table id numérique Jira → clé (DCC-xxx), indispensable quand `parent` n’a pas `key`. */
|
||||||
|
export function buildIssueIdToKeyMap(issues: JiraIssue[]): Map<string, string> {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.id) map.set(String(issue.id), issue.key)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentField = NonNullable<JiraIssue['fields']['parent']>
|
||||||
|
|
||||||
|
function parentKeyFromSelf(self: string): string | undefined {
|
||||||
|
const m = /\/rest\/api\/(?:\d+|latest)\/issue\/([^/?]+)/.exec(self)
|
||||||
|
if (m?.[1]) return m[1]
|
||||||
|
const m2 = /\/browse\/([^/?]+)/.exec(self)
|
||||||
|
if (m2?.[1]) return m2[1]
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résout la clé du parent : `parent.key`, sinon `id` → clé, sinon `parent.self`. */
|
||||||
|
export function resolveParentIssueKey(
|
||||||
|
issue: JiraIssue,
|
||||||
|
idToKey: Map<string, string>,
|
||||||
|
): string | undefined {
|
||||||
|
const p = issue.fields.parent as ParentField | undefined
|
||||||
|
if (!p) return undefined
|
||||||
|
if (typeof p === 'object' && p !== null && 'key' in p && p.key) return p.key
|
||||||
|
const rawId = (p as { id?: string | number }).id
|
||||||
|
if (rawId !== undefined && rawId !== null) {
|
||||||
|
const idStr = String(rawId)
|
||||||
|
const fromMap = idToKey.get(idStr)
|
||||||
|
if (fromMap) return fromMap
|
||||||
|
}
|
||||||
|
const self = (p as { self?: string }).self
|
||||||
|
if (typeof self === 'string' && self.length > 0) return parentKeyFromSelf(self)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
25
src/lib/priorityLabel.ts
Normal file
25
src/lib/priorityLabel.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
|
||||||
|
export type PriorityBand = 'Haute' | 'Moyenne' | 'Basse'
|
||||||
|
|
||||||
|
export function priorityBand(issue: JiraIssue): PriorityBand | null {
|
||||||
|
const raw = issue.fields.priority?.name?.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
const n = raw.toLowerCase()
|
||||||
|
if (/highest|critical|blocker|haute|maximale|p1/i.test(n)) return 'Haute'
|
||||||
|
if (/high|élev|eleve|major|p2/i.test(n)) return 'Haute'
|
||||||
|
if (/medium|moyen|normale|p3/i.test(n)) return 'Moyenne'
|
||||||
|
if (/low|lowest|mineur|faible|p4|p5/i.test(n)) return 'Basse'
|
||||||
|
return 'Moyenne'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function priorityBadgeClass(band: PriorityBand): string {
|
||||||
|
switch (band) {
|
||||||
|
case 'Haute':
|
||||||
|
return 'bg-rose-500/20 text-rose-100 ring-rose-400/50 shadow-[0_0_12px_rgba(244,63,94,0.35)]'
|
||||||
|
case 'Moyenne':
|
||||||
|
return 'bg-amber-500/15 text-amber-100 ring-amber-400/40 shadow-[0_0_10px_rgba(251,191,36,0.2)]'
|
||||||
|
case 'Basse':
|
||||||
|
return 'bg-slate-500/20 text-slate-200 ring-slate-400/35'
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/lib/statusPhase.ts
Normal file
96
src/lib/statusPhase.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { PhaseId } from '../types/jira'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajustez ce mapping pour refléter exactement vos 14 statuts Jira → 4 phases.
|
||||||
|
* Les clés sont comparées en insensible à la casse (trim).
|
||||||
|
*/
|
||||||
|
const STATUS_TO_PHASE: Record<string, PhaseId> = {
|
||||||
|
// Analyse
|
||||||
|
backlog: 'analyse',
|
||||||
|
nouveau: 'analyse',
|
||||||
|
new: 'analyse',
|
||||||
|
'à faire': 'analyse',
|
||||||
|
'a faire': 'analyse',
|
||||||
|
'to do': 'analyse',
|
||||||
|
todo: 'analyse',
|
||||||
|
open: 'analyse',
|
||||||
|
sélectionné: 'analyse',
|
||||||
|
selectionne: 'analyse',
|
||||||
|
'en attente': 'analyse',
|
||||||
|
'à analyser': 'analyse',
|
||||||
|
'a analyser': 'analyse',
|
||||||
|
analyse: 'analyse',
|
||||||
|
refinement: 'analyse',
|
||||||
|
|
||||||
|
// Design
|
||||||
|
spécification: 'design',
|
||||||
|
specification: 'design',
|
||||||
|
spec: 'design',
|
||||||
|
design: 'design',
|
||||||
|
maquette: 'design',
|
||||||
|
'design review': 'design',
|
||||||
|
'en design': 'design',
|
||||||
|
|
||||||
|
// Intégration
|
||||||
|
'prêt pour développement': 'integration',
|
||||||
|
'pret pour developpement': 'integration',
|
||||||
|
'ready for development': 'integration',
|
||||||
|
'ready for dev': 'integration',
|
||||||
|
'en cours': 'integration',
|
||||||
|
'in progress': 'integration',
|
||||||
|
développement: 'integration',
|
||||||
|
developpement: 'integration',
|
||||||
|
dev: 'integration',
|
||||||
|
'code review': 'integration',
|
||||||
|
review: 'integration',
|
||||||
|
'en test': 'integration',
|
||||||
|
test: 'integration',
|
||||||
|
qa: 'integration',
|
||||||
|
recette: 'integration',
|
||||||
|
'en recette': 'integration',
|
||||||
|
staging: 'integration',
|
||||||
|
bloqué: 'integration',
|
||||||
|
bloque: 'integration',
|
||||||
|
blocked: 'integration',
|
||||||
|
'en intégration': 'integration',
|
||||||
|
'en integration': 'integration',
|
||||||
|
|
||||||
|
// Terminé
|
||||||
|
terminé: 'done',
|
||||||
|
termine: 'done',
|
||||||
|
done: 'done',
|
||||||
|
closed: 'done',
|
||||||
|
resolved: 'done',
|
||||||
|
livré: 'done',
|
||||||
|
livre: 'done',
|
||||||
|
déployé: 'done',
|
||||||
|
deploye: 'done',
|
||||||
|
annulé: 'done',
|
||||||
|
annule: 'done',
|
||||||
|
cancelled: 'done',
|
||||||
|
canceled: 'done',
|
||||||
|
wontfix: 'done',
|
||||||
|
"won't fix": 'done',
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{M}/gu, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusToPhase(statusName: string): PhaseId {
|
||||||
|
const key = normalizeStatus(statusName)
|
||||||
|
return STATUS_TO_PHASE[key] ?? 'analyse'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASE_LABELS: Record<PhaseId, string> = {
|
||||||
|
analyse: 'Analyse',
|
||||||
|
design: 'Design',
|
||||||
|
integration: 'Intégration',
|
||||||
|
done: 'Terminé',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASE_ORDER: PhaseId[] = ['analyse', 'design', 'integration', 'done']
|
||||||
41
src/lib/storyMetrics.ts
Normal file
41
src/lib/storyMetrics.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||||
|
import { PHASE_ORDER, statusToPhase } from './statusPhase'
|
||||||
|
|
||||||
|
function phaseRank(p: PhaseId): number {
|
||||||
|
const i = PHASE_ORDER.indexOf(p)
|
||||||
|
return i >= 0 ? i : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (subtasks.length === 0) return 0
|
||||||
|
const sum = subtasks.reduce(
|
||||||
|
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* É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 {
|
||||||
|
if (subtasks.length === 0) return false
|
||||||
|
const stepIdx = phaseRank(stepPhase)
|
||||||
|
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepperStates(subtasks: JiraIssue[]): Record<PhaseId, boolean> {
|
||||||
|
return {
|
||||||
|
analyse: isStepComplete(subtasks, 'analyse'),
|
||||||
|
design: isStepComplete(subtasks, 'design'),
|
||||||
|
integration: isStepComplete(subtasks, 'integration'),
|
||||||
|
done: subtasks.length > 0 && subtasks.every((st) => statusToPhase(st.fields.status.name) === 'done'),
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/lib/subtaskUtils.ts
Normal file
11
src/lib/subtaskUtils.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type { JiraIssue } from '../types/jira'
|
||||||
|
|
||||||
|
/** L’API Jira n’expose pas toujours `issuetype.subtask` ; on complète par le nom du type. */
|
||||||
|
export function isJiraSubtask(issue: JiraIssue): boolean {
|
||||||
|
const t = issue.fields.issuetype
|
||||||
|
if (t.subtask === true) return true
|
||||||
|
const name = (t.name ?? '').toLowerCase()
|
||||||
|
return /sub-task|subtask|sous-tâche|sous-tache|sous tâche|sub task|technical task|tech task/i.test(
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/main.tsx
Normal file
7
src/main.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
// Sans StrictMode : en dev, React 18+ remontait l’effet deux fois et annulait le 1er fetch
|
||||||
|
// Jira (requête « canceled » dans Network alors que le 2e appel réussit).
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />)
|
||||||
84
src/types/jira.ts
Normal file
84
src/types/jira.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
export type PhaseId = 'analyse' | 'design' | 'integration' | 'done'
|
||||||
|
|
||||||
|
export type JiraIssueType = {
|
||||||
|
name: string
|
||||||
|
subtask?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraStatus = {
|
||||||
|
name: string
|
||||||
|
statusCategory?: {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraComponent = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraPriority = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraUser = {
|
||||||
|
accountId?: string
|
||||||
|
emailAddress?: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrls?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraParentRef = {
|
||||||
|
/** Parfois absent dans la recherche JQL « enhanced » : utiliser `id` + table id→clé. */
|
||||||
|
key?: string
|
||||||
|
id?: string
|
||||||
|
self?: string
|
||||||
|
fields?: { summary?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrées du champ `subtasks` renvoyées sur le parent (souvent compactes). */
|
||||||
|
export type JiraEmbeddedChildIssue = {
|
||||||
|
id?: string
|
||||||
|
key: string
|
||||||
|
self?: string
|
||||||
|
fields?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraTimeTracking = {
|
||||||
|
remainingEstimateSeconds?: number
|
||||||
|
originalEstimateSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraIssueFields = {
|
||||||
|
summary: string
|
||||||
|
status: JiraStatus
|
||||||
|
issuetype: JiraIssueType
|
||||||
|
/** Sous-tâches : parent de la story (clé et/ou id selon l’API). */
|
||||||
|
parent?: JiraParentRef
|
||||||
|
components?: JiraComponent[]
|
||||||
|
priority?: JiraPriority | null
|
||||||
|
assignee?: JiraUser | null
|
||||||
|
timetracking?: JiraTimeTracking
|
||||||
|
/** Si demandé dans `fields` : sous-tâches sous le parent (à fusionner dans le lot si absentes en racine). */
|
||||||
|
subtasks?: JiraEmbeddedChildIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JiraIssue = {
|
||||||
|
/** Identifiant numérique Jira (requis pour résoudre parent sans `key`). */
|
||||||
|
id?: string
|
||||||
|
key: string
|
||||||
|
fields: JiraIssueFields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse de POST/GET `/rest/api/3/search/jql` (recherche JQL « enhanced »). */
|
||||||
|
export type JiraSearchJqlResponse = {
|
||||||
|
issues: JiraIssue[]
|
||||||
|
isLast?: boolean
|
||||||
|
nextPageToken?: string
|
||||||
|
/** Présent sur certaines versions / extensions du schéma */
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoryGroup = {
|
||||||
|
story: JiraIssue
|
||||||
|
subtasks: JiraIssue[]
|
||||||
|
}
|
||||||
21
src/vite-env.d.ts
vendored
Normal file
21
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_JIRA_BASE_URL?: string
|
||||||
|
/** URL du site Jira pour les liens `/browse/KEY` (prod ou si `JIRA_DOMAIN` absent au build). */
|
||||||
|
readonly VITE_JIRA_BROWSE_BASE_URL?: string
|
||||||
|
/** Optionnel : accountId Atlassian pour le filtre « Ma vue » (prioritaire sur l’e-mail). */
|
||||||
|
readonly VITE_MY_JIRA_ACCOUNT_ID?: string
|
||||||
|
/** Optionnel : si l’API expose l’e-mail de l’assigné (RGPD). */
|
||||||
|
readonly VITE_MY_JIRA_EMAIL?: string
|
||||||
|
/** Champ Jira des Story Points (ex. customfield_10028). */
|
||||||
|
readonly VITE_JIRA_STORY_POINTS_FIELD?: string
|
||||||
|
/** Clé de l’épopée : insérée dans le JQL (`parentEpic`, `key NOT IN`) et pour le groupement UI. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
63
vite.config.js
Normal file
63
vite.config.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
/** @param {string | undefined} domain */
|
||||||
|
function jiraOrigin(domain) {
|
||||||
|
if (!domain?.trim()) return null
|
||||||
|
const d = domain.trim().replace(/\/$/, '')
|
||||||
|
if (d.startsWith('http://') || d.startsWith('https://')) return d
|
||||||
|
if (d.includes('atlassian.net')) return `https://${d.replace(/^https?:\/\//, '')}`
|
||||||
|
return `https://${d}.atlassian.net`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const origin = jiraOrigin(env.JIRA_DOMAIN)
|
||||||
|
|
||||||
|
return {
|
||||||
|
define: {
|
||||||
|
__JIRA_ORIGIN__: JSON.stringify(origin ?? ''),
|
||||||
|
},
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: origin
|
||||||
|
? {
|
||||||
|
'/jira-api': {
|
||||||
|
target: origin,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/jira-api/, ''),
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
// Évite qu’un Authorization invalide (extension, cache) écrase le Basic Jira
|
||||||
|
proxyReq.removeHeader('authorization')
|
||||||
|
proxyReq.removeHeader('cookie')
|
||||||
|
|
||||||
|
// Jira Cloud : contrôle XSRF — il faut Origin OU X-Requested-With.
|
||||||
|
// Le navigateur → Vite est souvent same-origin sans en-tête Origin ; sans cela, 403.
|
||||||
|
proxyReq.setHeader('X-Requested-With', 'XMLHttpRequest')
|
||||||
|
proxyReq.setHeader('Origin', origin)
|
||||||
|
proxyReq.setHeader('Referer', `${origin}/`)
|
||||||
|
|
||||||
|
const email = env.JIRA_EMAIL?.trim()
|
||||||
|
const apiKey = env.JIRA_API_KEY?.trim()
|
||||||
|
if (email && apiKey) {
|
||||||
|
const basic = Buffer.from(`${email}:${apiKey}`, 'utf8').toString(
|
||||||
|
'base64',
|
||||||
|
)
|
||||||
|
proxyReq.setHeader('Authorization', `Basic ${basic}`)
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[vite] Proxy Jira : JIRA_EMAIL ou JIRA_API_KEY manquant dans .env — la requête partira sans authentification.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
proxyReq.setHeader('Accept', 'application/json')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user