Agrego frontend app

This commit is contained in:
Pablo
2026-08-22 19:08:19 -03:00
parent 824e092b29
commit 2d0797e627
254 changed files with 37231 additions and 0 deletions
@@ -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;
});
@@ -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,
});
}
}