268 lines
13 KiB
JavaScript
268 lines
13 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, 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>
|
||
);
|
||
}
|