- UserService.refuseUser → /users/:id/refuser - AM + famille : envoi motifs, onSuccess + refresh liste - Famille : refus séquentiel pour chaque parent en_attente - ValidationRefusForm : état isSubmitting (UX) Co-authored-by: Cursor <cursoragent@cursor.com>
519 lines
19 KiB
Dart
519 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||
import 'package:p_tits_pas/utils/date_display_utils.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 'package:p_tits_pas/widgets/common/auth_network_image.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)),
|
||
];
|
||
|
||
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||
final u = d.user;
|
||
return [
|
||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||
AdminDetailField(
|
||
label: 'Date de naissance',
|
||
value: formatIsoDateFr(u.dateNaissance),
|
||
),
|
||
AdminDetailField(
|
||
label: 'Ville de naissance',
|
||
value: _v(u.lieuNaissanceVille),
|
||
),
|
||
AdminDetailField(
|
||
label: 'Pays de naissance',
|
||
value: _v(u.lieuNaissancePays),
|
||
),
|
||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||
AdminDetailField(
|
||
label: 'Date d’agrément',
|
||
value: formatIsoDateFr(d.dateAgrement),
|
||
),
|
||
AdminDetailField(
|
||
label: 'Capacité max (enfants)',
|
||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||
),
|
||
AdminDetailField(
|
||
label: 'Places disponibles',
|
||
value: d.placesDisponibles != null
|
||
? d.placesDisponibles.toString()
|
||
: '–',
|
||
),
|
||
];
|
||
}
|
||
|
||
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, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
|
||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||
|
||
Widget _buildPhotoSection(AppUser u) {
|
||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: AuthNetworkImage(
|
||
url: 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: 'Identité et coordonnées',
|
||
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;
|
||
const photoHeaderH = 0.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: 'Dossier professionnel',
|
||
fields: _photoProFields(d),
|
||
rowLayout: const [2, 2, 2, 2],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
case 2:
|
||
final presentation =
|
||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
||
? d.presentation!
|
||
: '–';
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text(
|
||
'Présentation',
|
||
style: 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(
|
||
isSubmitting: _submitting,
|
||
onCancel: widget.onClose,
|
||
onPrevious: () => setState(() => _showRefusForm = false),
|
||
onSubmit: (comment) {
|
||
if (comment == null || comment.trim().isEmpty) return;
|
||
_refuserEnvoyer(comment.trim());
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _refuserEnvoyer(String comment) async {
|
||
if (_submitting) return;
|
||
setState(() => _submitting = true);
|
||
try {
|
||
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
|
||
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);
|
||
}
|
||
}
|
||
}
|