feat: add AdminRules page with CRUD, reordering, and icon picker
This commit is contained in:
29
src/pages/admin/AdminRules.module.css
Normal file
29
src/pages/admin/AdminRules.module.css
Normal 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';
|
||||
}
|
||||
76
src/pages/admin/AdminRules.test.tsx
Normal file
76
src/pages/admin/AdminRules.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
178
src/pages/admin/AdminRules.tsx
Normal file
178
src/pages/admin/AdminRules.tsx
Normal 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
|
||||
Reference in New Issue
Block a user