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 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 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 (
"propositions",
"depenses",

View file

@ -14,6 +14,11 @@ class RapportRsg(Base):
reunion_dist: Mapped[date] = mapped_column(Date, nullable=False)
redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
decisions: Mapped[list | None] = mapped_column(JSON)
infos_district: Mapped[str | None] = mapped_column(Text)
infos_region: 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))

View file

@ -46,10 +46,10 @@ MODULES_SAISIE = {
ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"),
],
"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)": [
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)": [],
"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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@ -11,7 +11,7 @@ from app.models.affectation import Affectation
from app.models.poste import Poste
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)):
@ -39,8 +39,13 @@ async def _to_out(r: RapportRsg, db: AsyncSession) -> RapportRsgOut:
reunion_dist=r.reunion_dist,
redacteur_prenom=redacteur.prenom if redacteur else "?",
decisions=r.decisions,
infos_district=r.infos_district,
infos_region=r.infos_region,
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,
)
@ -51,7 +56,7 @@ async def liste_rapports_rsg(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les rapports RSG du groupe."""
"""Lister les renseignements de service du groupe."""
result = await db.execute(
select(RapportRsg)
.where(RapportRsg.groupe_id == membre.groupe_id)
@ -67,14 +72,19 @@ async def creer_rapport_rsg(
membre=Depends(require_rsg),
db: AsyncSession = Depends(get_db),
):
"""M3 — Rédiger un rapport de réunion de district."""
"""M3 — Renseigner les services généraux."""
rapport = RapportRsg(
groupe_id=membre.groupe_id,
reunion_dist=req.reunion_dist,
redacteur_id=membre.id,
decisions=req.decisions,
infos_district=req.infos_district,
infos_region=req.infos_region,
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)
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 datetime import date, datetime
from uuid import UUID
@ -79,12 +79,17 @@ class PvAdoption(BaseModel):
contenu: Optional[str] = None
# ── M3 Rapport RSG ──
# ── M3 Services ──
class RapportRsgCreate(BaseModel):
reunion_dist: date
decisions: Optional[list[str]] = None
infos_district: Optional[str] = None
infos_region: 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):
@ -92,8 +97,13 @@ class RapportRsgOut(BaseModel):
reunion_dist: date
redacteur_prenom: str
decisions: Optional[list[str]] = None
infos_district: Optional[str] = None
infos_region: 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
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.
## 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.
@ -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.
## 12. Gouvernance
## 13. Gouvernance
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`;
- `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.
@ -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;
- `Rejeter`: efface la demande.
## 14. Trésorerie
## 15. Trésorerie
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.
## 15. Donner au suivant
## 16. Donner au suivant
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.
## 16. Postes à pourvoir
## 17. Postes à pourvoir
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.
## 17. Gestion des membres
## 18. Gestion des membres
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.
## 18. Gestion des postes et mandats
## 19. Gestion des postes et mandats
`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.
## 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.
## 20. Événements
## 21. Événements
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:
@ -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.
## 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.
## 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.
@ -285,4 +302,3 @@ Sysadmin peut:
- réinitialiser le groupe de démonstration.
`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. |
| `presences` | Présences rattachées à une réunion. |
| `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. |
## 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.
- 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.

View file

@ -28,7 +28,7 @@ const CONTEXTES = {
description: "État général et consultations courantes.",
sections: [
{ 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'] },
],
},
@ -48,6 +48,7 @@ const CONTEXTES = {
sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G14', 'G2', 'G11', 'G12', 'G9', 'G13'] },
{ titre: 'Services', codes: ['M3'] },
{ titre: 'Inventaire', codes: ['M5'] },
],
},

View file

@ -12,7 +12,7 @@ const MODULES = [
['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"],
['C5', 'Gouvernance'],
['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'],
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'],
['G2', 'Gestion membres'], ['G9', 'Paramètres'],

View file

@ -3,151 +3,200 @@ import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
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() {
const [rapports, setRapports] = useState([]);
const [form, setForm] = useState({
reunion_dist: new Date().toISOString().slice(0, 10),
decisions_text: '',
infos_district: '',
infos_region: '',
sujets_groupe: '',
positions_groupe: '',
suivis: '',
prochaine_echeance: '',
liens_utiles: '',
});
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [viewRapport, setViewRapport] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
const chargerRapports = () => {
setLoading(true);
api.get('/rapports-rsg/').then((data) => {
setRapports(data);
setLoading(false);
}).catch(() => setLoading(false));
};
useEffect(() => {
chargerRapports();
}, []);
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');
return;
}
setError('');
try {
const decisionsList = form.decisions_text
.split('\n')
.map((d) => d.trim())
.filter((d) => d.length > 0);
const decisionsList = form.decisions_text
.split('\n')
.map((d) => d.trim())
.filter(Boolean);
try {
await api.post('/rapports-rsg/', {
reunion_dist: form.reunion_dist,
decisions: decisionsList.length > 0 ? decisionsList : null,
infos_district: form.infos_district.trim() || null,
infos_region: form.infos_region.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);
} catch (e) { setError(e.message); }
} catch (e) {
setError(e.message);
}
};
if (success) return <SuccessCheck message="Rapport RSG enregistré ✓" />;
// 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>
);
}
if (success) return <SuccessCheck message="Renseignement de service enregistré" />;
if (viewRapport) return <RapportDetail rapport={viewRapport} onBack={() => setViewRapport(null)} />;
return (
<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">
{/* Rapports existants */}
{rapports.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Rapports récents</h2>
{rapports.slice(0, 5).map((r) => (
<button key={r.id} onClick={() => setViewRapport(r)}
className="w-full flex items-center justify-between py-2 border-b border-gray-50 last:border-0 active:bg-gray-50">
<div>
<p className="font-medium text-sm text-gray-800">Réunion du {r.reunion_dist}</p>
<p className="text-xs text-gray-400">Par {r.redacteur_prenom}</p>
</div>
<span className="text-gray-300">→</span>
</button>
))}
</div>
)}
<div className="p-3 space-y-4">
<section className="bg-white rounded-xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Renseignements récents</h2>
{loading ? (
<p className="text-sm text-gray-500">Chargement...</p>
) : rapports.length === 0 ? (
<p className="text-sm text-gray-500">Aucun renseignement de service enregistré.</p>
) : (
<div className="divide-y divide-gray-100">
{rapports.slice(0, 8).map((rapport) => (
<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>
<span className="text-gray-300 text-lg">›</span>
</button>
))}
</div>
)}
</section>
{/* Nouveau rapport */}
<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">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 })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
className="w-full min-h-[48px] p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
/>
</section>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Décisions du district (une par ligne)</label>
<textarea value={form.decisions_text}
onChange={(e) => setForm({ ...form, decisions_text: e.target.value })} rows={4}
placeholder="Vote sur le budget annuel approuvé&#10;Nouveau format d'atelier proposé&#10;Date du prochain congrès confirmée"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<section className="bg-white rounded-xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Décisions du district</label>
<textarea
value={form.decisions_text}
onChange={(e) => setForm({ ...form, decisions_text: e.target.value })}
rows={4}
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">
<label className="text-xs text-gray-400 block mb-1">Informations de la région 87</label>
<textarea value={form.infos_region}
onChange={(e) => setForm({ ...form, infos_region: e.target.value })} rows={3}
placeholder="Nouvelles de l'assemblée régionale, rapports..."
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="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Sujets à rapporter au groupe</label>
<textarea value={form.sujets_groupe}
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>
{champsTexte.map(([cle, label, placeholder]) => (
<section key={cle} className="bg-white rounded-xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">{label}</label>
<textarea
value={form[cle]}
onChange={(e) => setForm({ ...form, [cle]: e.target.value })}
rows={cle === 'liens_utiles' || cle === 'prochaine_echeance' ? 2 : 3}
placeholder={placeholder}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm"
/>
</section>
))}
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit}
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">
📨 Enregistrer le rapport
<button
onClick={handleSubmit}
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>
</div>
</div>