- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor
508 lines
19 KiB
Dart
508 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||
import 'package:p_tits_pas/models/user.dart';
|
||
import 'package:p_tits_pas/services/user_service.dart';
|
||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||
import 'validation_modal_theme.dart';
|
||
import 'validation_refus_form.dart';
|
||
import 'validation_valider_confirm_dialog.dart';
|
||
|
||
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||
class ValidationAmWizard extends StatefulWidget {
|
||
final DossierAM dossier;
|
||
final VoidCallback onClose;
|
||
final VoidCallback onSuccess;
|
||
final void Function(int step, int total)? onStepChanged;
|
||
|
||
const ValidationAmWizard({
|
||
super.key,
|
||
required this.dossier,
|
||
required this.onClose,
|
||
required this.onSuccess,
|
||
this.onStepChanged,
|
||
});
|
||
|
||
@override
|
||
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
||
}
|
||
|
||
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
||
int _step = 0;
|
||
bool _showRefusForm = false;
|
||
bool _submitting = false;
|
||
|
||
static const int _stepCount = 3;
|
||
|
||
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
||
|
||
static String _v(String? s) =>
|
||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||
|
||
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
||
static String _formatNirForDisplay(String? nir) {
|
||
final v = _v(nir);
|
||
if (v == '–') return v;
|
||
final raw = nirToRaw(v).toUpperCase();
|
||
return raw.length == 15 ? formatNir(raw) : v;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||
}
|
||
|
||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||
|
||
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
||
List<AdminDetailField> _personalFields(AppUser u) => [
|
||
AdminDetailField(label: 'Nom', value: _v(u.nom)),
|
||
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
|
||
AdminDetailField(
|
||
label: 'Téléphone',
|
||
value: _v(u.telephone) != '–'
|
||
? formatPhoneForDisplay(_v(u.telephone))
|
||
: '–'),
|
||
AdminDetailField(label: 'Email', value: _v(u.email)),
|
||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
|
||
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
|
||
AdminDetailField(label: 'Ville', value: _v(u.ville)),
|
||
];
|
||
|
||
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
|
||
List<AdminDetailField> _proFields(DossierAM d) => [
|
||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||
AdminDetailField(
|
||
label: 'Date d’agrément',
|
||
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
|
||
? d.dateAgrement!.trim()
|
||
: '–',
|
||
),
|
||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||
AdminDetailField(
|
||
label: 'Capacité max (enfants)',
|
||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||
),
|
||
AdminDetailField(
|
||
label: 'Places disponibles',
|
||
value: d.placesDisponibles != null
|
||
? d.placesDisponibles.toString()
|
||
: '–',
|
||
),
|
||
AdminDetailField(
|
||
label: 'Ville de résidence', value: _v(d.villeResidence)),
|
||
];
|
||
|
||
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
||
static const Map<int, List<int>> _personalRowFlex = {
|
||
3: [2, 5]
|
||
}; // Code postal étroit, Ville large
|
||
|
||
/// Proportion photo d’identité (35×45 mm).
|
||
static const double _idPhotoAspectRatio = 35 / 45;
|
||
|
||
static const double _photoProGap = 24;
|
||
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
||
static const double _proColumnMinWidth = 260;
|
||
static const double _photoColumnMinWidth = 160;
|
||
|
||
/// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API.
|
||
static String _fullPhotoUrl(String? url) {
|
||
if (url == null || url.trim().isEmpty) return '';
|
||
final u = url.trim();
|
||
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||
final base = ApiConfig.baseUrl;
|
||
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||
}
|
||
|
||
Widget _buildPhotoSection(AppUser u) {
|
||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text(
|
||
'Photo de profil',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: LayoutBuilder(
|
||
builder: (context, c) {
|
||
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
||
const uniformFrame = 8.0;
|
||
final maxPhotoW =
|
||
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||
final maxPhotoH =
|
||
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||
const ar = _idPhotoAspectRatio;
|
||
double ph = maxPhotoH;
|
||
double pw = ph * ar;
|
||
if (pw > maxPhotoW) {
|
||
pw = maxPhotoW;
|
||
ph = pw / ar;
|
||
}
|
||
return Align(
|
||
alignment: Alignment.center,
|
||
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(
|
||
width: pw,
|
||
height: ph,
|
||
child: photoUrl.isEmpty
|
||
? ColoredBox(
|
||
color: Colors.grey.shade200,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.person_off_outlined,
|
||
size: 40,
|
||
color: Colors.grey.shade400),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Aucune photo fournie',
|
||
style: TextStyle(
|
||
color: Colors.grey.shade600,
|
||
fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: Image.network(
|
||
photoUrl,
|
||
fit: BoxFit.cover,
|
||
width: pw,
|
||
height: ph,
|
||
loadingBuilder: (_, child, progress) {
|
||
if (progress == null) return child;
|
||
return ColoredBox(
|
||
color: Colors.grey.shade200,
|
||
child: Center(
|
||
child: CircularProgressIndicator(
|
||
value: progress.expectedTotalBytes !=
|
||
null
|
||
? progress.cumulativeBytesLoaded /
|
||
(progress.expectedTotalBytes!)
|
||
: null,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
errorBuilder: (_, __, ___) => ColoredBox(
|
||
color: Colors.grey.shade200,
|
||
child: Column(
|
||
mainAxisAlignment:
|
||
MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.broken_image_outlined,
|
||
size: 40,
|
||
color: Colors.grey.shade400),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Impossible de charger la photo',
|
||
style: TextStyle(
|
||
color: Colors.grey.shade600,
|
||
fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_showRefusForm) {
|
||
return _buildRefusPage();
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const SizedBox(height: 4),
|
||
Expanded(child: _buildStepContent()),
|
||
const SizedBox(height: 24),
|
||
_buildNavigation(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStepContent() {
|
||
final d = widget.dossier;
|
||
final u = d.user;
|
||
switch (_step) {
|
||
case 0:
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||
child: ValidationDetailSection(
|
||
title: 'Informations personnelles',
|
||
fields: _personalFields(u),
|
||
rowLayout: _personalRowLayout,
|
||
rowFlex: _personalRowFlex,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
case 1:
|
||
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
||
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
||
return LayoutBuilder(
|
||
builder: (context, c) {
|
||
final maxRowW = c.maxWidth;
|
||
final maxRowH = c.maxHeight;
|
||
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
|
||
const photoHeaderH = 52.0;
|
||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||
final idealPhotoW =
|
||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||
.clamp(0.0, double.infinity);
|
||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
SizedBox(
|
||
width: photoW,
|
||
child: _buildPhotoSection(u),
|
||
),
|
||
const SizedBox(width: _photoProGap),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(
|
||
minWidth: constraints.maxWidth),
|
||
child: ValidationDetailSection(
|
||
title: 'Informations professionnelles',
|
||
fields: _proFields(d),
|
||
rowLayout: const [
|
||
2,
|
||
1,
|
||
2,
|
||
1
|
||
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
case 2:
|
||
final presentation =
|
||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
||
? d.presentation!
|
||
: '–';
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Présentation',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.black87),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints:
|
||
BoxConstraints(minHeight: constraints.maxHeight),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
),
|
||
child: SelectableText(
|
||
presentation,
|
||
style: const TextStyle(
|
||
color: Colors.black87, fontSize: 14),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
default:
|
||
return const SizedBox();
|
||
}
|
||
}
|
||
|
||
Widget _buildNavigation() {
|
||
if (_step == 2) {
|
||
return Row(
|
||
children: [
|
||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||
const Spacer(),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _step = 1);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Précédent'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (_isEnAttente) ...[
|
||
OutlinedButton(
|
||
onPressed: _submitting ? null : _refuser,
|
||
child: const Text('Refuser')),
|
||
const SizedBox(width: 12),
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: _submitting ? null : _onValiderPressed,
|
||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||
),
|
||
] else
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: widget.onClose,
|
||
child: const Text('Fermer'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return Row(
|
||
children: [
|
||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||
const Spacer(),
|
||
if (_step > 0) ...[
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _step--);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Précédent'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: () {
|
||
setState(() => _step++);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Suivant'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _onValiderPressed() async {
|
||
if (_submitting) return;
|
||
final ok = await showValidationValiderConfirmDialog(
|
||
context,
|
||
body:
|
||
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||
);
|
||
if (!mounted || !ok) return;
|
||
await _valider();
|
||
}
|
||
|
||
Future<void> _valider() async {
|
||
if (_submitting) return;
|
||
setState(() => _submitting = true);
|
||
try {
|
||
await UserService.validateUser(widget.dossier.user.id);
|
||
if (!mounted) return;
|
||
widget.onSuccess();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e is Exception
|
||
? e.toString().replaceFirst('Exception: ', '')
|
||
: 'Erreur'),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _submitting = false);
|
||
}
|
||
}
|
||
|
||
void _refuser() => setState(() => _showRefusForm = true);
|
||
|
||
Widget _buildRefusPage() {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: ValidationRefusForm(
|
||
onCancel: widget.onClose,
|
||
onPrevious: () => setState(() => _showRefusForm = false),
|
||
onSubmit: (comment) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Refus (à brancher sur l’API refus)')),
|
||
);
|
||
widget.onClose();
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|