Ajouter section services RSG

This commit is contained in:
Daniel Allaire 2026-06-01 14:42:37 -04:00
parent f04871c3b3
commit 0eb3ac05a5
10 changed files with 220 additions and 125 deletions

View file

@ -61,6 +61,11 @@ async def lifespan(app: FastAPI):
await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)")) await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)")) await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)"))
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID")) await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID"))
await conn.execute(text("ALTER TABLE rapports_rsg ADD COLUMN IF NOT EXISTS infos_district TEXT"))
await conn.execute(text("ALTER TABLE rapports_rsg ADD COLUMN IF NOT EXISTS positions_groupe TEXT"))
await conn.execute(text("ALTER TABLE rapports_rsg ADD COLUMN IF NOT EXISTS suivis TEXT"))
await conn.execute(text("ALTER TABLE rapports_rsg ADD COLUMN IF NOT EXISTS prochaine_echeance TEXT"))
await conn.execute(text("ALTER TABLE rapports_rsg ADD COLUMN IF NOT EXISTS liens_utiles TEXT"))
for table in ( for table in (
"propositions", "propositions",
"depenses", "depenses",

View file

@ -14,6 +14,11 @@ class RapportRsg(Base):
reunion_dist: Mapped[date] = mapped_column(Date, nullable=False) reunion_dist: Mapped[date] = mapped_column(Date, nullable=False)
redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
decisions: Mapped[list | None] = mapped_column(JSON) decisions: Mapped[list | None] = mapped_column(JSON)
infos_district: Mapped[str | None] = mapped_column(Text)
infos_region: Mapped[str | None] = mapped_column(Text) infos_region: Mapped[str | None] = mapped_column(Text)
sujets_groupe: Mapped[str | None] = mapped_column(Text) sujets_groupe: Mapped[str | None] = mapped_column(Text)
positions_groupe: Mapped[str | None] = mapped_column(Text)
suivis: Mapped[str | None] = mapped_column(Text)
prochaine_echeance: Mapped[str | None] = mapped_column(Text)
liens_utiles: Mapped[str | None] = mapped_column(Text)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -46,10 +46,10 @@ MODULES_SAISIE = {
ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"), ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"),
], ],
"RSG": [ "RSG": [
ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), ModuleTuile(code="M3", nom="Services", description="District, région, sujets et suivis", icone="📋", couleur="blue"),
], ],
"RSG Substitut(e)": [ "RSG Substitut(e)": [
ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"), ModuleTuile(code="M3", nom="Services", description="District, région, sujets et suivis", icone="📋", couleur="blue"),
], ],
"Animateur(trice)": [], "Animateur(trice)": [],
"Responsable littérature": [ "Responsable littérature": [

View file

@ -1,4 +1,4 @@
"""Routes M3 — Rapport du RSG.""" """Routes M3 — Services généraux."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
@ -11,7 +11,7 @@ from app.models.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.schemas.phase4 import RapportRsgCreate, RapportRsgOut from app.schemas.phase4 import RapportRsgCreate, RapportRsgOut
router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Rapport RSG"]) router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Services"])
async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)): async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)):
@ -39,8 +39,13 @@ async def _to_out(r: RapportRsg, db: AsyncSession) -> RapportRsgOut:
reunion_dist=r.reunion_dist, reunion_dist=r.reunion_dist,
redacteur_prenom=redacteur.prenom if redacteur else "?", redacteur_prenom=redacteur.prenom if redacteur else "?",
decisions=r.decisions, decisions=r.decisions,
infos_district=r.infos_district,
infos_region=r.infos_region, infos_region=r.infos_region,
sujets_groupe=r.sujets_groupe, sujets_groupe=r.sujets_groupe,
positions_groupe=r.positions_groupe,
suivis=r.suivis,
prochaine_echeance=r.prochaine_echeance,
liens_utiles=r.liens_utiles,
cree_le=r.cree_le, cree_le=r.cree_le,
) )
@ -51,7 +56,7 @@ async def liste_rapports_rsg(
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Lister les rapports RSG du groupe.""" """Lister les renseignements de service du groupe."""
result = await db.execute( result = await db.execute(
select(RapportRsg) select(RapportRsg)
.where(RapportRsg.groupe_id == membre.groupe_id) .where(RapportRsg.groupe_id == membre.groupe_id)
@ -67,14 +72,19 @@ async def creer_rapport_rsg(
membre=Depends(require_rsg), membre=Depends(require_rsg),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""M3 — Rédiger un rapport de réunion de district.""" """M3 — Renseigner les services généraux."""
rapport = RapportRsg( rapport = RapportRsg(
groupe_id=membre.groupe_id, groupe_id=membre.groupe_id,
reunion_dist=req.reunion_dist, reunion_dist=req.reunion_dist,
redacteur_id=membre.id, redacteur_id=membre.id,
decisions=req.decisions, decisions=req.decisions,
infos_district=req.infos_district,
infos_region=req.infos_region, infos_region=req.infos_region,
sujets_groupe=req.sujets_groupe, sujets_groupe=req.sujets_groupe,
positions_groupe=req.positions_groupe,
suivis=req.suivis,
prochaine_echeance=req.prochaine_echeance,
liens_utiles=req.liens_utiles,
) )
db.add(rapport) db.add(rapport)
await db.flush() await db.flush()

View file

@ -1,4 +1,4 @@
"""Schémas Phase 4 — PV, Présences, Rapport RSG.""" """Schémas Phase 4 — PV, Présences, Services."""
from pydantic import BaseModel from pydantic import BaseModel
from datetime import date, datetime from datetime import date, datetime
from uuid import UUID from uuid import UUID
@ -79,12 +79,17 @@ class PvAdoption(BaseModel):
contenu: Optional[str] = None contenu: Optional[str] = None
# ── M3 Rapport RSG ── # ── M3 Services ──
class RapportRsgCreate(BaseModel): class RapportRsgCreate(BaseModel):
reunion_dist: date reunion_dist: date
decisions: Optional[list[str]] = None decisions: Optional[list[str]] = None
infos_district: Optional[str] = None
infos_region: Optional[str] = None infos_region: Optional[str] = None
sujets_groupe: Optional[str] = None sujets_groupe: Optional[str] = None
positions_groupe: Optional[str] = None
suivis: Optional[str] = None
prochaine_echeance: Optional[str] = None
liens_utiles: Optional[str] = None
class RapportRsgOut(BaseModel): class RapportRsgOut(BaseModel):
@ -92,8 +97,13 @@ class RapportRsgOut(BaseModel):
reunion_dist: date reunion_dist: date
redacteur_prenom: str redacteur_prenom: str
decisions: Optional[list[str]] = None decisions: Optional[list[str]] = None
infos_district: Optional[str] = None
infos_region: Optional[str] = None infos_region: Optional[str] = None
sujets_groupe: Optional[str] = None sujets_groupe: Optional[str] = None
positions_groupe: Optional[str] = None
suivis: Optional[str] = None
prochaine_echeance: Optional[str] = None
liens_utiles: Optional[str] = None
cree_le: datetime cree_le: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View file

@ -132,7 +132,24 @@ La page de l'assemblée sert d'ordre du jour. Le rapport PDF sert de procès-ver
Dans une assemblée, ouvrir `Présences`, cocher les membres présents et enregistrer. Les présences alimentent le rapport PDF. Dans une assemblée, ouvrir `Présences`, cocher les membres présents et enregistrer. Les présences alimentent le rapport PDF.
## 11. Procès-verbal et rapport du trésorier ## 11. Services
Le module `Services` est destiné au RSG, au RSG substitut et aux exécutifs. Il sert à garder la mémoire des informations de service à transmettre entre le district, la région et le groupe.
On peut y renseigner:
- les décisions du district;
- les informations du district;
- les informations de la région;
- les sujets à rapporter au groupe;
- les positions du groupe à transmettre;
- les suivis à faire;
- les prochaines échéances;
- les liens ou références utiles.
Les renseignements précédents restent consultables par date de réunion de district.
## 12. Procès-verbal et rapport du trésorier
L'adoption d'un procès-verbal ou d'un rapport du trésorier est faite par un exécutif avec un bouton `Adopter`. L'adoption demande un proposeur et un secondeur. L'adoption d'un procès-verbal ou d'un rapport du trésorier est faite par un exécutif avec un bouton `Adopter`. L'adoption demande un proposeur et un secondeur.
@ -140,7 +157,7 @@ Avant adoption, le rapport peut être corrigé. Après adoption, il est cristall
Le rapport du trésorier doit être adopté avant de décider du montant des contributions. Le rapport du trésorier doit être adopté avant de décider du montant des contributions.
## 12. Gouvernance ## 13. Gouvernance
Le module `Gouvernance` rassemble les propositions, réflexions en cours, rejets et décisions. Le module `Gouvernance` rassemble les propositions, réflexions en cours, rejets et décisions.
@ -169,7 +186,7 @@ Les types de décisions sont:
- `Nomination`; - `Nomination`;
- `Contribution`. - `Contribution`.
## 13. Dépenses ## 14. Dépenses
Un membre peut soumettre une dépense en son nom. Un exécutif peut soumettre pour un autre membre. Un membre peut soumettre une dépense en son nom. Un exécutif peut soumettre pour un autre membre.
@ -179,7 +196,7 @@ La dépense est ensuite traitée dans `Trésorerie`:
- `Chèque` ou `Virement`: crée un engagement jusqu'au débit bancaire; - `Chèque` ou `Virement`: crée un engagement jusqu'au débit bancaire;
- `Rejeter`: efface la demande. - `Rejeter`: efface la demande.
## 14. Trésorerie ## 15. Trésorerie
Le module `Trésorerie` présente d'abord la position du groupe: Le module `Trésorerie` présente d'abord la position du groupe:
@ -203,7 +220,7 @@ Les exécutifs peuvent:
La page `Résultats` présente l'état des résultats par mois. La page `Résultats` présente l'état des résultats par mois.
## 15. Donner au suivant ## 16. Donner au suivant
Le module `Donner au suivant` sert à préparer une contribution. Le module `Donner au suivant` sert à préparer une contribution.
@ -217,7 +234,7 @@ Il faut choisir:
La contribution crée une décision de groupe. Elle doit ensuite être approuvée et traitée par la trésorerie avant de modifier les soldes. La contribution crée une décision de groupe. Elle doit ensuite être approuvée et traitée par la trésorerie avant de modifier les soldes.
## 16. Postes à pourvoir ## 17. Postes à pourvoir
Les postes disponibles sont affichés en tuiles. En ouvrant une tuile, le membre voit: Les postes disponibles sont affichés en tuiles. En ouvrant une tuile, le membre voit:
@ -228,7 +245,7 @@ Les postes disponibles sont affichés en tuiles. En ouvrant une tuile, le membre
`Postuler` sert pour soi-même. `Proposer` permet à un exécutif de proposer un autre membre. `Postuler` sert pour soi-même. `Proposer` permet à un exécutif de proposer un autre membre.
## 17. Gestion des membres ## 18. Gestion des membres
Les exécutifs peuvent: Les exécutifs peuvent:
@ -240,7 +257,7 @@ Les exécutifs peuvent:
Sysadmin peut impersonifier un membre pour aider ou vérifier un problème. Sysadmin peut impersonifier un membre pour aider ou vérifier un problème.
## 18. Gestion des postes et mandats ## 19. Gestion des postes et mandats
`Gestion des postes` permet de définir les postes et leurs permissions. `Gestion des postes` permet de définir les postes et leurs permissions.
@ -248,15 +265,15 @@ Sysadmin peut impersonifier un membre pour aider ou vérifier un problème.
Une affectation produit une décision de type `Nomination` avec proposeur et secondeur. Une affectation produit une décision de type `Nomination` avec proposeur et secondeur.
## 19. Littérature et jetons ## 20. Littérature et jetons
Les membres peuvent consulter l'inventaire de littérature. Les responsables peuvent ajuster les stocks. Les ventes diminuent les stocks; les remises statistiques de jetons ne les diminuent pas. Les membres peuvent consulter l'inventaire de littérature. Les responsables peuvent ajuster les stocks. Les ventes diminuent les stocks; les remises statistiques de jetons ne les diminuent pas.
## 20. Événements ## 21. Événements
Le module `Événements` affiche les événements passés et futurs. Les filtres peuvent être combinés. Le module `Événements` affiche les événements passés et futurs. Les filtres peuvent être combinés.
## 21. Rapports PDF et mode papier ## 22. Rapports PDF et mode papier
Les rapports PDF servent à imprimer ou conserver: Les rapports PDF servent à imprimer ou conserver:
@ -268,11 +285,11 @@ Les rapports PDF servent à imprimer ou conserver:
`Mode papier` soutient une reprise en main si l'informatique n'est pas disponible. `Mode papier` soutient une reprise en main si l'informatique n'est pas disponible.
## 22. Journal ## 23. Journal
Le journal affiche les changements enregistrés. Une ligne condensée montre date, heure et domaine; l'ouverture donne les détails. Il sert à comprendre ce qui a changé, pas à surveiller les membres. Le journal affiche les changements enregistrés. Une ligne condensée montre date, heure et domaine; l'ouverture donne les détails. Il sert à comprendre ce qui a changé, pas à surveiller les membres.
## 23. Gestion des groupes ## 24. Gestion des groupes
Sur la page de sélection des groupes, le bouton discret `Sysadmin` demande le PIN sysadmin. Il ouvre la gestion des groupes. Sur la page de sélection des groupes, le bouton discret `Sysadmin` demande le PIN sysadmin. Il ouvre la gestion des groupes.
@ -285,4 +302,3 @@ Sysadmin peut:
- réinitialiser le groupe de démonstration. - réinitialiser le groupe de démonstration.
`Groupe Démonstration` est toujours présent sur une installation vanille et ne peut pas être supprimé. `Groupe Démonstration` est toujours présent sur une installation vanille et ne peut pas être supprimé.

View file

@ -42,7 +42,7 @@ Ce document décrit les tables SQLAlchemy de l'application et leur rôle. Il ne
| `reunions` | Rencontres hebdomadaires et assemblées d'affaires. | | `reunions` | Rencontres hebdomadaires et assemblées d'affaires. |
| `presences` | Présences rattachées à une réunion. | | `presences` | Présences rattachées à une réunion. |
| `pv_reunions` | Procès-verbaux préparés ou adoptés. | | `pv_reunions` | Procès-verbaux préparés ou adoptés. |
| `rapport_rsg` / `rapports_rsg` | Rapports RSG. | | `rapport_rsg` / `rapports_rsg` | Renseignements de service portés par le RSG: district, région, sujets au groupe, positions transmises et suivis. |
| `rapports_adoptes` | Rapports cristallisés après adoption. | | `rapports_adoptes` | Rapports cristallisés après adoption. |
## Tables de gouvernance ## Tables de gouvernance
@ -108,4 +108,3 @@ Le démarrage supprime les anciennes tables devenues obsolètes:
- Une relation 1:1 ne justifie pas à elle seule la fusion de deux tables. Il faut aussi vérifier le cycle de vie, le niveau d'accès, l'historisation, la fréquence d'usage et la signification métier. - Une relation 1:1 ne justifie pas à elle seule la fusion de deux tables. Il faut aussi vérifier le cycle de vie, le niveau d'accès, l'historisation, la fréquence d'usage et la signification métier.
- Les caches de montant ou de statut ne doivent pas remplacer les registres d'origine. - Les caches de montant ou de statut ne doivent pas remplacer les registres d'origine.
- Toute nouvelle table métier doit documenter son rattachement à un groupe. - Toute nouvelle table métier doit documenter son rattachement à un groupe.

View file

@ -28,7 +28,7 @@ const CONTEXTES = {
description: "État général et consultations courantes.", description: "État général et consultations courantes.",
sections: [ sections: [
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'C9'] }, { titre: 'À faire maintenant', codes: ['S5', 'C5', 'C9'] },
{ titre: 'État', codes: ['S1T', 'C2', 'C1', 'C4'] }, { titre: 'État', codes: ['S1T', 'M3', 'C2', 'C1', 'C4'] },
{ titre: 'Mon espace', codes: ['C7', 'C11', 'C13', 'C12'] }, { titre: 'Mon espace', codes: ['C7', 'C11', 'C13', 'C12'] },
], ],
}, },
@ -48,6 +48,7 @@ const CONTEXTES = {
sections: [ sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] }, { titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G14', 'G2', 'G11', 'G12', 'G9', 'G13'] }, { titre: 'Membres et structure', codes: ['G14', 'G2', 'G11', 'G12', 'G9', 'G13'] },
{ titre: 'Services', codes: ['M3'] },
{ titre: 'Inventaire', codes: ['M5'] }, { titre: 'Inventaire', codes: ['M5'] },
], ],
}, },

View file

@ -12,7 +12,7 @@ const MODULES = [
['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"], ['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"],
['C5', 'Gouvernance'], ['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Donner au suivant'], ['S1T', 'Trésorerie'], ['M4', 'Donner au suivant'],
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'], ['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Services'],
['S2', 'Remise jeton'], ['M6', 'Commander jetons'], ['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'], ['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'],
['G2', 'Gestion membres'], ['G9', 'Paramètres'], ['G2', 'Gestion membres'], ['G9', 'Paramètres'],

View file

@ -3,151 +3,200 @@ import { api } from '../stores/api';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
const champsTexte = [
['infos_district', 'Informations du district', 'Informations reçues au district, comités, besoins de service...'],
['infos_region', 'Informations de la région', "Nouvelles de la région, ateliers, assemblées, documents utiles..."],
['sujets_groupe', 'Sujets à rapporter au groupe', "Questions, consultations ou points à amener à la prochaine assemblée d'affaires..."],
['positions_groupe', 'Positions du groupe à transmettre', 'Décisions, orientations ou messages que le RSG doit porter au district...'],
['suivis', 'Suivis à faire', 'Actions à ne pas oublier, personnes à relancer, documents à apporter...'],
['prochaine_echeance', 'Prochaine échéance', 'Date, rencontre ou action attendue...'],
['liens_utiles', 'Liens ou références utiles', 'Liens, noms de documents, coordonnées ou références à conserver...'],
];
function formatDate(valeur) {
if (!valeur) return '';
return new Date(`${valeur}T12:00:00`).toLocaleDateString('fr-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function SectionLecture({ titre, children }) {
if (!children) return null;
return (
<section className="bg-white rounded-xl p-4 shadow-sm">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">{titre}</h3>
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">{children}</div>
</section>
);
}
function RapportDetail({ rapport, onBack }) {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Services" sousTitre={`Réunion du ${formatDate(rapport.reunion_dist)}`} />
<div className="p-3 space-y-3">
<button onClick={onBack} className="tap-target px-4 rounded-xl bg-white text-aa-blue text-sm font-bold shadow-sm">
Retour
</button>
<section className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-xs text-gray-400">Renseigné par</p>
<p className="text-base font-bold text-gray-800">{rapport.redacteur_prenom}</p>
</section>
{rapport.decisions?.length > 0 && (
<section className="bg-white rounded-xl p-4 shadow-sm">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Décisions du district</h3>
<div className="space-y-2">
{rapport.decisions.map((decision, index) => (
<p key={index} className="text-sm text-gray-700 leading-relaxed">{decision}</p>
))}
</div>
</section>
)}
{champsTexte.map(([cle, label]) => (
<SectionLecture key={cle} titre={label}>{rapport[cle]}</SectionLecture>
))}
</div>
</div>
);
}
export default function RapportRSG() { export default function RapportRSG() {
const [rapports, setRapports] = useState([]); const [rapports, setRapports] = useState([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
reunion_dist: new Date().toISOString().slice(0, 10), reunion_dist: new Date().toISOString().slice(0, 10),
decisions_text: '', decisions_text: '',
infos_district: '',
infos_region: '', infos_region: '',
sujets_groupe: '', sujets_groupe: '',
positions_groupe: '',
suivis: '',
prochaine_echeance: '',
liens_utiles: '',
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [viewRapport, setViewRapport] = useState(null); const [viewRapport, setViewRapport] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { const chargerRapports = () => {
setLoading(true);
api.get('/rapports-rsg/').then((data) => { api.get('/rapports-rsg/').then((data) => {
setRapports(data); setRapports(data);
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
};
useEffect(() => {
chargerRapports();
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.decisions_text.trim() && !form.infos_region.trim() && !form.sujets_groupe.trim()) { const sectionsRemplies = champsTexte.some(([cle]) => form[cle].trim());
if (!form.decisions_text.trim() && !sectionsRemplies) {
setError('Au moins une section doit être remplie'); setError('Au moins une section doit être remplie');
return; return;
} }
setError(''); setError('');
try {
const decisionsList = form.decisions_text const decisionsList = form.decisions_text
.split('\n') .split('\n')
.map((d) => d.trim()) .map((d) => d.trim())
.filter((d) => d.length > 0); .filter(Boolean);
try {
await api.post('/rapports-rsg/', { await api.post('/rapports-rsg/', {
reunion_dist: form.reunion_dist, reunion_dist: form.reunion_dist,
decisions: decisionsList.length > 0 ? decisionsList : null, decisions: decisionsList.length > 0 ? decisionsList : null,
infos_district: form.infos_district.trim() || null,
infos_region: form.infos_region.trim() || null, infos_region: form.infos_region.trim() || null,
sujets_groupe: form.sujets_groupe.trim() || null, sujets_groupe: form.sujets_groupe.trim() || null,
positions_groupe: form.positions_groupe.trim() || null,
suivis: form.suivis.trim() || null,
prochaine_echeance: form.prochaine_echeance.trim() || null,
liens_utiles: form.liens_utiles.trim() || null,
}); });
setSuccess(true); setSuccess(true);
} catch (e) { setError(e.message); } } catch (e) {
setError(e.message);
}
}; };
if (success) return <SuccessCheck message="Rapport RSG enregistré ✓" />; if (success) return <SuccessCheck message="Renseignement de service enregistré" />;
if (viewRapport) return <RapportDetail rapport={viewRapport} onBack={() => setViewRapport(null)} />;
// Vue lecture
if (viewRapport) {
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={() => setViewRapport(null)} className="text-2xl">←</button>
<div>
<h1 className="text-lg font-bold">Rapport RSG</h1>
<p className="text-blue-200 text-xs">Réunion du {viewRapport.reunion_dist}</p>
</div>
</div>
<div className="p-4 space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-400 mb-1">Rédigé par {viewRapport.redacteur_prenom}</p>
</div>
{viewRapport.decisions && viewRapport.decisions.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-700 mb-2">Décisions du district</h3>
{viewRapport.decisions.map((d, i) => (
<div key={i} className="py-2 border-b border-gray-100 last:border-0">
<p className="text-sm text-gray-800">• {d}</p>
</div>
))}
</div>
)}
{viewRapport.infos_region && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-700 mb-2">Informations de la région</h3>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{viewRapport.infos_region}</p>
</div>
)}
{viewRapport.sujets_groupe && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-700 mb-2">Sujets pour le groupe</h3>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{viewRapport.sujets_groupe}</p>
</div>
)}
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
<PageHeader titre="Rapport du RSG" sousTitre="M3 — Réunion de district" /> <PageHeader titre="Services" sousTitre="District, région, sujets et suivis" />
<div className="p-4 space-y-4"> <div className="p-3 space-y-4">
{/* Rapports existants */} <section className="bg-white rounded-xl p-4 shadow-sm">
{rapports.length > 0 && ( <h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Renseignements récents</h2>
<div className="bg-white rounded-2xl p-4 shadow-sm"> {loading ? (
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Rapports récents</h2> <p className="text-sm text-gray-500">Chargement...</p>
{rapports.slice(0, 5).map((r) => ( ) : rapports.length === 0 ? (
<button key={r.id} onClick={() => setViewRapport(r)} <p className="text-sm text-gray-500">Aucun renseignement de service enregistré.</p>
className="w-full flex items-center justify-between py-2 border-b border-gray-50 last:border-0 active:bg-gray-50"> ) : (
<div> <div className="divide-y divide-gray-100">
<p className="font-medium text-sm text-gray-800">Réunion du {r.reunion_dist}</p> {rapports.slice(0, 8).map((rapport) => (
<p className="text-xs text-gray-400">Par {r.redacteur_prenom}</p> <button
key={rapport.id}
onClick={() => setViewRapport(rapport)}
className="w-full py-3 flex items-center justify-between gap-3 text-left active:bg-gray-50"
>
<div className="min-w-0">
<p className="font-bold text-sm text-gray-800 truncate">{formatDate(rapport.reunion_dist)}</p>
<p className="text-xs text-gray-400 truncate">Par {rapport.redacteur_prenom}</p>
</div> </div>
<span className="text-gray-300">→</span> <span className="text-gray-300 text-lg">›</span>
</button> </button>
))} ))}
</div> </div>
)} )}
</section>
{/* Nouveau rapport */} <section className="bg-white rounded-xl p-4 shadow-sm">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date de la réunion de district</label> <label className="text-xs text-gray-400 block mb-1">Date de la réunion de district</label>
<input type="date" value={form.reunion_dist} <input
type="date"
value={form.reunion_dist}
onChange={(e) => setForm({ ...form, reunion_dist: e.target.value })} onChange={(e) => setForm({ ...form, reunion_dist: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> className="w-full min-h-[48px] p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
</div> />
</section>
<div className="bg-white rounded-2xl p-4 shadow-sm"> <section className="bg-white rounded-xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Décisions du district (une par ligne)</label> <label className="text-xs text-gray-400 block mb-1">Décisions du district</label>
<textarea value={form.decisions_text} <textarea
onChange={(e) => setForm({ ...form, decisions_text: e.target.value })} rows={4} value={form.decisions_text}
placeholder="Vote sur le budget annuel approuvé&#10;Nouveau format d'atelier proposé&#10;Date du prochain congrès confirmée" onChange={(e) => setForm({ ...form, decisions_text: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" /> rows={4}
</div> placeholder="Une décision par ligne"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm"
/>
</section>
<div className="bg-white rounded-2xl p-4 shadow-sm"> {champsTexte.map(([cle, label, placeholder]) => (
<label className="text-xs text-gray-400 block mb-1">Informations de la région 87</label> <section key={cle} className="bg-white rounded-xl p-4 shadow-sm">
<textarea value={form.infos_region} <label className="text-xs text-gray-400 block mb-1">{label}</label>
onChange={(e) => setForm({ ...form, infos_region: e.target.value })} rows={3} <textarea
placeholder="Nouvelles de l'assemblée régionale, rapports..." value={form[cle]}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" /> onChange={(e) => setForm({ ...form, [cle]: e.target.value })}
</div> rows={cle === 'liens_utiles' || cle === 'prochaine_echeance' ? 2 : 3}
placeholder={placeholder}
<div className="bg-white rounded-2xl p-4 shadow-sm"> className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm"
<label className="text-xs text-gray-400 block mb-1">Sujets à rapporter au groupe</label> />
<textarea value={form.sujets_groupe} </section>
onChange={(e) => setForm({ ...form, sujets_groupe: e.target.value })} rows={3} ))}
placeholder="Points à discuter à la prochaine réunion d'affaires..."
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>} {error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit} <button
className="w-full p-5 bg-blue-700 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"> onClick={handleSubmit}
📨 Enregistrer le rapport className="w-full p-5 bg-blue-700 text-white rounded-xl text-lg font-bold disabled:opacity-40 active:scale-[0.98] transition shadow-lg"
>
Enregistrer
</button> </button>
</div> </div>
</div> </div>