This commit is contained in:
Bastien COIGNOUX
2026-04-24 21:08:34 +02:00
parent 19af51160a
commit 020f5d11de
22 changed files with 2032 additions and 71 deletions

View File

@ -1,28 +1,86 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { collectSprintOptions, filterGroupsBySprint } from '../lib/sprintExtract'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import {
collectSprintOptions,
filterGroupsBySprint,
filterGroupsBySprintIssueKeys,
sprintOptionsFromBoardAndGroups,
sprintOptionsFromBoardOnly,
} from '../lib/sprintExtract'
import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId: string | null
/** Sprints actifs/futurs depuis lAPI Agile du board (sans élargir le périmètre JQL). */
boardSprintsFromApi?: JiraSprintSnapshot[]
onOpenSettings: () => void
gapBadges?: FunctionalGapBadge[]
}
export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
export function SprintView({
groups,
sprintFieldId,
boardSprintsFromApi = [],
onOpenSettings,
gapBadges,
}: Props) {
const cfg = useStatusBuckets()
const options = useMemo(
() => collectSprintOptions(groups, sprintFieldId),
[groups, sprintFieldId],
)
const [focusId, setFocusId] = useState<number | 'all'>('all')
const [sprintIssueKeys, setSprintIssueKeys] = useState<Set<string>>(new Set())
const [sprintIssuesLoading, setSprintIssuesLoading] = useState(false)
const options = useMemo(() => {
if (boardSprintsFromApi.length > 0) {
if (sprintFieldId) {
const fromBoard = sprintOptionsFromBoardAndGroups(
boardSprintsFromApi,
groups,
sprintFieldId,
)
if (fromBoard.length > 0) return fromBoard
} else {
return sprintOptionsFromBoardOnly(boardSprintsFromApi)
}
}
if (sprintFieldId) return collectSprintOptions(groups, sprintFieldId)
return []
}, [boardSprintsFromApi, groups, sprintFieldId])
useEffect(() => {
if (sprintFieldId || focusId === 'all') {
setSprintIssueKeys(new Set())
setSprintIssuesLoading(false)
return
}
const sprintId = focusId as number
const ac = new AbortController()
setSprintIssuesLoading(true)
setSprintIssueKeys(new Set())
void fetchAllIssueKeysInSprint(sprintId, ac.signal)
.then((set) => {
if (!ac.signal.aborted) setSprintIssueKeys(set)
})
.catch(() => {
if (!ac.signal.aborted) setSprintIssueKeys(new Set())
})
.finally(() => {
if (!ac.signal.aborted) setSprintIssuesLoading(false)
})
return () => ac.abort()
}, [focusId, sprintFieldId])
const filtered = useMemo(() => {
if (!sprintFieldId || focusId === 'all') return groups
return filterGroupsBySprint(groups, focusId, sprintFieldId)
}, [groups, focusId, sprintFieldId])
if (focusId === 'all') return groups
if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
if (sprintIssuesLoading) return []
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
const stats = useMemo(() => {
let subs = 0
@ -39,20 +97,23 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
return { stories: filtered.length, subtasks: subs, avgPct }
}, [filtered, cfg])
if (!sprintFieldId) {
/** Aucune liste possible : ni board Agile, ni champ Sprint sur les tickets. */
const mustConfigureSprintField = options.length === 0 && !sprintFieldId && boardSprintsFromApi.length === 0
if (mustConfigureSprintField) {
return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Vue Sprint configuration requise
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90">
Indiquez lidentifiant du champ personnalisé Sprint Jira (souvent{' '}
Aucun sprint actif/futur na é reçu du board Agile, et le champ Sprint nest pas
renseigné. Indiquez lidentifiant du champ personnalisé Sprint (souvent{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">customfield_10020</code>) dans
les réglages ou via la variable{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code> dans
<code className="rounded bg-black/30 px-1 font-mono text-xs"> .env</code>, puis actualisez
les données. LID se trouve dans Jira : Administration Issues Champs personnalisés
Sprint.
les réglages ou via{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
) puis actualisez.
</p>
<button
type="button"
@ -72,16 +133,25 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint
</h2>
<p className="mt-3 text-sm text-slate-400">
Aucun sprint détecté sur les tickets chargés (story ou sous-tâches). Vérifiez que le
champ configuré est bien le champ Sprint Scrum, et que vos tickets sont affectés à un
sprint dans Jira.
Aucun sprint actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
</p>
</section>
)
}
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
return (
<section className="mb-10 space-y-6">
{filterModeAgile && (
<div className="rounded-xl border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs leading-relaxed text-sky-100/95">
Filtre sprint via lAPI Agile Jira (issues du sprint). Pour afficher les pastilles sprint
sur chaque carte, renseignez aussi le champ Sprint (ID{' '}
<code className="rounded bg-black/30 px-1 font-mono text-[11px]">customfield_</code>) dans
les réglages.
</div>
)}
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
@ -89,9 +159,18 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint
</h2>
<p className="mt-1 max-w-2xl text-xs text-slate-500">
Choisissez un sprint pour filtrer les stories (une story reste visible si la story ou
une sous-tâche est dans le sprint). Les pastilles sprint sur chaque carte proviennent
des données Jira.
{sprintFieldId ? (
<>
Sprints actifs/futurs du board Jira Agile ; le nombre de stories compte le
périmètre chargé (épopée) avec le champ Sprint sur les tickets.
</>
) : (
<>
Sprints actifs/futurs du board (API Agile). Le filtre par sprint interroge les
issues du sprint côté Jira ; le périmètre affiché reste celui de lépopée Golden
Carbon.
</>
)}
</p>
</div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
@ -110,7 +189,8 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
{options.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
{s.state ? ` (${s.state})` : ''} {s.storyCount} stories
{s.state ? ` (${s.state})` : ''}
{sprintFieldId ? `${s.storyCount} stories` : ''}
</option>
))}
</select>
@ -133,14 +213,24 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
</div>
</div>
{filtered.length === 0 ? (
{sprintIssuesLoading && focusId !== 'all' && !sprintFieldId ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story dans ce sprint pour le périmètre actuel (filtre « Ma vue » inclus).
Chargement des tickets du sprint
</p>
) : filtered.length === 0 ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story du périmètre dans ce sprint (filtre « Ma vue » inclus), ou erreur lors du
chargement des issues du sprint.
</p>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{filtered.map((g) => (
<StoryCard key={g.story.key} group={g} sprintFieldId={sprintFieldId} />
<StoryCard
key={g.story.key}
group={g}
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))}
</div>
)}