251 lines
13 KiB
JavaScript
251 lines
13 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
|
import { useStore } from '../stores/store';
|
|
import { api } from '../stores/api';
|
|
|
|
const DOMAINES = [
|
|
['', 'Tous les domaines'],
|
|
['propositions', 'Gouvernance'],
|
|
['pv_reunions', 'Procès-verbaux'],
|
|
['collectes', 'Collectes'],
|
|
['depenses', 'Dépenses'],
|
|
['envois_contributions', 'Contributions'],
|
|
['reserves', 'Réserves'],
|
|
['operations_bancaires', 'Opérations bancaires'],
|
|
['mouvements_reserves', 'Virements réserves'],
|
|
['litterature', 'Littérature'],
|
|
['reunions', 'Rencontres'],
|
|
['destinataires_contributions', 'Destinataires'],
|
|
];
|
|
|
|
export default function JournalActions() {
|
|
const { setPage } = useStore();
|
|
const [actions, setActions] = useState([]);
|
|
const [historique, setHistorique] = useState([]);
|
|
const [membres, setMembres] = useState([]);
|
|
const [tab, setTab] = useState('historique');
|
|
const [filtres, setFiltres] = useState({ date_debut: '', date_fin: '', membre_id: '', table_cible: '', action: '', q: '' });
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [ouvertHistorique, setOuvertHistorique] = useState(null);
|
|
const [ouvertTechnique, setOuvertTechnique] = useState(null);
|
|
|
|
const historiqueQuery = (source = filtres) => {
|
|
const params = new URLSearchParams({ limite: '100' });
|
|
Object.entries(source).forEach(([key, value]) => {
|
|
if (value) params.set(key, value);
|
|
});
|
|
return `/journal/historique?${params.toString()}`;
|
|
};
|
|
|
|
const charger = async (sourceFiltres = filtres) => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const [hist, acts, mbrs] = await Promise.all([
|
|
api.get(historiqueQuery(sourceFiltres)),
|
|
api.get('/journal/actions?limite=100'),
|
|
api.get('/membres/actifs'),
|
|
]);
|
|
setHistorique(hist);
|
|
setActions(acts);
|
|
setMembres(mbrs);
|
|
} catch (e) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { charger(); }, []);
|
|
|
|
const formatDate = (value) => new Date(value).toLocaleString('fr-CA', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
});
|
|
const formatDateCompacte = (value) => new Date(value).toLocaleString('fr-CA', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
const formatValeur = (value) => {
|
|
if (value === null || value === undefined || value === '') return '—';
|
|
if (typeof value === 'boolean') return value ? 'oui' : 'non';
|
|
if (Array.isArray(value)) return `${value.length} élément(s)`;
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
return String(value);
|
|
};
|
|
const changements = (avant = {}, apres = {}) => {
|
|
const cles = [...new Set([...Object.keys(avant || {}), ...Object.keys(apres || {})])];
|
|
return cles
|
|
.map((cle) => [cle, avant?.[cle], apres?.[cle]])
|
|
.filter(([, a, b]) => JSON.stringify(a) !== JSON.stringify(b));
|
|
};
|
|
const resumeChangements = (item) => {
|
|
const lignes = changements(item.avant, item.apres);
|
|
if (lignes.length === 0 && item.avant && !item.apres) {
|
|
return Object.keys(item.avant).slice(0, 2).join(', ') || item.raison;
|
|
}
|
|
if (lignes.length === 0) return item.raison;
|
|
const resume = lignes.slice(0, 2)
|
|
.map(([cle, avant, apres]) => `${cle}: ${formatValeur(avant)} → ${formatValeur(apres)}`)
|
|
.join(' · ');
|
|
return lignes.length > 2 ? `${resume} · +${lignes.length - 2}` : resume;
|
|
};
|
|
const domaineLabel = (value) => DOMAINES.find(([id]) => id === value)?.[1] || value || '—';
|
|
const updateFiltre = (key, value) => setFiltres((f) => ({ ...f, [key]: value }));
|
|
const resetFiltres = () => {
|
|
const vides = { date_debut: '', date_fin: '', membre_id: '', table_cible: '', action: '', q: '' };
|
|
setFiltres(vides);
|
|
charger(vides);
|
|
};
|
|
const actionsDisponibles = [...new Set(historique.map((item) => item.action))].sort();
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100">
|
|
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
|
|
<button onClick={() => setPage('accueil')} className="text-2xl">←</button>
|
|
<div>
|
|
<h1 className="text-lg font-bold">Journal</h1>
|
|
<p className="text-sm text-blue-100">Changements enregistrés</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-3">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button onClick={() => setTab('historique')} className={`p-3 rounded-xl text-sm font-bold ${tab === 'historique' ? 'bg-aa-blue text-white' : 'bg-white text-gray-600'}`}>
|
|
Historique
|
|
</button>
|
|
<button onClick={() => setTab('technique')} className={`p-3 rounded-xl text-sm font-bold ${tab === 'technique' ? 'bg-aa-blue text-white' : 'bg-white text-gray-600'}`}>
|
|
Technique
|
|
</button>
|
|
</div>
|
|
{tab === 'historique' && (
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
|
<input
|
|
value={filtres.q}
|
|
onChange={(e) => updateFiltre('q', e.target.value)}
|
|
placeholder="Rechercher dans les raisons"
|
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input type="date" value={filtres.date_debut} onChange={(e) => updateFiltre('date_debut', e.target.value)}
|
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm" />
|
|
<input type="date" value={filtres.date_fin} onChange={(e) => updateFiltre('date_fin', e.target.value)}
|
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<select value={filtres.membre_id} onChange={(e) => updateFiltre('membre_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="">Tous les membres</option>
|
|
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
|
|
</select>
|
|
<select value={filtres.table_cible} onChange={(e) => updateFiltre('table_cible', e.target.value)}
|
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
|
|
{DOMAINES.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
|
|
</select>
|
|
</div>
|
|
<select value={filtres.action} onChange={(e) => updateFiltre('action', 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="">Toutes les actions</option>
|
|
{actionsDisponibles.map((action) => <option key={action} value={action}>{action}</option>)}
|
|
</select>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button onClick={charger} className="p-3 bg-aa-blue text-white rounded-xl text-sm font-bold">Filtrer</button>
|
|
<button onClick={resetFiltres} className="p-3 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">Réinitialiser</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{loading && <p className="text-gray-500 text-center py-8">Chargement...</p>}
|
|
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-sm">{error}</div>}
|
|
{!loading && tab === 'historique' && historique.length === 0 && (
|
|
<p className="text-gray-500 text-center py-8">Aucun changement journalisé.</p>
|
|
)}
|
|
{!loading && tab === 'technique' && actions.length === 0 && (
|
|
<p className="text-gray-500 text-center py-8">Aucune action technique journalisée.</p>
|
|
)}
|
|
{tab === 'historique' && historique.map((item) => {
|
|
const ouvert = ouvertHistorique === item.id;
|
|
const lignes = changements(item.avant, item.apres);
|
|
return (
|
|
<div key={item.id} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOuvertHistorique(ouvert ? null : item.id)}
|
|
className="w-full p-3 text-left flex items-center gap-3 active:bg-gray-50"
|
|
>
|
|
<span className="w-[84px] shrink-0 text-xs font-bold text-gray-500">{formatDateCompacte(item.cree_le)}</span>
|
|
<span className="flex-1 min-w-0 text-sm font-medium text-gray-800 truncate">{resumeChangements(item)}</span>
|
|
<span className="text-gray-300 text-lg">{ouvert ? '⌃' : '⌄'}</span>
|
|
</button>
|
|
{ouvert && (
|
|
<div className="px-4 pb-4 space-y-3 border-t border-gray-100">
|
|
<div className="pt-3">
|
|
<p className="font-bold text-gray-800">{item.raison}</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{formatDate(item.cree_le)} · {item.membre_prenom || item.membre_id || 'Membre inconnu'}
|
|
</p>
|
|
</div>
|
|
{lignes.length > 0 && (
|
|
<div className="rounded-xl bg-gray-50 p-3 text-xs text-gray-600 space-y-2">
|
|
{lignes.map(([cle, avant, apres]) => (
|
|
<div key={cle} className="grid grid-cols-[92px_1fr] gap-2">
|
|
<span className="font-bold text-gray-500">{cle}</span>
|
|
<span>
|
|
<span className="text-red-700">{formatValeur(avant)}</span>
|
|
<span className="text-gray-400"> → </span>
|
|
<span className="text-green-700">{formatValeur(apres)}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{item.avant && !item.apres && (
|
|
<div className="rounded-xl bg-red-50 p-3 text-xs text-red-700 space-y-1">
|
|
{Object.entries(item.avant).map(([cle, valeur]) => (
|
|
<p key={cle}><span className="font-bold">{cle}:</span> {formatValeur(valeur)}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
|
<p><span className="font-bold text-gray-600">Domaine</span><br />{domaineLabel(item.table_cible)}</p>
|
|
<p><span className="font-bold text-gray-600">Action</span><br />{item.action}</p>
|
|
<p className="col-span-2 break-all"><span className="font-bold text-gray-600">Objet</span><br />{item.objet_id}</p>
|
|
{item.chemin_api && <p className="col-span-2 break-all"><span className="font-bold text-gray-600">API</span><br />{item.chemin_api}</p>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{tab === 'technique' && actions.map((action) => {
|
|
const ouvert = ouvertTechnique === action.id;
|
|
return (
|
|
<div key={action.id} className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOuvertTechnique(ouvert ? null : action.id)}
|
|
className="w-full p-3 text-left flex items-center gap-3 active:bg-gray-50"
|
|
>
|
|
<span className="w-[84px] shrink-0 text-xs font-bold text-gray-500">{formatDateCompacte(action.cree_le)}</span>
|
|
<span className="flex-1 min-w-0 text-sm font-medium text-gray-800 truncate">{action.methode} {action.chemin}</span>
|
|
<span className="text-xs font-bold rounded-full bg-gray-100 text-gray-600 px-2 py-1">{action.statut_http}</span>
|
|
</button>
|
|
{ouvert && (
|
|
<div className="px-4 pb-4 pt-3 border-t border-gray-100 text-xs text-gray-500 space-y-1">
|
|
<p><span className="font-bold text-gray-600">Date</span>: {formatDate(action.cree_le)}</p>
|
|
<p className="break-all"><span className="font-bold text-gray-600">Membre</span>: {action.membre_id}</p>
|
|
{action.admin_id && <p className="break-all"><span className="font-bold text-gray-600">Admin</span>: {action.admin_id}</p>}
|
|
{action.adresse_ip && <p><span className="font-bold text-gray-600">IP</span>: {action.adresse_ip}</p>}
|
|
{action.details?.impersonification && (
|
|
<p className="text-amber-700 font-medium">Action faite en impersonification</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|