groupe-meditation/frontend/src/pages/ReunionHebdomadaire.jsx

387 lines
21 KiB
React
Raw Normal View History

2026-05-25 17:39:38 -04:00
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();
2026-05-25 17:39:38 -04:00
const [membres, setMembres] = useState([]);
const [animateurDefautId, setAnimateurDefautId] = useState('');
2026-05-25 17:39:38 -04:00
const [catalogue, setCatalogue] = useState([]);
const [rencontres, setRencontres] = useState([]);
const [ouverteId, setOuverteId] = useState(null);
const [action, setAction] = useState('');
2026-05-25 17:39:38 -04:00
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: '' });
2026-05-25 17:39:38 -04:00
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'),
2026-05-25 17:39:38 -04:00
]);
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 = '';
}
}
2026-05-25 17:39:38 -04:00
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 }));
}
2026-05-25 17:39:38 -04:00
} 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 || '',
});
};
2026-05-25 17:39:38 -04:00
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,
});
2026-05-25 17:39:38 -04:00
};
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,
});
2026-05-25 17:39:38 -04:00
};
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);
};
2026-05-25 17:39:38 -04:00
const enregistrerCollecte = async () => {
if (!rencontreOuverte || !collecte.montant || !collecte.detenteur_id) { setError('Montant et détenteur requis'); return; }
setLoading(true);
2026-05-25 17:39:38 -04:00
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; }
2026-05-25 17:39:38 -04:00
setLoading(true);
setError('');
2026-05-25 17:39:38 -04:00
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,
2026-05-25 17:39:38 -04:00
});
}
setVente({ ...vente, litterature_id: '', quantite: '1', montant: '' });
flash('Vente saisie');
await charger();
2026-05-25 17:39:38 -04:00
} 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' }) : '—');
2026-05-25 17:39:38 -04:00
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Rencontres" sousTitre="Saisies et rapports des rencontres hebdomadaires" retour={!contexte} />
2026-05-25 17:39:38 -04:00
<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>
)}
2026-05-25 17:39:38 -04:00
{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>
2026-05-25 17:39:38 -04:00
)}
<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>
2026-05-25 17:39:38 -04:00
</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>
);
}
2026-05-25 17:39:38 -04:00
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>;
}
2026-05-25 17:39:38 -04:00
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>;
2026-05-25 17:39:38 -04:00
}