This commit is contained in:
Bastien COIGNOUX
2026-04-24 07:41:55 +02:00
commit 7cd2d6dc40
42 changed files with 4453 additions and 0 deletions

263
src/App.tsx Normal file
View File

@ -0,0 +1,263 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { StoryGroup } from './types/jira'
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient'
import { groupSubtasksUnderStories } from './lib/groupIssues'
import { statusToPhase } from './lib/statusPhase'
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
import {
loadDashboardConfig,
saveDashboardConfig,
type DashboardConfig,
} from './lib/dashboardConfig'
import { assigneeMatchesMyView } from './lib/assigneeMatch'
import { isAxiosError } from 'axios'
import { ExecutiveSummary } from './components/ExecutiveSummary'
import { BurnupChart } from './components/BurnupChart'
import { StoryCard } from './components/StoryCard'
import { BoardView } from './components/BoardView'
import { DashboardSkeleton } from './components/DashboardSkeleton'
import { MilestonesTimeline } from './components/MilestonesTimeline'
import { DashboardSettingsModal } from './components/DashboardSettingsModal'
type ViewMode = 'list' | 'board'
export default function App() {
const [groups, setGroups] = useState<StoryGroup[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
const [view, setView] = useState<ViewMode>('list')
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
const [settingsOpen, setSettingsOpen] = useState(false)
const myViewActive = Boolean(dashboardCfg.myViewActive)
const displayGroups = useMemo(() => {
if (!myViewActive) return groups
return groups.filter((g) =>
g.subtasks.some((st) => assigneeMatchesMyView(st, dashboardCfg)),
)
}, [groups, myViewActive, dashboardCfg])
const toggleMyView = () => {
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
setDashboardCfg(next)
saveDashboardConfig(next)
}
const saveSettings = (next: DashboardConfig) => {
setDashboardCfg(next)
saveDashboardConfig(next)
}
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true)
setError(null)
try {
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal)
const grouped = groupSubtasksUnderStories(issues)
setGroups(grouped)
setUpdatedAt(new Date())
const totalSubs = grouped.reduce((acc, g) => acc + g.subtasks.length, 0)
const doneSubs = grouped.reduce(
(acc, g) =>
acc +
g.subtasks.filter((s) => statusToPhase(s.fields.status.name) === 'done').length,
0,
)
setBurnupData(appendBurnupSnapshot(doneSubs, totalSubs))
} catch (e) {
if (signal?.aborted || (isAxiosError(e) && e.code === 'ERR_CANCELED')) {
return
}
if (isAxiosError(e)) {
const msg =
typeof e.response?.data === 'object' &&
e.response.data !== null &&
'errorMessages' in e.response.data &&
Array.isArray((e.response.data as { errorMessages: string[] }).errorMessages)
? (e.response.data as { errorMessages: string[] }).errorMessages.join(' ')
: e.message
setError(msg || 'Erreur réseau Jira')
} else {
setError(e instanceof Error ? e.message : 'Erreur inconnue')
}
setGroups([])
} finally {
if (!signal?.aborted) setLoading(false)
}
}, [])
useEffect(() => {
const ac = new AbortController()
void load(ac.signal)
return () => ac.abort()
}, [load])
const baseOk = import.meta.env.DEV ? true : Boolean(jiraClient.defaults.baseURL)
return (
<div className="min-h-screen px-4 pb-20 pt-8 sm:px-6 md:px-8 md:pt-10">
<header className="mx-auto mb-6 flex max-w-7xl flex-col gap-4 lg:mb-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-cyan-300/90">
OroCommerce · Migration exécutive
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight text-white md:text-4xl">
Pilotage migration
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-400">
Périmètre DCC sous lépopée{' '}
<span className="font-mono text-slate-200">{MIGRATION_EPIC_KEY}</span> (JQL{' '}
<span className="font-mono text-slate-400">parentEpic</span>, sous-tâches incluses),
parent résolu (clé ou id), jalons, export JSON pour Synology.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggleMyView}
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition ${
myViewActive
? 'border-cyan-400/60 bg-cyan-500/20 text-cyan-50 shadow-[0_0_16px_rgba(34,211,238,0.25)]'
: 'border-white/10 bg-slate-950/50 text-slate-300 hover:border-white/20 hover:text-white'
}`}
title="Stories avec au moins une sous-tâche qui vous est assignée (accountId ou e-mail configurés)."
>
Ma vue
</button>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="rounded-xl border border-white/10 bg-slate-950/50 px-4 py-2.5 text-sm font-medium text-slate-300 transition hover:border-white/20 hover:text-white"
>
Réglages
</button>
</div>
<div className="flex rounded-xl border border-white/10 bg-slate-950/50 p-1 backdrop-blur-md">
<button
type="button"
onClick={() => setView('list')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'list'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
>
Liste
</button>
<button
type="button"
onClick={() => setView('board')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'board'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
>
Board
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
{updatedAt && !loading && (
<span className="text-xs text-slate-500">
Mis à jour {updatedAt.toLocaleTimeString('fr-FR')}
</span>
)}
<button
type="button"
onClick={() => void load(undefined)}
disabled={loading}
className="inline-flex items-center justify-center rounded-xl bg-cyan-500 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/25 transition hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? 'Chargement…' : 'Actualiser'}
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl">
{!baseOk && (
<div className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
Configurez <code className="rounded bg-black/30 px-1">VITE_JIRA_BASE_URL</code> pour
un build de production pointant vers votre proxy HTTPS (le proxy Vite ne sapplique
quen <code className="rounded bg-black/30 px-1">npm run dev</code>).
</div>
)}
{error && (
<div
className="mb-6 rounded-xl border border-rose-500/40 bg-rose-950/40 px-4 py-3 text-sm text-rose-100"
role="alert"
>
{error}
</div>
)}
{loading && <DashboardSkeleton />}
{!loading && !error && groups.length === 0 && (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-10 text-center text-slate-400 backdrop-blur-md">
Aucun ticket ne correspond au JQL actuel.
</p>
)}
{!loading && !error && groups.length > 0 && (
<>
<MilestonesTimeline
milestones={dashboardCfg.milestones}
groups={groups}
onOpenSettings={() => setSettingsOpen(true)}
/>
<ExecutiveSummary groups={displayGroups} />
<section className="mb-10">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Burnup
</h2>
<BurnupChart data={burnupData} />
</section>
<section>
<div className="mb-4 flex flex-wrap items-end justify-between gap-2">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
{view === 'list' ? 'Stories (liste)' : 'Stories par composant'}
</h2>
{myViewActive && (
<span className="text-xs text-cyan-300/90">
Filtre actif : {displayGroups.length} / {groups.length} stories
</span>
)}
</div>
{displayGroups.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 backdrop-blur-md">
Aucune story ne correspond à « Ma vue ». Vérifiez vos assignations ou configurez
votre accountId / e-mail dans les réglages.
</p>
) : view === 'list' ? (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{displayGroups.map((g) => (
<StoryCard key={g.story.key} group={g} />
))}
</div>
) : (
<BoardView groups={displayGroups} />
)}
</section>
</>
)}
</main>
<DashboardSettingsModal
open={settingsOpen}
config={dashboardCfg}
onClose={() => setSettingsOpen(false)}
onSave={saveSettings}
/>
</div>
)
}