diff --git a/.env.example b/.env.example index f712bb4..d4844dc 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ JIRA_API_KEY= # Champ Sprint (Scrum) pour la vue Sprint — ID souvent proche de 10020 selon les instances # VITE_JIRA_SPRINT_FIELD=customfield_10020 +# Board logiciel DCC (URL …/boards/1445/) : sprints actifs/futurs via API Agile (sans élargir le JQL). +# 0 ou false = désactiver (liste déduite uniquement des tickets chargés). +# VITE_JIRA_BOARD_ID=1445 + # Clé de l’épopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI # VITE_JIRA_EPIC_KEY=DCC-5514 diff --git a/src/App.tsx b/src/App.tsx index 42b0b82..65e4b37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { StoryGroup } from './types/jira' -import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient' +import { + fetchAllIssuesByJql, + fetchBoardSprints, + MIGRATION_EPIC_KEY, + MIGRATION_JQL, + jiraClient, +} from './api/jiraClient' import { groupSubtasksUnderStories } from './lib/groupIssues' import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory' import { isIssueDone } from './lib/statusBuckets' @@ -15,9 +21,12 @@ import { } from './lib/executiveHealth' import { countSubtasksByPhase } from './lib/phaseAggregate' import { + exportSynologyBackupJson, loadDashboardConfig, + sanitizeGanttSprintRowMetric, saveDashboardConfig, type DashboardConfig, + type GanttSprintRowMetric, } from './lib/dashboardConfig' import { assigneeMatchesMyView } from './lib/assigneeMatch' import { isAxiosError } from 'axios' @@ -31,15 +40,18 @@ import { DashboardSettingsModal } from './components/DashboardSettingsModal' import { ManagementOverview } from './components/ManagementOverview' import { PhaseDistributionChart } from './components/PhaseDistributionChart' import { ExportDashboardButton } from './components/ExportDashboardButton' +import { MacroCockpitStrip } from './components/MacroCockpitStrip' import { StatusBucketProvider } from './context/StatusBucketContext' import { LaneLabelsProvider } from './context/LaneLabelsContext' import { PipelineOverview } from './components/PipelineOverview' import { LaneTicketsListView } from './components/LaneTicketsListView' import { ProjectTimelineView } from './components/ProjectTimelineView' +import { SprintGanttView } from './components/SprintGanttView' import { SprintView } from './components/SprintView' -import { resolveSprintFieldId } from './lib/jiraSprintField' +import { resolveJiraBoardId, resolveSprintFieldId } from './lib/jiraSprintField' +import type { JiraSprintSnapshot } from './lib/sprintExtract' -type ViewMode = 'list' | 'board' | 'project' | 'sprint' +type ViewMode = 'list' | 'board' | 'project' | 'gantt' | 'sprint' export default function App() { const dashboardRef = useRef(null) @@ -51,6 +63,7 @@ export default function App() { const [burnupData, setBurnupData] = useState(() => loadBurnupHistory()) const [dashboardCfg, setDashboardCfg] = useState(() => loadDashboardConfig()) const [settingsOpen, setSettingsOpen] = useState(false) + const [boardSprints, setBoardSprints] = useState([]) const statusBucketsRef = useRef(dashboardCfg.statusBuckets) statusBucketsRef.current = dashboardCfg.statusBuckets @@ -128,7 +141,24 @@ export default function App() { ], ) - const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups]) + const phaseCounts = useMemo( + () => countSubtasksByPhase(groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels), + [groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels], + ) + + const boardSprintsVisible = useMemo(() => { + const ex = new Set(dashboardCfg.excludedSprintIds) + return boardSprints.filter((s) => !ex.has(s.id)) + }, [boardSprints, dashboardCfg.excludedSprintIds]) + + const setGanttSprintRowMetric = useCallback((metric: GanttSprintRowMetric) => { + const m = sanitizeGanttSprintRowMetric(metric) + setDashboardCfg((prev) => { + const next: DashboardConfig = { ...prev, ganttSprintRowMetric: m } + saveDashboardConfig(next) + return next + }) + }, []) const toggleMyView = () => { const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive } @@ -146,9 +176,22 @@ export default function App() { setError(null) try { const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId }) - const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal, { - additionalFields: sprintField ? [sprintField] : [], - }) + const boardId = resolveJiraBoardId() + const sprintListPromise = + boardId != null + ? fetchBoardSprints(boardId, signal, ['active', 'future']).catch((err) => { + console.warn('[jira] Sprints board Agile (active/future) :', err) + return [] as JiraSprintSnapshot[] + }) + : Promise.resolve([] as JiraSprintSnapshot[]) + + const [issues, sprintsFromBoard] = await Promise.all([ + fetchAllIssuesByJql(MIGRATION_JQL, signal, { + additionalFields: sprintField ? [sprintField] : [], + }), + sprintListPromise, + ]) + setBoardSprints(sprintsFromBoard) const grouped = groupSubtasksUnderStories(issues) setGroups(grouped) setUpdatedAt(new Date()) @@ -177,6 +220,7 @@ export default function App() { setError(e instanceof Error ? e.message : 'Erreur inconnue') } setGroups([]) + setBoardSprints([]) } finally { if (!signal?.aborted) setLoading(false) } @@ -266,6 +310,18 @@ export default function App() { > Projet + + )} {updatedAt && !loading && ( @@ -329,20 +395,63 @@ export default function App() { {!loading && !error && groups.length > 0 && (
{view === 'project' ? ( - setSettingsOpen(true)} - /> +
+ + setSettingsOpen(true)} + /> +
+ ) : view === 'gantt' ? ( +
+ + setSettingsOpen(true)} + /> +
) : view === 'sprint' ? ( - setSettingsOpen(true)} - /> +
+ + setSettingsOpen(true)} + gapBadges={dashboardCfg.functionalGaps} + /> +
) : ( <> + + ))}
) : ( - + )} @@ -417,6 +531,7 @@ export default function App() { config={dashboardCfg} onClose={() => setSettingsOpen(false)} onSave={saveSettings} + boardSprints={boardSprints} /> diff --git a/src/api/jiraClient.ts b/src/api/jiraClient.ts index e2a6866..7c6a58c 100644 --- a/src/api/jiraClient.ts +++ b/src/api/jiraClient.ts @@ -1,5 +1,6 @@ import axios from 'axios' import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira' +import { coerceSprintObject, type JiraSprintSnapshot } from '../lib/sprintExtract' /** * Même périmètre qu’un filtre Jira type filter=25111 : tout ce qui est sous l’épopée @@ -155,3 +156,88 @@ export async function fetchAllIssuesByJql( return collected } + +type AgileSprintPage = { + values?: Record[] + isLast?: boolean +} + +/** + * Sprints d’un board logiciel (API Agile Jira), avec pagination. + * @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get + */ +export async function fetchBoardSprints( + boardId: number, + signal: AbortSignal | undefined, + states: ('active' | 'future' | 'closed')[], +): Promise { + const base = clientBaseUrl() + if (!base || !Number.isFinite(boardId) || boardId <= 0) return [] + + const stateParam = states.join(',') + const all: JiraSprintSnapshot[] = [] + let startAt = 0 + const maxResults = 50 + for (let page = 0; page < 40; page += 1) { + const { data } = await jiraClient.get( + `/rest/agile/1.0/board/${boardId}/sprint`, + { + params: { state: stateParam, startAt, maxResults }, + signal, + }, + ) + const values = data.values ?? [] + for (const row of values) { + if (row && typeof row === 'object') { + const s = coerceSprintObject(row as Record) + if (s) all.push(s) + } + } + if (values.length === 0) break + if (data.isLast === true) break + if (values.length < maxResults) break + startAt += values.length + } + return all +} + +type AgileSprintIssuesPage = { + issues?: { key?: string }[] + isLast?: boolean + maxResults?: number + startAt?: number +} + +/** + * Toutes les clés d’issues d’un sprint (API Agile), pour filtrer sans `customfield` Sprint. + * @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-sprint/#api-rest-agile-1-0-sprint-sprintid-issue-get + */ +export async function fetchAllIssueKeysInSprint( + sprintId: number, + signal: AbortSignal | undefined, +): Promise> { + const base = clientBaseUrl() + if (!base || !Number.isFinite(sprintId) || sprintId <= 0) return new Set() + + const keys = new Set() + let startAt = 0 + const maxResults = 100 + for (let page = 0; page < 100; page += 1) { + const { data } = await jiraClient.get( + `/rest/agile/1.0/sprint/${sprintId}/issue`, + { + params: { startAt, maxResults, fields: 'key' }, + signal, + }, + ) + const issues = data.issues ?? [] + for (const row of issues) { + if (row?.key && typeof row.key === 'string') keys.add(row.key) + } + if (issues.length === 0) break + if (data.isLast === true) break + if (issues.length < maxResults) break + startAt += issues.length + } + return keys +} diff --git a/src/components/BoardView.tsx b/src/components/BoardView.tsx index c3b61be..3bc34f1 100644 --- a/src/components/BoardView.tsx +++ b/src/components/BoardView.tsx @@ -1,13 +1,15 @@ import type { StoryGroup } from '../types/jira' +import type { FunctionalGapBadge } from '../lib/dashboardConfig' import { groupStoriesByComponent } from '../lib/boardGrouping' import { StoryCard } from './StoryCard' type Props = { groups: StoryGroup[] sprintFieldId?: string | null + gapBadges?: FunctionalGapBadge[] } -export function BoardView({ groups, sprintFieldId = null }: Props) { +export function BoardView({ groups, sprintFieldId = null, gapBadges }: Props) { const columns = groupStoriesByComponent(groups) return ( @@ -25,7 +27,13 @@ export function BoardView({ groups, sprintFieldId = null }: Props) {
{col.map((g) => ( - + ))}
diff --git a/src/components/DashboardSettingsModal.tsx b/src/components/DashboardSettingsModal.tsx index 8966b88..8162325 100644 --- a/src/components/DashboardSettingsModal.tsx +++ b/src/components/DashboardSettingsModal.tsx @@ -1,13 +1,20 @@ import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react' import { exportConfigJson, + exportSynologyBackupJson, + GANTT_SPRINT_METRIC_OPTIONS, mergeImportedConfig, + normalizeFunctionalGapsForSave, + sanitizeExcludedSprintIds, type DashboardConfig, + type FunctionalGapBadge, + type GanttSprintRowMetric, type LaneLabelsConfig, type Milestone, type MilestoneKind, type StatusBucketConfig, } from '../lib/dashboardConfig' +import type { JiraSprintSnapshot } from '../lib/sprintExtract' import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds' function parseBucketLines(raw: string): string[] { @@ -61,6 +68,8 @@ type Props = { config: DashboardConfig onClose: () => void onSave: (next: DashboardConfig) => void + /** Sprints board (API) pour masquage sélectif ; optionnel si pas encore chargés. */ + boardSprints?: JiraSprintSnapshot[] } function newMilestone(): Milestone { @@ -75,7 +84,26 @@ function newMilestone(): Milestone { } } -export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) { +function newGapBadge(): FunctionalGapBadge { + return { + id: + typeof crypto !== 'undefined' && crypto.randomUUID + ? `gap-${crypto.randomUUID().slice(0, 10)}` + : `gap-${Date.now()}`, + label: '', + terms: [''], + criticalFlow: false, + } +} + +function parseGapTerms(raw: string): string[] { + return raw + .split(/[,;]+/) + .map((s) => s.trim()) + .filter(Boolean) +} + +export function DashboardSettingsModal({ open, config, onClose, onSave, boardSprints }: Props) { const dialogRef = useRef(null) const fileRef = useRef(null) const titleId = useId() @@ -119,7 +147,7 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) 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).') + else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).') } catch { alert('Impossible de lire ce fichier JSON.') } @@ -322,6 +350,159 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) +
+

