fix(#131): polish fiche AM — champs pro, places, vigilance et photo.

Fiche pro entièrement éditable, places déclarées en lecture seule avec alerte rouge, icône vigilance sur la liste AM, et cadrage photo aligné sur le wizard validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-07-06 17:37:21 +02:00
parent 479a32b4bf
commit 4985726bc6
10 changed files with 315 additions and 113 deletions

View File

@ -575,6 +575,9 @@ class UserService {
'ville',
'code_postal',
'statut',
'date_naissance',
'lieu_naissance_ville',
'lieu_naissance_pays',
]) {
if (body.containsKey(k)) userFields[k] = body[k];
}

View File

@ -0,0 +1,45 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
/// Places disponibles attendues : capacité max enfants rattachés.
int? amExpectedPlacesAvailable({
required int? maxChildren,
required int childrenCount,
}) {
if (maxChildren == null) return null;
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
required int? placesAvailable,
required int childrenCount,
}) {
final expected = amExpectedPlacesAvailable(
maxChildren: maxChildren,
childrenCount: childrenCount,
);
if (expected == null) return false;
if (placesAvailable == null) return childrenCount > (maxChildren ?? 0);
return placesAvailable != expected;
}
String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) {
if (!amHasPlacesInconsistency(
maxChildren: am.maxChildren,
placesAvailable: am.placesAvailable,
childrenCount: am.children.length,
)) {
return null;
}
final stored = am.placesAvailable;
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), '
'alors que capacité ${am.maxChildren ?? ''} '
'${am.children.length} enfant(s) rattaché(s) = $expectedLabel.';
}

View File

