Files
gestion_projet/src/components/BugModal.tsx
T
2026-08-10 22:06:49 +02:00

307 lines
9.3 KiB
TypeScript

import { useMemo, useState } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { humanize, mutate, run } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { notifyBug } from '../lib/notify'
import {
Bug,
BugSeverity,
BugStatus,
BUG_STATUS_LABELS,
BUG_STATUS_ORDER,
Pole,
SEVERITY_HINTS,
SEVERITY_LABELS,
SEVERITY_ORDER,
severityBadge
} from '../lib/types'
import { Badge, Button, Field, Input } from './ui'
import { Modal, ModalActions } from './Modal'
import { Select } from './Select'
import { AssigneePicker, PoleChips } from './people'
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
interface Draft {
title: string
description: string
severity: BugSeverity
status: BugStatus
poles: Pole[]
assignees: string[]
}
function draftOf(bug: Bug | null): Draft {
return {
title: bug?.title ?? '',
description: bug?.description ?? '',
severity: bug?.severity ?? 'medium',
status: bug?.status ?? 'open',
poles: bug?.poles ?? [],
assignees: bug?.assignee_ids ?? []
}
}
export default function BugModal({
bug,
projectId,
onClose,
onSaved
}: {
bug: Bug | null
projectId: string
onClose: () => void
onSaved: () => Promise<void> | void
}): JSX.Element {
const { profile, members, membersLoading, memberById } = useApp()
const { toast, confirm } = useFeedback()
const initial = useMemo(() => draftOf(bug), [bug])
const [d, setD] = useState<Draft>(initial)
const [busy, setBusy] = useState(false)
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
const save = async (): Promise<void> => {
if (!d.title.trim()) {
toast('Donne un titre au bug.', 'error')
return
}
setBusy(true)
const payload = {
project_id: projectId,
title: d.title.trim(),
description: d.description.trim() || null,
severity: d.severity,
status: d.status,
poles: d.poles,
assignee_ids: d.assignees
}
try {
let entityId = bug?.id ?? null
if (bug) {
await mutate(supa().from('bugs').update(payload).eq('id', bug.id))
} else {
const created = await run<{ id: string }>(
supa()
.from('bugs')
.insert({ ...payload, reporter_id: profile?.id ?? null })
.select('id')
.single()
)
entityId = created?.id ?? null
}
if (entityId) {
const newPoles = d.poles.filter((p) => !(bug?.poles ?? []).includes(p))
const newAssignees = d.assignees.filter((a) => !(bug?.assignee_ids ?? []).includes(a))
if (newPoles.length || newAssignees.length) {
await notifyBug({
projectId,
bugId: entityId,
title: d.title.trim(),
members,
poles: newPoles,
assigneeIds: newAssignees,
actorId: profile?.id ?? null,
isNew: !bug
})
}
}
await onSaved()
toast(bug ? 'Bug enregistré.' : 'Bug signalé.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
const remove = async (): Promise<void> => {
if (!bug) return
const ok = await confirm({
title: 'Supprimer ce bug ?',
message: ${bug.title} » et tout ce qui y est rattaché (temps, discussion, captures) seront supprimés.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
setBusy(true)
try {
await mutate(supa().from('bugs').delete().eq('id', bug.id))
await onSaved()
toast('Bug supprimé.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
/** Passe le bug en résolu en un clic : le geste le plus fréquent. */
const resolve = async (): Promise<void> => {
if (!bug) return
setBusy(true)
try {
await mutate(supa().from('bugs').update({ status: 'resolved' }).eq('id', bug.id))
await onSaved()
toast('Bug marqué comme résolu.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
const reporter = memberById(bug?.reporter_id)
const subtitle = bug ? (
<span>
Signalé {reporter ? `par ${reporter.full_name} ` : ''}
le {format(new Date(bug.created_at), 'd MMM yyyy', { locale: fr })}
</span>
) : null
return (
<Modal
title={bug ? 'Bug' : 'Signaler un bug'}
subtitle={subtitle}
onClose={onClose}
size="lg"
dirty={dirty && !busy}
footer={
<ModalActions onDelete={bug ? remove : undefined} onCancel={onClose}>
{bug && bug.status !== 'resolved' && bug.status !== 'closed' && (
<Button onClick={resolve} disabled={busy}>
Marquer résolu
</Button>
)}
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
{bug ? 'Enregistrer' : 'Signaler'}
</Button>
</ModalActions>
}
>
<div className="space-y-4">
<Field label="Que se passe-t-il ?" required>
{({ id }) => (
<Input
id={id}
autoFocus
value={d.title}
maxLength={160}
placeholder="Le joueur traverse le mur en sautant contre la porte"
onChange={(e) => patch({ title: e.target.value })}
/>
)}
</Field>
<Field
label="Étapes pour reproduire"
hint="Ce que tu faisais, ce qui devait arriver, ce qui est arrivé. Plus c'est précis, plus c'est vite corrigé."
>
{({ id }) => (
<textarea
id={id}
className="input h-28 resize-none"
value={d.description}
placeholder={'1. Aller au niveau 2\n2. Sauter contre la porte de droite\n→ Le personnage passe à travers'}
onChange={(e) => patch({ description: e.target.value })}
/>
)}
</Field>
{/* ---- Sévérité : un seul axe d'importance, expliqué ---- */}
<div className="grid grid-cols-2 gap-3">
<Field label="Sévérité" hint={SEVERITY_HINTS[d.severity]}>
<Select
value={d.severity}
onChange={(severity) => patch({ severity })}
ariaLabel="Sévérité du bug"
options={SEVERITY_ORDER.map((s) => ({
value: s,
label: SEVERITY_LABELS[s],
hint: SEVERITY_HINTS[s]
}))}
/>
</Field>
<Field label="Statut">
<Select
value={d.status}
onChange={(status) => patch({ status })}
ariaLabel="Statut du bug"
options={BUG_STATUS_ORDER.map((s) => ({ value: s, label: BUG_STATUS_LABELS[s] }))}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Badge className={severityBadge(d.severity)}>{SEVERITY_LABELS[d.severity]}</Badge>
<span className="text-xs text-subtle">{SEVERITY_HINTS[d.severity]}</span>
</div>
{/* ---- Qui ? ---- */}
<div className="space-y-3 border-t border-border pt-4">
<Field
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
hint="Tous les membres du pôle reçoivent une notification."
>
<PoleChips
selected={d.poles}
onToggle={(p) =>
patch({
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
})
}
/>
</Field>
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
<AssigneePicker
members={members}
loading={membersLoading}
selected={d.assignees}
onChange={(assignees) => patch({ assignees })}
poles={d.poles}
/>
</Field>
</div>
{bug ? (
<div className="space-y-3 border-t border-border pt-4">
<AttachmentPanel
entity={{
kind: 'bug',
id: bug.id,
projectId,
title: bug.title,
involvedIds: bug.assignee_ids
}}
/>
<TimeSection target={{ kind: 'bug', id: bug.id }} projectId={projectId} />
<CommentThread
entity={{
kind: 'bug',
id: bug.id,
projectId,
title: bug.title,
involvedIds: [...bug.assignee_ids, ...(bug.reporter_id ? [bug.reporter_id] : [])]
}}
/>
</div>
) : (
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
Une fois le bug créé, tu pourras y coller des captures d'écran (Ctrl+V), suivre le temps
passé et en discuter avec l'équipe.
</p>
)}
</div>
</Modal>
)
}