+ Gantt & vue Sprint +

+ + +

+ Sprints à masquer +

+

+ Sprints cochés : retirés du Gantt et du menu de la vue Sprint. Rechargez les données si la + liste est vide. +

+ {boardSprints && boardSprints.length > 0 ? ( +
    + {boardSprints.map((sp) => ( +
  • + + setDraft((d) => { + const next = new Set(d.excludedSprintIds) + if (e.target.checked) next.add(sp.id) + else next.delete(sp.id) + return { ...d, excludedSprintIds: [...next] } + }) + } + className="mt-0.5 rounded border-indigo-400/50" + /> + +
  • + ))} +
+ ) : ( +

+ Aucun sprint en mémoire : actualisez le cockpit puis rouvrez les réglages. +

+ )} +
+ +
+
+ + Badges « gaps » (PO) + + +
+

+ Termes cherchés dans clés, résumés et étiquettes (insensible casse / accents). Cochez + « flux critique » pour renforcer le feu rouge macro (Panier, Checkout…). +

+
    + {draft.functionalGaps.map((g) => ( +
  • +
    + + setDraft((d) => ({ + ...d, + functionalGaps: d.functionalGaps.map((x) => + x.id === g.id ? { ...x, label: e.target.value } : x, + ), + })) + } + placeholder="Libellé (ex. Panier)" + /> + + +
    +