feat: add AdminRules page with CRUD, reordering, and icon picker

This commit is contained in:
belviskhoremk
2026-08-13 09:28:58 +02:00
parent 28b103cf2c
commit 194144fa79
3 changed files with 283 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
/* src/pages/admin/AdminRules.module.css */
.page {
composes: page from './adminShared.module.css';
}
.table {
composes: table from './adminShared.module.css';
}
.form {
composes: form from './adminShared.module.css';
}
.button {
composes: button from './adminShared.module.css';
}
.buttonSecondary {
composes: buttonSecondary from './adminShared.module.css';
margin-right: var(--admin-space-xs);
}
.buttonDanger {
composes: buttonDanger from './adminShared.module.css';
}
.error {
composes: error from './adminShared.module.css';
}

View File

@@ -0,0 +1,76 @@
// src/pages/admin/AdminRules.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const insertMock = vi.fn()
const eqUpdateMock = vi.fn()
const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
const eqDeleteMock = vi.fn()
const deleteMock = vi.fn(() => ({ eq: eqDeleteMock }))
const fromMock = vi.fn<(table: string) => {
select: typeof selectMock
insert: typeof insertMock
update: typeof updateMock
delete: typeof deleteMock
}>(() => ({ select: selectMock, insert: insertMock, update: updateMock, delete: deleteMock }))
vi.mock('../../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
const RULES = [
{ id: 'r1', title: 'Acompte', body: 'Un acompte est requis.', icon_key: 'deposit', sort_order: 0 },
{ id: 'r2', title: 'Retard', body: 'Des frais s\'appliquent.', icon_key: 'lateness', sort_order: 1 },
]
import AdminRules from './AdminRules'
describe('AdminRules', () => {
beforeEach(() => {
orderMock.mockReset().mockResolvedValue({ data: RULES, error: null })
selectMock.mockClear()
insertMock.mockReset().mockResolvedValue({ error: null })
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockClear()
eqDeleteMock.mockReset().mockResolvedValue({ error: null })
deleteMock.mockClear()
fromMock.mockClear()
})
it('lists existing rules', async () => {
render(<AdminRules />)
expect(await screen.findByText('Acompte')).toBeInTheDocument()
expect(screen.getByText('Retard')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('rules')
})
it('creates a new rule from the form', async () => {
render(<AdminRules />)
await screen.findByText('Acompte')
await userEvent.type(screen.getByLabelText(/^titre$/i), 'Animaux')
await userEvent.type(screen.getByLabelText(/^texte$/i), 'Aucun animal admis.')
await userEvent.selectOptions(screen.getByLabelText(/icône/i), 'guests')
await userEvent.click(screen.getByRole('button', { name: /ajouter le règlement/i }))
await waitFor(() =>
expect(insertMock).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Animaux', body: 'Aucun animal admis.', icon_key: 'guests', sort_order: 2 }),
),
)
})
it('deletes a rule with confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<AdminRules />)
await screen.findByText('Acompte')
await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0])
expect(deleteMock).toHaveBeenCalled()
expect(eqDeleteMock).toHaveBeenCalledWith('id', 'r1')
})
})

View File

@@ -0,0 +1,178 @@
// src/pages/admin/AdminRules.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { friendlyError } from '../../lib/friendlyError'
import RuleIcon from '../../components/RuleIcon'
import styles from './AdminRules.module.css'
interface AdminRule {
id: string
title: string
body: string
icon_key: string
sort_order: number
}
const ICON_OPTIONS: { key: string; label: string }[] = [
{ key: 'deposit', label: 'Bouclier (dépôt)' },
{ key: 'hair-prep', label: 'Cheveux (préparation)' },
{ key: 'extensions', label: 'Mèches (extensions)' },
{ key: 'lateness', label: 'Horloge (retard)' },
{ key: 'guests', label: 'Personnes (accompagnant)' },
{ key: 'payment', label: 'Carte (paiement)' },
]
function emptyForm(): Omit<AdminRule, 'sort_order'> {
return { id: '', title: '', body: '', icon_key: 'deposit' }
}
function AdminRules() {
const [rules, setRules] = useState<AdminRule[]>([])
const [form, setForm] = useState(emptyForm())
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadRules() {
const { data, error: fetchError } = await supabase.from('rules').select('*').order('sort_order')
if (fetchError) {
setError(fetchError.message)
return
}
setRules((data ?? []) as AdminRule[])
}
useEffect(() => {
void loadRules()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
if (form.id) {
const { error: updateError } = await supabase
.from('rules')
.update({ title: form.title, body: form.body, icon_key: form.icon_key })
.eq('id', form.id)
setSaving(false)
if (updateError) {
setError(updateError.message)
return
}
} else {
const maxOrder = rules.reduce((max, r) => Math.max(max, r.sort_order), -1)
const { error: insertError } = await supabase
.from('rules')
.insert({ title: form.title, body: form.body, icon_key: form.icon_key, sort_order: maxOrder + 1 })
setSaving(false)
if (insertError) {
setError(insertError.message)
return
}
}
setForm(emptyForm())
await loadRules()
}
async function handleDelete(id: string) {
if (!window.confirm('Supprimer ce règlement ?')) return
const { error: deleteError } = await supabase.from('rules').delete().eq('id', id)
if (deleteError) {
setError(friendlyError(deleteError))
return
}
await loadRules()
}
async function handleMove(index: number, direction: -1 | 1) {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= rules.length) return
const current = rules[index]
const target = rules[targetIndex]
setError('')
const { error: err1 } = await supabase.from('rules').update({ sort_order: target.sort_order }).eq('id', current.id)
if (err1) {
setError(err1.message)
return
}
const { error: err2 } = await supabase.from('rules').update({ sort_order: current.sort_order }).eq('id', target.id)
if (err2) {
setError(err2.message)
return
}
await loadRules()
}
const isEditing = Boolean(form.id)
return (
<div className={styles.page}>
<h1>Règlements</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Icône</th>
<th>Titre</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => (
<tr key={rule.id}>
<td><RuleIcon iconKey={rule.icon_key} /></td>
<td>{rule.title}</td>
<td>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, -1)} disabled={index === 0}>Monter</button>
<button type="button" className={styles.buttonSecondary} onClick={() => handleMove(index, 1)} disabled={index === rules.length - 1}>Descendre</button>
<button type="button" className={styles.buttonSecondary} onClick={() => setForm(rule)}>Modifier</button>
<button type="button" className={styles.buttonDanger} onClick={() => handleDelete(rule.id)}>Supprimer</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Modifier le règlement' : 'Nouveau règlement'}</h2>
<label htmlFor="rule-title">
Titre
<input
id="rule-title"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
/>
</label>
<label htmlFor="rule-body">
Texte
<textarea
id="rule-body"
value={form.body}
onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))}
required
/>
</label>
<label htmlFor="rule-icon">
Icône
<select
id="rule-icon"
value={form.icon_key}
onChange={(e) => setForm((f) => ({ ...f, icon_key: e.target.value }))}
>
{ICON_OPTIONS.map((option) => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
</label>
<RuleIcon iconKey={form.icon_key} />
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>
{isEditing ? 'Enregistrer le règlement' : 'Ajouter le règlement'}
</button>
</form>
</div>
)
}
export default AdminRules