Compare commits

...
Author SHA1 Message Date
jmartinandCursor 6fe2b89a61 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
- Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only)
- Front: ne plus effacer la sélection si le chargement échoue
- Fallback affichage depuis relaisNom de l'utilisateur

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:52:46 +02:00
jmartin d6a9b3fd66 Merge branch 'feature/149-clic-case-libre-rattacher-enfant' into develop
Clic case libre → rattacher enfant (#149).
2026-07-17 16:19:14 +02:00
jmartinandCursor 134b9781c8 feat(#149): clic sur case libre pour rattacher un enfant (fiche AM).
Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:19:12 +02:00
jmartin b936d27445 Merge branch 'feature/148-rattacher-enfant-capacite-max' into develop
Fix capacité max — lien rattacher enfant (#148).
2026-07-17 16:12:54 +02:00
jmartinandCursor 18c1d1eba7 fix(#148): désactiver « Rattacher un enfant » si capacité AM pleine.
Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:12:53 +02:00
4 changed files with 92 additions and 21 deletions
@@ -24,15 +24,19 @@ export class RelaisController {
} }
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister tous les relais' }) @ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' }) @ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() { findAll() {
return this.relaisService.findAll(); return this.relaisService.findAll();
} }
@Get(':id') @Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose(); super.dispose();
} }
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async { Future<void> _loadRelais() async {
try { try {
final list = await RelaisService.getRelais(); final list = await RelaisService.getRelais();
@@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) { if (selected != null) {
filtered.add(selected); filtered.add(selected);
} else { } else {
_selectedRelaisId = null; // Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
} }
} }
@@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}); });
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() { setState(() {
_selectedRelaisId = null; _relais = _fallbackRelaisFromUser();
_relais = [];
_isLoadingRelais = false; _isLoadingRelais = false;
}); });
} }
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity; final int capacity;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach; final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({ const AdminAmChildrenCapacityGrid({
super.key, super.key,
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity, required this.capacity,
required this.onOpen, required this.onOpen,
required this.onDetach, required this.onDetach,
this.onAttachEmpty,
}); });
@override @override
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
); );
} }
if (index < maxSlots) { if (index < maxSlots) {
return const _EmptySlot(); return _EmptySlot(onTap: onAttachEmpty);
} }
return const _UnavailableSlot(); return const _UnavailableSlot();
} }
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
} }
} }
class _EmptySlot extends StatelessWidget { class _EmptySlot extends StatefulWidget {
const _EmptySlot(); final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SlotShell( final clickable = widget.onTap != null;
backgroundColor: Colors.grey.shade50, return MouseRegion(
borderColor: Colors.grey.shade300, onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center( child: Center(
child: Text( child: Text(
'Place libre', 'Place libre',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
), ),
), ),
); );
@@ -238,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl); int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable( int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(), maxChildren: _capaciteMax(),
childrenCount: _children.length, childrenCount: _children.length,
@@ -466,7 +473,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (!mounted) return; if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show( final selected = await AdminSelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
@@ -738,6 +745,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
onDetach: _detachChild, onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
), ),
], ],
); );
@@ -745,6 +753,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() { Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2; final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row( return Row(
children: [ children: [
TextButton( TextButton(
@@ -753,11 +762,16 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
const Spacer(), const Spacer(),
if (isChildrenTab) if (isChildrenTab)
TextButton.icon( Tooltip(
onPressed: _attachChild, message: _capacityFull
? 'Capacité maximale atteinte'
: 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18), icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'), label: const Text('Rattacher un enfant'),
), ),
),
if (isChildrenTab) const SizedBox(width: 12), if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton( ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle, style: ValidationModalTheme.primaryElevatedStyle,