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

268 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import { dateHeureReunionIso, datesAssembleesAffaires, dateLocale, labelDateReunion } from '../utils/datesReunion';
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
export default function AssembleeAffaires() {
const { postes, groupe } = useStore();
const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
const choixDates = datesAssembleesAffaires(groupe);
const [membres, setMembres] = useState([]);
const [reunions, setReunions] = useState([]);
const [pvs, setPvs] = useState([]);
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [postesVacants, setPostesVacants] = useState([]);
const [reunionId, setReunionId] = useState('');
const [dateSelection, setDateSelection] = useState(dateLocale());
const [dateReunion, setDateReunion] = useState(dateLocale());
const [note, setNote] = useState('');
const [presencesMap, setPresencesMap] = useState({});
const [contenu, setContenu] = useState('');
const [pvPrecedent, setPvPrecedent] = useState(null);
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const charger = async () => {
try {
const [m, r, pv, props, res, postesDispo] = await Promise.all([
api.get('/membres/actifs'),
api.get('/presences/reunions'),
api.get('/pv/'),
api.get('/propositions/?statut=secondee&limite=100'),
api.get('/propositions/resolutions'),
api.get('/postes/disponibles'),
]);
setMembres(m);
setReunions(r);
setPvs(pv);
setPropositions(props);
setResolutions(res);
setPostesVacants(postesDispo);
const map = {};
m.forEach((mb) => { map[mb.id] = true; });
setPresencesMap(map);
const duJour = r.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
if (duJour) setReunionId(duJour.id);
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
useEffect(() => {
if (choixDates.length > 0) {
if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
}
}, [groupe?.jour_reunion, groupe?.assemblee_affaires_ordre]);
useEffect(() => {
const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
setReunionId(duJour?.id || '');
const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection);
setPvPrecedent(precedents[0] || null);
}, [dateSelection, reunions, pvs]);
const reunion = reunions.find((r) => r.id === reunionId);
const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateSelection);
const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateSelection);
const nbPresents = Object.values(presencesMap).filter(Boolean).length;
const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
const imprimer = () => window.print();
const creerRapport = async () => {
const res = await api.post('/presences/reunion', {
date_reunion: dateHeureReunionIso(dateReunion, groupe),
note: note.trim() || null,
});
setReunionId(res.id);
setReunions([res, ...reunions]);
setDateSelection(res.date_reunion.slice(0, 10));
return res.id;
};
const enregistrer = async () => {
setError('');
setMsg('');
setLoading(true);
try {
const id = reunionId || await creerRapport();
const presences = Object.entries(presencesMap).map(([membre_id, present]) => ({ membre_id, present }));
await api.post('/presences/', { reunion_id: id, presences });
if (contenu.trim()) {
await api.post('/pv/', {
reunion_id: id,
contenu: contenu.trim(),
decisions: null,
});
}
setMsg(contenu.trim() ? 'Présences et PV enregistrés' : 'Présences enregistrées');
await charger();
} catch (e) { setError(e.message); }
setLoading(false);
};
const adopterPvPrecedent = async () => {
if (!pvPrecedent) return;
if (!adoption.proposeur_id || !adoption.secondeur_id) {
setError('Proposeur et secondeur requis');
return;
}
setError('');
try {
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption);
setMsg('PV précédent adopté');
await charger();
} catch (e) { setError(e.message); }
};
const textePvAuto = [
'Ouverture de l’assemblée.',
'',
pvPrecedent ? `Lecture du procès-verbal précédent (${new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}).` : 'Lecture du procès-verbal précédent.',
'',
'Réflexions en cours:',
...propositions.map((p) => `- ${p.sujet} (${p.proposeur_prenom} / ${p.secondeur_prenom || 'à confirmer'})`),
'',
'Décisions prises pendant cette assemblée:',
...resolutionsJour.map((r) => `- ${r.numero_resolution || ''} ${r.sujet}`.trim()),
'',
'Postes disponibles:',
...postesVacants.map((p) => `- ${p.nom}`),
].join('\n');
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Assemblée d'affaires" sousTitre="Assistant et rapport imprimable" />
<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>}
<Card titre="Consulter le rapport">
<Champ label="Date">
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
</Champ>
<div className="grid grid-cols-2 gap-2">
<button onClick={imprimer} className="p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer</button>
{pvPrecedent && (
<button onClick={() => setShowPvPrecedent(!showPvPrecedent)} className="p-3 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-100">
PV précédent
</button>
)}
</div>
</Card>
{showPvPrecedent && pvPrecedent && (
<Card titre="Procès-verbal précédent">
<p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
{estExecutif && !pvPrecedent.approuve && (
<div className="space-y-2 pt-2 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2">
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
<option value="">Proposeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
<select value={adoption.secondeur_id} onChange={(e) => setAdoption({ ...adoption, secondeur_id: e.target.value })} className={INPUT}>
<option value="">Secondeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id}
className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100 disabled:opacity-40">
Adopter le PV précédent
</button>
</div>
)}
</Card>
)}
<div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
<h2 className="text-xl font-bold text-gray-900">Rapport d’assemblée d’affaires</h2>
<p className="text-sm text-gray-500">{dateSelection}</p>
<Section titre="Présences">
{reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => <li key={p.membre_id}>{p.membre_prenom}</li>) : <li>Présences à cocher sur papier</li>}
</Section>
<Section titre="Réflexions en cours">
{propositions.length ? propositions.map((p) => <li key={p.id}>{p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})</li>) : <li>Aucune proposition secondée non résolue</li>}
</Section>
<Section titre="Décisions prises">
{resolutionsJour.length ? resolutionsJour.map((r) => <li key={r.id}>{r.numero_resolution} — {r.sujet}</li>) : <li>Aucune décision enregistrée pour cette date</li>}
</Section>
<Section titre="Postes disponibles">
{postesVacants.length ? postesVacants.map((p) => <li key={p.id}>{p.nom}</li>) : <li>Aucun poste vacant</li>}
</Section>
<div className="mt-4">
<h3 className="font-bold text-gray-800">Procès-verbal</h3>
<p className="text-sm whitespace-pre-wrap mt-1">{pvJour?.contenu || textePvAuto}</p>
</div>
</div>
{estExecutif && (
<>
<Card titre="Créer ou renseigner l’assemblée">
{reunions.length > 0 && (
<Champ label="Assemblée existante">
<select value={reunionId} onChange={(e) => setReunionId(e.target.value)} className={INPUT}>
<option value="">Nouvelle assemblée</option>
{reunions.map((r) => <option key={r.id} value={r.id}>{new Date(r.date_reunion).toLocaleDateString('fr-CA')}</option>)}
</select>
</Champ>
)}
{!reunionId && (
<>
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
<Champ label="Note"><input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} /></Champ>
<button onClick={creerRapport} className="w-full p-3 bg-indigo-50 text-indigo-700 rounded-xl text-sm font-bold border border-indigo-100">Créer le rapport d’assemblée</button>
</>
)}
</Card>
<Card titre={`Présences (${nbPresents}/${membres.length})`}>
<div className="space-y-1">{membres.map((m) => (
<button key={m.id} onClick={() => toggle(m.id)} className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${presencesMap[m.id] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-100 opacity-50'}`}>
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-sm font-bold ${presencesMap[m.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'}`}>{presencesMap[m.id] ? '✓' : ''}</span>
<span className="font-medium text-gray-800">{m.prenom}</span>
</button>
))}</div>
</Card>
<Card titre="Procès-verbal">
<textarea value={contenu} onChange={(e) => setContenu(e.target.value)} rows={10} placeholder={textePvAuto} className={`${INPUT} resize-none text-sm`} />
</Card>
<button onClick={enregistrer} disabled={loading} className="w-full p-5 bg-indigo-600 text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">{loading ? '...' : 'Enregistrer l’assemblée'}</button>
</>
)}
</div>
</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 Section({ titre, children }) {
return <div className="mt-4"><h3 className="font-bold text-gray-800">{titre}</h3><ul className="list-disc ml-5 text-sm mt-1 space-y-1">{children}</ul></div>;
}
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 dans les paramètres du groupe.</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>
);
}