init
This commit is contained in:
227
src/components/DashboardSettingsModal.tsx
Normal file
227
src/components/DashboardSettingsModal.tsx
Normal 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 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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user