diff --git a/TAREAS.md b/TAREAS.md index 095747a..c89c049 100644 --- a/TAREAS.md +++ b/TAREAS.md @@ -44,6 +44,16 @@ ## Tareas +### ✅ Completadas (sesión 5 — 2026-03-22) + +- [x] **MEMB-02a** — Acciones de estado en ficha de abonado + - Menú dinámico: muestra "Retener/Cancelar retención" según estado, "Dar de baja/Reactivar" según si está activo + - **Retener / Cancelar retención:** diálogo de confirmación → `POST /members/retener` o `/retener-cancel` → actualiza la ficha en pantalla + - **Dar de baja:** pre-verificación (`POST /members/pre-delete`), alerta si hay recibos pendientes/devueltos, selección de fecha con date picker, motivo opcional → `POST /members/delete` + - **Reactivar:** diálogo de confirmación → `POST /members/recover` + - `Member.copyWith` para campos de estado (retenido, unregDate, unregReason, etc.) + - Mock con estado mutable: los cambios persisten al navegar entre socios + ### ✅ Completadas (sesión 4 — 2026-03-18) - [x] **BOOK-01** — Reservas de pistas (maquetación completa) @@ -105,7 +115,7 @@ _(ninguna)_ ### ⏳ Pendientes (próximas sesiones) - [ ] **AUTH-02** — Logo/imagen en pantalla de login (pendiente de asset) -- [ ] **MEMB-01** — Listado de abonados +- [x] **MEMB-01** — Listado de abonados (no aplica, no existe en el sistema) - [ ] **MEMB-02** — Ficha de abonado (detalle + edición) - [ ] **MEMB-03** — Pre-inscripciones - [ ] **MEMB-04** — Alta de nuevo abonado diff --git a/lib/models/member.dart b/lib/models/member.dart index 34254d6..471ef0a 100644 --- a/lib/models/member.dart +++ b/lib/models/member.dart @@ -169,4 +169,44 @@ class Member { String get formattedId => id.toString().padLeft(5, '0'); bool get isActive => unregDate == null; + + static const _sentinel = Object(); + + Member copyWith({ + bool? paid, + bool? retenido, + bool? remSpecial, + String? comment2, + List? comments, + Object? unregDate = _sentinel, + String? unregReason, + }) { + return Member( + id: id, + regDate: regDate, + address: address, + city: city, + zip: zip, + email: email, + phone: phone, + bank: bank, + bankOffice: bankOffice, + bankSecCode: bankSecCode, + bankAccount: bankAccount, + bankName: bankName, + mandate: mandate, + type: type, + comment: comment, + comment2: comment2 ?? this.comment2, + paid: paid ?? this.paid, + remSpecial: remSpecial ?? this.remSpecial, + retenido: retenido ?? this.retenido, + unregDate: identical(unregDate, _sentinel) ? this.unregDate : unregDate as String?, + unregReason: unregReason ?? this.unregReason, + fee: fee, + people: people, + comments: comments ?? this.comments, + monthlyFees: monthlyFees, + ); + } } diff --git a/lib/screens/members/members_screen.dart b/lib/screens/members/members_screen.dart index fdb5c1d..14e21af 100644 --- a/lib/screens/members/members_screen.dart +++ b/lib/screens/members/members_screen.dart @@ -24,15 +24,119 @@ class _MembersScreenState extends State { } void _handleAction(String action) { - final labels = { - 'receipts': 'Recibos anteriores — pendiente de implementar', - 'edit': 'Editar abonado — pendiente de implementar', - 'add_family': 'Añadir familiar — pendiente de implementar', - 'retain': 'Retener abonado — pendiente de implementar', - 'unregister': 'Dar de baja — pendiente de implementar', - }; + final member = _member; + if (member == null) return; + switch (action) { + case 'receipts': _doPaidCaution(member); + case 'retain': _doRetener(member); + case 'unregister': _doDarDeBaja(member); + case 'recover': _doReactivar(member); + default: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$action — pendiente de implementar'), + duration: const Duration(seconds: 2)), + ); + } + } + + Future _doPaidCaution(Member member) async { + try { + final updated = await _service.paidCaution(member.id); + if (!mounted) return; + setState(() => _member = updated); + } catch (e) { + if (!mounted) return; + _showError('Error: $e'); + } + } + + Future _doRetener(Member member) async { + final cancelar = member.retenido; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(cancelar ? 'Cancelar retención' : 'Retener abonado'), + content: Text(cancelar + ? '¿Cancelar la retención del socio ${member.formattedId}?' + : '¿Marcar como retenido al socio ${member.formattedId}?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(cancelar ? 'Cancelar retención' : 'Retener'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + final updated = cancelar + ? await _service.retenerCancel(member.id) + : await _service.retener(member.id); + if (!mounted) return; + setState(() => _member = updated); + } catch (e) { + if (!mounted) return; + _showError('Error: $e'); + } + } + + Future _doDarDeBaja(Member member) async { + Map preCheck; + try { + preCheck = await _service.preDelete(member.id); + } catch (e) { + if (!mounted) return; + _showError('Error al verificar: $e'); + return; + } + if (!mounted) return; + + final result = await showDialog<(String, String)>( + context: context, + builder: (_) => _BajaDialog(member: member, preCheck: preCheck), + ); + if (result == null || !mounted) return; + + try { + final updated = await _service.delete(member.id, result.$1, result.$2); + if (!mounted) return; + setState(() => _member = updated); + } catch (e) { + if (!mounted) return; + _showError('Error al dar de baja: $e'); + } + } + + Future _doReactivar(Member member) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reactivar abonado'), + content: Text('¿Reactivar al socio ${member.formattedId}?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Reactivar'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + final updated = await _service.recover(member.id); + if (!mounted) return; + setState(() => _member = updated); + } catch (e) { + if (!mounted) return; + _showError('Error al reactivar: $e'); + } + } + + void _showError(String msg) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(labels[action] ?? action), duration: const Duration(seconds: 2)), + SnackBar(content: Text(msg), duration: const Duration(seconds: 3)), ); } @@ -157,51 +261,67 @@ class _NavBar extends StatelessWidget { tooltip: 'Acciones', enabled: !disabled, onSelected: onAction, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'receipts', - child: ListTile( - leading: Icon(Icons.receipt_long_outlined), - title: Text('Recibos anteriores'), - dense: true, + itemBuilder: (context) { + final m = member; + final isActive = m?.isActive ?? true; + final retenido = m?.retenido ?? false; + return [ + const PopupMenuItem( + value: 'receipts', + child: ListTile( + leading: Icon(Icons.receipt_long_outlined), + title: Text('Recibos anteriores'), + dense: true, + ), ), - ), - const PopupMenuItem( - value: 'edit', - child: ListTile( - leading: Icon(Icons.edit_outlined), - title: Text('Editar abonado'), - dense: true, + const PopupMenuItem( + value: 'edit', + child: ListTile( + leading: Icon(Icons.edit_outlined), + title: Text('Editar abonado'), + dense: true, + ), ), - ), - const PopupMenuItem( - value: 'add_family', - child: ListTile( - leading: Icon(Icons.person_add_outlined), - title: Text('Añadir familiar'), - dense: true, + const PopupMenuItem( + value: 'add_family', + child: ListTile( + leading: Icon(Icons.person_add_outlined), + title: Text('Añadir familiar'), + dense: true, + ), ), - ), - const PopupMenuItem( - value: 'retain', - child: ListTile( - leading: Icon(Icons.pause_circle_outline), - title: Text('Retener abonado'), - dense: true, - ), - ), - const PopupMenuDivider(), - PopupMenuItem( - value: 'unregister', - child: ListTile( - leading: Icon(Icons.person_remove_outlined, - color: cs.error), - title: Text('Dar de baja', - style: TextStyle(color: cs.error)), - dense: true, - ), - ), - ], + if (isActive) + PopupMenuItem( + value: 'retain', + child: ListTile( + leading: Icon(retenido + ? Icons.play_circle_outline + : Icons.pause_circle_outline), + title: Text(retenido ? 'Cancelar retención' : 'Retener abonado'), + dense: true, + ), + ), + const PopupMenuDivider(), + if (!isActive) + const PopupMenuItem( + value: 'recover', + child: ListTile( + leading: Icon(Icons.person_add_alt_1_outlined), + title: Text('Reactivar abonado'), + dense: true, + ), + ), + if (isActive) + PopupMenuItem( + value: 'unregister', + child: ListTile( + leading: Icon(Icons.person_remove_outlined, color: cs.error), + title: Text('Dar de baja', style: TextStyle(color: cs.error)), + dense: true, + ), + ), + ]; + }, ), ], ), @@ -438,6 +558,11 @@ class _InfoCard extends StatelessWidget { label: 'Remesa especial', color: cs.primary, icon: Icons.star_outline), + if (member.paid) + _StatusChip( + label: 'Debe recibos', + color: cs.error, + icon: Icons.warning_amber_rounded), ], ), ), @@ -1045,6 +1170,140 @@ class _InfoRow extends StatelessWidget { } } +// ── Dar de baja dialog ──────────────────────────────────────────────────────── + +class _BajaDialog extends StatefulWidget { + final Member member; + final Map preCheck; + + const _BajaDialog({required this.member, required this.preCheck}); + + @override + State<_BajaDialog> createState() => _BajaDialogState(); +} + +class _BajaDialogState extends State<_BajaDialog> { + late DateTime _date; + final _reasonCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + _date = DateTime.now(); + } + + @override + void dispose() { + _reasonCtrl.dispose(); + super.dispose(); + } + + String get _dateStr => + '${_date.year.toString().padLeft(4, '0')}-' + '${_date.month.toString().padLeft(2, '0')}-' + '${_date.day.toString().padLeft(2, '0')}'; + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null) setState(() => _date = picked); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pendientes = widget.preCheck['pendientes'] ?? 0; + final devueltos = widget.preCheck['devueltos'] ?? 0; + final revision = widget.preCheck['revision'] ?? 0; + final hasWarning = pendientes > 0 || devueltos > 0 || revision > 0; + + return AlertDialog( + title: Text('Dar de baja — ${widget.member.formattedId}'), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasWarning) ...[ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: cs.error), + const SizedBox(width: 6), + Text('Atención', style: TextStyle(fontWeight: FontWeight.bold, color: cs.error)), + ]), + const SizedBox(height: 4), + if (pendientes > 0) Text('• $pendientes recibo(s) pendiente(s)', style: const TextStyle(fontSize: 13)), + if (devueltos > 0) Text('• $devueltos recibo(s) devuelto(s)', style: const TextStyle(fontSize: 13)), + if (revision > 0) Text('• $revision recibo(s) en revisión', style: const TextStyle(fontSize: 13)), + ], + ), + ), + const SizedBox(height: 16), + ], + // Fecha de baja + Row( + children: [ + Expanded( + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Fecha de baja', + border: OutlineInputBorder(), + isDense: true, + ), + child: Text(_dateStr), + ), + ), + const SizedBox(width: 8), + IconButton.outlined( + icon: const Icon(Icons.calendar_today, size: 18), + onPressed: _pickDate, + tooltip: 'Seleccionar fecha', + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _reasonCtrl, + decoration: const InputDecoration( + labelText: 'Motivo (opcional)', + border: OutlineInputBorder(), + isDense: true, + ), + maxLines: 2, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: () => Navigator.pop(context, (_dateStr, _reasonCtrl.text)), + child: const Text('Dar de baja'), + ), + ], + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + String _fmtDate(String? date) { if (date == null || date.isEmpty) return '—'; try { diff --git a/lib/services/member_repository.dart b/lib/services/member_repository.dart index 4326df2..cb4783a 100644 --- a/lib/services/member_repository.dart +++ b/lib/services/member_repository.dart @@ -7,4 +7,10 @@ abstract class MemberRepository { Future prev(int id); Future next(int id); Future> search(String term); + Future paidCaution(int id); + Future retener(int id); + Future retenerCancel(int id); + Future recover(int id); + Future> preDelete(int id); + Future delete(int id, String unregDate, String unregReason); } diff --git a/lib/services/member_service.dart b/lib/services/member_service.dart index 6d58e78..00c96fa 100644 --- a/lib/services/member_service.dart +++ b/lib/services/member_service.dart @@ -43,4 +43,63 @@ class MemberService implements MemberRepository { } return {}; } + + Future> _postHeaders() async => + {...await _headers(), 'Content-Type': 'application/json'}; + + // POST → respuesta con clave "member" + Future _postWrapped(String path, Map body) async { + final res = await http.post(Uri.parse('$kApiBase/$path'), + headers: await _postHeaders(), body: jsonEncode(body)); + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + return Member.fromJson(data['member'] as Map); + } + throw Exception('Error ${res.statusCode}'); + } + + // POST → respuesta es directamente el objeto member + Future _postDirect(String path, Map body) async { + final res = await http.post(Uri.parse('$kApiBase/$path'), + headers: await _postHeaders(), body: jsonEncode(body)); + if (res.statusCode == 200) { + return Member.fromJson(jsonDecode(res.body) as Map); + } + throw Exception('Error ${res.statusCode}'); + } + + @override + Future paidCaution(int id) => _postDirect('members/paid-caution', {'pk_i_id': id}); + + @override + Future retener(int id) => _postWrapped('members/retener', {'member': id}); + + @override + Future retenerCancel(int id) => _postWrapped('members/retener-cancel', {'member': id}); + + @override + Future recover(int id) => _postDirect('members/recover', {'pk_i_id': id}); + + @override + Future> preDelete(int id) async { + final res = await http.post(Uri.parse('$kApiBase/members/pre-delete'), + headers: await _postHeaders(), body: jsonEncode({'pk_i_id': id})); + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + return { + 'pendientes': (data['pendientes'] as num).toInt(), + 'devueltos': (data['devueltos'] as num).toInt(), + 'revision': (data['revision'] as num).toInt(), + }; + } + throw Exception('Error ${res.statusCode}'); + } + + @override + Future delete(int id, String unregDate, String unregReason) => + _postDirect('members/delete', { + 'pk_i_id': id, + 'd_unreg_date': unregDate, + 's_unreg_reason': unregReason, + }); } diff --git a/lib/services/mock/mock_member_service.dart b/lib/services/mock/mock_member_service.dart index 942ce96..7369317 100644 --- a/lib/services/mock/mock_member_service.dart +++ b/lib/services/mock/mock_member_service.dart @@ -2,17 +2,25 @@ import '../../models/member.dart'; import '../member_repository.dart'; class MockMemberService implements MemberRepository { - static final _members = [_member7, _member42, _member85]; + final _members = [_member7, _member42, _member85]; int _indexOf(int id) { final i = _members.indexWhere((m) => m.id == id); return i == -1 ? 0 : i; } + Member _update(Member updated) { + final i = _indexOf(updated.id); + if (i != -1) _members[i] = updated; + return updated; + } + + Member _get(int id) => + _members.firstWhere((m) => m.id == id, orElse: () => _members.first); + @override Future first() async => _members.first; @override Future last() async => _members.last; - @override Future show(int id) async => - _members.firstWhere((m) => m.id == id, orElse: () => _members.first); + @override Future show(int id) async => _get(id); @override Future prev(int id) async { @@ -41,6 +49,30 @@ class MockMemberService implements MemberRepository { } return results; } + + @override + Future paidCaution(int id) async => + _update(_get(id).copyWith(paid: !_get(id).paid)); + + @override + Future retener(int id) async => + _update(_get(id).copyWith(retenido: true)); + + @override + Future retenerCancel(int id) async => + _update(_get(id).copyWith(retenido: false)); + + @override + Future recover(int id) async => + _update(_get(id).copyWith(unregDate: null, unregReason: '')); + + @override + Future> preDelete(int id) async => + {'pendientes': 0, 'devueltos': 0, 'revision': 0}; + + @override + Future delete(int id, String unregDate, String unregReason) async => + _update(_get(id).copyWith(unregDate: unregDate, unregReason: unregReason)); } // ── Datos mock ────────────────────────────────────────────────────────────────