Agrego frontend app
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user