@ -10,7 +10,25 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
}
}
/// Parties d'âge calendaire entre [birth] et [reference] (date-only).
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
String? parseFrDateToIso(String text) {
final t = text.trim();
if (t.isEmpty) return null;
try {
return DateFormat('dd/MM/yyyy')
.parseStrict(t)
.toIso8601String()
.split('T')
.first;
} catch (_) {
try {
return DateTime.parse(t).toIso8601String().split('T').first;
} catch (_) {
return null;
}
}
}
({int years, int months, int days}) computeChildAgeParts(
DateTime birth,
DateTime reference,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
@ -76,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState
itemCount: filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
vigilanceTooltip: vigilance,
subtitleLines: [
assistante.user.email,
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
@ -41,8 +42,12 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late final TextEditingController _placesCtrl;
late String _statut;
late bool _disponible;
@ -87,12 +92,22 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_agrementCtrl = TextEditingController(text: am.approvalNumber ?? '');
_nirCtrl = TextEditingController(text: _formatNirDisplay());
_dateNaissanceCtrl = TextEditingController(
text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''),
);
_lieuNaissanceVilleCtrl = TextEditingController(
text: u.lieuNaissanceVille ?? '',
);
_lieuNaissancePaysCtrl = TextEditingController(
text: u.lieuNaissancePays ?? '',
);
_dateAgrementCtrl = TextEditingController(
text: formatIsoDateFr(am.agreementDate, ifEmpty: ''),
);
_capaciteCtrl = TextEditingController(
text: am.maxChildren?.toString() ?? '',
);
_placesCtrl = TextEditingController(
text: am.placesAvailable?.toString() ?? '',
);
_statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true;
_children = List.of(am.children);
@ -107,11 +122,16 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
_placesCtrl,
]) {
c.addListener(_markDirty);
}
_capaciteCtrl.addListener(_onCapacityChanged);
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_tabCtrl.addListener(_onTabChanged);
@ -124,6 +144,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
void _onNameFieldChanged() => setState(() {});
void _onCapacityChanged() => setState(() {});
@override
void dispose() {
_tabCtrl.dispose();
@ -136,8 +158,12 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
_placesCtrl,
]) {
c.dispose();
}
@ -175,11 +201,43 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
String _formatNirDisplay() {
final raw = widget.assistante.nir?.trim() ?? '';
if (raw.isEmpty) return '';
if (raw.isEmpty) return '';
final digits = nirToRaw(raw).toUpperCase();
return digits.length == 15 ? formatNir(digits) : raw;
}
String? _frDateToIso(String text) => parseFrDateToIso(text);
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
);
bool _placesInconsistent() => amHasPlacesInconsistency(
maxChildren: _capaciteMax(),
placesAvailable: widget.assistante.placesAvailable,
childrenCount: _children.length,
);
String _placesDisplayValue() {
final stored = widget.assistante.placesAvailable;
if (stored != null) return stored.toString();
return '';
}
String? _placesInconsistencyMessage() {
if (!_placesInconsistent()) return null;
final stored = widget.assistante.placesAvailable;
final expected = _computedPlacesAvailable();
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), '
'le calcul (capacité ${_capaciteMax() ?? ''} ${_children.length} '
'enfant(s) rattaché(s)) donne $expectedLabel.';
}
int? _parseIntField(TextEditingController c) {
final t = c.text.trim();
if (t.isEmpty) return null;
@ -223,10 +281,14 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
'approval_number': _agrementCtrl.text.trim(),
if (_parseIntField(_capaciteCtrl) != null)
'max_children': _parseIntField(_capaciteCtrl),
if (_parseIntField(_placesCtrl) != null)
'places_available': _parseIntField(_placesCtrl),
'nir': nirToRaw(_nirCtrl.text),
if (_frDateToIso(_dateNaissanceCtrl.text) != null)
'date_naissance': _frDateToIso(_dateNaissanceCtrl.text),
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
if (_frDateToIso(_dateAgrementCtrl.text) != null)
'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
if (_capaciteMax() != null) 'max_children': _capaciteMax(),
'available': _disponible,
},
);
@ -388,8 +450,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
}
}
String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
Widget _identityTab() {
return SingleChildScrollView(
@ -407,7 +467,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
}
Widget _proFieldsGrid() {
final u = widget.assistante.user;
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
@ -415,29 +474,34 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationReadOnlyField(
value: _formatNirDisplay(),
field: ValidationEditableField(
controller: _nirCtrl,
compact: true,
hintText: '15 chiffres',
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
],
),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationReadOnlyField(
value: formatIsoDateFr(u.dateNaissance),
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
compact: true,
hintText: 'jj/mm/aaaa',
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationReadOnlyField(
value: _v(u.lieuNaissanceVille),
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
compact: true,
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationReadOnlyField(
value: _v(u.lieuNaissancePays),
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
compact: true,
),
),
@ -450,9 +514,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
),
ValidationLabeledField(
label: 'Date d\'agrément',
field: ValidationReadOnlyField(
value: formatIsoDateFr(widget.assistante.agreementDate),
field: ValidationEditableField(
controller: _dateAgrementCtrl,
compact: true,
hintText: 'jj/mm/aaaa',
),
),
],
@ -513,76 +578,78 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
}
Widget _childrenCapacityFields() {
return ValidationEditableSection(
compact: true,
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
),
final inconsistent = _placesInconsistent();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValidationEditableSection(
compact: true,
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationReadOnlyField(
value: _placesDisplayValue(),
compact: true,
error: inconsistent,
),
),
],
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationEditableField(
controller: _placesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
if (inconsistent) ...[
const SizedBox(height: 6),
Text(
_placesInconsistencyMessage()!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
height: 1.3,
),
),
),
],
],
);
}
Widget _childrenTab() {
return LayoutBuilder(
builder: (context, constraints) {
const headerBlock = 88.0;
const capacityBlock = 56.0;
const gaps = 20.0;
final listHeight = (constraints.maxHeight -
headerBlock -
capacityBlock -
gaps)
.clamp(72.0, AdminChildrenAffiliationPanel.defaultViewportHeight);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'Enfants rattachés à cette assistante (accueil / garde).',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
Expanded(
child: AdminChildrenAffiliationPanel(
height: listHeight,
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
onDetach: _detachChild,
emptyMessage: 'Aucun enfant rattaché à cette assistante',
),
),
],
);
},
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'Enfants rattachés à cette assistante (accueil / garde).',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
Expanded(
child: AdminChildrenAffiliationPanel(
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
onDetach: _detachChild,
emptyMessage: 'Aucun enfant rattaché à cette assistante',
),
),
],
);
}

