689 lines
27 KiB
TypeScript
689 lines
27 KiB
TypeScript
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 l’une 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 n’utiliser que la variable d’environnement, ou saisissez l’ID exact du
|
||
champ Sprint de votre projet Scrum.
|
||
</p>
|
||
</div>
|
||
<p className="mt-2 text-[10px] text-slate-500">
|
||
La date d’atterrissage 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 l’API)
|
||
</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 n’est 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, l’ancienne détection par mots dans le résumé et le statut s’applique. 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 d’impact 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>
|
||
)
|
||
}
|