diff --git a/src/pages/admin/AdminRules.module.css b/src/pages/admin/AdminRules.module.css new file mode 100644 index 0000000..3247478 --- /dev/null +++ b/src/pages/admin/AdminRules.module.css @@ -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'; +} diff --git a/src/pages/admin/AdminRules.test.tsx b/src/pages/admin/AdminRules.test.tsx new file mode 100644 index 0000000..2ef0dc9 --- /dev/null +++ b/src/pages/admin/AdminRules.test.tsx @@ -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() + 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() + 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() + await screen.findByText('Acompte') + + await userEvent.click(screen.getAllByRole('button', { name: /supprimer/i })[0]) + + expect(deleteMock).toHaveBeenCalled() + expect(eqDeleteMock).toHaveBeenCalledWith('id', 'r1') + }) +}) diff --git a/src/pages/admin/AdminRules.tsx b/src/pages/admin/AdminRules.tsx new file mode 100644 index 0000000..4109130 --- /dev/null +++ b/src/pages/admin/AdminRules.tsx @@ -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 { + return { id: '', title: '', body: '', icon_key: 'deposit' } +} + +function AdminRules() { + const [rules, setRules] = useState([]) + 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 ( + + Règlements + + + + Icône + Titre + + + + + {rules.map((rule, index) => ( + + + {rule.title} + + handleMove(index, -1)} disabled={index === 0}>Monter + handleMove(index, 1)} disabled={index === rules.length - 1}>Descendre + setForm(rule)}>Modifier + handleDelete(rule.id)}>Supprimer + + + ))} + + + + + {isEditing ? 'Modifier le règlement' : 'Nouveau règlement'} + + Titre + setForm((f) => ({ ...f, title: e.target.value }))} + required + /> + + + Texte + setForm((f) => ({ ...f, body: e.target.value }))} + required + /> + + + Icône + setForm((f) => ({ ...f, icon_key: e.target.value }))} + > + {ICON_OPTIONS.map((option) => ( + {option.label} + ))} + + + + {error && {error}} + + {isEditing ? 'Enregistrer le règlement' : 'Ajouter le règlement'} + + + + ) +} + +export default AdminRules
{error}