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