feat(inscription-am): places disponibles, UI étape 2, plafond capacité 4 côté app

- POST /auth/register/am : places_disponibles, enregistrement place_disponible, contrôle ≤ capacité

- Formulaire pro AM : places sur la ligne capacité, mobile un champ par ligne, carte desktop plus compacte (espacements, polices, labelFieldSpacing)

- Capacité et places validées jusqu’à 4 dans l’app (kAmCapaciteAccueilMax) ; hint NIR « 13 chiffres + clé »

- Scripts d’inscription AM (Node + smoke shell) : places_disponibles

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-04-13 13:13:27 +02:00
parent 58607cdbc9
commit b4b44cb1b9
12 changed files with 163 additions and 68 deletions

View File

@ -26,6 +26,7 @@ curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
"nir": "186127500100279",
"numero_agrement": "AGR-SMOKE-CURL-001",
"capacite_accueil": 3,
"places_disponibles": 2,
"acceptation_cgu": true,
"acceptation_privacy": true
}'

View File

@ -418,6 +418,12 @@ export class AuthService {
);
}
if (dto.places_disponibles > dto.capacite_accueil) {
throw new BadRequestException(
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
);
}
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance,

View File

@ -138,6 +138,17 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number;
@ApiProperty({
example: 2,
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
minimum: 0,
maximum: 10,
})
@IsInt()
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
places_disponibles: number;
// ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================

View File

@ -1,5 +1,8 @@
import 'package:flutter/foundation.dart';
/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
const int kAmCapaciteAccueilMax = 4;
class AmRegistrationData extends ChangeNotifier {
// Step 1: Identity Info
String firstName = '';
@ -27,6 +30,8 @@ class AmRegistrationData extends ChangeNotifier {
/// Date d'obtention de l'agrément obligatoire (API `date_agrement`).
DateTime? agreementDate;
int? capacity; // Number of children the AM can look after
/// Places libres actuellement (0 valeur capacité) API `places_disponibles`.
int? placesAvailable;
// Step 3: Presentation & CGU
String presentationText = '';
@ -72,6 +77,7 @@ class AmRegistrationData extends ChangeNotifier {
String? agrementNumber,
DateTime? agreementDate,
int? capacity,
int? placesAvailable,
}) {
this.photoPath = photoPath;
this.photoBytes = photoBytes;
@ -85,6 +91,7 @@ class AmRegistrationData extends ChangeNotifier {
this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity;
this.placesAvailable = placesAvailable ?? this.placesAvailable;
notifyListeners();
}
@ -125,7 +132,10 @@ class AmRegistrationData extends ChangeNotifier {
agreementDate != null &&
capacity != null &&
capacity! >= 1 &&
capacity! <= 10;
capacity! <= kAmCapaciteAccueilMax &&
placesAvailable != null &&
placesAvailable! >= 0 &&
placesAvailable! <= capacity!;
bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire)
@ -142,7 +152,7 @@ class AmRegistrationData extends ChangeNotifier {
'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
}

View File

@ -25,6 +25,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
agrementNumber: registrationData.agrementNumber,
agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity,
placesAvailable: registrationData.placesAvailable,
);
return ProfessionalInfoFormScreen(
@ -46,6 +47,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
placesAvailable: data.placesAvailable,
);
context.go('/am-register-step3');
},

View File

@ -206,6 +206,7 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
placesAvailable: data.placesAvailable,
photoConsent: data.photoConsent,
),
onSubmit: (d) {},

View File

