This commit is contained in:
Bastien COIGNOUX
2026-04-24 07:41:55 +02:00
commit 7cd2d6dc40
42 changed files with 4453 additions and 0 deletions

29
.env.example Normal file
View 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 dun proxy HTTPS vers Jira
# (le dev server injecte déjà lauth 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View 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 sapplique
quen <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
View File

@ -0,0 +1,153 @@
import axios from 'axios'
import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira'
/**
* Même périmètre quun 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) dissues 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

View 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>
)
}

View 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>
)
}

View 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 lAPI)
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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 sil 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
}

View 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
View 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
View 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 lentré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 nest 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

View 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
View 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
View 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'
}

View 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 nest 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
View File

@ -0,0 +1,39 @@
import type { JiraIssue } from '../types/jira'
/** Construit la table id numérique Jira → clé (DCC-xxx), indispensable quand `parent` na 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
View 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
View 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
View 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
/**
* % davancement 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
View File

@ -0,0 +1,11 @@
import type { JiraIssue } from '../types/jira'
/** LAPI Jira nexpose 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
View 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 leffet 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
View 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 lAPI). */
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
View 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 le-mail). */
readonly VITE_MY_JIRA_ACCOUNT_ID?: string
/** Optionnel : si lAPI expose le-mail de lassigné (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` (1100, 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
View 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
View 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 quun 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')
})
},
},
}
: {},
},
}
})