groupe-meditation/frontend/src/pages/Decisions.jsx
Daniel Allaire 6677b0f57b Ajouter historique métier et protection concurrente
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.
2026-05-31 10:59:41 -04:00

358 lines
17 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
const STATUTS = {
proposee: { label: 'À seconder', cls: 'bg-yellow-100 text-yellow-700' },
secondee: { label: 'Secondée', cls: 'bg-blue-100 text-blue-700' },
adoptee: { label: 'Résolution', cls: 'bg-green-100 text-green-700' },
rejetee: { label: 'Rejetée', cls: 'bg-red-100 text-red-700' },
};
const TYPES = {
proposition: 'Proposition',
resolution: 'Résolution',
generale: 'Résolution',
nomination: 'Nomination',
contribution: 'Contribution',
};
export default function Decisions() {
const { estExecutif } = useStore();
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [membres, setMembres] = useState([]);
const [tab, setTab] = useState('cours');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [msg, setMsg] = useState('');
const [sujet, setSujet] = useState('');
const [description, setDescription] = useState('');
const [resolutionForm, setResolutionForm] = useState({});
const [seconderPour, setSeconderPour] = useState({});
const [decisionOuverte, setDecisionOuverte] = useState(null);
const peutExecutif = estExecutif;
const charger = async () => {
setLoading(true);
try {
const [props, res, membresActifs] = await Promise.all([
api.get('/propositions/?limite=100'),
api.get('/propositions/resolutions'),
api.get('/membres/actifs'),
]);
setPropositions(props);
setResolutions(res);
setMembres(membresActifs);
} catch (e) { setError(e.message); }
setLoading(false);
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const soumettre = async () => {
if (!sujet.trim()) { setError("L'objet est obligatoire"); return; }
setError('');
try {
await api.post('/propositions/', {
sujet: sujet.trim(),
description: description.trim() || null,
});
setSujet('');
setDescription('');
setTab('cours');
flash('Proposition soumise');
await charger();
} catch (e) { setError(e.message); }
};
const seconder = async (id, membreId = '') => {
setError('');
try {
const proposition = propositions.find((p) => p.id === id);
const query = membreId ? `?membre_id=${membreId}` : '';
await api.post(`/propositions/${id}/seconder${query}`, null, { version: proposition?.modifie_le });
flash('Proposition secondée');
await charger();
} catch (e) { setError(e.message); }
};
const mettreAJourResolution = (id, champ, valeur) => {
setResolutionForm({
...resolutionForm,
[id]: { ...(resolutionForm[id] || {}), [champ]: valeur },
});
};
const transformer = async (id) => {
setError('');
const form = resolutionForm[id] || {};
const etantDonneQue = (form.etant_donne_que || '').trim();
const groupeADecideDe = (form.groupe_a_decide_de || '').trim();
if (!etantDonneQue || !groupeADecideDe) {
setError('Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires');
return;
}
try {
const proposition = propositions.find((p) => p.id === id);
await api.patch(`/propositions/${id}/voter`, {
statut: 'adoptee',
etant_donne_que: etantDonneQue,
groupe_a_decide_de: groupeADecideDe,
}, { version: proposition?.modifie_le });
setResolutionForm({ ...resolutionForm, [id]: { etant_donne_que: '', groupe_a_decide_de: '' } });
flash('Résolution créée');
await charger();
} catch (e) { setError(e.message); }
};
const rejeter = async (id) => {
setError('');
const form = resolutionForm[id] || {};
const etantDonneQue = (form.etant_donne_que || '').trim();
if (!etantDonneQue) {
setError('La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition');
return;
}
try {
const proposition = propositions.find((p) => p.id === id);
await api.patch(`/propositions/${id}/voter`, {
statut: 'rejetee',
etant_donne_que: etantDonneQue,
}, { version: proposition?.modifie_le });
flash('Proposition rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const enCours = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut));
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gouvernance" sousTitre="Décisions du groupe" />
<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>}
<div className="flex gap-2">
{[
['cours', `En cours (${enCours.length})`],
['nouvelle', 'Soumettre'],
['historique', `Résolutions (${resolutions.length})`],
].map(([val, label]) => (
<button key={val} onClick={() => { setTab(val); setError(''); }}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${
tab === val ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>
{label}
</button>
))}
</div>
{tab === 'cours' && (
enCours.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📌</p>
<p className="text-gray-500">Aucune décision en cours</p>
<button onClick={() => setTab('nouvelle')} className="mt-3 text-aa-blue font-bold text-sm">
Soumettre une proposition
</button>
</div>
) : (
<div className="space-y-3">
{enCours.map((p) => {
const statut = STATUTS[p.statut] || STATUTS.proposee;
const form = resolutionForm[p.id] || {};
const resolutionComplete = (form.etant_donne_que || '').trim() && (form.groupe_a_decide_de || '').trim();
const motifRejetPresent = (form.etant_donne_que || '').trim();
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-bold text-gray-800">{p.sujet}</p>
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${statut.cls}`}>
{statut.label}
</span>
</div>
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Proposée par <span className="font-medium text-gray-600">{p.proposeur_prenom}</span></p>
{p.secondeur_prenom && <p>Secondée par <span className="font-medium text-gray-600">{p.secondeur_prenom}</span></p>}
</div>
{p.statut === 'proposee' && (
<div className="mt-3 space-y-2">
{peutExecutif && (
<textarea value={form.etant_donne_que || ''}
onChange={(e) => mettreAJourResolution(p.id, 'etant_donne_que', e.target.value)}
placeholder="ÉTANT DONNÉ QUE..."
rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
)}
<div className="grid grid-cols-2 gap-2">
{peutExecutif && (
<button onClick={() => rejeter(p.id)} disabled={!motifRejetPresent}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
)}
<button onClick={() => seconder(p.id)}
className={`${peutExecutif ? '' : 'col-span-2'} py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-200 active:scale-[0.97] transition`}>
Je seconde
</button>
</div>
</div>
)}
{p.statut === 'proposee' && peutExecutif && (
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
<select value={seconderPour[p.id] || ''} onChange={(e) => setSeconderPour({ ...seconderPour, [p.id]: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">Seconder pour...</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
<button onClick={() => seconder(p.id, seconderPour[p.id])} disabled={!seconderPour[p.id]}
className="px-4 bg-blue-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">
OK
</button>
</div>
)}
{p.statut === 'secondee' && peutExecutif && (
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">ÉTANT DONNÉ QUE</label>
<textarea value={form.etant_donne_que || ''}
onChange={(e) => mettreAJourResolution(p.id, 'etant_donne_que', e.target.value)}
placeholder="Contexte et considérants de la résolution"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">LE GROUPE A DÉCIDÉ DE</label>
<textarea value={form.groupe_a_decide_de || ''}
onChange={(e) => mettreAJourResolution(p.id, 'groupe_a_decide_de', e.target.value)}
placeholder="Décision adoptée par le groupe"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => rejeter(p.id)} disabled={!motifRejetPresent}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 disabled:opacity-40 active:scale-[0.97] transition">
Rejeter
</button>
<button onClick={() => transformer(p.id)} disabled={!resolutionComplete}
className="py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 disabled:opacity-40 active:scale-[0.97] transition">
Transformer
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)
)}
{tab === 'nouvelle' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Objet</label>
<input type="text" value={sujet} onChange={(e) => setSujet(e.target.value)}
placeholder="Objet de la proposition"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
autoFocus />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Détails (optionnel)</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
placeholder="Contexte, justification, informations utiles"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
</div>
<button onClick={soumettre} disabled={!sujet.trim()}
className="w-full p-5 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
Soumettre la proposition
</button>
</div>
)}
{tab === 'historique' && (
resolutions.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📜</p>
<p className="text-gray-500">Aucune résolution</p>
</div>
) : (
<div className="space-y-3">
{resolutions.map((decision) => {
const ouverte = decisionOuverte === decision.id;
const statut = STATUTS[decision.statut] || STATUTS.adoptee;
return (
<div key={decision.id} className={`bg-white rounded-xl p-4 shadow-sm border ${ouverte ? 'border-aa-blue/30' : 'border-transparent'}`}>
<button type="button" onClick={() => setDecisionOuverte(ouverte ? null : decision.id)}
className="w-full text-left">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-mono text-aa-blue font-bold">
{decision.numero_resolution || (decision.statut === 'rejetee' ? 'REJETÉE' : 'Résolution')}
</p>
<p className="font-bold text-gray-800">{decision.sujet}</p>
<p className="text-xs text-gray-400 mt-1">
{decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || 'Sans date'}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${statut.cls}`}>
{decision.statut === 'adoptee' ? 'Adoptée' : statut.label}
</span>
</div>
</button>
{ouverte && (
<>
{decision.description && <p className="text-sm text-gray-500 mt-3">{decision.description}</p>}
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Type : {TYPES[decision.type_decision] || decision.type_decision}</p>
<p>Date : {decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || '—'}</p>
<p>Proposée par {decision.proposeur_prenom}</p>
{decision.secondeur_prenom && <p>Secondée par {decision.secondeur_prenom}</p>}
</div>
{(decision.etant_donne_que || decision.groupe_a_decide_de || decision.note) && (
<div className="mt-3 bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-400 mb-1">{decision.statut === 'rejetee' ? 'Motif conservé' : 'Texte de la résolution'}</p>
{decision.etant_donne_que && (
<p className="text-sm text-gray-700 whitespace-pre-wrap"><strong>ÉTANT DONNÉ QUE</strong>{'\n'}{decision.etant_donne_que}</p>
)}
{decision.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap mt-3"><strong>LE GROUPE A DÉCIDÉ DE</strong>{'\n'}{decision.groupe_a_decide_de}</p>
)}
{!decision.etant_donne_que && !decision.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{decision.note}</p>
)}
</div>
)}
</>
)}
</div>
);
})}
</div>
)
)}
</div>
</div>
);
}