feat(front): câbler PATCH refus utilisateur depuis wizards validation (#110)

- 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>
This commit is contained in:
MARTIN Julien 2026-05-13 17:38:29 +02:00
parent 43dabd1885
commit 7ba8d51668
4 changed files with 115 additions and 24 deletions

View File

@ -215,6 +215,30 @@ class UserService {
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {})); return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
} }
/// Refuser un compte en attente (AM ou parent). PATCH /users/:id/refuser. Ticket #110.
static Future<AppUser> refuseUser(String userId, {String? comment}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/refuser'),
headers: await _headers(),
body: jsonEncode(
comment != null && comment.trim().isNotEmpty
? {'comment': comment.trim()}
: {},
),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur refus (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
}
/// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108. /// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async { static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
final response = await http.post( final response = await http.post(

View File

@ -480,15 +480,12 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
children: [ children: [
Expanded( Expanded(
child: ValidationRefusForm( child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose, onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false), onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) { onSubmit: (comment) {
if (!mounted) return; if (comment == null || comment.trim().isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar( _refuserEnvoyer(comment.trim());
const SnackBar(
content: Text('Refus (à brancher sur lAPI refus)')),
);
widget.onClose();
}, },
), ),
), ),
@ -496,4 +493,26 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
), ),
); );
} }
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);
}
}
} }

View File

@ -697,16 +697,12 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
children: [ children: [
Expanded( Expanded(
child: ValidationRefusForm( child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose, onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false), onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) { onSubmit: (comment) {
if (!mounted) return; if (comment == null || comment.trim().isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar( _refuserEnvoyer(comment.trim());
const SnackBar(
content: Text(
'Refus (à brancher sur lAPI refus ticket #110)')),
);
widget.onClose();
}, },
), ),
), ),
@ -714,4 +710,42 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
), ),
); );
} }
/// Refuse chaque parent encore `en_attente` (même commentaire, e-mail reprise par compte). Ticket #110.
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
final pending = widget.dossier.parents
.where((p) => p.statut == 'en_attente' && p.id.trim().isNotEmpty)
.toList();
if (pending.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _submitting = true);
try {
for (final p in pending) {
await UserService.refuseUser(p.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);
}
}
} }

View File

@ -8,12 +8,15 @@ class ValidationRefusForm extends StatefulWidget {
/// Retour à létape précédente du wizard (écran avec Valider / Refuser). /// Retour à létape précédente du wizard (écran avec Valider / Refuser).
final VoidCallback onPrevious; final VoidCallback onPrevious;
final ValueChanged<String?> onSubmit; final ValueChanged<String?> onSubmit;
/// Pendant l'appel API : désactive les actions (ticket #110).
final bool isSubmitting;
const ValidationRefusForm({ const ValidationRefusForm({
super.key, super.key,
required this.onCancel, required this.onCancel,
required this.onPrevious, required this.onPrevious,
required this.onSubmit, required this.onSubmit,
this.isSubmitting = false,
}); });
@override @override
@ -67,6 +70,7 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
), ),
child: TextFormField( child: TextFormField(
controller: _controller, controller: _controller,
readOnly: widget.isSubmitting,
maxLines: null, maxLines: null,
minLines: 1, minLines: 1,
validator: _validateMotifs, validator: _validateMotifs,
@ -91,7 +95,7 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
Row( Row(
children: [ children: [
TextButton( TextButton(
onPressed: widget.onCancel, onPressed: widget.isSubmitting ? null : widget.onCancel,
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
const Spacer(), const Spacer(),
@ -99,19 +103,29 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton( TextButton(
onPressed: widget.onPrevious, onPressed: widget.isSubmitting ? null : widget.onPrevious,
child: const Text('Précédent'), child: const Text('Précédent'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( if (widget.isSubmitting)
style: ValidationModalTheme.primaryElevatedStyle, const Padding(
onPressed: () { padding: EdgeInsets.symmetric(horizontal: 12),
if (_formKey.currentState!.validate()) { child: SizedBox(
widget.onSubmit(_controller.text.trim()); width: 22,
} height: 22,
}, child: CircularProgressIndicator(strokeWidth: 2),
child: const Text('Envoyer'), ),
), )
else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(_controller.text.trim());
}
},
child: const Text('Envoyer'),
),
], ],
), ),
], ],