init
This commit is contained in:
263
src/App.tsx
Normal file
263
src/App.tsx
Normal 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 s’applique
|
||||
qu’en <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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user