@ -165,6 +165,9 @@ class AuthService {
if (data.agreementDate == null) {
throw Exception('La date d\'obtention de l\'agrément est requise.');
}
if (data.placesAvailable == null) {
throw Exception('Le nombre de places disponibles est requis.');
}
String? photoBase64;
String? photoFilename;
@ -211,6 +214,7 @@ class AuthService {
'date_agrement':
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1,
'places_disponibles': data.placesAvailable!,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted,

View File

@ -27,6 +27,8 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon;
final double labelFontSize;
final double inputFontSize;
/// Espace vertical entre le libellé et la zone de saisie.
final double labelFieldSpacing;
final bool showLabel;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
@ -56,6 +58,7 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
this.labelFieldSpacing = 6.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
@ -105,7 +108,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
SizedBox(height: widget.labelFieldSpacing),
],
// Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait.

View File

@ -18,12 +18,13 @@ class NirTextField extends StatelessWidget {
final bool enabled;
final bool readOnly;
final CustomAppTextFieldStyle style;
final double labelFieldSpacing;
const NirTextField({
super.key,
required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 caractères dont clé valide (dépt 2A ou 2B si Corse)',
this.hintText = '13 chiffres + clé',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
@ -32,6 +33,7 @@ class NirTextField extends StatelessWidget {
this.enabled = true,
this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige,
this.labelFieldSpacing = 6.0,
});
@override
@ -50,6 +52,7 @@ class NirTextField extends StatelessWidget {
enabled: enabled,
readOnly: readOnly,
style: style,
labelFieldSpacing: labelFieldSpacing,
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'dart:math' as math;
import 'dart:io';
import '../models/am_registration_data.dart' show kAmCapaciteAccueilMax;
import '../models/card_assets.dart';
import '../config/display_config.dart';
import '../utils/nir_utils.dart';
@ -34,6 +35,7 @@ class ProfessionalInfoData {
/// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
final DateTime? agreementDate;
final int? capacity;
final int? placesAvailable;
ProfessionalInfoData({
this.photoPath,
@ -48,6 +50,7 @@ class ProfessionalInfoData {
this.agrementNumber = '',
this.agreementDate,
this.capacity,
this.placesAvailable,
});
}
@ -94,6 +97,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final _agrementController = TextEditingController();
final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController();
final _placesAvailableController = TextEditingController();
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
@ -133,6 +137,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
: '';
_capacityController.text = data.capacity?.toString() ?? '';
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
_photoPathFramework = data.photoPath;
_photoFile = data.photoFile;
_photoBytes = data.photoBytes;
@ -180,9 +185,20 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_agrementController.dispose();
_agreementDateController.dispose();
_capacityController.dispose();
_placesAvailableController.dispose();
super.dispose();
}
String? _validatePlacesAvailable(String? v) {
if (v == null || v.isEmpty) return 'Places disponibles requises';
final n = int.tryParse(v);
if (n == null || n < 0) return 'Nombre entre 0 et la capacité';
if (n > kAmCapaciteAccueilMax) return 'Maximum $kAmCapaciteAccueilMax';
final cap = int.tryParse(_capacityController.text);
if (cap != null && n > cap) return 'Ne peut pas dépasser la capacité';
return null;
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
@ -285,6 +301,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text),
placesAvailable: int.tryParse(_placesAvailableController.text),
);
widget.onSubmit(data);
@ -308,7 +325,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0),
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -329,7 +346,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
SizedBox(height: config.isMobile ? 16 : 20),
_buildCard(context, config, screenSize),
// Boutons mobile sous la carte
@ -400,7 +417,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28),
horizontal: config.isMobile ? 24 : 50,
),
decoration: BoxDecoration(
@ -428,7 +445,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(height: config.isMobile ? 20.0 : 14.0),
],
config.isMobile
? _buildMobileFields(context, config)
@ -550,7 +567,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
],
),
const SizedBox(height: 18),
const SizedBox(height: 10),
// Contenu
Expanded(
@ -578,7 +595,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
},
),
),
const SizedBox(height: 10),
const SizedBox(height: 5),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
@ -589,7 +606,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
],
),
),
const SizedBox(width: 32),
const SizedBox(width: 24),
// CHAMPS (2/3) - Layout optimisé compact
Expanded(
@ -605,7 +622,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
// Ligne 2 : Pays + NIR
Row(
@ -615,7 +632,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
// Ligne 3 : N° agrément + Date d'obtention
Row(
@ -634,12 +651,19 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
Row(
children: [
Expanded(
child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
),
const SizedBox(width: 16),
Expanded(
child: _buildReadonlyField(
'Places disponibles',
_placesAvailableController.text,
),
),
],
),
],
@ -668,10 +692,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
const SizedBox(height: 3),
Container(
width: double.infinity,
height: 45.0, // Hauteur réduite pour compacter
@ -685,7 +709,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
child: Text(
value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: 16.0),
style: GoogleFonts.merienda(fontSize: 14.0),
overflow: TextOverflow.ellipsis,
),
),
@ -695,7 +719,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
/// Layout DESKTOP : Photo à gauche, champs à droite
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
final double verticalSpacing = config.isReadonly ? 8.0 : 14.0;
return Column(
mainAxisSize: MainAxisSize.min,
@ -708,7 +732,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
width: 300,
child: _buildPhotoSection(context, config),
),
const SizedBox(width: 30),
const SizedBox(width: 22),
// Champs à droite
Expanded(
child: Column(
@ -751,8 +775,9 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _nirController,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFontSize: config.isMobile ? 15.0 : 20.0,
inputFontSize: config.isMobile ? 14.0 : 18.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
),
SizedBox(height: verticalSpacing),
Row(
@ -767,7 +792,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
),
const SizedBox(width: 20),
const SizedBox(width: 14),
Expanded(
child: _buildField(
config: config,
@ -784,19 +809,41 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
],
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à 10 enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10';
if (n > 10) return 'Maximum 10 (plafond agrément)';
return null;
},
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) {
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null;
},
),
),
const SizedBox(width: 14),
Expanded(
child: _buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
),
),
],
),
],
);
@ -852,49 +899,53 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
config: config,
label: 'N° d\'agrément',
controller: _agrementController,
hint: 'N° agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
),
),
],
_buildField(
config: config,
label: 'N° d\'agrément',
controller: _agrementController,
hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à 10 enfants',
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10';
if (n > 10) return 'Maximum 10 (plafond agrément)';
if (n == null || n < 1) {
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null;
},
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
),
],
);
}
@ -918,7 +969,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
baseShadowColor: Colors.green.shade300,
isMobile: config.isMobile,
),
const SizedBox(height: 10),
SizedBox(height: config.isMobile ? 10.0 : 6.0),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
@ -959,8 +1010,9 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hintText: hint ?? label,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFontSize: config.isMobile ? 15.0 : 20.0,
inputFontSize: config.isMobile ? 14.0 : 18.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
keyboardType: keyboardType ?? TextInputType.text,
readOnly: readOnly,
onTap: onTap,

View File

@ -61,6 +61,7 @@ try {
numero_agrement: 'AGR-2019-095001',
date_agrement: '2019-09-01',
capacite_accueil: 4,
places_disponibles: 2,
biographie:
"Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. " +
'Accueil bienveillant et cadre sécurisant. 2 places disponibles.',

View File

@ -60,6 +60,7 @@ try {
numero_agrement: 'AGR-2017-095002',
date_agrement: '2017-06-15',
capacite_accueil: 3,
places_disponibles: 1,
biographie:
"Assistante maternelle expérimentée. Née à l'étranger. Spécialité 1-3 ans. " +
'Accueil à la journée. 1 place disponible.',