Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409. Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur. Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
386 lines
21 KiB
JavaScript
386 lines
21 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { api } from '../stores/api';
|
|
import { useStore } from '../stores/store';
|
|
import PageHeader from '../components/PageHeader';
|
|
import { dateHeureReunionIso, datesReunion, dateLocale, labelDateReunion } from '../utils/datesReunion';
|
|
|
|
const TYPES_JETONS = [['desir', 'Désir'], ['24h', '24 heures'], ['1mois', '1 mois'], ['2mois', '2 mois'], ['3mois', '3 mois'], ['6mois', '6 mois'], ['9mois', '9 mois'], ['1an', '1 an'], ['multiple', 'Multiple']];
|
|
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
|
|
|
|
export default function ReunionHebdomadaire({ contexte = false }) {
|
|
const { estExecutif, groupe } = useStore();
|
|
const choixDatesChrono = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
|
|
const choixDates = [...choixDatesChrono].reverse();
|
|
const prochaineDate = choixDatesChrono.find((d) => d >= dateLocale()) || choixDatesChrono[choixDatesChrono.length - 1] || dateLocale();
|
|
const [membres, setMembres] = useState([]);
|
|
const [animateurDefautId, setAnimateurDefautId] = useState('');
|
|
const [catalogue, setCatalogue] = useState([]);
|
|
const [rencontres, setRencontres] = useState([]);
|
|
const [ouverteId, setOuverteId] = useState(null);
|
|
const [action, setAction] = useState('');
|
|
const [msg, setMsg] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [planifier, setPlanifier] = useState(false);
|
|
const [plan, setPlan] = useState({ date: prochaineDate, animateur_id: '', theme: '', annonces: '', note: '' });
|
|
const [collecte, setCollecte] = useState({ montant: '', detenteur_id: '' });
|
|
const [vente, setVente] = useState({ type: 'litterature', litterature_id: '', type_jeton: 'desir', quantite: '1', montant: '', vendu_par_id: '' });
|
|
const [jeton, setJeton] = useState({ type_jeton: '', membre_id: '', annees: '' });
|
|
const [rapport, setRapport] = useState({ animateur_id: '', theme: '', annonces: '', note: '' });
|
|
|
|
const charger = async () => {
|
|
try {
|
|
const [m, l, r] = await Promise.all([
|
|
api.get('/membres/actifs'),
|
|
api.get('/litterature/'),
|
|
api.get('/reunions/rencontres/details?limite=80'),
|
|
]);
|
|
let animateurId = '';
|
|
if (estExecutif) {
|
|
try {
|
|
const affectations = await api.get('/postes/affectations/groupe');
|
|
const affectationAnimateur = affectations.find((a) => (a.poste_nom || '').toLowerCase().includes('animateur'));
|
|
animateurId = affectationAnimateur?.membre_id || '';
|
|
} catch {
|
|
animateurId = '';
|
|
}
|
|
}
|
|
setMembres(m);
|
|
setCatalogue(l);
|
|
setRencontres(r);
|
|
setAnimateurDefautId(animateurId);
|
|
if (m.length > 0) {
|
|
const fallbackAnimateur = animateurId || m[0].id;
|
|
setPlan((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
|
|
setRapport((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
|
|
setCollecte((f) => ({ ...f, detenteur_id: f.detenteur_id || m[0].id }));
|
|
setVente((f) => ({ ...f, vendu_par_id: f.vendu_par_id || m[0].id }));
|
|
}
|
|
} catch (e) { setError(e.message); }
|
|
};
|
|
|
|
useEffect(() => { charger(); }, []);
|
|
|
|
const flash = (texte) => { setMsg(texte); setTimeout(() => setMsg(''), 2500); };
|
|
const dateRencontre = (r) => r.date_reunion?.slice(0, 10);
|
|
const rencontreOuverte = rencontres.find((r) => r.id === ouverteId);
|
|
|
|
const ouvrir = (id) => {
|
|
const prochaine = ouverteId === id ? null : id;
|
|
setOuverteId(prochaine);
|
|
setAction('');
|
|
const r = rencontres.find((x) => x.id === id);
|
|
const animateur = membres.find((m) => m.prenom === r?.animateur_prenom);
|
|
setRapport({
|
|
animateur_id: animateur?.id || animateurDefautId || membres[0]?.id || '',
|
|
theme: r?.theme || '',
|
|
annonces: (r?.annonces || []).join('\n'),
|
|
note: r?.note || '',
|
|
});
|
|
};
|
|
|
|
const choisirLitterature = (id) => {
|
|
const item = catalogue.find((c) => c.id === id);
|
|
const quantite = parseInt(vente.quantite || '1');
|
|
setVente({
|
|
...vente,
|
|
litterature_id: id,
|
|
montant: item?.prix_coutant ? (item.prix_coutant * quantite).toFixed(2) : vente.montant,
|
|
});
|
|
};
|
|
|
|
const changerQuantite = (valeur) => {
|
|
const item = catalogue.find((c) => c.id === vente.litterature_id);
|
|
const qte = parseInt(valeur || '0');
|
|
setVente({
|
|
...vente,
|
|
quantite: valeur,
|
|
montant: vente.type === 'litterature' && item?.prix_coutant && qte > 0 ? (item.prix_coutant * qte).toFixed(2) : vente.montant,
|
|
});
|
|
};
|
|
|
|
const creerRencontre = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await api.post('/reunions/', {
|
|
type: 'reguliere',
|
|
date_reunion: dateHeureReunionIso(plan.date, groupe),
|
|
animateur_id: plan.animateur_id || animateurDefautId || null,
|
|
theme: plan.theme.trim() || 'Rencontre hebdomadaire',
|
|
annonces: plan.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
|
|
note: plan.note.trim() || null,
|
|
});
|
|
setPlanifier(false);
|
|
setPlan({ date: prochaineDate, animateur_id: animateurDefautId, theme: '', annonces: '', note: '' });
|
|
flash('Rencontre planifiée');
|
|
await charger();
|
|
} catch (e) { setError(e.message); }
|
|
setLoading(false);
|
|
};
|
|
|
|
const enregistrerCollecte = async () => {
|
|
if (!rencontreOuverte || !collecte.montant || !collecte.detenteur_id) { setError('Montant et détenteur requis'); return; }
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await api.post('/collectes/', {
|
|
reunion_id: rencontreOuverte.id,
|
|
date_collecte: dateRencontre(rencontreOuverte),
|
|
montant: parseFloat(collecte.montant),
|
|
detenteur_id: collecte.detenteur_id,
|
|
});
|
|
setCollecte({ ...collecte, montant: '' });
|
|
flash('Collecte saisie');
|
|
await charger();
|
|
} catch (e) { setError(e.message); }
|
|
setLoading(false);
|
|
};
|
|
|
|
const enregistrerVente = async () => {
|
|
if (!rencontreOuverte || !vente.montant || !vente.vendu_par_id) { setError('Montant et vendeur requis'); return; }
|
|
const quantite = parseInt(vente.quantite || '0');
|
|
if (quantite <= 0) { setError('Quantité invalide'); return; }
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
if (vente.type === 'litterature') {
|
|
if (!vente.litterature_id) { setError('Sélectionnez un titre'); setLoading(false); return; }
|
|
await api.post('/ventes-litterature/', {
|
|
reunion_id: rencontreOuverte.id,
|
|
litterature_id: vente.litterature_id,
|
|
quantite,
|
|
montant: parseFloat(vente.montant),
|
|
date_vente: dateRencontre(rencontreOuverte),
|
|
vendu_par_id: vente.vendu_par_id,
|
|
});
|
|
} else {
|
|
await api.post('/jetons/ventes', {
|
|
reunion_id: rencontreOuverte.id,
|
|
type_jeton: vente.type_jeton,
|
|
quantite,
|
|
montant: parseFloat(vente.montant),
|
|
date_vente: dateRencontre(rencontreOuverte),
|
|
vendu_par_id: vente.vendu_par_id,
|
|
});
|
|
}
|
|
setVente({ ...vente, litterature_id: '', quantite: '1', montant: '' });
|
|
flash('Vente saisie');
|
|
await charger();
|
|
} catch (e) { setError(e.message); }
|
|
setLoading(false);
|
|
};
|
|
|
|
const enregistrerJeton = async () => {
|
|
if (!rencontreOuverte || !jeton.type_jeton) { setError('Sélectionnez un type de jeton'); return; }
|
|
if (jeton.type_jeton !== 'desir' && !jeton.membre_id) { setError('Sélectionnez un membre'); return; }
|
|
if (jeton.type_jeton === 'multiple' && (!jeton.annees || parseInt(jeton.annees) < 2)) { setError("Nombre d'années requis"); return; }
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const body = {
|
|
reunion_id: rencontreOuverte.id,
|
|
type_jeton: jeton.type_jeton,
|
|
date_remise: dateRencontre(rencontreOuverte),
|
|
};
|
|
if (jeton.membre_id) body.membre_id = jeton.membre_id;
|
|
if (jeton.type_jeton === 'multiple') body.annees = parseInt(jeton.annees);
|
|
await api.post('/jetons/', body);
|
|
setJeton({ type_jeton: '', membre_id: '', annees: '' });
|
|
flash('Jeton/gâteau comptabilisé');
|
|
await charger();
|
|
} catch (e) { setError(e.message); }
|
|
setLoading(false);
|
|
};
|
|
|
|
const enregistrerRapport = async () => {
|
|
if (!rencontreOuverte) return;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await api.patch(`/reunions/${rencontreOuverte.id}`, {
|
|
type: 'reguliere',
|
|
date_reunion: rencontreOuverte.date_reunion,
|
|
animateur_id: rapport.animateur_id || null,
|
|
theme: rapport.theme.trim() || 'Rencontre hebdomadaire',
|
|
annonces: rapport.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
|
|
note: rapport.note.trim() || null,
|
|
}, { version: rencontreOuverte.modifie_le });
|
|
flash('Rapport enregistré');
|
|
await charger();
|
|
} catch (e) { setError(e.message); }
|
|
setLoading(false);
|
|
};
|
|
|
|
const telechargerRapport = async (rencontre) => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const token = api.getToken();
|
|
const res = await fetch(`/api/reunions/${rencontre.id}/rapport.pdf`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) throw new Error('Erreur de génération du rapport PDF');
|
|
const blob = await res.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `rapport_rencontre_${dateRencontre(rencontre)}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const heure = (dateIso) => (dateIso ? new Date(dateIso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : '—');
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100">
|
|
<PageHeader titre="Rencontres" sousTitre="Saisies et rapports des rencontres hebdomadaires" retour={!contexte} />
|
|
<div className="p-4 space-y-4">
|
|
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
|
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
|
|
|
{estExecutif && (
|
|
<button onClick={() => setPlanifier(!planifier)} className="w-full p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">
|
|
Planifier une rencontre
|
|
</button>
|
|
)}
|
|
|
|
{planifier && (
|
|
<Card titre="Nouvelle rencontre">
|
|
<Champ label="Date"><SelectDate value={plan.date} onChange={(v) => setPlan({ ...plan, date: v })} dates={choixDates} /></Champ>
|
|
<Champ label="Animé par"><MembreSelect value={plan.animateur_id} onChange={(v) => setPlan({ ...plan, animateur_id: v })} membres={membres} /></Champ>
|
|
<Champ label="Sujet"><input value={plan.theme} onChange={(e) => setPlan({ ...plan, theme: e.target.value })} className={INPUT} /></Champ>
|
|
<Champ label="Annonces"><textarea value={plan.annonces} onChange={(e) => setPlan({ ...plan, annonces: e.target.value })} rows={3} className={`${INPUT} resize-none`} /></Champ>
|
|
<Champ label="Note"><textarea value={plan.note} onChange={(e) => setPlan({ ...plan, note: e.target.value })} rows={2} className={`${INPUT} resize-none`} /></Champ>
|
|
<button onClick={creerRencontre} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Créer</button>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{rencontres.map((r) => (
|
|
<div key={r.id} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
|
<div className="flex items-start gap-2 p-4">
|
|
<button onClick={() => ouvrir(r.id)} className="min-w-0 flex-1 text-left active:bg-gray-50">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="font-extrabold text-gray-900">{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}</p>
|
|
<p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}</p>
|
|
</div>
|
|
<span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span>
|
|
</div>
|
|
</button>
|
|
<button onClick={() => telechargerRapport(r)} disabled={loading}
|
|
className="shrink-0 rounded-xl bg-gray-900 px-3 py-2 text-xs font-bold text-white disabled:opacity-40">
|
|
Rapport PDF
|
|
</button>
|
|
</div>
|
|
|
|
{ouverteId === r.id && (
|
|
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
|
|
<Resume rencontre={r} />
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
|
|
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)}
|
|
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
|
{nom}
|
|
</button>
|
|
))}
|
|
{estExecutif && (
|
|
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
|
|
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
|
Modifier
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{action === 'Collecte' && (
|
|
<Card titre="Collecte">
|
|
{r.collecte ? <p className="text-sm text-gray-500">Collecte déjà saisie pour cette rencontre.</p> : (
|
|
<>
|
|
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={collecte.montant} onChange={(e) => setCollecte({ ...collecte, montant: e.target.value })} className={INPUT} /></Champ>
|
|
<Champ label="Détenteur de l'argent"><MembreSelect value={collecte.detenteur_id} onChange={(v) => setCollecte({ ...collecte, detenteur_id: v })} membres={membres} /></Champ>
|
|
<button onClick={enregistrerCollecte} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Saisir la collecte</button>
|
|
</>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{action === 'Ventes' && (
|
|
<Card titre="Ventes">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{['litterature', 'jeton'].map((type) => <button key={type} onClick={() => setVente({ ...vente, type })} className={`p-3 rounded-xl text-sm font-bold ${vente.type === type ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-600'}`}>{type === 'litterature' ? 'Littérature' : 'Jeton'}</button>)}
|
|
</div>
|
|
{vente.type === 'litterature' ? (
|
|
<Champ label="Titre"><select value={vente.litterature_id} onChange={(e) => choisirLitterature(e.target.value)} className={INPUT}><option value="">Sélectionner</option>{catalogue.map((c) => <option key={c.id} value={c.id}>{c.titre} ({c.quantite_stock})</option>)}</select></Champ>
|
|
) : (
|
|
<Champ label="Type de jeton"><select value={vente.type_jeton} onChange={(e) => setVente({ ...vente, type_jeton: e.target.value })} className={INPUT}>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Champ label="Quantité"><input type="number" min="1" value={vente.quantite} onChange={(e) => changerQuantite(e.target.value)} className={INPUT} /></Champ>
|
|
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={vente.montant} onChange={(e) => setVente({ ...vente, montant: e.target.value })} className={INPUT} /></Champ>
|
|
</div>
|
|
<Champ label="Vendu par"><MembreSelect value={vente.vendu_par_id} onChange={(v) => setVente({ ...vente, vendu_par_id: v })} membres={membres} /></Champ>
|
|
<button onClick={enregistrerVente} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Saisir la vente</button>
|
|
</Card>
|
|
)}
|
|
|
|
{action === 'Jetons/Gâteaux' && (
|
|
<Card titre="Jetons/Gâteaux">
|
|
<Champ label="Type"><select value={jeton.type_jeton} onChange={(e) => setJeton({ ...jeton, type_jeton: e.target.value })} className={INPUT}><option value="">Sélectionner</option>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
|
|
<Champ label="Membre"><MembreSelect value={jeton.membre_id} onChange={(v) => setJeton({ ...jeton, membre_id: v })} membres={membres} empty="Anonyme / non applicable" /></Champ>
|
|
{jeton.type_jeton === 'multiple' && <Champ label="Années"><input type="number" min="2" value={jeton.annees} onChange={(e) => setJeton({ ...jeton, annees: e.target.value })} className={INPUT} /></Champ>}
|
|
<button onClick={enregistrerJeton} disabled={loading} className="w-full p-3 bg-aa-gold text-white rounded-xl text-sm font-bold disabled:opacity-40">Comptabiliser</button>
|
|
</Card>
|
|
)}
|
|
|
|
{action === 'Modifier' && (
|
|
<Card titre="Modifier la rencontre">
|
|
<Champ label="Animé par"><MembreSelect value={rapport.animateur_id} onChange={(v) => setRapport({ ...rapport, animateur_id: v })} membres={membres} /></Champ>
|
|
<Champ label="Sujet"><input value={rapport.theme} onChange={(e) => setRapport({ ...rapport, theme: e.target.value })} className={INPUT} /></Champ>
|
|
<Champ label="Annonces"><textarea value={rapport.annonces} onChange={(e) => setRapport({ ...rapport, annonces: e.target.value })} rows={3} className={`${INPUT} resize-none`} /></Champ>
|
|
<Champ label="Note"><textarea value={rapport.note} onChange={(e) => setRapport({ ...rapport, note: e.target.value })} rows={2} className={`${INPUT} resize-none`} /></Champ>
|
|
<button onClick={enregistrerRapport} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Enregistrer</button>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Resume({ rencontre }) {
|
|
return (
|
|
<div className="text-sm text-gray-600 space-y-1">
|
|
<p>Animateur : {rencontre.animateur_prenom || '—'}</p>
|
|
{rencontre.collecte && <p>Collecte : {rencontre.collecte.montant.toFixed(2)} $ ({rencontre.collecte.statut}) détenue par {rencontre.collecte.detenteur_prenom || '—'}</p>}
|
|
{rencontre.ventes_litterature.map((v) => <p key={v.id}>Vente littérature : {v.titre} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || '—'}</p>)}
|
|
{rencontre.ventes_jetons.map((v) => <p key={v.id}>Vente jeton : {v.type_jeton} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || '—'}</p>)}
|
|
{rencontre.jetons.map((j) => <p key={j.id}>Jeton/gâteau : {j.membre_prenom || 'Anonyme'} — {j.type_jeton}{j.annees ? ` (${j.annees} ans)` : ''}</p>)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Card({ titre, children }) {
|
|
return <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"><h2 className="font-bold text-gray-800">{titre}</h2>{children}</div>;
|
|
}
|
|
|
|
function Champ({ label, children }) {
|
|
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
|
|
}
|
|
|
|
function MembreSelect({ value, onChange, membres, empty = 'Sélectionner' }) {
|
|
return <select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}><option value="">{empty}</option>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select>;
|
|
}
|
|
|
|
function SelectDate({ value, onChange, dates }) {
|
|
if (!dates.length) return <div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">Configurez le jour de réunion.</div>;
|
|
return <select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}</select>;
|
|
}
|