This commit is contained in:
Daniel Esteban 2026-03-23 00:54:12 +01:00
parent 425ee590fa
commit dd7b75aae5
6 changed files with 460 additions and 54 deletions

View file

@ -44,6 +44,16 @@
## Tareas ## 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) ### ✅ Completadas (sesión 4 — 2026-03-18)
- [x] **BOOK-01** — Reservas de pistas (maquetación completa) - [x] **BOOK-01** — Reservas de pistas (maquetación completa)
@ -105,7 +115,7 @@ _(ninguna)_
### ⏳ Pendientes (próximas sesiones) ### ⏳ Pendientes (próximas sesiones)
- [ ] **AUTH-02** — Logo/imagen en pantalla de login (pendiente de asset) - [ ] **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-02** — Ficha de abonado (detalle + edición)
- [ ] **MEMB-03** — Pre-inscripciones - [ ] **MEMB-03** — Pre-inscripciones
- [ ] **MEMB-04** — Alta de nuevo abonado - [ ] **MEMB-04** — Alta de nuevo abonado

View file

@ -169,4 +169,44 @@ class Member {
String get formattedId => id.toString().padLeft(5, '0'); String get formattedId => id.toString().padLeft(5, '0');
bool get isActive => unregDate == null; bool get isActive => unregDate == null;
static const _sentinel = Object();
Member copyWith({
bool? paid,
bool? retenido,
bool? remSpecial,
String? comment2,
List<MemberComment>? 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,
);
}
} }

View file

