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,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';
}
}
}