Compare commits

..
Author SHA1 Message Date
jmartinandCursor 91f2c3e1ef fix(#170): brancher l’API couples-garde AM et unifier le bandeau
Évite le 403 en appelant /assistantes-maternelles/me/couples-garde,
parse parents[], et affiche « Prénom1 et Prénom2 » sans vignette.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:04:34 +02:00
jmartin 4007d7010e Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-09-24 12:19:40 +02:00
jmartinandCursor 4261667198 feat(#170): sélecteur couple enfant-parent(s) pour AM
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:19:26 +02:00
jmartin 15e2d8d850 Merge branch 'feature/171-api-couples-garde-am' into develop
API couples-garde AM (#171).
2026-09-24 12:17:09 +02:00
jmartinandCursor 34a516d509 feat(#169): coquille TdB AM 3 colonnes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:16:44 +02:00
6 changed files with 262 additions and 70 deletions
+14 -5
View File
@@ -39,13 +39,15 @@ class CoupleMembre {
class CoupleGarde { class CoupleGarde {
final String id; final String id;
final CoupleMembre enfant; final CoupleMembre enfant;
final CoupleMembre am; final CoupleMembre? am;
final List<CoupleMembre> parents;
final bool courant; final bool courant;
const CoupleGarde({ const CoupleGarde({
required this.id, required this.id,
required this.enfant, required this.enfant,
required this.am, this.am,
this.parents = const [],
this.courant = false, this.courant = false,
}); });
@@ -55,9 +57,15 @@ class CoupleGarde {
enfant: CoupleMembre.fromJson( enfant: CoupleMembre.fromJson(
Map<String, dynamic>.from(json['enfant'] ?? const {}), Map<String, dynamic>.from(json['enfant'] ?? const {}),
), ),
am: CoupleMembre.fromJson( am: json['am'] != null
Map<String, dynamic>.from(json['am'] ?? const {}), ? CoupleMembre.fromJson(Map<String, dynamic>.from(json['am']))
), : null,
parents: json['parents'] != null
? (json['parents'] as List)
.whereType<Map>()
.map((e) => CoupleMembre.fromJson(Map<String, dynamic>.from(e)))
.toList()
: const [],
courant: json['courant'] == true, courant: json['courant'] == true,
); );
} }
@@ -67,6 +75,7 @@ class CoupleGarde {
id: id, id: id,
enfant: enfant, enfant: enfant,
am: am, am: am,
parents: parents,
courant: courant ?? this.courant, courant: courant ?? this.courant,
); );
} }
+152 -42
View File
@@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/couple_garde.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/services/couple_garde_service.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
/// Dashboard assistante maternelle – page blanche avec bandeau générique. /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
/// Contenu détaillé à venir. /// Colonne gauche : sélecteur de couple enfant–parent(s) (#170).
/// Métier cartes / blog / messagerie : tickets C/D/E.
class AmDashboardScreen extends StatefulWidget { class AmDashboardScreen extends StatefulWidget {
const AmDashboardScreen({super.key}); const AmDashboardScreen({super.key});
@@ -14,13 +19,19 @@ class AmDashboardScreen extends StatefulWidget {
} }
class _AmDashboardScreenState extends State<AmDashboardScreen> { class _AmDashboardScreenState extends State<AmDashboardScreen> {
int selectedTabIndex = 0; QuotidienNavSection _section = QuotidienNavSection.liaison;
AppUser? _user; AppUser? _user;
List<CoupleGarde> _couples = const [];
String? _selectedCoupleId;
bool _couplesLoading = true;
String? _couplesError;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadUser(); _loadUser();
_loadCouples();
} }
Future<void> _loadUser() async { Future<void> _loadUser() async {
@@ -28,51 +39,150 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
if (mounted) setState(() => _user = user); if (mounted) setState(() => _user = user);
} }
Future<void> _loadCouples() async {
setState(() {
_couplesLoading = true;
_couplesError = null;
});
try {
final res = await CoupleGardeService.getAmCouplesGarde();
if (!mounted) return;
setState(() {
_couples = res.couples;
_selectedCoupleId = res.coupleCourant?.id;
_couplesLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_couplesError = e.toString().replaceFirst('Exception: ', '');
_couplesLoading = false;
});
}
}
Future<void> _selectCouple(CoupleGarde couple) async {
if (couple.id == _selectedCoupleId) return;
setState(() => _selectedCoupleId = couple.id);
try {
final res = await CoupleGardeService.definirAmCoupleCourant(couple.id);
if (!mounted) return;
setState(() {
_couples = res.couples;
_selectedCoupleId = res.coupleCourant?.id ?? couple.id;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Impossible de changer d'enfant : "
'${e.toString().replaceFirst('Exception: ', '')}',
),
),
);
}
}
String get _displayName {
final n = _user?.fullName.trim() ?? '';
if (n.isNotEmpty) return n;
final email = _user?.email.trim() ?? '';
if (email.isNotEmpty) return email.split('@').first;
return 'Assistante maternelle';
}
void _soon(String label) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$label — à venir')),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return QuotidienShell(
appBar: PreferredSize( selectedSection: _section,
preferredSize: const Size.fromHeight(60.0), onSectionSelected: (s) => setState(() => _section = s),
child: DashboardBandeau( userDisplayName: _displayName,
tabItems: const [ userEmail: _user?.email,
DashboardTabItem(label: 'Mon tableau de bord'), onProfileTap: () => _soon('Profil'),
DashboardTabItem(label: 'Paramètres'), onSettingsTap: () => _soon('Paramètres'),
], // L'AM n'a pas de bouton "Recherche AM" dans le bandeau
selectedTabIndex: selectedTabIndex, onSearchAmTap: null,
onTabSelected: (index) => setState(() => selectedTabIndex = index), leftColumn: _LeftColumn(
userDisplayName: _user?.fullName.isNotEmpty == true couples: _couples,
? _user!.fullName selectedCoupleId: _selectedCoupleId,
: 'Assistante maternelle', loading: _couplesLoading,
userEmail: _user?.email, error: _couplesError,
userRole: _user?.role, onRetry: _loadCouples,
onProfileTap: () { onCoupleSelected: _selectCouple,
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Modification du profil – à venir')),
);
},
onSettingsTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres – à venir')),
);
},
onLogout: () {},
showLogoutConfirmation: true,
),
), ),
body: Column( centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog',
subtitle:
'Fil du quotidien (affichage par défaut)\n(à brancher — ticket #179).',
icon: Icons.auto_stories_outlined,
),
rightColumn: const QuotidienColumnPlaceholder(
title: 'Messagerie',
subtitle:
'Mess. Parents · Mess. RPE\n(à brancher — ticket #185).',
icon: Icons.chat_bubble_outline,
),
agendaBody: AgendaAbsencesStub(placementId: _selectedCoupleId),
contratBody: const QuotidienStubPage(
title: 'Contrat',
message: 'Contrat — contenu à venir (stub #187).',
),
);
}
}
class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples;
final String? selectedCoupleId;
final bool loading;
final String? error;
final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected;
const _LeftColumn({
required this.couples,
required this.selectedCoupleId,
required this.loading,
required this.error,
required this.onRetry,
required this.onCoupleSelected,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( CoupleSelectorBandeau(
child: Center( mode: CoupleBandeauMode.assistanteMaternelle,
child: Text( couples: couples,
'Dashboard AM – à venir', selectedCoupleId: selectedCoupleId,
style: Theme.of(context).textTheme.titleLarge, loading: loading,
), errorMessage: error,
onRetry: onRetry,
onCoupleSelected: onCoupleSelected,
),
const SizedBox(height: 14),
const Expanded(
child: QuotidienColumnPlaceholder(
title: 'Cartes',
subtitle:
'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).',
icon: Icons.style_outlined,
), ),
), ),
const AppFooter(),
], ],
), ),
); );
} }
} }
+5 -1
View File
@@ -67,7 +67,11 @@ class ApiConfig {
static const String parentsCoupleGardeCourant = static const String parentsCoupleGardeCourant =
'/parents/me/couples-garde/courant'; '/parents/me/couples-garde/courant';
static const String assistantesMaternelles = '/assistantes-maternelles'; static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM. /// Couples de garde de l'AM connectée (#170 / #171).
static const String assistantesMaternellesCouplesGarde =
'/assistantes-maternelles/me/couples-garde';
static const String assistantesMaternellesCoupleGardeCourant =
'/assistantes-maternelles/me/couples-garde/courant';
static const String assistantesMaternellesDossier = static const String assistantesMaternellesDossier =
'/assistantes-maternelles/dossier'; '/assistantes-maternelles/dossier';
static const String enfants = '/enfants'; static const String enfants = '/enfants';
@@ -73,4 +73,45 @@ class CoupleGardeService {
Map<String, dynamic>.from(decoded as Map), Map<String, dynamic>.from(decoded as Map),
); );
} }
/// Liste les couples de garde et le couple courant de l'AM connectée.
static Future<CouplesGardeResponse> getAmCouplesGarde() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCouplesGarde}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement des couples de garde (AM)'),
);
}
final decoded = jsonDecode(response.body);
return CouplesGardeResponse.fromJson(
Map<String, dynamic>.from(decoded as Map),
);
}
/// Persiste le couple courant (AM) et renvoie la liste à jour.
static Future<CouplesGardeResponse> definirAmCoupleCourant(
String coupleId,
) async {
final response = await http.put(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesCoupleGardeCourant}'),
headers: await _headers(),
body: jsonEncode({'couple_id': coupleId}),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur sélection du couple de garde (AM)'),
);
}
final decoded = jsonDecode(response.body);
return CouplesGardeResponse.fromJson(
Map<String, dynamic>.from(decoded as Map),
);
}
} }
@@ -37,7 +37,7 @@ class CoupleSelectorBandeau extends StatelessWidget {
CoupleGarde? get _selected { CoupleGarde? get _selected {
if (couples.isEmpty) return null; if (couples.isEmpty) return null;
if (selectedCoupleId != null) { if (selectedCoupleId != null) {
for (final c in couples) { for (final c in couples) {
if (c.id == selectedCoupleId) return c; if (c.id == selectedCoupleId) return c;
} }
@@ -133,8 +133,47 @@ class _CoupleCard extends StatelessWidget {
required this.colorIndex, required this.colorIndex,
}); });
/// Libellé parents côté AM : « Sophie » ou « Thomas et Claire ».
static String _parentsLabel(List<CoupleMembre> parents) {
if (parents.isEmpty) return 'Parents';
String prenomOf(CoupleMembre p) {
final prenom = (p.prenom ?? '').trim();
if (prenom.isNotEmpty) return prenom;
return p.displayName(fallback: 'Parent');
}
if (parents.length == 1) return prenomOf(parents.first);
return '${prenomOf(parents[0])} et ${prenomOf(parents[1])}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Parent mode: enfant | AM (photo + prénom)
// AM mode: enfant | parent(s) — texte seul, pas de vignette parent
final isParentMode = mode == CoupleBandeauMode.parent;
Widget amOrParentsWidget;
if (isParentMode) {
amOrParentsWidget = _MembreTile(
photoUrl: couple.am?.photoUrl,
name: (couple.am?.prenom != null && couple.am!.prenom!.isNotEmpty)
? couple.am!.prenom!
: (couple.am?.displayName(fallback: 'Nounou') ?? 'Nounou'),
fallbackIcon: Icons.volunteer_activism,
);
} else {
amOrParentsWidget = Text(
_parentsLabel(couple.parents),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 22,
fontWeight: FontWeight.bold,
color: QuotidienTheme.ink,
),
);
}
return Container( return Container(
height: 90, height: 90,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -171,19 +210,7 @@ class _CoupleCard extends StatelessWidget {
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
child: _MembreTile( child: amOrParentsWidget,
photoUrl: couple.am.photoUrl,
name: (couple.am.prenom != null && couple.am.prenom!.isNotEmpty)
? couple.am.prenom!
: couple.am.displayName(
fallback: mode == CoupleBandeauMode.parent
? 'Nounou'
: 'Parent',
),
fallbackIcon: mode == CoupleBandeauMode.parent
? Icons.volunteer_activism
: Icons.person_outline,
),
), ),
), ),
SizedBox( SizedBox(
@@ -183,15 +183,16 @@ class _UserMenu extends StatelessWidget {
title: Text('Profil'), title: Text('Profil'),
), ),
), ),
const PopupMenuItem( if (onSearchAmTap != null)
value: 'search_am', const PopupMenuItem(
child: ListTile( value: 'search_am',
dense: true, child: ListTile(
contentPadding: EdgeInsets.zero, dense: true,
leading: Icon(Icons.search, size: 20), contentPadding: EdgeInsets.zero,
title: Text('Recherche AM'), leading: Icon(Icons.search, size: 20),
title: Text('Recherche AM'),
),
), ),
),
const PopupMenuItem( const PopupMenuItem(
value: 'settings', value: 'settings',
child: ListTile( child: ListTile(