Files
jira/src/components/SprintView.tsx
Bastien COIGNOUX 020f5d11de gantt
2026-04-24 21:08:34 +02:00

240 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
import { useStatusBuckets } from '../context/StatusBucketContext'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import {
collectSprintOptions,
filterGroupsBySprint,
filterGroupsBySprintIssueKeys,
sprintOptionsFromBoardAndGroups,
sprintOptionsFromBoardOnly,
} from '../lib/sprintExtract'
import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId: string | null
/** Sprints actifs/futurs depuis lAPI Agile du board (sans élargir le périmètre JQL). */
boardSprintsFromApi?: JiraSprintSnapshot[]
onOpenSettings: () => void
gapBadges?: FunctionalGapBadge[]
}
export function SprintView({
groups,
sprintFieldId,
boardSprintsFromApi = [],
onOpenSettings,
gapBadges,
}: Props) {
const cfg = useStatusBuckets()
const [focusId, setFocusId] = useState<number | 'all'>('all')
const [sprintIssueKeys, setSprintIssueKeys] = useState<Set<string>>(new Set())
const [sprintIssuesLoading, setSprintIssuesLoading] = useState(false)
const options = useMemo(() => {
if (boardSprintsFromApi.length > 0) {
if (sprintFieldId) {
const fromBoard = sprintOptionsFromBoardAndGroups(
boardSprintsFromApi,
groups,
sprintFieldId,
)
if (fromBoard.length > 0) return fromBoard
} else {
return sprintOptionsFromBoardOnly(boardSprintsFromApi)
}
}
if (sprintFieldId) return collectSprintOptions(groups, sprintFieldId)
return []
}, [boardSprintsFromApi, groups, sprintFieldId])
useEffect(() => {
if (sprintFieldId || focusId === 'all') {
setSprintIssueKeys(new Set())
setSprintIssuesLoading(false)
return
}
const sprintId = focusId as number
const ac = new AbortController()
setSprintIssuesLoading(true)
setSprintIssueKeys(new Set())
void fetchAllIssueKeysInSprint(sprintId, ac.signal)
.then((set) => {
if (!ac.signal.aborted) setSprintIssueKeys(set)
})
.catch(() => {
if (!ac.signal.aborted) setSprintIssueKeys(new Set())
})
.finally(() => {
if (!ac.signal.aborted) setSprintIssuesLoading(false)
})
return () => ac.abort()
}, [focusId, sprintFieldId])
const filtered = useMemo(() => {
if (focusId === 'all') return groups
if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
if (sprintIssuesLoading) return []
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
const stats = useMemo(() => {
let subs = 0
let sumPct = 0
let nWithSubs = 0
for (const g of filtered) {
subs += g.subtasks.length
if (g.subtasks.length > 0) {
sumPct += subtaskDoneRatioPercent(g.subtasks, cfg)
nWithSubs += 1
}
}
const avgPct = nWithSubs > 0 ? Math.round(sumPct / nWithSubs) : 0
return { stories: filtered.length, subtasks: subs, avgPct }
}, [filtered, cfg])
/** Aucune liste possible : ni board Agile, ni champ Sprint sur les tickets. */
const mustConfigureSprintField = options.length === 0 && !sprintFieldId && boardSprintsFromApi.length === 0
if (mustConfigureSprintField) {
return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Vue Sprint configuration requise
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90">
Aucun sprint actif/futur 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{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
) puis actualisez.
</p>
<button
type="button"
onClick={onOpenSettings}
className="mt-5 rounded-lg border border-cyan-500/50 bg-cyan-500/15 px-4 py-2 text-sm font-medium text-cyan-100"
>
Ouvrir les réglages
</button>
</section>
)
}
if (options.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Vue Sprint
</h2>
<p className="mt-3 text-sm text-slate-400">
Aucun sprint actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
</p>
</section>
)
}
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
return (
<section className="mb-10 space-y-6">
{filterModeAgile && (
<div className="rounded-xl border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs leading-relaxed text-sky-100/95">
Filtre sprint via 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>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Vue Sprint
</h2>
<p className="mt-1 max-w-2xl text-xs text-slate-500">
{sprintFieldId ? (
<>
Sprints actifs/futurs du board Jira Agile ; le nombre de stories compte le
périmètre chargé (épopée) avec le champ Sprint sur les tickets.
</>
) : (
<>
Sprints actifs/futurs du board (API Agile). Le filtre par sprint interroge les
issues du sprint côté Jira ; le périmètre affiché reste celui de lépopée Golden
Carbon.
</>
)}
</p>
</div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-500">
Sprint ciblé
</label>
<select
value={focusId === 'all' ? 'all' : String(focusId)}
onChange={(e) => {
const v = e.target.value
setFocusId(v === 'all' ? 'all' : Number(v))
}}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 outline-none ring-cyan-500/20 focus:ring-2"
>
<option value="all">Tous les sprints ({groups.length} stories)</option>
{options.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
{s.state ? ` (${s.state})` : ''}
{sprintFieldId ? `${s.storyCount} stories` : ''}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-4 border-t border-white/[0.06] pt-4 text-sm text-slate-400">
<span>
Stories :{' '}
<span className="font-mono font-semibold text-slate-200">{stats.stories}</span>
</span>
<span>
Sous-tâches :{' '}
<span className="font-mono font-semibold text-slate-200">{stats.subtasks}</span>
</span>
<span title="Moyenne des % sous-tâches terminées sur les stories qui ont des sous-tâches">
Avancement moy. :{' '}
<span className="font-mono font-semibold text-emerald-300">{stats.avgPct}%</span>
</span>
</div>
</div>
{sprintIssuesLoading && focusId !== 'all' && !sprintFieldId ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Chargement des tickets du sprint
</p>
) : filtered.length === 0 ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story du périmètre dans ce sprint (filtre « Ma vue » inclus), ou erreur lors du
chargement des issues du sprint.
</p>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{filtered.map((g) => (
<StoryCard
key={g.story.key}
group={g}
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))}
</div>
)}
</section>
)
}