View File

@ -36,17 +36,19 @@ class AdminAmPhotoFrame extends StatelessWidget {
ph = pw / ar;
}
return Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: Align(
alignment: Alignment.topCenter,
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
@ -84,6 +86,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(

View File

@ -26,7 +26,20 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final panelHeight = height ?? defaultViewportHeight;
if (height != null) {
return _panel(height!);
}
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0
? constraints.maxHeight
: defaultViewportHeight;
return _panel(h);
},
);
}
Widget _panel(double panelHeight) {
return Container(
height: panelHeight,
decoration: BoxDecoration(

View File

@ -12,6 +12,7 @@ class AdminUserCard extends StatefulWidget {
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
final String? vigilanceTooltip;
const AdminUserCard({
super.key,
@ -24,6 +25,7 @@ class AdminUserCard extends StatefulWidget {
this.backgroundColor,
this.titleColor,
this.infoColor,
this.vigilanceTooltip,
});
@override
@ -65,6 +67,17 @@ class _AdminUserCardState extends State<AdminUserCard> {
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 10),
if (widget.vigilanceTooltip != null) ...[
Tooltip(
message: widget.vigilanceTooltip!,
child: Icon(
Icons.error_outline,
size: 20,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 6),
],
Expanded(
child: Row(
children: [

View File

@ -210,6 +210,28 @@ class ValidationEditableField extends StatelessWidget {
static const double _compactFieldHeight = 34;
static BoxDecoration _compactDecoration({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
static InputDecoration _compactInputDecoration({String? hint}) {
return InputDecoration(
isDense: true,
filled: false,
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
);
}
@override
Widget build(BuildContext context) {
if (!compact || maxLines > 1) {
@ -230,17 +252,19 @@ class ValidationEditableField extends StatelessWidget {
}
return SizedBox(
height: _compactFieldHeight,
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 13, height: 1.15),
decoration: ValidationFieldDecoration.input(
hint: hintText,
compact: true,
).copyWith(
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
child: DecoratedBox(
decoration: _compactDecoration(),
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.15,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
);
@ -278,12 +302,14 @@ class ValidationReadOnlyField extends StatelessWidget {
final String value;
final int? maxLines;
final bool compact;
final bool error;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
this.compact = false,
this.error = false,
});
static const double _compactFieldHeight = 34;
@ -298,13 +324,20 @@ class ValidationReadOnlyField extends StatelessWidget {
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
decoration: ValidationFieldDecoration.container(),
decoration: error
? BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.shade400),
)
: ValidationFieldDecoration.container(),
child: Text(
value,
style: TextStyle(
color: Colors.black87,
color: error ? Colors.red.shade800 : Colors.black87,
fontSize: compact ? 13 : 14,
height: compact ? 1.2 : null,
fontWeight: error ? FontWeight.w600 : null,
),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,

View File

@ -13,6 +13,7 @@ class AuthNetworkImage extends StatefulWidget {
this.width,
this.height,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.loadingBuilder,
this.errorBuilder,
});
@ -21,6 +22,7 @@ class AuthNetworkImage extends StatefulWidget {
final double? width;
final double? height;
final BoxFit fit;
final AlignmentGeometry alignment;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
@ -75,6 +77,7 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
width: widget.width,
height: widget.height,
fit: widget.fit,
alignment: widget.alignment,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
@ -105,6 +108,7 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
width: widget.width,
height: widget.height,
fit: widget.fit,
alignment: widget.alignment,
headers: headers,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,