Files
jira/src/components/DashboardSettingsModal.tsx
Bastien COIGNOUX 1813603bb3 gantt
2026-04-26 10:57:13 +02:00

689 lines
27 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 { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import {
exportConfigJson,
exportSynologyBackupJson,
GANTT_SPRINT_METRIC_OPTIONS,
mergeImportedConfig,
normalizeFunctionalGapsForSave,
parseGanttNonWorkingDatesFromText,
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[] {
return raw
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
}
const BUCKET_FIELD_DEFS: {
key: keyof StatusBucketConfig
title: string
hint: string
}[] = [
{ key: 'todo', title: 'À faire', hint: 'Ex. À faire, Open, Backlog' },
{ key: 'in_progress', title: 'En cours', hint: 'Ex. In Progress, Code Review, Recette' },
{ key: 'blocked', title: 'Bloqué', hint: 'Ex. Bloqué, Blocked' },
{ key: 'done', title: 'Terminé', hint: 'Ex. Done, Terminé, Closed, Livré' },
{ key: 'cancel', title: 'Annulé', hint: 'Ex. Annulé, Cancelled, Won\'t fix' },
]
const LANE_LABEL_FIELD_DEFS: {
key: keyof LaneLabelsConfig
title: string
hint: string
}[] = [
{
key: 'analyse',
title: 'Piste Analyse',
hint: 'Étiquettes Jira comptées comme Analyse (ex. analyse). Une par ligne ou virgules.',
},
{
key: 'design',
title: 'Piste Design',
hint: 'Ex. design, maquette (si vous utilisez ces étiquettes en Jira).',
},
{
key: 'integration',
title: 'Piste Intégration',
hint: 'Ex. integration — ou tout libellé détiquette utilisé côté dev / recette.',
},
{
key: 'blocked',
title: 'Marquage bloqué (étiquettes)',
hint: 'Ex. blocked : les tickets portant lune de ces étiquettes sont signalés comme bloqués par étiquette (liste et tooltips).',
},
]
type Props = {
open: boolean
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 {
return {
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `m-${Date.now()}`,
title: '',
date: new Date().toISOString().slice(0, 10),
linkedStoryKeys: [],
critical: false,
kind: 'generic',
expectedActions: undefined,
}
}
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<HTMLDialogElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const [draft, setDraft] = useState<DashboardConfig>(config)
const [ganttNonWorkInput, setGanttNonWorkInput] = useState('')
useEffect(() => {
if (open) setDraft(config)
}, [open, config])
const configNonWorkKey = config.ganttNonWorkingDates.join('|')
useEffect(() => {
if (!open) return
setGanttNonWorkInput(config.ganttNonWorkingDates.join('\n'))
}, [open, configNonWorkKey])
useEffect(() => {
const el = dialogRef.current
if (!el) return
if (open) {
if (!el.open) el.showModal()
} else if (el.open) {
el.close()
}
}, [open])
const updateMilestone = (id: string, patch: Partial<Milestone>) => {
setDraft((d) => ({
...d,
milestones: d.milestones.map((m) => (m.id === id ? { ...m, ...patch } : m)),
}))
}
const removeMilestone = (id: string) => {
setDraft((d) => ({ ...d, milestones: d.milestones.filter((m) => m.id !== id) }))
}
const addMilestone = () => {
setDraft((d) => ({ ...d, milestones: [...d.milestones, newMilestone()] }))
}
const onImportFile = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result)) as unknown
const merged = mergeImportedConfig(draft, parsed)
if (merged) {
setDraft(merged)
setGanttNonWorkInput(merged.ganttNonWorkingDates.join('\n'))
} else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
} catch {
alert('Impossible de lire ce fichier JSON.')
}
e.target.value = ''
}
reader.readAsText(file)
}
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="max-h-[90vh] w-[min(100%,520px)] overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/95 p-0 text-slate-200 shadow-2xl backdrop:bg-black/70"
onClose={onClose}
onCancel={(e) => {
e.preventDefault()
onClose()
}}
>
<div className="border-b border-white/10 px-5 py-4">
<h2 id={titleId} className="text-lg font-semibold text-white">
Configuration dashboard
</h2>
<p className="mt-1 text-xs text-slate-500">
Sauvegarde locale (navigateur). Exportez le JSON pour le versionner sur votre Synology.
</p>
</div>
<div className="space-y-5 px-5 py-4">
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-amber-200/90">
Capacité & reporting
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<div>
<label className="text-[10px] uppercase text-slate-500">Effectif actif</label>
<input
type="number"
min={0.25}
step={0.25}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.teamCapacity}
onChange={(e) =>
setDraft((d) => ({
...d,
teamCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
}))
}
/>
</div>
<div>
<label className="text-[10px] uppercase text-slate-500">Baseline vélocité</label>
<input
type="number"
min={0.25}
step={0.25}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.baselineCapacity}
onChange={(e) =>
setDraft((d) => ({
...d,
baselineCapacity: Math.max(0.25, Number(e.target.value) || 0.25),
}))
}
/>
</div>
<div>
<label className="text-[10px] uppercase text-slate-500">WIP / pers.</label>
<input
type="number"
min={1}
step={1}
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm outline-none"
value={draft.wipSlotsPerDev}
onChange={(e) =>
setDraft((d) => ({
...d,
wipSlotsPerDev: Math.max(1, Math.floor(Number(e.target.value) || 1)),
}))
}
/>
</div>
</div>
<div className="mt-3">
<label className="text-[10px] uppercase text-slate-500">
Champ Sprint Jira (ID customfield)
</label>
<input
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-xs outline-none"
value={draft.sprintFieldId ?? ''}
onChange={(e) =>
setDraft((d) => ({
...d,
sprintFieldId: e.target.value.trim() || undefined,
}))
}
placeholder="ex. customfield_10020 (vide = utiliser VITE_JIRA_SPRINT_FIELD ou désactiver)"
/>
<p className="mt-1 text-[10px] text-slate-600">
Laissez vide pour nutiliser que la variable denvironnement, ou saisissez lID exact du
champ Sprint de votre projet Scrum.
</p>
</div>
<p className="mt-2 text-[10px] text-slate-500">
La date datterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
nominaux pour la jauge « Ressources ».
</p>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
Mon accountId Jira (recommandé pour « Ma vue »)
</label>
<input
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none ring-cyan-500/30 focus:ring-2"
value={draft.myJiraAccountId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, myJiraAccountId: e.target.value || undefined }))}
placeholder="ex. 5b10a2844c20165700ede21g"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wide text-slate-500">
Mon e-mail Jira (alternative si visible dans lAPI)
</label>
<input
type="email"
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none ring-cyan-500/30 focus:ring-2"
value={draft.myJiraEmail ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, myJiraEmail: e.target.value || undefined }))}
placeholder="vous@entreprise.com"
/>
</div>
<div className="rounded-xl border border-violet-500/25 bg-violet-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-violet-200/90">
Cartographie des statuts Jira
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Libellés exacts des statuts dans Jira (un par ligne ou séparés par des virgules). La
comparaison ignore casse et accents. Si un statut nest dans aucune liste, la catégorie
Jira (nouveau, en cours, terminé) sert de secours. Les listes vides au prochain
chargement reprennent les valeurs par défaut.
</p>
<div className="mt-3 space-y-3">
{BUCKET_FIELD_DEFS.map(({ key, title, hint }) => (
<div key={key}>
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
{title}
</label>
<p className="text-[10px] text-slate-600">{hint}</p>
<textarea
rows={3}
spellCheck={false}
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
value={draft.statusBuckets[key].join('\n')}
onChange={(e) =>
setDraft((d) => ({
...d,
statusBuckets: { ...d.statusBuckets, [key]: parseBucketLines(e.target.value) },
}))
}
/>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-cyan-500/25 bg-cyan-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200/90">
Étiquettes Jira (pistes & bloqué)
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Les sous-tâches sont classées en Analyse / Design / Intégration si elles portent au moins
une des étiquettes listées (ordre de priorité : Analyse, puis Design, puis Intégration).
Sinon, lancienne détection par mots dans le résumé et le statut sapplique. Les listes
vides au prochain chargement reprennent les valeurs par défaut (analyse, design,
integration, blocked).
</p>
<div className="mt-3 space-y-3">
{LANE_LABEL_FIELD_DEFS.map(({ key, title, hint }) => (
<div key={key}>
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-400">
{title}
</label>
<p className="text-[10px] text-slate-600">{hint}</p>
<textarea
rows={2}
spellCheck={false}
className="mt-1 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-cyan-500/20 focus:ring-1"
value={draft.laneLabels[key].join('\n')}
onChange={(e) =>
setDraft((d) => ({
...d,
laneLabels: { ...d.laneLabels, [key]: parseBucketLines(e.target.value) },
}))
}
/>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-indigo-500/25 bg-indigo-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-200/90">
Gantt & vue Sprint
</p>
<label className="mt-2 block text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Infos sous les barres (Gantt)
</label>
<select
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-2 text-sm text-slate-200 outline-none"
value={draft.ganttSprintRowMetric}
onChange={(e) =>
setDraft((d) => ({
...d,
ganttSprintRowMetric: e.target.value as GanttSprintRowMetric,
}))
}
>
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label className="mt-3 block text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
Jours non travaillés (Gantt)
</label>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Une date par ligne au format <span className="font-mono text-slate-400">AAAA-MM-JJ</span> (fuseau
local du navigateur). Même style que sam. / dim. : fériés, ponts, fermeture.
</p>
<textarea
rows={4}
spellCheck={false}
placeholder={'2026-05-01\n2026-05-08'}
className="mt-1.5 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-indigo-500/20 focus:ring-1"
value={ganttNonWorkInput}
onChange={(e) => setGanttNonWorkInput(e.target.value)}
/>
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
Sprints à masquer
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Sprints cochés : retirés du Gantt et du menu de la vue Sprint. Rechargez les données si la
liste est vide.
</p>
{boardSprints && boardSprints.length > 0 ? (
<ul className="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
{boardSprints.map((sp) => (
<li key={sp.id} className="flex items-start gap-2 text-xs text-slate-200">
<input
type="checkbox"
id={`ex-sp-${sp.id}`}
checked={draft.excludedSprintIds.includes(sp.id)}
onChange={(e) =>
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"
/>
<label htmlFor={`ex-sp-${sp.id}`} className="cursor-pointer leading-snug">
<span className="font-medium">{sp.name}</span>
<span className="ml-2 font-mono text-[10px] text-slate-500">#{sp.id}</span>
{sp.state ? (
<span className="ml-2 text-[10px] uppercase text-slate-500">{sp.state}</span>
) : null}
</label>
</li>
))}
</ul>
) : (
<p className="mt-2 text-[11px] text-slate-500">
Aucun sprint en mémoire : actualisez le cockpit puis rouvrez les réglages.
</p>
)}
</div>
<div className="rounded-xl border border-rose-500/20 bg-rose-500/[0.06] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-rose-100/90">
Badges « gaps » (PO)
</span>
<button
type="button"
onClick={() =>
setDraft((d) => ({ ...d, functionalGaps: [...d.functionalGaps, newGapBadge()] }))
}
className="text-xs font-medium text-rose-200/90 hover:text-rose-100"
>
+ Ajouter
</button>
</div>
<p className="text-[10px] leading-relaxed text-slate-500">
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).
</p>
<ul className="mt-3 max-h-48 space-y-3 overflow-y-auto pr-1">
{draft.functionalGaps.map((g) => (
<li
key={g.id}
className="rounded-lg border border-white/10 bg-black/25 p-2 text-xs text-slate-200"
>
<div className="flex flex-wrap gap-2">
<input
className="min-w-[100px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1"
value={g.label}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, label: e.target.value } : x,
),
}))
}
placeholder="Libellé (ex. Panier)"
/>
<label className="flex cursor-pointer items-center gap-1.5 text-[11px] text-rose-100/90">
<input
type="checkbox"
checked={Boolean(g.criticalFlow)}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, criticalFlow: e.target.checked } : x,
),
}))
}
className="rounded border-rose-400/50"
/>
Flux critique
</label>
<button
type="button"
onClick={() =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.filter((x) => x.id !== g.id),
}))
}
className="text-[11px] text-rose-400 hover:text-rose-300"
>
Supprimer
</button>
</div>
<textarea
rows={2}
spellCheck={false}
className="mt-2 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 font-mono text-[11px] outline-none"
value={g.terms.join(', ')}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, terms: parseGapTerms(e.target.value) } : x,
),
}))
}
placeholder="panier, cart, basket…"
/>
</li>
))}
</ul>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
Jalons
</span>
<button
type="button"
onClick={addMilestone}
className="text-xs font-medium text-cyan-400 hover:text-cyan-300"
>
+ Ajouter
</button>
</div>
<ul className="max-h-52 space-y-3 overflow-y-auto pr-1">
{draft.milestones.map((m) => (
<li
key={m.id}
className="rounded-xl border border-white/10 bg-black/25 p-3 text-sm"
>
<input
className="mb-2 w-full rounded border border-white/10 bg-transparent px-2 py-1 text-sm text-white"
value={m.title}
onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
placeholder="ex. Fin design"
/>
<div className="mb-2">
<label className="text-[10px] uppercase text-slate-500">Type de jalon</label>
<select
className="mt-1 w-full rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
value={m.kind ?? 'generic'}
onChange={(e) =>
updateMilestone(m.id, { kind: e.target.value as MilestoneKind })
}
>
{MILESTONE_KIND_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} {opt.hint}
</option>
))}
</select>
</div>
<div className="mb-2">
<label className="text-[10px] uppercase text-slate-500">Actions attendues à cette date</label>
<textarea
rows={2}
spellCheck={false}
className="mt-1 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
value={m.expectedActions ?? ''}
onChange={(e) =>
updateMilestone(m.id, {
expectedActions: e.target.value.trim() ? e.target.value : undefined,
})
}
placeholder="Ex. Recette signée, doc runbook, passage en prod…"
/>
</div>
<label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90">
<input
type="checkbox"
checked={Boolean(m.critical)}
onChange={(e) => updateMilestone(m.id, { critical: e.target.checked })}
className="rounded border-amber-400/50"
/>
Jalon critique (alerte dimpact si retard)
</label>
<div className="mt-2 flex flex-wrap gap-2">
<input
type="date"
className="rounded border border-white/10 bg-transparent px-2 py-1 text-xs"
value={m.date}
onChange={(e) => updateMilestone(m.id, { date: e.target.value })}
/>
<input
className="min-w-[120px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1 text-xs"
value={(m.linkedStoryKeys ?? []).join(', ')}
onChange={(e) =>
updateMilestone(m.id, {
linkedStoryKeys: e.target.value
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="Stories (DCC-1, DCC-2) — vide = toutes les stories chargées"
/>
<button
type="button"
onClick={() => removeMilestone(m.id)}
className="text-xs text-rose-400 hover:text-rose-300"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-wrap gap-2 border-t border-white/10 pt-4">
<button
type="button"
onClick={() => exportConfigJson(draft)}
className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-100"
>
Exporter configuration (JSON)
</button>
<button
type="button"
onClick={() => exportSynologyBackupJson(draft)}
className="rounded-lg border border-emerald-400/35 bg-emerald-950/40 px-3 py-2 text-xs font-semibold text-emerald-50"
title="Enveloppe bundleVersion + exportedAt pour sauvegarde NAS / Docker Synology."
>
Bundle Synology (JSON)
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-xs font-semibold text-slate-200"
>
Importer JSON
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={onImportFile}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm text-slate-400 hover:text-white"
>
Annuler
</button>
<button
type="button"
onClick={() => {
onSave({
...draft,
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
ganttNonWorkingDates: parseGanttNonWorkingDatesFromText(ganttNonWorkInput),
})
onClose()
}}
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
>
Enregistrer
</button>
</div>
</dialog>
)
}