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(null) const fileRef = useRef(null) const titleId = useId() const [draft, setDraft] = useState(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) => { 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) => { 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 ( { e.preventDefault() onClose() }} >

Configuration dashboard

Sauvegarde locale (navigateur). Exportez le JSON pour le versionner sur votre Synology.

Capacité & reporting

setDraft((d) => ({ ...d, teamCapacity: Math.max(0.25, Number(e.target.value) || 0.25), })) } />
setDraft((d) => ({ ...d, baselineCapacity: Math.max(0.25, Number(e.target.value) || 0.25), })) } />
setDraft((d) => ({ ...d, wipSlotsPerDev: Math.max(1, Math.floor(Number(e.target.value) || 1)), })) } />
setDraft((d) => ({ ...d, sprintFieldId: e.target.value.trim() || undefined, })) } placeholder="ex. customfield_10020 (vide = utiliser VITE_JIRA_SPRINT_FIELD ou désactiver)" />

Laissez vide pour n’utiliser que la variable d’environnement, ou saisissez l’ID exact du champ Sprint de votre projet Scrum.

La date d’atterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux nominaux pour la jauge « Ressources ».

setDraft((d) => ({ ...d, myJiraAccountId: e.target.value || undefined }))} placeholder="ex. 5b10a2844c20165700ede21g" />
setDraft((d) => ({ ...d, myJiraEmail: e.target.value || undefined }))} placeholder="vous@entreprise.com" />

Cartographie des statuts Jira

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.

{BUCKET_FIELD_DEFS.map(({ key, title, hint }) => (

{hint}