307 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|