gantt
This commit is contained in:
@ -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 l’API 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 l’identifiant du champ personnalisé Sprint Jira (souvent{' '}
|
||||
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 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. L’ID 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 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>
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user