Agrego frontend app
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:gimnasio_soma/core/config/app_constants.dart';
|
||||
import 'package:gimnasio_soma/core/config/supabase_config.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
|
||||
|
||||
class PagosRepositoryImpl implements PagosRepository {
|
||||
Future<String> _getToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(AppConstants.tokenKey);
|
||||
if (token == null) throw Exception('Sin sesión activa');
|
||||
return token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Pago>> getPagos({
|
||||
int pagina = 1,
|
||||
int cantidad = 50,
|
||||
String? dni,
|
||||
bool incluirAnulados = false,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
|
||||
final params = <String, dynamic>{
|
||||
'p_token': token,
|
||||
'p_pagina': pagina,
|
||||
'p_cantidad': cantidad,
|
||||
'p_incluir_anulados': incluirAnulados,
|
||||
};
|
||||
if (dni != null && dni.isNotEmpty) {
|
||||
params['p_dni'] = dni;
|
||||
}
|
||||
|
||||
final response = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcGetPagos,
|
||||
params: params,
|
||||
);
|
||||
|
||||
if (response is List) {
|
||||
return response
|
||||
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Pago>> getMisPagos({
|
||||
int pagina = 1,
|
||||
int cantidad = 20,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
|
||||
final response = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcGetMisPagos,
|
||||
params: {
|
||||
'p_token': token,
|
||||
'p_pagina': pagina,
|
||||
'p_cantidad': cantidad,
|
||||
},
|
||||
);
|
||||
|
||||
if (response is List) {
|
||||
return response
|
||||
.map((e) => Pago.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertPago(Map<String, dynamic> datos) async {
|
||||
final token = await _getToken();
|
||||
|
||||
await SupabaseConfig.rpc(
|
||||
AppConstants.rpcInsertPago,
|
||||
params: {
|
||||
'p_datos': datos,
|
||||
'p_token': token,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pago> editPago(String id, Map<String, dynamic> datos) async {
|
||||
final token = await _getToken();
|
||||
|
||||
final response = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcEditarPago,
|
||||
params: {
|
||||
'p_id': id,
|
||||
'p_datos': datos,
|
||||
'p_token': token,
|
||||
},
|
||||
);
|
||||
|
||||
if (response is Map<String, dynamic>) {
|
||||
return Pago.fromMap(response);
|
||||
}
|
||||
throw Exception('Respuesta inválida al editar el pago.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Pago> anularPago(String id, String? motivo) async {
|
||||
final token = await _getToken();
|
||||
// El backend hace NULLIF(trim(p_motivo), ''); igualmente normalizamos
|
||||
// a null antes de mandar para evitar enviar whitespace innecesario.
|
||||
final motivoNormalizado =
|
||||
(motivo == null || motivo.trim().isEmpty) ? null : motivo;
|
||||
|
||||
final response = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcAnularPago,
|
||||
params: {
|
||||
'p_id': id,
|
||||
'p_motivo': motivoNormalizado,
|
||||
'p_token': token,
|
||||
},
|
||||
);
|
||||
|
||||
if (response is Map<String, dynamic>) {
|
||||
return Pago.fromMap(response);
|
||||
}
|
||||
throw Exception('Respuesta inválida al anular el pago.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MetodoPago>> getMetodosPago() async {
|
||||
final token = await _getToken();
|
||||
|
||||
final response = await SupabaseConfig.rpc(
|
||||
AppConstants.rpcGetMetodosPago,
|
||||
params: {'p_token': token},
|
||||
);
|
||||
|
||||
if (response is List) {
|
||||
return response
|
||||
.map((e) => MetodoPago.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMetodoPago(Map<String, dynamic> datos) async {
|
||||
final token = await _getToken();
|
||||
|
||||
await SupabaseConfig.rpc(
|
||||
AppConstants.rpcUpdateMetodoPago,
|
||||
params: {
|
||||
'p_token': token,
|
||||
'p_datos': datos,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
class MetodoPago {
|
||||
final int id;
|
||||
final String descripcion;
|
||||
final bool activo;
|
||||
final String? icono;
|
||||
|
||||
const MetodoPago({
|
||||
required this.id,
|
||||
required this.descripcion,
|
||||
this.activo = true,
|
||||
this.icono,
|
||||
});
|
||||
|
||||
factory MetodoPago.fromMap(Map<String, dynamic> map) {
|
||||
return MetodoPago(
|
||||
id: map['id'] as int,
|
||||
descripcion: map['descripcion'] as String? ?? '',
|
||||
activo: map['activo'] as bool? ?? true,
|
||||
icono: map['icono'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
|
||||
|
||||
class Pago {
|
||||
final String id;
|
||||
final PagoTipo tipo;
|
||||
// Cuando tipo == ajuste, anioMesPagado puede venir vacío (NULL en DB).
|
||||
final String anioMesPagado; // date string "YYYY-MM-DD" (siempre día 1) o vacío
|
||||
final DateTime? fechaPago;
|
||||
final double montoTotal;
|
||||
// Cuando tipo == ajuste, metodo puede venir vacío (NULL en DB).
|
||||
final String metodo;
|
||||
final Map<String, dynamic>? detalle;
|
||||
// Sólo presente en fc_obtener_pagos (admin), no en fc_obtener_mis_pagos.
|
||||
final PagoCliente? cliente;
|
||||
|
||||
// Auditoría de creación. createdBy es el UUID del autor — habilita
|
||||
// gating local "este pago lo creé yo".
|
||||
final DateTime? createdAt;
|
||||
final String? createdBy;
|
||||
final String? createdByNombre;
|
||||
|
||||
// Auditoría de última edición (null si nunca se editó).
|
||||
final DateTime? updatedAt;
|
||||
final String? updatedByNombre;
|
||||
|
||||
// Auditoría de anulación (soft-delete).
|
||||
final DateTime? anuladoAt;
|
||||
final String? anuladoPorNombre;
|
||||
final String? motivoAnulacion;
|
||||
|
||||
const Pago({
|
||||
required this.id,
|
||||
required this.tipo,
|
||||
required this.anioMesPagado,
|
||||
this.fechaPago,
|
||||
required this.montoTotal,
|
||||
required this.metodo,
|
||||
this.detalle,
|
||||
this.cliente,
|
||||
this.createdAt,
|
||||
this.createdBy,
|
||||
this.createdByNombre,
|
||||
this.updatedAt,
|
||||
this.updatedByNombre,
|
||||
this.anuladoAt,
|
||||
this.anuladoPorNombre,
|
||||
this.motivoAnulacion,
|
||||
});
|
||||
|
||||
factory Pago.fromMap(Map<String, dynamic> map) {
|
||||
return Pago(
|
||||
id: map['id'] as String,
|
||||
tipo: PagoTipo.fromString(map['tipo'] as String?),
|
||||
anioMesPagado: map['anio_mes_pagado'] as String? ?? '',
|
||||
fechaPago: _parseDate(map['fecha_pago']),
|
||||
montoTotal: (map['monto_total'] as num?)?.toDouble() ?? 0,
|
||||
metodo: map['metodo'] as String? ?? '',
|
||||
detalle: map['detalle'] as Map<String, dynamic>?,
|
||||
cliente: map['cliente'] != null
|
||||
? PagoCliente.fromMap(map['cliente'] as Map<String, dynamic>)
|
||||
: null,
|
||||
createdAt: _parseDate(map['created_at']),
|
||||
createdBy: map['created_by'] as String?,
|
||||
createdByNombre: map['created_by_nombre'] as String?,
|
||||
updatedAt: _parseDate(map['updated_at']),
|
||||
updatedByNombre: map['updated_by_nombre'] as String?,
|
||||
anuladoAt: _parseDate(map['anulado_at']),
|
||||
anuladoPorNombre: map['anulado_por_nombre'] as String?,
|
||||
motivoAnulacion: map['motivo_anulacion'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic raw) {
|
||||
if (raw == null) return null;
|
||||
return DateTime.tryParse(raw.toString());
|
||||
}
|
||||
|
||||
/// Mes y año formateado: "Marzo 2026"
|
||||
String get mesPagadoDisplay {
|
||||
final date = DateTime.tryParse(anioMesPagado);
|
||||
if (date == null) return anioMesPagado;
|
||||
const meses = [
|
||||
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||
];
|
||||
return '${meses[date.month]} ${date.year}';
|
||||
}
|
||||
|
||||
/// Fecha de pago formateada: "15/03/2026"
|
||||
String get fechaPagoDisplay {
|
||||
if (fechaPago == null) return '-';
|
||||
final d = fechaPago!;
|
||||
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||
}
|
||||
|
||||
bool get isAnulado => anuladoAt != null;
|
||||
bool get isEditado => updatedAt != null;
|
||||
|
||||
/// True si el pago todavía está dentro de la ventana de edición desde su
|
||||
/// creación. La ventana se pasa como parámetro para no acoplar la entidad
|
||||
/// a un provider; el caller obtiene el valor de
|
||||
/// AppConstants.pagosVentanaEdicionMinutosDefault.
|
||||
///
|
||||
/// El backend sigue siendo la fuente de verdad: este getter sirve sólo
|
||||
/// para gating local de UI.
|
||||
bool isEditableWindow(int ventanaMinutos) {
|
||||
if (createdAt == null) return false;
|
||||
final diff = DateTime.now().difference(createdAt!).inMinutes;
|
||||
return diff < ventanaMinutos;
|
||||
}
|
||||
|
||||
/// True si el actor puede editar este pago. La ventana aplica a todos
|
||||
/// (incluso superadmin); ownership sólo si NO es superadmin.
|
||||
/// El backend es la fuente de verdad; este getter es para gating local.
|
||||
bool puedeEditar(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
|
||||
if (isAnulado) return false;
|
||||
if (!isEditableWindow(ventanaMinutos)) return false;
|
||||
if (isSuperadmin) return true;
|
||||
if (actorUserId == null || createdBy == null) return false;
|
||||
return actorUserId == createdBy;
|
||||
}
|
||||
|
||||
/// True si el actor puede anular este pago. Superadmin bypassea ownership
|
||||
/// y ventana; el resto necesita ser owner y estar dentro de ventana.
|
||||
bool puedeAnular(String? actorUserId, bool isSuperadmin, int ventanaMinutos) {
|
||||
if (isAnulado) return false;
|
||||
if (isSuperadmin) return true;
|
||||
if (actorUserId == null || createdBy == null) return false;
|
||||
if (actorUserId != createdBy) return false;
|
||||
return isEditableWindow(ventanaMinutos);
|
||||
}
|
||||
}
|
||||
|
||||
class PagoCliente {
|
||||
final String nombre;
|
||||
final String apellido;
|
||||
final String dni;
|
||||
|
||||
const PagoCliente({
|
||||
required this.nombre,
|
||||
required this.apellido,
|
||||
required this.dni,
|
||||
});
|
||||
|
||||
factory PagoCliente.fromMap(Map<String, dynamic> map) {
|
||||
return PagoCliente(
|
||||
nombre: map['nombre'] as String? ?? '',
|
||||
apellido: map['apellido'] as String? ?? '',
|
||||
dni: map['dni'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
if (nombre.isNotEmpty && apellido.isNotEmpty) return '$nombre $apellido';
|
||||
if (nombre.isNotEmpty) return nombre;
|
||||
return dni;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/// Discriminador de la tabla pagos. El backend acepta los cuatro valores
|
||||
/// del schema; el frontend de este sprint sólo CREA cuotaMensual (los
|
||||
/// correctivos llegan desde SQL directo hasta que tengan sus propias
|
||||
/// funciones fc_*).
|
||||
enum PagoTipo {
|
||||
cuotaMensual,
|
||||
devolucion,
|
||||
descuentoRetroactivo,
|
||||
ajuste;
|
||||
|
||||
/// Parsea el valor del backend. Default defensivo: cuotaMensual.
|
||||
/// El backfill de la migración garantiza que pagos.tipo nunca sea NULL,
|
||||
/// pero ante un valor desconocido no rompemos el parseo.
|
||||
factory PagoTipo.fromString(String? raw) {
|
||||
switch (raw) {
|
||||
case 'cuota_mensual':
|
||||
return PagoTipo.cuotaMensual;
|
||||
case 'devolucion':
|
||||
return PagoTipo.devolucion;
|
||||
case 'descuento_retroactivo':
|
||||
return PagoTipo.descuentoRetroactivo;
|
||||
case 'ajuste':
|
||||
return PagoTipo.ajuste;
|
||||
default:
|
||||
return PagoTipo.cuotaMensual;
|
||||
}
|
||||
}
|
||||
|
||||
/// Valor snake_case que espera el backend.
|
||||
String get backendValue {
|
||||
switch (this) {
|
||||
case PagoTipo.cuotaMensual:
|
||||
return 'cuota_mensual';
|
||||
case PagoTipo.devolucion:
|
||||
return 'devolucion';
|
||||
case PagoTipo.descuentoRetroactivo:
|
||||
return 'descuento_retroactivo';
|
||||
case PagoTipo.ajuste:
|
||||
return 'ajuste';
|
||||
}
|
||||
}
|
||||
|
||||
/// Etiqueta legible en español. Vive con la entidad por simplicidad
|
||||
/// (SOMA es app monolingüe).
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case PagoTipo.cuotaMensual:
|
||||
return 'Cuota mensual';
|
||||
case PagoTipo.devolucion:
|
||||
return 'Devolución';
|
||||
case PagoTipo.descuentoRetroactivo:
|
||||
return 'Descuento retroactivo';
|
||||
case PagoTipo.ajuste:
|
||||
return 'Ajuste';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
|
||||
abstract class PagosRepository {
|
||||
/// Obtener pagos (admin: todos o filtrados por DNI).
|
||||
/// [incluirAnulados] default false — equivale al toggle "Mostrar anulados"
|
||||
/// que el backend gobierna con el parámetro p_incluir_anulados.
|
||||
Future<List<Pago>> getPagos({
|
||||
int pagina = 1,
|
||||
int cantidad = 50,
|
||||
String? dni,
|
||||
bool incluirAnulados = false,
|
||||
});
|
||||
|
||||
/// Obtener pagos propios del usuario logueado. Siempre incluye anulados:
|
||||
/// el cliente puede haber visto el pago antes de la edición/anulación,
|
||||
/// la app cliente marca visualmente los modificados.
|
||||
Future<List<Pago>> getMisPagos({int pagina = 1, int cantidad = 20});
|
||||
|
||||
/// Registrar un nuevo pago.
|
||||
Future<void> insertPago(Map<String, dynamic> datos);
|
||||
|
||||
/// Editar un pago existente. Devuelve el pago actualizado (shape de
|
||||
/// fc_obtener_pagos para un único objeto).
|
||||
/// El backend rechaza el llamado si:
|
||||
/// - el pago no existe o está anulado.
|
||||
/// - el actor no tiene gestionar_cualquier_pago y no es el creador.
|
||||
/// - el actor no tiene gestionar_cualquier_pago y la ventana venció.
|
||||
/// - se intenta editar cliente_id o tipo.
|
||||
Future<Pago> editPago(String id, Map<String, dynamic> datos);
|
||||
|
||||
/// Anular (soft-delete) un pago. [motivo] puede ser null o vacío;
|
||||
/// el backend lo trimea y persiste como NULL en ese caso. Devuelve
|
||||
/// el pago actualizado. Misma matriz de permisos que [editPago].
|
||||
Future<Pago> anularPago(String id, String? motivo);
|
||||
|
||||
/// Obtener métodos de pago activos.
|
||||
Future<List<MetodoPago>> getMetodosPago();
|
||||
|
||||
/// Actualizar un método de pago (descripción, activo, icono).
|
||||
Future<void> updateMetodoPago(Map<String, dynamic> datos);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
|
||||
|
||||
class PagoConUsuario {
|
||||
final Pago pago;
|
||||
final Usuario usuario;
|
||||
|
||||
const PagoConUsuario({required this.pago, required this.usuario});
|
||||
}
|
||||
|
||||
class PagosEstadoMes {
|
||||
final String mes;
|
||||
final List<PagoConUsuario> pagaron;
|
||||
final List<Usuario> unMesSinPagar;
|
||||
final List<Usuario> masDe1MesSinPagar;
|
||||
|
||||
const PagosEstadoMes({
|
||||
required this.mes,
|
||||
required this.pagaron,
|
||||
required this.unMesSinPagar,
|
||||
required this.masDe1MesSinPagar,
|
||||
});
|
||||
|
||||
int get total => pagaron.length + unMesSinPagar.length + masDe1MesSinPagar.length;
|
||||
double get progressValue => total == 0 ? 0 : pagaron.length / total;
|
||||
}
|
||||
|
||||
/// Clasifica usuarios activos con plan en 3 grupos para el mes dado ("YYYY-MM"):
|
||||
/// - pagaron: tienen pago registrado ese mes
|
||||
/// - unMesSinPagar: no pagaron ese mes pero sí el anterior
|
||||
/// - masDe1MesSinPagar: no pagaron ese mes ni el anterior
|
||||
final pagosEstadoProvider =
|
||||
FutureProvider.autoDispose.family<PagosEstadoMes, String>((ref, mes) async {
|
||||
final usuarios = await ref.watch(allUsuariosProvider.future);
|
||||
final pagosValue = ref.watch(pagosProvider);
|
||||
final pagos = pagosValue.valueOrNull ?? [];
|
||||
|
||||
// Mes anterior
|
||||
final parts = mes.split('-');
|
||||
final mesDate = DateTime(int.parse(parts[0]), int.parse(parts[1]));
|
||||
final mesAnteriorDate = DateTime(mesDate.year, mesDate.month - 1);
|
||||
final mesAnterior =
|
||||
'${mesAnteriorDate.year}-${mesAnteriorDate.month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Indexar pagos por DNI para el mes seleccionado y el anterior
|
||||
final pagosMes = <String, Pago>{};
|
||||
final pagosAnterior = <String, bool>{};
|
||||
for (final p in pagos) {
|
||||
if (p.cliente == null) continue;
|
||||
if (p.anioMesPagado.startsWith('$mes-')) {
|
||||
pagosMes[p.cliente!.dni] = p;
|
||||
}
|
||||
if (p.anioMesPagado.startsWith('$mesAnterior-')) {
|
||||
pagosAnterior[p.cliente!.dni] = true;
|
||||
}
|
||||
}
|
||||
|
||||
final conPlan =
|
||||
usuarios.where((u) => u.isActive && u.tipoCuota != null).toList();
|
||||
|
||||
final pagaron = <PagoConUsuario>[];
|
||||
final unMes = <Usuario>[];
|
||||
final masDe1Mes = <Usuario>[];
|
||||
|
||||
for (final u in conPlan) {
|
||||
final pago = pagosMes[u.dni];
|
||||
if (pago != null) {
|
||||
pagaron.add(PagoConUsuario(pago: pago, usuario: u));
|
||||
} else if (pagosAnterior.containsKey(u.dni)) {
|
||||
unMes.add(u);
|
||||
} else {
|
||||
masDe1Mes.add(u);
|
||||
}
|
||||
}
|
||||
|
||||
pagaron.sort((a, b) => a.usuario.displayName.compareTo(b.usuario.displayName));
|
||||
unMes.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
masDe1Mes.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
return PagosEstadoMes(
|
||||
mes: mes,
|
||||
pagaron: pagaron,
|
||||
unMesSinPagar: unMes,
|
||||
masDe1MesSinPagar: masDe1Mes,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PagosFilter {
|
||||
final String? selectedMonth; // "YYYY-MM" o null para todos
|
||||
final String? selectedMetodo; // Nombre del método o null para todos
|
||||
|
||||
const PagosFilter({
|
||||
this.selectedMonth,
|
||||
this.selectedMetodo,
|
||||
});
|
||||
|
||||
PagosFilter copyWith({
|
||||
String? Function()? selectedMonth,
|
||||
String? Function()? selectedMetodo,
|
||||
}) {
|
||||
return PagosFilter(
|
||||
selectedMonth:
|
||||
selectedMonth != null ? selectedMonth() : this.selectedMonth,
|
||||
selectedMetodo:
|
||||
selectedMetodo != null ? selectedMetodo() : this.selectedMetodo,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasActiveFilters =>
|
||||
selectedMonth != null || selectedMetodo != null;
|
||||
}
|
||||
|
||||
final pagosFilterProvider =
|
||||
StateNotifierProvider.autoDispose<PagosFilterNotifier, PagosFilter>((ref) {
|
||||
return PagosFilterNotifier();
|
||||
});
|
||||
|
||||
class PagosFilterNotifier extends StateNotifier<PagosFilter> {
|
||||
PagosFilterNotifier() : super(const PagosFilter());
|
||||
|
||||
void setMonth(String? month) {
|
||||
state = state.copyWith(selectedMonth: () => month);
|
||||
}
|
||||
|
||||
void setMetodo(String? metodo) {
|
||||
state = state.copyWith(selectedMetodo: () => metodo);
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
state = const PagosFilter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/data/repositories/pagos_repository_impl.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago_tipo.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/repositories/pagos_repository.dart';
|
||||
|
||||
final pagosRepositoryProvider = Provider<PagosRepository>((ref) {
|
||||
return PagosRepositoryImpl();
|
||||
});
|
||||
|
||||
/// Métodos de pago con CRUD. Dato de configuración, persiste en sesión.
|
||||
final metodosPagoProvider = StateNotifierProvider<MetodosPagoNotifier,
|
||||
AsyncValue<List<MetodoPago>>>((ref) {
|
||||
return MetodosPagoNotifier(ref.read(pagosRepositoryProvider));
|
||||
});
|
||||
|
||||
class MetodosPagoNotifier
|
||||
extends StateNotifier<AsyncValue<List<MetodoPago>>> {
|
||||
final PagosRepository _repository;
|
||||
|
||||
MetodosPagoNotifier(this._repository)
|
||||
: super(const AsyncValue.loading()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final data = await _repository.getMetodosPago();
|
||||
state = AsyncValue.data(data);
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> update(Map<String, dynamic> datos) async {
|
||||
try {
|
||||
await _repository.updateMetodoPago(datos);
|
||||
await load();
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Últimos pagos de un usuario por DNI (para detalle de usuario).
|
||||
/// autoDispose: se descarta al salir del detalle y refetchea al volver.
|
||||
/// [incluirAnulados] default false; cambiar a true para el toggle
|
||||
/// "Mostrar anulados" en el detalle.
|
||||
final userPagosProvider = FutureProvider.autoDispose
|
||||
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
|
||||
final repo = ref.read(pagosRepositoryProvider);
|
||||
return repo.getPagos(
|
||||
dni: args.dni,
|
||||
cantidad: 10,
|
||||
incluirAnulados: args.incluirAnulados,
|
||||
);
|
||||
});
|
||||
|
||||
/// Historial completo de pagos por DNI.
|
||||
/// cantidad=200 cubre 2 años de cuotas + correctivos teóricos máximos (96)
|
||||
/// con margen 2x. Si en algún momento un usuario supera esto, hay que pensar
|
||||
/// en paginación dedicada.
|
||||
/// autoDispose: se descarta al salir del historial y refetchea al volver.
|
||||
final userHistorialProvider = FutureProvider.autoDispose
|
||||
.family<List<Pago>, ({String dni, bool incluirAnulados})>((ref, args) async {
|
||||
final repo = ref.read(pagosRepositoryProvider);
|
||||
return repo.getPagos(
|
||||
dni: args.dni,
|
||||
cantidad: 200,
|
||||
incluirAnulados: args.incluirAnulados,
|
||||
);
|
||||
});
|
||||
|
||||
/// Lista de pagos. autoDispose: al navegar fuera de la pantalla de pagos
|
||||
/// los datos se descartan; al volver se cargan frescos del backend.
|
||||
final pagosProvider =
|
||||
StateNotifierProvider.autoDispose<PagosNotifier, AsyncValue<List<Pago>>>((ref) {
|
||||
final user = ref.read(authStateProvider).valueOrNull;
|
||||
final isAdmin = user != null && user.isStaff;
|
||||
return PagosNotifier(ref.read(pagosRepositoryProvider), isAdmin);
|
||||
});
|
||||
|
||||
class PagosNotifier extends StateNotifier<AsyncValue<List<Pago>>> {
|
||||
final PagosRepository _repository;
|
||||
String? _searchDni;
|
||||
bool _viewingOwn;
|
||||
bool _incluirAnulados = false;
|
||||
|
||||
PagosNotifier(this._repository, bool isAdmin)
|
||||
: _viewingOwn = !isAdmin,
|
||||
super(const AsyncValue.loading()) {
|
||||
if (isAdmin) {
|
||||
loadPagos();
|
||||
} else {
|
||||
loadMisPagos();
|
||||
}
|
||||
}
|
||||
|
||||
bool get incluirAnulados => _incluirAnulados;
|
||||
|
||||
/// Cargar pagos como admin (todos o filtrados por DNI).
|
||||
Future<void> loadPagos() async {
|
||||
_viewingOwn = false;
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final pagos = await _repository.getPagos(
|
||||
dni: _searchDni,
|
||||
incluirAnulados: _incluirAnulados,
|
||||
);
|
||||
if (!mounted) return;
|
||||
state = AsyncValue.data(pagos);
|
||||
} catch (e, st) {
|
||||
if (!mounted) return;
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cargar pagos propios del usuario logueado.
|
||||
/// El backend siempre incluye anulados para el cliente.
|
||||
Future<void> loadMisPagos() async {
|
||||
_viewingOwn = true;
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final pagos = await _repository.getMisPagos();
|
||||
if (!mounted) return;
|
||||
state = AsyncValue.data(pagos);
|
||||
} catch (e, st) {
|
||||
if (!mounted) return;
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String? dni) async {
|
||||
_searchDni = (dni == null || dni.isEmpty) ? null : dni;
|
||||
await loadPagos();
|
||||
}
|
||||
|
||||
/// Toggle "Mostrar anulados". Sólo afecta la vista admin
|
||||
/// (loadMisPagos siempre los incluye).
|
||||
Future<void> setIncluirAnulados(bool value) async {
|
||||
if (_incluirAnulados == value) return;
|
||||
_incluirAnulados = value;
|
||||
if (!_viewingOwn) {
|
||||
await loadPagos();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> insertPago(Map<String, dynamic> datos) async {
|
||||
try {
|
||||
await _repository.insertPago(datos);
|
||||
if (_viewingOwn) {
|
||||
await loadMisPagos();
|
||||
} else {
|
||||
await loadPagos();
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> editPago(String id, Map<String, dynamic> datos) async {
|
||||
try {
|
||||
await _repository.editPago(id, datos);
|
||||
// Refetch para consistencia: el backend devuelve el pago actualizado
|
||||
// pero la lista puede haber cambiado de orden (fecha_pago editada).
|
||||
if (_viewingOwn) {
|
||||
await loadMisPagos();
|
||||
} else {
|
||||
await loadPagos();
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> anularPago(String id, String? motivo) async {
|
||||
try {
|
||||
await _repository.anularPago(id, motivo);
|
||||
if (_viewingOwn) {
|
||||
await loadMisPagos();
|
||||
} else {
|
||||
await loadPagos();
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapa DNI → fecha del último pago (fechaPago del pago más reciente).
|
||||
/// Filtra por tipo == cuotaMensual: un correctivo (devolución, descuento o
|
||||
/// ajuste) no representa "haber pagado el mes" — esto matchea el filtro
|
||||
/// que aplica fc_reservar_turno en la regla de los 2 meses.
|
||||
/// Se invalida automáticamente cuando la lista de usuarios cambia.
|
||||
final ultimoPagoMapProvider =
|
||||
FutureProvider.autoDispose<Map<String, DateTime?>>((ref) async {
|
||||
ref.watch(allUsuariosProvider);
|
||||
final repo = ref.read(pagosRepositoryProvider);
|
||||
final pagos = await repo.getPagos(cantidad: 500);
|
||||
|
||||
final map = <String, DateTime?>{};
|
||||
for (final p in pagos) {
|
||||
if (p.tipo != PagoTipo.cuotaMensual) continue;
|
||||
if (p.cliente == null) continue;
|
||||
final dni = p.cliente!.dni;
|
||||
if (!map.containsKey(dni)) {
|
||||
// La lista viene ordenada más reciente primero
|
||||
map[dni] = p.fechaPago ?? DateTime.tryParse(p.anioMesPagado);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// ── Deudores ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class Deudor {
|
||||
final Usuario usuario;
|
||||
final int mesesAdeudados;
|
||||
const Deudor({required this.usuario, required this.mesesAdeudados});
|
||||
}
|
||||
|
||||
int _calcMesesAdeudados(DateTime? ultimoPago, DateTime currentMonth) {
|
||||
if (ultimoPago == null) return 24;
|
||||
final lastMonth = DateTime(ultimoPago.year, ultimoPago.month);
|
||||
final diff = (currentMonth.year - lastMonth.year) * 12 +
|
||||
currentMonth.month -
|
||||
lastMonth.month;
|
||||
return diff.clamp(0, 24);
|
||||
}
|
||||
|
||||
/// Lista de socios activos con plan que adeudan al menos un mes, ordenados
|
||||
/// de mayor a menor cantidad de meses sin pagar.
|
||||
final deudoresProvider = FutureProvider.autoDispose<List<Deudor>>((ref) async {
|
||||
final usuarios = await ref.watch(allUsuariosProvider.future);
|
||||
final ultimoPagoMap = await ref.watch(ultimoPagoMapProvider.future);
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMonth = DateTime(now.year, now.month);
|
||||
|
||||
final deudores = <Deudor>[];
|
||||
for (final u in usuarios) {
|
||||
if (!u.isActive || u.tipoCuota == null) continue;
|
||||
final meses = _calcMesesAdeudados(ultimoPagoMap[u.dni], currentMonth);
|
||||
if (meses <= 0) continue;
|
||||
deudores.add(Deudor(usuario: u, mesesAdeudados: meses));
|
||||
}
|
||||
|
||||
deudores.sort((a, b) => b.mesesAdeudados.compareTo(a.mesesAdeudados));
|
||||
return deudores;
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:gimnasio_soma/core/config/app_constants.dart';
|
||||
|
||||
enum PagosViewMode { overview, list, estado }
|
||||
|
||||
final pagosViewModeProvider =
|
||||
StateNotifierProvider<PagosViewModeNotifier, PagosViewMode>((ref) {
|
||||
return PagosViewModeNotifier();
|
||||
});
|
||||
|
||||
class PagosViewModeNotifier extends StateNotifier<PagosViewMode> {
|
||||
PagosViewModeNotifier() : super(PagosViewMode.overview) {
|
||||
_loadViewMode();
|
||||
}
|
||||
|
||||
Future<void> _loadViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString(AppConstants.pagosViewModeKey);
|
||||
state = switch (stored) {
|
||||
'list' => PagosViewMode.list,
|
||||
'estado' => PagosViewMode.estado,
|
||||
_ => PagosViewMode.overview,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> setMode(PagosViewMode mode) async {
|
||||
state = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
AppConstants.pagosViewModeKey,
|
||||
switch (mode) {
|
||||
PagosViewMode.list => 'list',
|
||||
PagosViewMode.estado => 'estado',
|
||||
PagosViewMode.overview => 'overview',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggle() async {
|
||||
await setMode(switch (state) {
|
||||
PagosViewMode.overview => PagosViewMode.list,
|
||||
PagosViewMode.list => PagosViewMode.estado,
|
||||
PagosViewMode.estado => PagosViewMode.overview,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_header_help.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/metodo_pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
|
||||
class MetodosPagoScreen extends ConsumerWidget {
|
||||
const MetodosPagoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(metodosPagoProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 800;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16,
|
||||
isWide ? 28 : 16,
|
||||
isWide ? 32 : 16,
|
||||
12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Métodos de pago',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SomaHeaderHelp(
|
||||
items: [
|
||||
SomaHelpItem(
|
||||
icon: Icons.touch_app_outlined,
|
||||
text: 'Tocá un método para activarlo, desactivarlo o '
|
||||
'cambiarle el ícono.',
|
||||
),
|
||||
SomaHelpItem(
|
||||
icon: Icons.refresh,
|
||||
text: 'Recarga la lista de métodos de pago.',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Recargar',
|
||||
onPressed: () =>
|
||||
ref.read(metodosPagoProvider.notifier).load(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: state.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SomaColors.primary),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurface.withAlpha(100)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(metodosPagoProvider.notifier).load(),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (metodos) {
|
||||
if (metodos.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No hay métodos de pago',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 4, isWide ? 32 : 16, 80,
|
||||
),
|
||||
itemCount: metodos.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return _MetodoCard(metodo: metodos[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetodoCard extends ConsumerWidget {
|
||||
final MetodoPago metodo;
|
||||
const _MetodoCard({required this.metodo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final railColor = metodo.activo
|
||||
? SomaColors.success.withAlpha(180)
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
|
||||
final card = InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _showEditDialog(context, ref),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Rail de estado
|
||||
Container(width: 4, color: railColor),
|
||||
|
||||
// Contenido
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
||||
child: Row(
|
||||
children: [
|
||||
// Ícono
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: metodo.activo
|
||||
? SomaColors.primary.withAlpha(20)
|
||||
: theme.colorScheme.onSurface.withAlpha(12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
_iconForMetodo(metodo.icono),
|
||||
size: 20,
|
||||
color: metodo.activo
|
||||
? SomaColors.primary
|
||||
: theme.colorScheme.onSurface.withAlpha(80),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Descripción + badge
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
metodo.descripcion,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_EstadoBadge(activo: metodo.activo),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// Toggle activo
|
||||
Switch(
|
||||
value: metodo.activo,
|
||||
activeTrackColor: SomaColors.success,
|
||||
activeThumbColor: Colors.white,
|
||||
onChanged: (value) async {
|
||||
final error =
|
||||
await ref.read(metodosPagoProvider.notifier).update({
|
||||
'id': metodo.id,
|
||||
'activo': value,
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
if (error != null) {
|
||||
SomaToast.show(context,
|
||||
message: error, type: ToastType.error);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Edit
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
tooltip: 'Editar',
|
||||
onPressed: () => _showEditDialog(context, ref),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 34,
|
||||
minHeight: 34,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!metodo.activo) {
|
||||
return Opacity(opacity: 0.6, child: card);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
static const _iconOptions = [
|
||||
(null, Icons.payment_outlined, 'Sin ícono'),
|
||||
('efectivo', Icons.payments_outlined, 'Efectivo'),
|
||||
('transferencia', Icons.account_balance_outlined, 'Transferencia'),
|
||||
('tarjeta', Icons.credit_card_outlined, 'Tarjeta'),
|
||||
('qr', Icons.qr_code, 'QR'),
|
||||
];
|
||||
|
||||
Future<void> _showEditDialog(BuildContext context, WidgetRef ref) async {
|
||||
final ctrl = TextEditingController(text: metodo.descripcion);
|
||||
String? selectedIcon = metodo.icono;
|
||||
|
||||
final result = await showDialog<({String descripcion, String? icono})>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setLocal) {
|
||||
final theme = Theme.of(ctx);
|
||||
return AlertDialog(
|
||||
title: const Text('Editar método de pago'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: ctrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Descripción',
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
autofocus: true,
|
||||
maxLength: 50,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ícono',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _iconOptions.map((opt) {
|
||||
final (key, icon, label) = opt;
|
||||
final isSelected = selectedIcon == key;
|
||||
return GestureDetector(
|
||||
onTap: () => setLocal(() => selectedIcon = key),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? SomaColors.primary.withAlpha(22)
|
||||
: theme.colorScheme.surfaceContainerHighest
|
||||
.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? SomaColors.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
width: isSelected ? 1.5 : 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? SomaColors.primary
|
||||
: theme.colorScheme.onSurface.withAlpha(150),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? SomaColors.primary
|
||||
: theme.colorScheme.onSurface
|
||||
.withAlpha(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withAlpha(178),
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final text = ctrl.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
Navigator.of(ctx)
|
||||
.pop((descripcion: text, icono: selectedIcon));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('Guardar'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
ctrl.dispose();
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final changed = result.descripcion != metodo.descripcion ||
|
||||
result.icono != metodo.icono;
|
||||
if (!changed) return;
|
||||
|
||||
final error = await ref.read(metodosPagoProvider.notifier).update({
|
||||
'id': metodo.id,
|
||||
'descripcion': result.descripcion,
|
||||
'icono': result.icono,
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
if (error != null) {
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
} else {
|
||||
SomaToast.show(context,
|
||||
message: 'Método actualizado', type: ToastType.success);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconForMetodo(String? icono) {
|
||||
switch (icono) {
|
||||
case 'efectivo':
|
||||
case 'cash':
|
||||
return Icons.payments_outlined;
|
||||
case 'transferencia':
|
||||
case 'transfer':
|
||||
return Icons.account_balance_outlined;
|
||||
case 'tarjeta':
|
||||
case 'card':
|
||||
return Icons.credit_card_outlined;
|
||||
case 'qr':
|
||||
return Icons.qr_code;
|
||||
default:
|
||||
return Icons.payment_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _EstadoBadge extends StatelessWidget {
|
||||
final bool activo;
|
||||
const _EstadoBadge({required this.activo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = activo ? SomaColors.success : SomaColors.error;
|
||||
final label = activo ? 'Activo' : 'Inactivo';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: color.withAlpha(60), width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 5,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_text_field.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
|
||||
/// Dialog de anulación de un pago. Devuelve `true` en éxito y `null` /
|
||||
/// `false` al cancelar. Errores del backend se muestran inline; el dialog
|
||||
/// no se cierra hasta éxito o cancelación explícita.
|
||||
///
|
||||
/// Presets de motivo: orientados a errores de carga. NO incluye
|
||||
/// "Cliente devolvió plata" — devolución no es anulación
|
||||
/// (ver plan: "Anular vs correctivos").
|
||||
class AnularPagoDialog extends ConsumerStatefulWidget {
|
||||
final Pago pago;
|
||||
const AnularPagoDialog({super.key, required this.pago});
|
||||
|
||||
@override
|
||||
ConsumerState<AnularPagoDialog> createState() => _AnularPagoDialogState();
|
||||
}
|
||||
|
||||
class _AnularPagoDialogState extends ConsumerState<AnularPagoDialog> {
|
||||
static const _presets = <String>[
|
||||
'Cobro duplicado',
|
||||
'Cliente equivocado',
|
||||
'Error de monto',
|
||||
'Error de mes',
|
||||
];
|
||||
|
||||
final _motivoCtrl = TextEditingController();
|
||||
String? _selectedPreset;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motivoCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPresetTap(String preset) {
|
||||
setState(() {
|
||||
_selectedPreset = preset;
|
||||
_motivoCtrl.text = preset;
|
||||
_motivoCtrl.selection = TextSelection.collapsed(
|
||||
offset: preset.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onMotivoChanged(String value) {
|
||||
// Si el texto deja de coincidir con el preset seleccionado, deselecciono.
|
||||
if (_selectedPreset != null && value.trim() != _selectedPreset) {
|
||||
setState(() => _selectedPreset = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_submitting) return;
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final motivo = _motivoCtrl.text.trim();
|
||||
final err = await ref
|
||||
.read(pagosProvider.notifier)
|
||||
.anularPago(widget.pago.id, motivo.isEmpty ? null : motivo);
|
||||
|
||||
if (!mounted) return;
|
||||
if (err == null) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
_error = err;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final pago = widget.pago;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 480,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Header(pago: pago, submitting: _submitting),
|
||||
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_PagoResumenCard(pago: pago),
|
||||
const SizedBox(height: 14),
|
||||
_Warning(),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Motivo (opcional)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface.withAlpha(160),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: _presets.map((p) {
|
||||
final selected = _selectedPreset == p;
|
||||
return FilterChip(
|
||||
label: Text(p),
|
||||
selected: selected,
|
||||
onSelected: _submitting
|
||||
? null
|
||||
: (_) => _onPresetTap(p),
|
||||
selectedColor: SomaColors.primary.withAlpha(40),
|
||||
checkmarkColor: SomaColors.primaryText,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: selected
|
||||
? SomaColors.primaryText
|
||||
: cs.onSurface,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: selected
|
||||
? SomaColors.primary
|
||||
: cs.surfaceContainerHighest,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SomaTextField(
|
||||
controller: _motivoCtrl,
|
||||
labelText: null,
|
||||
hintText:
|
||||
'Escribí un motivo o seleccioná uno arriba',
|
||||
maxLines: 2,
|
||||
enabled: !_submitting,
|
||||
onChanged: _onMotivoChanged,
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
_ErrorBanner(message: _error!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
'Cancelar',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withAlpha(178),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.cancel_outlined, size: 18),
|
||||
label: Text(_submitting ? 'Anulando…' : 'Anular pago'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: SomaColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 42),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final Pago pago;
|
||||
final bool submitting;
|
||||
const _Header({required this.pago, required this.submitting});
|
||||
|
||||
String get _initials {
|
||||
final c = pago.cliente;
|
||||
if (c == null) return '?';
|
||||
final n = c.nombre.isNotEmpty ? c.nombre[0] : '';
|
||||
final a = c.apellido.isNotEmpty ? c.apellido[0] : '';
|
||||
final combo = (n + a).toUpperCase();
|
||||
return combo.isEmpty ? '?' : combo;
|
||||
}
|
||||
|
||||
String get _displayName =>
|
||||
pago.cliente?.displayName ?? 'Pago';
|
||||
|
||||
String get _subtitle =>
|
||||
pago.cliente != null ? 'DNI ${pago.cliente!.dni}' : '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(30),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Anular pago',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_subtitle.isEmpty ? _displayName : '$_displayName · $_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: submitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PagoResumenCard extends StatelessWidget {
|
||||
final Pago pago;
|
||||
const _PagoResumenCard({required this.pago});
|
||||
|
||||
String _formatMonto(double n) =>
|
||||
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pago.mesPagadoDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${pago.metodo} · Cargado ${pago.fechaPagoDisplay}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withAlpha(140),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'\$${_formatMonto(pago.montoTotal)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Warning extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: SomaColors.error.withAlpha(60),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Esta acción no elimina el pago. Queda registrado como '
|
||||
'anulado con tu nombre y, si dejás motivo, también con esa nota.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurface,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(28),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: SomaColors.error.withAlpha(120),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 16,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: SomaColors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
|
||||
class PagoCard extends StatelessWidget {
|
||||
final Pago pago;
|
||||
final bool showCliente;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onHistorial;
|
||||
// Callbacks de acción admin. Si ambos null y pago no anulado → no se
|
||||
// muestra el menú 3-puntos (vista cliente o admin sin permiso).
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onAnular;
|
||||
|
||||
const PagoCard({
|
||||
super.key,
|
||||
required this.pago,
|
||||
this.showCliente = true,
|
||||
this.onTap,
|
||||
this.onHistorial,
|
||||
this.onEdit,
|
||||
this.onAnular,
|
||||
});
|
||||
|
||||
bool get _showMenu => !pago.isAnulado && (onEdit != null || onAnular != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final anulado = pago.isAnulado;
|
||||
final editado = pago.isEditado && !anulado;
|
||||
final railColor = anulado
|
||||
? SomaColors.error.withAlpha(180)
|
||||
: SomaColors.success.withAlpha(180);
|
||||
final montoColor = anulado
|
||||
? SomaColors.error.withAlpha(160)
|
||||
: SomaColors.success;
|
||||
final montoDecoration = anulado ? TextDecoration.lineThrough : null;
|
||||
final mainTextColor = anulado
|
||||
? cs.onSurface.withAlpha(140)
|
||||
: cs.onSurface;
|
||||
|
||||
return GestureDetector(
|
||||
onSecondaryTap: onHistorial,
|
||||
onLongPress: onHistorial,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 4, color: railColor),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 11, 8, 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: anulado ? 0.55 : 1,
|
||||
child: _MonthStamp(anioMes: pago.anioMesPagado),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Info central
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
showCliente && pago.cliente != null
|
||||
? pago.cliente!.displayName
|
||||
: pago.mesPagadoDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: mainTextColor,
|
||||
decoration: montoDecoration,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (anulado)
|
||||
const _AnuladoBadge()
|
||||
else
|
||||
_MetodoBadge(metodo: pago.metodo),
|
||||
if (!anulado &&
|
||||
showCliente &&
|
||||
pago.cliente != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
pago.mesPagadoDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withAlpha(110),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!anulado &&
|
||||
pago.detalle?['tipo_cuota'] != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
pago.detalle!['tipo_cuota'].toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: SomaColors.primaryText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (anulado) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
_anuladoSubline(pago),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'\$${_formatMonto(pago.montoTotal)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: montoColor,
|
||||
decoration: montoDecoration,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (editado) ...[
|
||||
Tooltip(
|
||||
message: _editadoTooltip(pago),
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 12,
|
||||
color: cs.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
pago.fechaPagoDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withAlpha(110),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_showMenu) ...[
|
||||
const SizedBox(width: 4),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
size: 18,
|
||||
color: cs.onSurface.withAlpha(140),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 44,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 8,
|
||||
tooltip: 'Acciones',
|
||||
itemBuilder: (_) => [
|
||||
if (onEdit != null)
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
color: cs.onSurface.withAlpha(180),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Editar'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onAnular != null)
|
||||
PopupMenuItem(
|
||||
value: 'anular',
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel_outlined,
|
||||
size: 18,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Anular',
|
||||
style: TextStyle(
|
||||
color: SomaColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (val) {
|
||||
if (val == 'edit') onEdit?.call();
|
||||
if (val == 'anular') onAnular?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatMonto(double n) =>
|
||||
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
|
||||
|
||||
static String _anuladoSubline(Pago p) {
|
||||
final motivo = (p.motivoAnulacion?.trim().isNotEmpty ?? false)
|
||||
? p.motivoAnulacion!.trim()
|
||||
: 'Sin motivo';
|
||||
final autor = p.anuladoPorNombre ?? 'admin';
|
||||
final hace = p.anuladoAt != null ? _timeagoEs(p.anuladoAt!) : '';
|
||||
return hace.isEmpty
|
||||
? '$motivo · por $autor'
|
||||
: '$motivo · por $autor · $hace';
|
||||
}
|
||||
|
||||
static String _editadoTooltip(Pago p) {
|
||||
final autor = p.updatedByNombre ?? 'admin';
|
||||
final cuando = p.updatedAt;
|
||||
if (cuando == null) return 'Editado por $autor';
|
||||
final f =
|
||||
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
|
||||
return 'Editado por $autor el $f';
|
||||
}
|
||||
|
||||
static String _timeagoEs(DateTime when) {
|
||||
final diff = DateTime.now().difference(when);
|
||||
if (diff.inSeconds < 60) return 'hace unos segundos';
|
||||
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
|
||||
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
|
||||
if (diff.inDays < 30) {
|
||||
final d = diff.inDays;
|
||||
return d == 1 ? 'hace 1 día' : 'hace $d días';
|
||||
}
|
||||
if (diff.inDays < 365) {
|
||||
final m = (diff.inDays / 30).floor();
|
||||
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
|
||||
}
|
||||
final y = (diff.inDays / 365).floor();
|
||||
return y == 1 ? 'hace 1 año' : 'hace $y años';
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge "ANULADO" en lugar del método cuando el pago está soft-deleted.
|
||||
class _AnuladoBadge extends StatelessWidget {
|
||||
const _AnuladoBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(28),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'ANULADO',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.error,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp de mes estilo mini-calendario.
|
||||
class _MonthStamp extends StatelessWidget {
|
||||
final String anioMes; // 'YYYY-MM-DD' o 'YYYY-MM'
|
||||
|
||||
const _MonthStamp({required this.anioMes});
|
||||
|
||||
static const _meses = [
|
||||
'',
|
||||
'ENE',
|
||||
'FEB',
|
||||
'MAR',
|
||||
'ABR',
|
||||
'MAY',
|
||||
'JUN',
|
||||
'JUL',
|
||||
'AGO',
|
||||
'SEP',
|
||||
'OCT',
|
||||
'NOV',
|
||||
'DIC',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final date = DateTime.tryParse(anioMes);
|
||||
final mes = date != null ? _meses[date.month] : '??';
|
||||
final anio = date != null ? date.year.toString().substring(2) : '';
|
||||
|
||||
return Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: SomaColors.primary.withAlpha(45), width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(5, 5, 5, 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
mes,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: SomaColors.onPrimary,
|
||||
letterSpacing: 0.4,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
anio,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge del método de pago.
|
||||
class _MetodoBadge extends StatelessWidget {
|
||||
final String metodo;
|
||||
|
||||
const _MetodoBadge({required this.metodo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
metodo,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface.withAlpha(160),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/features/auth/presentation/providers/auth_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
|
||||
class PagoDetailDialog extends ConsumerWidget {
|
||||
final Pago pago;
|
||||
// Callbacks opcionales para acciones admin. Si ambos null, no se muestra
|
||||
// el footer de acciones (vista cliente, o admin sin permiso sobre este pago).
|
||||
// El call-site decide cerrar el detail dialog antes de abrir el siguiente.
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onAnular;
|
||||
|
||||
const PagoDetailDialog({
|
||||
super.key,
|
||||
required this.pago,
|
||||
this.onEdit,
|
||||
this.onAnular,
|
||||
});
|
||||
|
||||
bool get _showActions =>
|
||||
!pago.isAnulado && (onEdit != null || onAnular != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final user = ref.watch(authStateProvider).valueOrNull;
|
||||
final isAdmin = user?.isStaff ?? false;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 500,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: pago.isAnulado
|
||||
? SomaColors.error.withAlpha(30)
|
||||
: SomaColors.primary.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
pago.isAnulado
|
||||
? Icons.cancel_outlined
|
||||
: Icons.receipt_long,
|
||||
color: pago.isAnulado
|
||||
? SomaColors.error
|
||||
: SomaColors.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pago.isAnulado ? 'Pago anulado' : 'Detalle de Pago',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
pago.mesPagadoDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Banner de auditoría arriba de todo si está anulado o editado.
|
||||
if (pago.isAnulado)
|
||||
_AnuladoBanner(pago: pago)
|
||||
else if (pago.isEditado)
|
||||
_EditadoBanner(pago: pago),
|
||||
if (pago.isAnulado || pago.isEditado)
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Cliente (solo si es admin)
|
||||
if (isAdmin && pago.cliente != null) ...[
|
||||
_DetailRow(
|
||||
icon: Icons.person_outline,
|
||||
label: 'Cliente',
|
||||
value: pago.cliente!.displayName,
|
||||
valueStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_DetailRow(
|
||||
icon: Icons.badge_outlined,
|
||||
label: 'DNI',
|
||||
value: pago.cliente!.dni,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Monto (destacado)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 20,
|
||||
horizontal: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (pago.isAnulado ? SomaColors.error : SomaColors.success)
|
||||
.withAlpha(14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: (pago.isAnulado
|
||||
? SomaColors.error
|
||||
: SomaColors.success)
|
||||
.withAlpha(50),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
pago.isAnulado ? 'MONTO ANULADO' : 'MONTO TOTAL',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: (pago.isAnulado
|
||||
? SomaColors.error
|
||||
: SomaColors.success)
|
||||
.withAlpha(180),
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'\$${_formatMonto(pago.montoTotal)}',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: pago.isAnulado
|
||||
? SomaColors.error
|
||||
: SomaColors.success,
|
||||
decoration: pago.isAnulado
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_SectionTitle(label: 'Detalle'),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
_DetailRow(
|
||||
icon: Icons.payment_outlined,
|
||||
label: 'Método de Pago',
|
||||
value: pago.metodo,
|
||||
valueStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DetailRow(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: 'Fecha de Pago',
|
||||
value: pago.fechaPagoDisplay,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DetailRow(
|
||||
icon: Icons.event_note_outlined,
|
||||
label: 'Mes Pagado',
|
||||
value: pago.mesPagadoDisplay,
|
||||
),
|
||||
|
||||
if (pago.detalle != null && pago.detalle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_SectionTitle(label: 'Información Adicional'),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withAlpha(80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: pago.detalle!.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'${entry.key}:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(130),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
entry.value.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showActions) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (onAnular != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAnular,
|
||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||
label: const Text('Anular'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: SomaColors.error,
|
||||
side: BorderSide(
|
||||
color: SomaColors.error.withAlpha(140),
|
||||
width: 1,
|
||||
),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
),
|
||||
if (onAnular != null && onEdit != null)
|
||||
const SizedBox(width: 10),
|
||||
if (onEdit != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('Editar'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatMonto(double n) =>
|
||||
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
|
||||
}
|
||||
|
||||
class _AnuladoBanner extends StatelessWidget {
|
||||
final Pago pago;
|
||||
const _AnuladoBanner({required this.pago});
|
||||
|
||||
String get _fecha {
|
||||
final a = pago.anuladoAt;
|
||||
if (a == null) return '';
|
||||
return '${a.day.toString().padLeft(2, '0')}/${a.month.toString().padLeft(2, '0')}/${a.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final autor = pago.anuladoPorNombre ?? 'admin';
|
||||
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
|
||||
? pago.motivoAnulacion!.trim()
|
||||
: null;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: SomaColors.error.withAlpha(80), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel_outlined,
|
||||
size: 18,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_fecha.isEmpty
|
||||
? 'Anulado por $autor'
|
||||
: 'Anulado por $autor el $_fecha',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.error,
|
||||
),
|
||||
),
|
||||
if (motivo != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Motivo: $motivo',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurface.withAlpha(180),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditadoBanner extends StatelessWidget {
|
||||
final Pago pago;
|
||||
const _EditadoBanner({required this.pago});
|
||||
|
||||
String get _fecha {
|
||||
final u = pago.updatedAt;
|
||||
if (u == null) return '';
|
||||
return '${u.day.toString().padLeft(2, '0')}/${u.month.toString().padLeft(2, '0')}/${u.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final autor = pago.updatedByNombre ?? 'admin';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 16,
|
||||
color: cs.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_fecha.isEmpty
|
||||
? 'Última edición: $autor'
|
||||
: 'Última edición: $autor el $_fecha',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String label;
|
||||
const _SectionTitle({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final TextStyle? valueStyle;
|
||||
|
||||
const _DetailRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(100),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: valueStyle ??
|
||||
TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
|
||||
|
||||
class PagosDeudoresView extends ConsumerWidget {
|
||||
const PagosDeudoresView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final deudoresAsync = ref.watch(deudoresProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 800;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return deudoresAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator(color: SomaColors.primary)),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48, color: theme.colorScheme.onSurface.withAlpha(100)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(153)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.invalidate(deudoresProvider),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Reintentar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (deudores) {
|
||||
if (deudores.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 60,
|
||||
color: SomaColors.success.withAlpha(160)),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Todos al día',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'No hay socios con cuotas pendientes',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final unMes = deudores.where((d) => d.mesesAdeudados == 1).length;
|
||||
final masDe1 = deudores.where((d) => d.mesesAdeudados > 1).length;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Resumen
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 4, isWide ? 32 : 16, 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryChip(
|
||||
label: '${deudores.length} deudor${deudores.length == 1 ? '' : 'es'}',
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
if (unMes > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
_SummaryChip(
|
||||
label: '$unMes × 1 mes',
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
if (masDe1 > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
_SummaryChip(
|
||||
label: '$masDe1 × 2+ meses',
|
||||
color: SomaColors.error,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Lista
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isWide ? 32 : 16, 0, isWide ? 32 : 16, 80,
|
||||
),
|
||||
itemCount: deudores.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) => _DeudorCard(deudor: deudores[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chip de resumen ────────────────────────────────────────────────────────────
|
||||
|
||||
class _SummaryChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
const _SummaryChip({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(16),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withAlpha(50), width: 0.8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color.withAlpha(200),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tarjeta de deudor ──────────────────────────────────────────────────────────
|
||||
|
||||
class _DeudorCard extends ConsumerWidget {
|
||||
final Deudor deudor;
|
||||
const _DeudorCard({required this.deudor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final u = deudor.usuario;
|
||||
final meses = deudor.mesesAdeudados;
|
||||
final theme = Theme.of(context);
|
||||
final railColor =
|
||||
meses == 1 ? Colors.orange : SomaColors.error;
|
||||
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Rail de severidad
|
||||
Container(width: 4, color: railColor.withAlpha(180)),
|
||||
|
||||
// Contenido
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: railColor.withAlpha(30),
|
||||
child: Text(
|
||||
u.initials,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: railColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Nombre + DNI + badge
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
u.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
u.dni,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_MesesBadge(meses: meses),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Acciones
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.history,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130),
|
||||
),
|
||||
tooltip: 'Ver historial',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _verHistorial(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.chat_outlined,
|
||||
size: 18,
|
||||
color: Color(0xFF25D366),
|
||||
),
|
||||
tooltip: 'Enviar WhatsApp',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _abrirWhatsApp(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.payment_outlined,
|
||||
size: 18,
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
tooltip: 'Registrar pago',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _registrarPago(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _verHistorial(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => UsuarioHistorialDialog(
|
||||
dni: deudor.usuario.dni,
|
||||
nombre: deudor.usuario.displayName,
|
||||
initials: deudor.usuario.initials,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirWhatsApp(BuildContext context) async {
|
||||
final meses = deudor.mesesAdeudados;
|
||||
final nombre = deudor.usuario.nombre;
|
||||
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
|
||||
|
||||
final ok = await WhatsAppService.abrirChat(
|
||||
telefono: deudor.usuario.telefono,
|
||||
mensaje: 'Hola $nombre, te contactamos desde el gimnasio SOMA. '
|
||||
'Tenés $mesesStr de cuota pendiente. '
|
||||
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (!ok) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: WhatsAppService.normalizarNumeroAr(deudor.usuario.telefono) == null
|
||||
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
|
||||
: 'No se pudo abrir WhatsApp',
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (_) => PagoFormDialog(prefilledDni: deudor.usuario.dni),
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final planUpdate =
|
||||
result.remove('actualizar_plan') as Map<String, dynamic>?;
|
||||
|
||||
final error = await ref.read(pagosProvider.notifier).insertPago(result);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planUpdate != null) {
|
||||
final planError =
|
||||
await ref.read(usuariosProvider.notifier).updateUsuario({
|
||||
'id': planUpdate['usuario_id'],
|
||||
'tipo_cuota': planUpdate['tipo_cuota_id'],
|
||||
});
|
||||
if (context.mounted && planError != null) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: 'Pago registrado, pero error al actualizar plan: $planError',
|
||||
type: ToastType.info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message:
|
||||
planUpdate != null ? 'Pago registrado y plan actualizado' : 'Pago registrado',
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badge de meses ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _MesesBadge extends StatelessWidget {
|
||||
final int meses;
|
||||
const _MesesBadge({required this.meses});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = meses == 1 ? Colors.orange : SomaColors.error;
|
||||
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withAlpha(55), width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/services/whatsapp_service.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_estado_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_detail_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/widgets/pago_form_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/widgets/usuario_historial_dialog.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/domain/entities/usuario.dart';
|
||||
import 'package:gimnasio_soma/features/usuarios/presentation/providers/usuarios_provider.dart';
|
||||
|
||||
const _amberColor = Color(0xFFFF8F00);
|
||||
|
||||
class PagosEstadoView extends ConsumerStatefulWidget {
|
||||
const PagosEstadoView({super.key, required this.initialMes});
|
||||
|
||||
final String initialMes;
|
||||
|
||||
@override
|
||||
ConsumerState<PagosEstadoView> createState() => _PagosEstadoViewState();
|
||||
}
|
||||
|
||||
class _PagosEstadoViewState extends ConsumerState<PagosEstadoView> {
|
||||
late String _selectedMes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedMes = widget.initialMes;
|
||||
}
|
||||
|
||||
List<String> _getLast12Months() {
|
||||
final now = DateTime.now();
|
||||
return List.generate(12, (i) {
|
||||
final date = DateTime(now.year, now.month - i, 1);
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}';
|
||||
});
|
||||
}
|
||||
|
||||
String _formatMonth(String yearMonth) {
|
||||
final parts = yearMonth.split('-');
|
||||
if (parts.length != 2) return yearMonth;
|
||||
final month = int.tryParse(parts[1]) ?? 0;
|
||||
const meses = [
|
||||
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||
];
|
||||
return '${meses[month]} ${parts[0]}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 800;
|
||||
final hPad = isWide ? 32.0 : 16.0;
|
||||
final months = _getLast12Months();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedMes,
|
||||
selectedItemBuilder: (ctx) => months
|
||||
.map(
|
||||
(m) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_formatMonth(m),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(ctx).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
underline: const SizedBox(),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
items: months
|
||||
.map(
|
||||
(m) => DropdownMenuItem<String>(
|
||||
value: m,
|
||||
child: Text(
|
||||
_formatMonth(m),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _selectedMes = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: _EstadoContent(mes: _selectedMes, hPad: hPad),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EstadoContent extends ConsumerWidget {
|
||||
const _EstadoContent({required this.mes, required this.hPad});
|
||||
|
||||
final String mes;
|
||||
final double hPad;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pagosLoading = ref.watch(pagosProvider).isLoading;
|
||||
final estadoAsync = ref.watch(pagosEstadoProvider(mes));
|
||||
final deudoresAsync = ref.watch(deudoresProvider);
|
||||
|
||||
if (pagosLoading || estadoAsync.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator(color: SomaColors.primary));
|
||||
}
|
||||
|
||||
if (estadoAsync.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(100)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
estadoAsync.error.toString().replaceFirst('Exception: ', ''),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(153)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final estado = estadoAsync.valueOrNull;
|
||||
if (estado == null) return const SizedBox.shrink();
|
||||
|
||||
if (estado.total == 0) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.group_outlined, size: 56,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(60)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No hay socios con plan activo',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(130)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build meses map from deudoresProvider to show exact count for +1 month debtors
|
||||
final mesesMap = <String, int>{};
|
||||
if (deudoresAsync.hasValue) {
|
||||
for (final d in deudoresAsync.requireValue) {
|
||||
mesesMap[d.usuario.id] = d.mesesAdeudados;
|
||||
}
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(hPad, 4, hPad, 80),
|
||||
children: [
|
||||
_SummaryCard(estado: estado),
|
||||
|
||||
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'Más de un mes sin pagar',
|
||||
count: estado.masDe1MesSinPagar.length,
|
||||
accentColor: SomaColors.error,
|
||||
initiallyExpanded: true,
|
||||
children: estado.masDe1MesSinPagar
|
||||
.map((u) => _NoPageCard(
|
||||
usuario: u,
|
||||
meses: mesesMap[u.id] ?? 2,
|
||||
accentColor: SomaColors.error,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
|
||||
if (estado.unMesSinPagar.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_Section(
|
||||
title: 'Sin pagar este mes',
|
||||
count: estado.unMesSinPagar.length,
|
||||
accentColor: _amberColor,
|
||||
initiallyExpanded: true,
|
||||
children: estado.unMesSinPagar
|
||||
.map((u) => _NoPageCard(
|
||||
usuario: u,
|
||||
meses: 1,
|
||||
accentColor: _amberColor,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
|
||||
if (estado.pagaron.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_Section(
|
||||
title: 'Pagaron',
|
||||
count: estado.pagaron.length,
|
||||
accentColor: SomaColors.success,
|
||||
initiallyExpanded: false,
|
||||
children: estado.pagaron
|
||||
.map((e) => _PagaronCard(pagoConUsuario: e))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Section extends StatefulWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final Color accentColor;
|
||||
final bool initiallyExpanded;
|
||||
final List<Widget> children;
|
||||
|
||||
const _Section({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.accentColor,
|
||||
required this.initiallyExpanded,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_Section> createState() => _SectionState();
|
||||
}
|
||||
|
||||
class _SectionState extends State<_Section> {
|
||||
late bool _expanded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expanded = widget.initiallyExpanded;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.accentColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
widget.title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: cs.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.accentColor.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.count}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: cs.onSurface.withAlpha(120),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 6),
|
||||
...widget.children.map(
|
||||
(c) => Padding(padding: const EdgeInsets.only(bottom: 8), child: c),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
const _SummaryCard({required this.estado});
|
||||
|
||||
final PagosEstadoMes estado;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${estado.pagaron.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.success,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4, left: 6),
|
||||
child: Text(
|
||||
'de ${estado.total} socios pagaron',
|
||||
style: TextStyle(fontSize: 15, color: cs.onSurface.withAlpha(180)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: estado.progressValue,
|
||||
minHeight: 8,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(SomaColors.success),
|
||||
),
|
||||
),
|
||||
if (estado.unMesSinPagar.isNotEmpty ||
|
||||
estado.masDe1MesSinPagar.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (estado.unMesSinPagar.isNotEmpty) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: _amberColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${estado.unMesSinPagar.length} ${estado.unMesSinPagar.length == 1 ? 'debe' : 'deben'} este mes',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
|
||||
),
|
||||
],
|
||||
if (estado.unMesSinPagar.isNotEmpty &&
|
||||
estado.masDe1MesSinPagar.isNotEmpty)
|
||||
Text(
|
||||
' · ',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(80)),
|
||||
),
|
||||
if (estado.masDe1MesSinPagar.isNotEmpty) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: SomaColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${estado.masDe1MesSinPagar.length} ${estado.masDe1MesSinPagar.length == 1 ? 'moroso' : 'morosos'}',
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(120)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _NoPageCard extends ConsumerWidget {
|
||||
final Usuario usuario;
|
||||
final int meses;
|
||||
final Color accentColor;
|
||||
|
||||
const _NoPageCard({
|
||||
required this.usuario,
|
||||
required this.meses,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 4, color: accentColor.withAlpha(180)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: accentColor.withAlpha(30),
|
||||
child: Text(
|
||||
usuario.initials,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
usuario.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
usuario.dni,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_MesesBadge(meses: meses),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.history, size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130)),
|
||||
tooltip: 'Ver historial',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _verHistorial(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined, size: 18,
|
||||
color: Color(0xFF25D366)),
|
||||
tooltip: 'Enviar WhatsApp',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _abrirWhatsApp(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.payment_outlined, size: 18,
|
||||
color: SomaColors.primary),
|
||||
tooltip: 'Registrar pago',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => _registrarPago(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _verHistorial(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => UsuarioHistorialDialog(
|
||||
dni: usuario.dni,
|
||||
nombre: usuario.displayName,
|
||||
initials: usuario.initials,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _abrirWhatsApp(BuildContext context) async {
|
||||
final mesesStr = meses == 1 ? '1 mes' : '$meses meses';
|
||||
final ok = await WhatsAppService.abrirChat(
|
||||
telefono: usuario.telefono,
|
||||
mensaje: 'Hola ${usuario.nombre}, te contactamos desde el gimnasio SOMA. '
|
||||
'Tenés $mesesStr de cuota pendiente. '
|
||||
'Por favor, coordiná el pago cuando puedas. ¡Muchas gracias!',
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (!ok) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: WhatsAppService.normalizarNumeroAr(usuario.telefono) == null
|
||||
? 'No se puede enviar WhatsApp: el número de teléfono del usuario es inválido o está vacío'
|
||||
: 'No se pudo abrir WhatsApp',
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registrarPago(BuildContext context, WidgetRef ref) async {
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (_) => PagoFormDialog(prefilledDni: usuario.dni),
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final planUpdate = result.remove('actualizar_plan') as Map<String, dynamic>?;
|
||||
final error = await ref.read(pagosProvider.notifier).insertPago(result);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
SomaToast.show(context, message: error, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planUpdate != null) {
|
||||
final planError = await ref.read(usuariosProvider.notifier).updateUsuario({
|
||||
'id': planUpdate['usuario_id'],
|
||||
'tipo_cuota': planUpdate['tipo_cuota_id'],
|
||||
});
|
||||
if (context.mounted && planError != null) {
|
||||
SomaToast.show(context,
|
||||
message: 'Pago registrado, pero error al actualizar plan: $planError',
|
||||
type: ToastType.info);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: planUpdate != null
|
||||
? 'Pago registrado y plan actualizado'
|
||||
: 'Pago registrado',
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PagaronCard extends StatelessWidget {
|
||||
final dynamic pagoConUsuario;
|
||||
|
||||
const _PagaronCard({required this.pagoConUsuario});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final u = pagoConUsuario.usuario as Usuario;
|
||||
final pago = pagoConUsuario.pago as Pago;
|
||||
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 4, color: SomaColors.success.withAlpha(180)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: SomaColors.success.withAlpha(30),
|
||||
child: Text(
|
||||
u.initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SomaColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
u.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
u.dni,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_MontoChip(monto: pago.montoTotal),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.history, size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130)),
|
||||
tooltip: 'Ver historial',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => UsuarioHistorialDialog(
|
||||
dni: u.dni,
|
||||
nombre: u.displayName,
|
||||
initials: u.initials,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.receipt_outlined, size: 18,
|
||||
color: theme.colorScheme.onSurface.withAlpha(130)),
|
||||
tooltip: 'Ver pago',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => PagoDetailDialog(pago: pago),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MontoChip extends StatelessWidget {
|
||||
final double monto;
|
||||
const _MontoChip({required this.monto});
|
||||
|
||||
String get _label {
|
||||
if (monto >= 1000) {
|
||||
final k = monto / 1000;
|
||||
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
|
||||
}
|
||||
return '\$${monto.toStringAsFixed(0)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.success.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: SomaColors.success.withAlpha(55), width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
_label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SomaColors.success,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MesesBadge extends StatelessWidget {
|
||||
final int meses;
|
||||
const _MesesBadge({required this.meses});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = meses == 1 ? _amberColor : SomaColors.error;
|
||||
final label = meses == 1 ? '1 mes sin pagar' : '$meses meses sin pagar';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(18),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withAlpha(55), width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/utils/pagos_import.dart';
|
||||
|
||||
class PagosImportDialog extends ConsumerStatefulWidget {
|
||||
const PagosImportDialog({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PagosImportDialog> createState() => _PagosImportDialogState();
|
||||
}
|
||||
|
||||
class _PagosImportDialogState extends ConsumerState<PagosImportDialog> {
|
||||
_Step _step = _Step.idle;
|
||||
PagosImportResult? _result;
|
||||
int _imported = 0;
|
||||
int _failed = 0;
|
||||
String? _currentError;
|
||||
|
||||
Future<void> _pickAndParse() async {
|
||||
setState(() => _step = _Step.picking);
|
||||
|
||||
final picked = await FilePicker.platform.pickFiles(
|
||||
dialogTitle: 'Seleccionar CSV de pagos',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (picked == null || picked.files.isEmpty) {
|
||||
setState(() => _step = _Step.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
final bytes = picked.files.first.bytes;
|
||||
if (bytes == null) {
|
||||
setState(() {
|
||||
_step = _Step.idle;
|
||||
_currentError = 'No se pudo leer el archivo';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = parsePagosCsv(bytes);
|
||||
setState(() {
|
||||
_result = result;
|
||||
_step = result.parseErrors.isNotEmpty ? _Step.idle : _Step.preview;
|
||||
_currentError = result.parseErrors.isNotEmpty ? result.parseErrors.first : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runImport() async {
|
||||
final result = _result!;
|
||||
final metodosAsync = ref.read(metodosPagoProvider);
|
||||
final metodos = metodosAsync.valueOrNull ?? [];
|
||||
|
||||
// Construir mapa nombre (lowercase) → id
|
||||
final metodoMap = <String, int>{
|
||||
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_step = _Step.importing;
|
||||
_imported = 0;
|
||||
_failed = 0;
|
||||
});
|
||||
|
||||
for (final row in result.valid) {
|
||||
final metodoId = _resolveMetodo(row.metodoNombre, metodoMap);
|
||||
if (metodoId == null) {
|
||||
setState(() => _failed++);
|
||||
continue;
|
||||
}
|
||||
|
||||
final datos = <String, dynamic>{
|
||||
'dni': row.dni,
|
||||
'metodo_id': metodoId,
|
||||
'anio_mes_pagado': row.anioMesPagado,
|
||||
'monto_total': row.montoTotal,
|
||||
if (row.fechaPago != null) 'fecha_pago': row.fechaPago,
|
||||
};
|
||||
|
||||
final error = await ref.read(pagosProvider.notifier).insertPago(datos);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (error == null) {
|
||||
_imported++;
|
||||
} else {
|
||||
_failed++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _step = _Step.done);
|
||||
}
|
||||
|
||||
int? _resolveMetodo(String nombre, Map<String, int> metodoMap) {
|
||||
// Exact match (case-insensitive)
|
||||
return metodoMap[nombre.toLowerCase().trim()];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Importar pagos desde CSV'),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: _buildContent(theme),
|
||||
),
|
||||
actions: _buildActions(theme),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ThemeData theme) {
|
||||
switch (_step) {
|
||||
case _Step.idle:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Seleccioná un archivo CSV exportado desde esta app. '
|
||||
'Las columnas requeridas son: dni, anio_mes_pagado, '
|
||||
'monto_total, metodo.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurface.withAlpha(180),
|
||||
),
|
||||
),
|
||||
if (_currentError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorChip(message: _currentError!),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
case _Step.picking:
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: CircularProgressIndicator(color: SomaColors.primary),
|
||||
),
|
||||
);
|
||||
|
||||
case _Step.preview:
|
||||
final valid = _result!.valid;
|
||||
final invalid = _result!.invalid;
|
||||
final metodos = ref.watch(metodosPagoProvider).valueOrNull ?? [];
|
||||
final metodoMap = <String, int>{
|
||||
for (final m in metodos) m.descripcion.toLowerCase().trim(): m.id,
|
||||
};
|
||||
final sinMetodo = valid
|
||||
.where((r) => _resolveMetodo(r.metodoNombre, metodoMap) == null)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SummaryRow(
|
||||
icon: Icons.check_circle_outline,
|
||||
color: SomaColors.success,
|
||||
label: '${valid.length} filas válidas',
|
||||
),
|
||||
if (sinMetodo.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_SummaryRow(
|
||||
icon: Icons.warning_amber_outlined,
|
||||
color: Colors.orange,
|
||||
label: '${sinMetodo.length} con método de pago no reconocido '
|
||||
'(se saltarán)',
|
||||
),
|
||||
],
|
||||
if (invalid.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_SummaryRow(
|
||||
icon: Icons.error_outline,
|
||||
color: SomaColors.error,
|
||||
label: '${invalid.length} filas con errores (se saltarán)',
|
||||
),
|
||||
],
|
||||
if (invalid.isNotEmpty || sinMetodo.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 160),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...sinMetodo.map((r) => _ErrorRow(
|
||||
rowNum: r.rowNumber,
|
||||
msg: 'Método no reconocido: "${r.metodoNombre}"',
|
||||
)),
|
||||
...invalid.map((r) => _ErrorRow(
|
||||
rowNum: r.rowNumber,
|
||||
msg: r.validationError ?? 'Error desconocido',
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
case _Step.importing:
|
||||
final total = _result!.valid.length;
|
||||
final done = _imported + _failed;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: total > 0 ? done / total : null,
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Importando $done / $total...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
case _Step.done:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
if (_imported > 0)
|
||||
_SummaryRow(
|
||||
icon: Icons.check_circle_outline,
|
||||
color: SomaColors.success,
|
||||
label: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'} correctamente',
|
||||
),
|
||||
if (_failed > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
_SummaryRow(
|
||||
icon: Icons.error_outline,
|
||||
color: SomaColors.error,
|
||||
label: '$_failed fila${_failed == 1 ? '' : 's'} con error',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(ThemeData theme) {
|
||||
switch (_step) {
|
||||
case _Step.idle:
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _pickAndParse,
|
||||
icon: const Icon(Icons.folder_open_outlined, size: 18),
|
||||
label: const Text('Seleccionar archivo'),
|
||||
),
|
||||
];
|
||||
|
||||
case _Step.picking:
|
||||
case _Step.importing:
|
||||
return const [];
|
||||
|
||||
case _Step.preview:
|
||||
final importable = _result!.valid.isNotEmpty;
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_step = _Step.idle;
|
||||
_result = null;
|
||||
});
|
||||
},
|
||||
child: const Text('Cambiar archivo'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: importable ? _runImport : null,
|
||||
child: Text(
|
||||
'Importar ${_result!.valid.length} pago${_result!.valid.length == 1 ? '' : 's'}',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
case _Step.done:
|
||||
return [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
if (_imported > 0) {
|
||||
SomaToast.show(
|
||||
context,
|
||||
message: '$_imported pago${_imported == 1 ? '' : 's'} importado${_imported == 1 ? '' : 's'}',
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Cerrar'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers visuales ──────────────────────────────────────────────────────────
|
||||
|
||||
enum _Step { idle, picking, preview, importing, done }
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _SummaryRow({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 13, color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorRow extends StatelessWidget {
|
||||
final int rowNum;
|
||||
final String msg;
|
||||
|
||||
const _ErrorRow({required this.rowNum, required this.msg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
'Fila $rowNum: $msg',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(160),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorChip extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorChip({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: SomaColors.error.withAlpha(50), width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 14, color: SomaColors.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(fontSize: 12, color: SomaColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+821
@@ -0,0 +1,821 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gimnasio_soma/core/theme/soma_colors.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/presentation/providers/pagos_provider.dart';
|
||||
|
||||
/// Muestra el historial de pagos de un usuario en dos layers:
|
||||
/// 1. Grilla calendar de meses (verde = pagó, gris = no estuvo).
|
||||
/// Pinta sólo en base a pagos NO anulados (un pago anulado no
|
||||
/// cuenta el mes como pagado).
|
||||
/// 2. Lista cronológica de pagos individuales debajo, con toggle
|
||||
/// "Mostrar anulados" para que Juani vea exactamente qué se cargó,
|
||||
/// qué se editó y qué se anuló (con motivo y autor).
|
||||
///
|
||||
/// El fetch al backend pide siempre incluirAnulados=true (un único request);
|
||||
/// el toggle de la sección filtra localmente.
|
||||
class UsuarioHistorialDialog extends ConsumerStatefulWidget {
|
||||
const UsuarioHistorialDialog({
|
||||
super.key,
|
||||
required this.dni,
|
||||
required this.nombre,
|
||||
required this.initials,
|
||||
});
|
||||
|
||||
final String dni;
|
||||
final String nombre;
|
||||
final String initials;
|
||||
|
||||
@override
|
||||
ConsumerState<UsuarioHistorialDialog> createState() =>
|
||||
_UsuarioHistorialDialogState();
|
||||
}
|
||||
|
||||
class _UsuarioHistorialDialogState
|
||||
extends ConsumerState<UsuarioHistorialDialog> {
|
||||
bool _incluirAnulados = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final historialAsync = ref.watch(
|
||||
userHistorialProvider((dni: widget.dni, incluirAnulados: true)),
|
||||
);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 560,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary.withAlpha(30),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.nombre,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'DNI ${widget.dni}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenido scrollable
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: historialAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: SomaColors.primary),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Error al cargar historial',
|
||||
style: TextStyle(color: cs.onSurface.withAlpha(130)),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (pagosAll) {
|
||||
// pagosAll viene del backend con anulados incluidos.
|
||||
// Para la grilla calendar: sólo los efectivos (no anulados).
|
||||
// Para la lista cronológica: filtra según toggle local.
|
||||
final pagosEfectivos =
|
||||
pagosAll.where((p) => !p.isAnulado).toList();
|
||||
final pagosLista = _incluirAnulados
|
||||
? pagosAll
|
||||
: pagosEfectivos;
|
||||
|
||||
final paidMonths = <String>{};
|
||||
for (final p in pagosEfectivos) {
|
||||
if (p.anioMesPagado.length >= 7) {
|
||||
paidMonths.add(p.anioMesPagado.substring(0, 7));
|
||||
}
|
||||
}
|
||||
|
||||
final totalEfectivo = pagosEfectivos.fold<double>(
|
||||
0,
|
||||
(sum, p) => sum + p.montoTotal,
|
||||
);
|
||||
final anuladosCount =
|
||||
pagosAll.where((p) => p.isAnulado).length;
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMonth =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}';
|
||||
|
||||
final months = _buildMonthRange(pagosEfectivos, currentMonth);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Stats
|
||||
_StatsRow(
|
||||
pagoCount: pagosEfectivos.length,
|
||||
totalMonto: totalEfectivo,
|
||||
firstMonth: months.isNotEmpty ? months.first : null,
|
||||
anuladosCount: anuladosCount,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section: Historial calendar
|
||||
_sectionLabel(context, 'Historial'),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (months.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'Sin pagos registrados',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: months
|
||||
.map(
|
||||
(m) => _MonthTile(
|
||||
yearMonth: m,
|
||||
paid: paidMonths.contains(m),
|
||||
isCurrent: m == currentMonth,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Leyenda
|
||||
Row(
|
||||
children: [
|
||||
_LegendDot(
|
||||
color: SomaColors.success,
|
||||
label: 'Pagó',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_LegendDot(
|
||||
color: cs.onSurface.withAlpha(40),
|
||||
label: 'No estuvo',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section: Detalle de pagos + toggle anulados
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
_sectionLabel(context, 'Detalle de pagos'),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Mostrar anulados'),
|
||||
selected: _incluirAnulados,
|
||||
onSelected: (v) =>
|
||||
setState(() => _incluirAnulados = v),
|
||||
avatar: anuladosCount > 0
|
||||
? CircleAvatar(
|
||||
radius: 9,
|
||||
backgroundColor: _incluirAnulados
|
||||
? SomaColors.error
|
||||
: SomaColors.error.withAlpha(120),
|
||||
child: Text(
|
||||
'$anuladosCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
selectedColor:
|
||||
SomaColors.error.withAlpha(30),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: _incluirAnulados
|
||||
? SomaColors.error.withAlpha(160)
|
||||
: cs.surfaceContainerHighest,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (pagosLista.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'Sin pagos para mostrar',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: pagosLista
|
||||
.map((p) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: _PagoListItem(pago: p),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Genera la lista de meses desde el primero pagado hasta el mes actual.
|
||||
/// Recibe sólo los pagos efectivos (anulados ya filtrados).
|
||||
List<String> _buildMonthRange(List<Pago> pagos, String currentMonth) {
|
||||
if (pagos.isEmpty) return [];
|
||||
|
||||
String? firstMonth;
|
||||
for (final p in pagos) {
|
||||
if (p.anioMesPagado.length < 7) continue;
|
||||
final m = p.anioMesPagado.substring(0, 7);
|
||||
if (firstMonth == null || m.compareTo(firstMonth) < 0) {
|
||||
firstMonth = m;
|
||||
}
|
||||
}
|
||||
if (firstMonth == null) return [];
|
||||
|
||||
final result = <String>[];
|
||||
final startParts = firstMonth.split('-');
|
||||
var cursor = DateTime(int.parse(startParts[0]), int.parse(startParts[1]));
|
||||
final endParts = currentMonth.split('-');
|
||||
final end = DateTime(int.parse(endParts[0]), int.parse(endParts[1]));
|
||||
|
||||
while (!cursor.isAfter(end)) {
|
||||
result.add('${cursor.year}-${cursor.month.toString().padLeft(2, '0')}');
|
||||
cursor = DateTime(cursor.year, cursor.month + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionLabel(BuildContext context, String text) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
text.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({
|
||||
required this.pagoCount,
|
||||
required this.totalMonto,
|
||||
required this.firstMonth,
|
||||
required this.anuladosCount,
|
||||
});
|
||||
|
||||
final int pagoCount;
|
||||
final double totalMonto;
|
||||
final String? firstMonth;
|
||||
final int anuladosCount;
|
||||
|
||||
String _formatMonto(double n) {
|
||||
if (n >= 1000000) return '\$${(n / 1000000).toStringAsFixed(1)}M';
|
||||
if (n >= 1000) {
|
||||
final k = n / 1000;
|
||||
return '\$${k % 1 == 0 ? k.toStringAsFixed(0) : k.toStringAsFixed(1)}k';
|
||||
}
|
||||
return '\$${n.toStringAsFixed(0)}';
|
||||
}
|
||||
|
||||
String? _formatFirstMonth(String? m) {
|
||||
if (m == null) return null;
|
||||
final parts = m.split('-');
|
||||
if (parts.length != 2) return m;
|
||||
const abrev = [
|
||||
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||
];
|
||||
final month = int.tryParse(parts[1]) ?? 0;
|
||||
final year = parts[0].substring(2);
|
||||
return "${abrev[month]} '$year";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cs.surfaceContainerHighest, width: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_StatCell(
|
||||
value: '$pagoCount',
|
||||
label: pagoCount == 1 ? 'pago' : 'pagos',
|
||||
),
|
||||
_StatDivider(),
|
||||
_StatCell(
|
||||
value: _formatMonto(totalMonto),
|
||||
label: 'total acumulado',
|
||||
),
|
||||
if (firstMonth != null) ...[
|
||||
_StatDivider(),
|
||||
_StatCell(
|
||||
value: _formatFirstMonth(firstMonth) ?? firstMonth!,
|
||||
label: 'primer pago',
|
||||
),
|
||||
],
|
||||
if (anuladosCount > 0) ...[
|
||||
_StatDivider(),
|
||||
_StatCell(
|
||||
value: '$anuladosCount',
|
||||
label: anuladosCount == 1 ? 'anulado' : 'anulados',
|
||||
valueColor: SomaColors.error,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCell extends StatelessWidget {
|
||||
const _StatCell({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.valueColor,
|
||||
});
|
||||
final String value;
|
||||
final String label;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor ?? cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: cs.onSurface.withAlpha(120),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatDivider extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MonthTile extends StatelessWidget {
|
||||
const _MonthTile({
|
||||
required this.yearMonth,
|
||||
required this.paid,
|
||||
required this.isCurrent,
|
||||
});
|
||||
|
||||
final String yearMonth; // "YYYY-MM"
|
||||
final bool paid;
|
||||
final bool isCurrent;
|
||||
|
||||
static const _mesAbrev = [
|
||||
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final parts = yearMonth.split('-');
|
||||
final month = int.tryParse(parts[1]) ?? 0;
|
||||
final year = parts[0].substring(2);
|
||||
|
||||
final Color bg;
|
||||
final Color textColor;
|
||||
final Color borderColor;
|
||||
final Widget icon;
|
||||
|
||||
if (paid) {
|
||||
bg = SomaColors.success.withAlpha(18);
|
||||
textColor = SomaColors.success;
|
||||
borderColor = SomaColors.success.withAlpha(90);
|
||||
icon = Icon(Icons.check_rounded, size: 14, color: SomaColors.success);
|
||||
} else if (isCurrent) {
|
||||
bg = SomaColors.primary.withAlpha(12);
|
||||
textColor = cs.onSurface;
|
||||
borderColor = SomaColors.primary.withAlpha(120);
|
||||
icon = Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
size: 12,
|
||||
color: cs.onSurface.withAlpha(80),
|
||||
);
|
||||
} else {
|
||||
bg = cs.surfaceContainerHighest.withAlpha(80);
|
||||
textColor = cs.onSurface.withAlpha(100);
|
||||
borderColor = cs.surfaceContainerHighest;
|
||||
icon = SizedBox(
|
||||
height: 14,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 1.5,
|
||||
color: cs.onSurface.withAlpha(40),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 52,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: borderColor, width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_mesAbrev[month],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"'$year",
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: textColor.withAlpha(180),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
icon,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Item compacto de la lista cronológica de pagos. Si el pago está anulado
|
||||
/// muestra sub-línea VISIBLE con motivo + autor + tiempo. Si está editado
|
||||
/// muestra mini ícono lápiz con tooltip (info secundaria).
|
||||
class _PagoListItem extends StatelessWidget {
|
||||
final Pago pago;
|
||||
const _PagoListItem({required this.pago});
|
||||
|
||||
static const _mesAbrev = [
|
||||
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||
];
|
||||
|
||||
String _mesAbreviado() {
|
||||
final d = DateTime.tryParse(pago.anioMesPagado);
|
||||
if (d == null) return pago.anioMesPagado;
|
||||
return "${_mesAbrev[d.month]} '${d.year.toString().substring(2)}";
|
||||
}
|
||||
|
||||
String _formatMonto(double n) =>
|
||||
n.toStringAsFixed(n.truncateToDouble() == n ? 0 : 2);
|
||||
|
||||
String _cargadoLabel() {
|
||||
final f = pago.fechaPago;
|
||||
if (f == null) return 'Cargado —';
|
||||
return 'Cargado ${f.day.toString().padLeft(2, '0')}/${f.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _anuladoSubline() {
|
||||
final motivo = (pago.motivoAnulacion?.trim().isNotEmpty ?? false)
|
||||
? pago.motivoAnulacion!.trim()
|
||||
: 'Sin motivo';
|
||||
final autor = pago.anuladoPorNombre ?? 'admin';
|
||||
final hace = pago.anuladoAt != null ? _timeagoEs(pago.anuladoAt!) : '';
|
||||
return hace.isEmpty
|
||||
? '$motivo · por $autor'
|
||||
: '$motivo · por $autor · $hace';
|
||||
}
|
||||
|
||||
String _editadoTooltip() {
|
||||
final autor = pago.updatedByNombre ?? 'admin';
|
||||
final cuando = pago.updatedAt;
|
||||
if (cuando == null) return 'Editado por $autor';
|
||||
final f =
|
||||
'${cuando.day.toString().padLeft(2, '0')}/${cuando.month.toString().padLeft(2, '0')}/${cuando.year}';
|
||||
return 'Editado por $autor el $f';
|
||||
}
|
||||
|
||||
static String _timeagoEs(DateTime when) {
|
||||
final diff = DateTime.now().difference(when);
|
||||
if (diff.inSeconds < 60) return 'hace unos segundos';
|
||||
if (diff.inMinutes < 60) return 'hace ${diff.inMinutes} min';
|
||||
if (diff.inHours < 24) return 'hace ${diff.inHours} h';
|
||||
if (diff.inDays < 30) {
|
||||
final d = diff.inDays;
|
||||
return d == 1 ? 'hace 1 día' : 'hace $d días';
|
||||
}
|
||||
if (diff.inDays < 365) {
|
||||
final m = (diff.inDays / 30).floor();
|
||||
return m == 1 ? 'hace 1 mes' : 'hace $m meses';
|
||||
}
|
||||
final y = (diff.inDays / 365).floor();
|
||||
return y == 1 ? 'hace 1 año' : 'hace $y años';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final anulado = pago.isAnulado;
|
||||
final editado = pago.isEditado && !anulado;
|
||||
final mainTextColor =
|
||||
anulado ? cs.onSurface.withAlpha(140) : cs.onSurface;
|
||||
final montoColor =
|
||||
anulado ? SomaColors.error.withAlpha(160) : SomaColors.success;
|
||||
final decoration = anulado ? TextDecoration.lineThrough : null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.surfaceContainerHighest, width: 0.6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
_mesAbreviado(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: mainTextColor,
|
||||
decoration: decoration,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Text(
|
||||
'\$${_formatMonto(pago.montoTotal)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: montoColor,
|
||||
decoration: decoration,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pago.metodo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSurface.withAlpha(140),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (editado) ...[
|
||||
Tooltip(
|
||||
message: _editadoTooltip(),
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 12,
|
||||
color: cs.onSurface.withAlpha(140),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
_cargadoLabel(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (anulado) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: SomaColors.error.withAlpha(28),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'ANULADO',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SomaColors.error,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_anuladoSubline(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: cs.onSurface.withAlpha(130),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gimnasio_soma/core/widgets/soma_toast.dart';
|
||||
import 'package:gimnasio_soma/features/pagos/domain/entities/pago.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
class PagosExport {
|
||||
// ── CSV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static Future<void> exportToCsv(
|
||||
BuildContext context,
|
||||
List<Pago> pagos, {
|
||||
String? filtroMes,
|
||||
}) async {
|
||||
final csvBytes = _buildCsvBytes(pagos);
|
||||
|
||||
final stamp = DateTime.now();
|
||||
final defaultName =
|
||||
'pagos_${stamp.year}${stamp.month.toString().padLeft(2, '0')}${stamp.day.toString().padLeft(2, '0')}.csv';
|
||||
|
||||
final outputPath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Guardar pagos como CSV',
|
||||
fileName: defaultName,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
);
|
||||
|
||||
if (outputPath == null) return; // usuario canceló
|
||||
|
||||
await File(outputPath).writeAsBytes(csvBytes);
|
||||
|
||||
if (context.mounted) {
|
||||
SomaToast.show(context, message: 'CSV guardado correctamente', type: ToastType.success);
|
||||
}
|
||||
}
|
||||
|
||||
static List<int> _buildCsvBytes(List<Pago> pagos) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('dni,nombre_apellido,anio_mes_pagado,monto_total,metodo,fecha_pago');
|
||||
for (final p in pagos) {
|
||||
final c = p.cliente;
|
||||
final dni = _csvCell(c?.dni ?? '');
|
||||
final nombre = _csvCell('${c?.nombre ?? ''} ${c?.apellido ?? ''}'.trim());
|
||||
// anio_mes: usar solo YYYY-MM para reimportar
|
||||
final mes = p.anioMesPagado.length >= 7 ? p.anioMesPagado.substring(0, 7) : p.anioMesPagado;
|
||||
final monto = p.montoTotal.toStringAsFixed(2);
|
||||
final metodo = _csvCell(p.metodo);
|
||||
final fecha = p.fechaPago != null
|
||||
? '${p.fechaPago!.year}-'
|
||||
'${p.fechaPago!.month.toString().padLeft(2, '0')}-'
|
||||
'${p.fechaPago!.day.toString().padLeft(2, '0')}'
|
||||
: '';
|
||||
buf.writeln('$dni,$nombre,$mes,$monto,$metodo,$fecha');
|
||||
}
|
||||
// BOM para compatibilidad con Excel (UTF-8)
|
||||
return [0xEF, 0xBB, 0xBF, ...utf8.encode(buf.toString())];
|
||||
}
|
||||
|
||||
// Envuelve la celda en comillas si contiene coma, comilla o salto de línea.
|
||||
static String _csvCell(String value) {
|
||||
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
||||
return '"${value.replaceAll('"', '""')}"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static Future<void> exportToPdf(
|
||||
BuildContext context,
|
||||
List<Pago> pagos, {
|
||||
String? filtroMes,
|
||||
}) async {
|
||||
final doc = _buildPdfDocument(pagos, filtroMes: filtroMes);
|
||||
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (_) => doc.save(),
|
||||
name: filtroMes != null ? 'Pagos $filtroMes' : 'Pagos',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Document _buildPdfDocument(List<Pago> pagos, {String? filtroMes}) {
|
||||
final doc = pw.Document();
|
||||
|
||||
final totalMonto = pagos.fold<double>(0, (sum, p) => sum + p.montoTotal);
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 36),
|
||||
header: (_) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SOMA – Listado de Pagos',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (filtroMes != null)
|
||||
pw.Text(
|
||||
filtroMes,
|
||||
style: const pw.TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Divider(thickness: 0.5),
|
||||
],
|
||||
),
|
||||
footer: (ctx) => pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Total: \$${totalMonto.toStringAsFixed(2)} · ${pagos.length} pago${pagos.length == 1 ? '' : 's'}',
|
||||
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.Text(
|
||||
'Pág. ${ctx.pageNumber} / ${ctx.pagesCount}',
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
build: (ctx) => [
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['DNI', 'Socio', 'Mes pagado', 'Método', 'Monto'],
|
||||
headerStyle: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
cellStyle: const pw.TextStyle(fontSize: 9),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerLeft,
|
||||
3: pw.Alignment.centerLeft,
|
||||
4: pw.Alignment.centerRight,
|
||||
},
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(72),
|
||||
1: const pw.FlexColumnWidth(2.5),
|
||||
2: const pw.FlexColumnWidth(1.8),
|
||||
3: const pw.FlexColumnWidth(1.8),
|
||||
4: const pw.FixedColumnWidth(68),
|
||||
},
|
||||
data: pagos.map((p) {
|
||||
final c = p.cliente;
|
||||
return [
|
||||
c?.dni ?? '',
|
||||
c != null ? '${c.apellido}, ${c.nombre}'.trim() : '',
|
||||
p.mesPagadoDisplay,
|
||||
p.metodo,
|
||||
'\$${p.montoTotal.toStringAsFixed(2)}',
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Fila parseada de un CSV de pagos.
|
||||
class PagosImportRow {
|
||||
final int rowNumber;
|
||||
final String dni;
|
||||
final String anioMesPagado; // formato YYYY-MM-01
|
||||
final double montoTotal;
|
||||
final String metodoNombre;
|
||||
final String? fechaPago; // YYYY-MM-DD, opcional
|
||||
final String? validationError;
|
||||
|
||||
const PagosImportRow({
|
||||
required this.rowNumber,
|
||||
required this.dni,
|
||||
required this.anioMesPagado,
|
||||
required this.montoTotal,
|
||||
required this.metodoNombre,
|
||||
this.fechaPago,
|
||||
this.validationError,
|
||||
});
|
||||
|
||||
bool get isValid => validationError == null;
|
||||
}
|
||||
|
||||
/// Resultado del parseo de un CSV exportado por la app.
|
||||
class PagosImportResult {
|
||||
final List<PagosImportRow> rows; // incluye válidas e inválidas
|
||||
final List<String> parseErrors; // errores que impidieron leer el archivo
|
||||
|
||||
const PagosImportResult({required this.rows, this.parseErrors = const []});
|
||||
|
||||
List<PagosImportRow> get valid => rows.where((r) => r.isValid).toList();
|
||||
List<PagosImportRow> get invalid => rows.where((r) => !r.isValid).toList();
|
||||
}
|
||||
|
||||
/// Parsea el contenido de un CSV exportado con [PagosExport.exportToCsv].
|
||||
/// Columnas esperadas: dni, nombre_apellido, anio_mes_pagado, monto_total, metodo, fecha_pago
|
||||
PagosImportResult parsePagosCsv(List<int> bytes) {
|
||||
// Quitar BOM UTF-8 si está presente
|
||||
final content = bytes.length >= 3 &&
|
||||
bytes[0] == 0xEF &&
|
||||
bytes[1] == 0xBB &&
|
||||
bytes[2] == 0xBF
|
||||
? utf8.decode(bytes.sublist(3))
|
||||
: utf8.decode(bytes);
|
||||
|
||||
final lines = content
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return const PagosImportResult(
|
||||
rows: [],
|
||||
parseErrors: ['El archivo está vacío'],
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar encabezado
|
||||
final headerCells = _splitCsvLine(lines[0]);
|
||||
const expectedHeaders = [
|
||||
'dni',
|
||||
'nombre_apellido',
|
||||
'anio_mes_pagado',
|
||||
'monto_total',
|
||||
'metodo',
|
||||
'fecha_pago',
|
||||
];
|
||||
final missingHeaders = expectedHeaders
|
||||
.where((h) => !headerCells.map((c) => c.toLowerCase()).contains(h))
|
||||
.toList();
|
||||
if (missingHeaders.isNotEmpty) {
|
||||
return PagosImportResult(
|
||||
rows: const [],
|
||||
parseErrors: [
|
||||
'Formato de archivo incorrecto. Columnas faltantes: ${missingHeaders.join(', ')}',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final headerIndex = {
|
||||
for (var i = 0; i < headerCells.length; i++) headerCells[i].toLowerCase(): i
|
||||
};
|
||||
|
||||
final rows = <PagosImportRow>[];
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
final cells = _splitCsvLine(lines[i]);
|
||||
if (cells.length < 4) continue;
|
||||
|
||||
int col(String name) => headerIndex[name] ?? -1;
|
||||
String get(String name) {
|
||||
final idx = col(name);
|
||||
return (idx >= 0 && idx < cells.length) ? cells[idx].trim() : '';
|
||||
}
|
||||
|
||||
final rowNum = i;
|
||||
final dni = get('dni');
|
||||
final mesRaw = get('anio_mes_pagado'); // YYYY-MM o YYYY-MM-DD
|
||||
final montoStr = get('monto_total');
|
||||
final metodo = get('metodo');
|
||||
final fechaRaw = get('fecha_pago');
|
||||
|
||||
// Validaciones
|
||||
String? error;
|
||||
if (dni.isEmpty) {
|
||||
error = 'DNI vacío';
|
||||
} else if (mesRaw.isEmpty || !RegExp(r'^\d{4}-\d{2}').hasMatch(mesRaw)) {
|
||||
error = 'Mes inválido: "$mesRaw"';
|
||||
} else if (double.tryParse(montoStr) == null ||
|
||||
(double.tryParse(montoStr) ?? 0) <= 0) {
|
||||
error = 'Monto inválido: "$montoStr"';
|
||||
} else if (metodo.isEmpty) {
|
||||
error = 'Método vacío';
|
||||
}
|
||||
|
||||
// Normalizar anio_mes_pagado a YYYY-MM-01
|
||||
final anioMes = mesRaw.length >= 7
|
||||
? '${mesRaw.substring(0, 7)}-01'
|
||||
: mesRaw;
|
||||
|
||||
// Normalizar fecha_pago (aceptar YYYY-MM-DD, dejar null si vacío/inválido)
|
||||
String? fechaFinal;
|
||||
if (fechaRaw.isNotEmpty &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(fechaRaw)) {
|
||||
fechaFinal = fechaRaw;
|
||||
}
|
||||
|
||||
rows.add(PagosImportRow(
|
||||
rowNumber: rowNum,
|
||||
dni: dni,
|
||||
anioMesPagado: anioMes,
|
||||
montoTotal: double.tryParse(montoStr) ?? 0,
|
||||
metodoNombre: metodo,
|
||||
fechaPago: fechaFinal,
|
||||
validationError: error,
|
||||
));
|
||||
}
|
||||
|
||||
return PagosImportResult(rows: rows);
|
||||
}
|
||||
|
||||
/// Divide una línea CSV respetando celdas entre comillas.
|
||||
List<String> _splitCsvLine(String line) {
|
||||
final result = <String>[];
|
||||
final buf = StringBuffer();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < line.length; i++) {
|
||||
final ch = line[i];
|
||||
if (ch == '"') {
|
||||
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
|
||||
buf.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch == ',' && !inQuotes) {
|
||||
result.add(buf.toString());
|
||||
buf.clear();
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
result.add(buf.toString());
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user