@ -24,15 +24,119 @@ class _MembersScreenState extends State<MembersScreen> {
} }
void _handleAction(String action) { void _handleAction(String action) {
final labels = { final member = _member;
'receipts': 'Recibos anteriores — pendiente de implementar', if (member == null) return;
'edit': 'Editar abonado — pendiente de implementar', switch (action) {
'add_family': 'Añadir familiar — pendiente de implementar', case 'receipts': _doPaidCaution(member);
'retain': 'Retener abonado — pendiente de implementar', case 'retain': _doRetener(member);
'unregister': 'Dar de baja — pendiente de implementar', case 'unregister': _doDarDeBaja(member);
}; case 'recover': _doReactivar(member);
default:
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(labels[action] ?? action), duration: const Duration(seconds: 2)), SnackBar(content: Text('$action — pendiente de implementar'),
duration: const Duration(seconds: 2)),
);
}
}
Future<void> _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<void> _doRetener(Member member) async {
final cancelar = member.retenido;
final confirmed = await showDialog<bool>(
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<void> _doDarDeBaja(Member member) async {
Map<String, int> 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<void> _doReactivar(Member member) async {
final confirmed = await showDialog<bool>(
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(msg), duration: const Duration(seconds: 3)),
); );
} }
@ -157,7 +261,11 @@ class _NavBar extends StatelessWidget {
tooltip: 'Acciones', tooltip: 'Acciones',
enabled: !disabled, enabled: !disabled,
onSelected: onAction, onSelected: onAction,
itemBuilder: (context) => [ itemBuilder: (context) {
final m = member;
final isActive = m?.isActive ?? true;
final retenido = m?.retenido ?? false;
return [
const PopupMenuItem( const PopupMenuItem(
value: 'receipts', value: 'receipts',
child: ListTile( child: ListTile(
@ -182,26 +290,38 @@ class _NavBar extends StatelessWidget {
dense: true, dense: true,
), ),
), ),
const PopupMenuItem( if (isActive)
PopupMenuItem(
value: 'retain', value: 'retain',
child: ListTile( child: ListTile(
leading: Icon(Icons.pause_circle_outline), leading: Icon(retenido
title: Text('Retener abonado'), ? Icons.play_circle_outline
: Icons.pause_circle_outline),
title: Text(retenido ? 'Cancelar retención' : 'Retener abonado'),
dense: true, dense: true,
), ),
), ),
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( if (!isActive)
value: 'unregister', const PopupMenuItem(
value: 'recover',
child: ListTile( child: ListTile(
leading: Icon(Icons.person_remove_outlined, leading: Icon(Icons.person_add_alt_1_outlined),
color: cs.error), title: Text('Reactivar abonado'),
title: Text('Dar de baja',
style: TextStyle(color: cs.error)),
dense: true, 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', label: 'Remesa especial',
color: cs.primary, color: cs.primary,
icon: Icons.star_outline), 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<String, int> 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<void> _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) { String _fmtDate(String? date) {
if (date == null || date.isEmpty) return ''; if (date == null || date.isEmpty) return '';
try { try {

View file

@ -7,4 +7,10 @@ abstract class MemberRepository {
Future<Member> prev(int id); Future<Member> prev(int id);
Future<Member> next(int id); Future<Member> next(int id);
Future<Map<String, String>> search(String term); Future<Map<String, String>> search(String term);
Future<Member> paidCaution(int id);
Future<Member> retener(int id);
Future<Member> retenerCancel(int id);
Future<Member> recover(int id);
Future<Map<String, int>> preDelete(int id);
Future<Member> delete(int id, String unregDate, String unregReason);
} }

View file

@ -43,4 +43,63 @@ class MemberService implements MemberRepository {
} }
return {}; return {};
} }
Future<Map<String, String>> _postHeaders() async =>
{...await _headers(), 'Content-Type': 'application/json'};
// POST respuesta con clave "member"
Future<Member> _postWrapped(String path, Map<String, dynamic> 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<String, dynamic>;
return Member.fromJson(data['member'] as Map<String, dynamic>);
}
throw Exception('Error ${res.statusCode}');
}
// POST respuesta es directamente el objeto member
Future<Member> _postDirect(String path, Map<String, dynamic> 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<String, dynamic>);
}
throw Exception('Error ${res.statusCode}');
}
@override
Future<Member> paidCaution(int id) => _postDirect('members/paid-caution', {'pk_i_id': id});
@override
Future<Member> retener(int id) => _postWrapped('members/retener', {'member': id});
@override
Future<Member> retenerCancel(int id) => _postWrapped('members/retener-cancel', {'member': id});
@override
Future<Member> recover(int id) => _postDirect('members/recover', {'pk_i_id': id});
@override
Future<Map<String, int>> 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<String, dynamic>;
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<Member> delete(int id, String unregDate, String unregReason) =>
_postDirect('members/delete', {
'pk_i_id': id,
'd_unreg_date': unregDate,
's_unreg_reason': unregReason,
});
} }

View file

@ -2,17 +2,25 @@ import '../../models/member.dart';
import '../member_repository.dart'; import '../member_repository.dart';
class MockMemberService implements MemberRepository { class MockMemberService implements MemberRepository {
static final _members = [_member7, _member42, _member85]; final _members = [_member7, _member42, _member85];
int _indexOf(int id) { int _indexOf(int id) {
final i = _members.indexWhere((m) => m.id == id); final i = _members.indexWhere((m) => m.id == id);
return i == -1 ? 0 : i; 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<Member> first() async => _members.first; @override Future<Member> first() async => _members.first;
@override Future<Member> last() async => _members.last; @override Future<Member> last() async => _members.last;
@override Future<Member> show(int id) async => @override Future<Member> show(int id) async => _get(id);
_members.firstWhere((m) => m.id == id, orElse: () => _members.first);
@override @override
Future<Member> prev(int id) async { Future<Member> prev(int id) async {
@ -41,6 +49,30 @@ class MockMemberService implements MemberRepository {
} }
return results; return results;
} }
@override
Future<Member> paidCaution(int id) async =>
_update(_get(id).copyWith(paid: !_get(id).paid));
@override
Future<Member> retener(int id) async =>
_update(_get(id).copyWith(retenido: true));
@override
Future<Member> retenerCancel(int id) async =>
_update(_get(id).copyWith(retenido: false));
@override
Future<Member> recover(int id) async =>
_update(_get(id).copyWith(unregDate: null, unregReason: ''));
@override
Future<Map<String, int>> preDelete(int id) async =>
{'pendientes': 0, 'devueltos': 0, 'revision': 0};
@override
Future<Member> delete(int id, String unregDate, String unregReason) async =>
_update(_get(id).copyWith(unregDate: unregDate, unregReason: unregReason));
} }
// Datos mock // Datos mock