This commit is contained in:
Bastien COIGNOUX
2026-04-24 07:41:55 +02:00
commit 7cd2d6dc40
42 changed files with 4453 additions and 0 deletions

View File

@ -0,0 +1,227 @@
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import {
exportConfigJson,
mergeImportedConfig,
type DashboardConfig,
type Milestone,
} from '../lib/dashboardConfig'
type Props = {
open: boolean
config: DashboardConfig
onClose: () => void
onSave: (next: DashboardConfig) => void
}
function newMilestone(): Milestone {
return {
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `m-${Date.now()}`,
title: '',
date: new Date().toISOString().slice(0, 10),
linkedStoryKeys: [],
}
}
export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const [draft, setDraft] = useState<DashboardConfig>(config)
useEffect(() => {
if (open) setDraft(config)
}, [open, config])
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)
else alert('Fichier JSON invalide (version 1 attendue).')
} 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>
<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>
<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="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 liées (DCC-1, DCC-2) — vide = toutes"
/>
<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={() => 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)
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>
)
}