Files
jira/src/App.tsx
Bastien COIGNOUX 7cd2d6dc40 init
2026-04-24 07:41:55 +02:00

264 lines
10 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 { 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>
)
}