Agrego frontend app
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/// Cómo interactúa una edición de plantilla regular con planificaciones
|
||||
/// futuras existentes para el mismo día de la semana.
|
||||
///
|
||||
/// El backend define tres variantes (ver §5.3 del brief de horarios):
|
||||
/// * `indefinido` → borra las planificaciones futuras posteriores y deja el
|
||||
/// nuevo horario sin fecha de fin.
|
||||
/// * `hasta_proximo` (default backend) → respeta la próxima planificación
|
||||
/// futura, cerrando el nuevo horario justo antes.
|
||||
/// * `hasta` + fecha → cierra el nuevo horario en una fecha específica. Si
|
||||
/// hay planificaciones futuras dentro del intervalo, el
|
||||
/// backend rechaza con `ConflictoAlcance`.
|
||||
sealed class Alcance {
|
||||
const Alcance();
|
||||
|
||||
/// Serialización aceptada por `fc_insertar_horario_con_actividades`.
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
class AlcanceIndefinido extends Alcance {
|
||||
const AlcanceIndefinido();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => const {'tipo': 'indefinido'};
|
||||
}
|
||||
|
||||
class AlcanceHastaProximo extends Alcance {
|
||||
const AlcanceHastaProximo();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => const {'tipo': 'hasta_proximo'};
|
||||
}
|
||||
|
||||
class AlcanceHasta extends Alcance {
|
||||
final DateTime fecha;
|
||||
|
||||
const AlcanceHasta(this.fecha);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final f = '${fecha.year}-${fecha.month.toString().padLeft(2, '0')}-'
|
||||
'${fecha.day.toString().padLeft(2, '0')}';
|
||||
return {'tipo': 'hasta', 'fecha': f};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
class BloqueActividadEspecial {
|
||||
final int id;
|
||||
final String horaInicio;
|
||||
final String horaFin;
|
||||
final int actividadId;
|
||||
final String actividadNombre;
|
||||
final int actividadDuracion;
|
||||
|
||||
const BloqueActividadEspecial({
|
||||
required this.id,
|
||||
required this.horaInicio,
|
||||
required this.horaFin,
|
||||
required this.actividadId,
|
||||
required this.actividadNombre,
|
||||
required this.actividadDuracion,
|
||||
});
|
||||
|
||||
factory BloqueActividadEspecial.fromMap(Map<String, dynamic> m) {
|
||||
final act = m['actividad'] as Map<String, dynamic>;
|
||||
return BloqueActividadEspecial(
|
||||
id: m['id'] as int,
|
||||
horaInicio: m['hora_inicio'] as String,
|
||||
horaFin: m['hora_fin'] as String,
|
||||
actividadId: act['id'] as int,
|
||||
actividadNombre: act['nombre'] as String,
|
||||
actividadDuracion: act['duracion'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DiaEspecialResumen {
|
||||
final DateTime fecha;
|
||||
final String tipo; // 'cerrado' | 'horario_diferente'
|
||||
final String? motivo;
|
||||
final List<BloqueActividadEspecial> rangos;
|
||||
|
||||
const DiaEspecialResumen({
|
||||
required this.fecha,
|
||||
required this.tipo,
|
||||
this.motivo,
|
||||
required this.rangos,
|
||||
});
|
||||
|
||||
bool get esCerrado => tipo == 'cerrado';
|
||||
|
||||
/// Construye un resumen a partir del item de día devuelto por
|
||||
/// `fc_obtener_horarios` (donde la fecha viene como clave del objeto raíz y
|
||||
/// el valor trae `tipo`, `motivo`, `horarios`).
|
||||
factory DiaEspecialResumen.fromHorariosResponse(
|
||||
DateTime fecha,
|
||||
Map<String, dynamic> m,
|
||||
) {
|
||||
final rawHorarios = m['horarios'] as List<dynamic>? ?? [];
|
||||
return DiaEspecialResumen(
|
||||
fecha: fecha,
|
||||
tipo: m['tipo'] as String,
|
||||
motivo: m['motivo'] as String?,
|
||||
rangos: rawHorarios
|
||||
.map((e) => BloqueActividadEspecial.fromMap(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
const _mesesCortos = [
|
||||
'', 'ene', 'feb', 'mar', 'abr', 'may', 'jun',
|
||||
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
|
||||
];
|
||||
|
||||
String _fmt(DateTime d) => '${d.day} ${_mesesCortos[d.month]} ${d.year}';
|
||||
|
||||
/// Errores tipados que el módulo de horarios produce al consumir la API de
|
||||
/// backend. El traductor [fromException] mapea mensajes conocidos de las
|
||||
/// funciones PL/pgSQL a una variante específica; mensajes no reconocidos
|
||||
/// caen en [Desconocido] conservando el texto original.
|
||||
sealed class HorarioError {
|
||||
const HorarioError();
|
||||
|
||||
/// Texto en español listo para mostrar al usuario. Distinto del raw del
|
||||
/// backend: explica el problema y, donde aplica, sugiere la acción.
|
||||
String mensajeUsuario();
|
||||
|
||||
static final RegExp _conflictoRegex = RegExp(
|
||||
r'Conflicto: existe una planificación con valido_desde = '
|
||||
r'(\d{4}-\d{2}-\d{2}) dentro del intervalo '
|
||||
r'\[(\d{4}-\d{2}-\d{2}), (\d{4}-\d{2}-\d{2})\]',
|
||||
);
|
||||
|
||||
static final RegExp _alcanceFechaRegex = RegExp(
|
||||
r'alcance\.fecha \((\d{4}-\d{2}-\d{2})\) no puede ser anterior a '
|
||||
r'valido_desde \((\d{4}-\d{2}-\d{2})\)',
|
||||
);
|
||||
|
||||
factory HorarioError.fromException(Object e) {
|
||||
final raw = e is PostgrestException
|
||||
? e.message
|
||||
: e.toString().replaceFirst('Exception: ', '');
|
||||
|
||||
final mConflicto = _conflictoRegex.firstMatch(raw);
|
||||
if (mConflicto != null) {
|
||||
final vd = DateTime.tryParse(mConflicto.group(1)!);
|
||||
final id = DateTime.tryParse(mConflicto.group(2)!);
|
||||
final ih = DateTime.tryParse(mConflicto.group(3)!);
|
||||
if (vd != null && id != null && ih != null) {
|
||||
return ConflictoAlcance(
|
||||
validoDesdeConflicto: vd,
|
||||
intervaloDesde: id,
|
||||
intervaloHasta: ih,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final mAlcance = _alcanceFechaRegex.firstMatch(raw);
|
||||
if (mAlcance != null) {
|
||||
final af = DateTime.tryParse(mAlcance.group(1)!);
|
||||
final vd = DateTime.tryParse(mAlcance.group(2)!);
|
||||
if (af != null && vd != null) {
|
||||
return AlcanceFechaAnterior(alcanceFecha: af, validoDesde: vd);
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.contains('valido_desde no puede ser una fecha pasada')) {
|
||||
return const FechaPasada();
|
||||
}
|
||||
if (raw.contains('No se pueden alterar horarios en fechas pasadas')) {
|
||||
return const FechaPasadaEspecial();
|
||||
}
|
||||
|
||||
return Desconocido(raw);
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictoAlcance extends HorarioError {
|
||||
final DateTime validoDesdeConflicto;
|
||||
final DateTime intervaloDesde;
|
||||
final DateTime intervaloHasta;
|
||||
|
||||
const ConflictoAlcance({
|
||||
required this.validoDesdeConflicto,
|
||||
required this.intervaloDesde,
|
||||
required this.intervaloHasta,
|
||||
});
|
||||
|
||||
@override
|
||||
String mensajeUsuario() =>
|
||||
'Ya hay un horario planificado para el ${_fmt(validoDesdeConflicto)}, '
|
||||
'que cae dentro del rango elegido (${_fmt(intervaloDesde)} → '
|
||||
'${_fmt(intervaloHasta)}). Cambiá el alcance o eliminá esa '
|
||||
'planificación antes de continuar.';
|
||||
}
|
||||
|
||||
class FechaPasada extends HorarioError {
|
||||
const FechaPasada();
|
||||
|
||||
@override
|
||||
String mensajeUsuario() =>
|
||||
'La fecha de vigencia no puede ser anterior a hoy.';
|
||||
}
|
||||
|
||||
class AlcanceFechaAnterior extends HorarioError {
|
||||
final DateTime alcanceFecha;
|
||||
final DateTime validoDesde;
|
||||
|
||||
const AlcanceFechaAnterior({
|
||||
required this.alcanceFecha,
|
||||
required this.validoDesde,
|
||||
});
|
||||
|
||||
@override
|
||||
String mensajeUsuario() =>
|
||||
'La fecha de fin (${_fmt(alcanceFecha)}) no puede ser anterior al '
|
||||
'inicio de vigencia (${_fmt(validoDesde)}).';
|
||||
}
|
||||
|
||||
class FechaPasadaEspecial extends HorarioError {
|
||||
const FechaPasadaEspecial();
|
||||
|
||||
@override
|
||||
String mensajeUsuario() =>
|
||||
'No se pueden modificar horarios en fechas pasadas.';
|
||||
}
|
||||
|
||||
class Desconocido extends HorarioError {
|
||||
final String raw;
|
||||
const Desconocido(this.raw);
|
||||
|
||||
@override
|
||||
String mensajeUsuario() => raw;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
class BloqueActividadInfo {
|
||||
final int id;
|
||||
final String nombre;
|
||||
final int duracion;
|
||||
final int capacidad;
|
||||
|
||||
const BloqueActividadInfo({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
required this.duracion,
|
||||
required this.capacidad,
|
||||
});
|
||||
|
||||
factory BloqueActividadInfo.fromMap(Map<String, dynamic> m) {
|
||||
return BloqueActividadInfo(
|
||||
id: m['id'] as int,
|
||||
nombre: m['nombre'] as String,
|
||||
duracion: m['duracion'] as int,
|
||||
capacidad: m['capacidad'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BloqueHorario {
|
||||
final int id;
|
||||
final String horaInicio;
|
||||
final String horaFin;
|
||||
final BloqueActividadInfo actividad;
|
||||
|
||||
const BloqueHorario({
|
||||
required this.id,
|
||||
required this.horaInicio,
|
||||
required this.horaFin,
|
||||
required this.actividad,
|
||||
});
|
||||
|
||||
factory BloqueHorario.fromMap(Map<String, dynamic> m) {
|
||||
return BloqueHorario(
|
||||
id: m['id'] as int,
|
||||
horaInicio: m['hora_inicio'] as String,
|
||||
horaFin: m['hora_fin'] as String,
|
||||
actividad:
|
||||
BloqueActividadInfo.fromMap(m['actividad'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum TipoDia { normal, horarioDiferente, cerrado }
|
||||
|
||||
class DiaHorarios {
|
||||
final DateTime fecha;
|
||||
final int diaSemana;
|
||||
final TipoDia tipo;
|
||||
final String? motivo;
|
||||
final List<BloqueHorario> bloques;
|
||||
/// Solo presente para días `normal`: cuándo entró a regir la plantilla.
|
||||
final DateTime? validoDesde;
|
||||
/// Solo presente para días `normal`: cuándo deja de regir la plantilla
|
||||
/// (`null` = vigencia indefinida).
|
||||
final DateTime? validoHasta;
|
||||
|
||||
const DiaHorarios({
|
||||
required this.fecha,
|
||||
required this.diaSemana,
|
||||
required this.tipo,
|
||||
this.motivo,
|
||||
required this.bloques,
|
||||
this.validoDesde,
|
||||
this.validoHasta,
|
||||
});
|
||||
|
||||
bool get esCerrado => tipo == TipoDia.cerrado;
|
||||
bool get esEspecial => tipo != TipoDia.normal;
|
||||
|
||||
factory DiaHorarios.fromMap(DateTime fecha, Map<String, dynamic> m) {
|
||||
final tipoStr = m['tipo'] as String;
|
||||
final tipo = switch (tipoStr) {
|
||||
'cerrado' => TipoDia.cerrado,
|
||||
'horario_diferente' => TipoDia.horarioDiferente,
|
||||
_ => TipoDia.normal,
|
||||
};
|
||||
|
||||
final rawBloques = m['horarios'] as List<dynamic>? ?? [];
|
||||
final validoDesdeStr = m['valido_desde'] as String?;
|
||||
final validoHastaStr = m['valido_hasta'] as String?;
|
||||
return DiaHorarios(
|
||||
fecha: fecha,
|
||||
diaSemana: m['dia_semana'] as int,
|
||||
tipo: tipo,
|
||||
motivo: m['motivo'] as String?,
|
||||
bloques: rawBloques
|
||||
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
validoDesde:
|
||||
validoDesdeStr != null ? DateTime.tryParse(validoDesdeStr) : null,
|
||||
validoHasta:
|
||||
validoHastaStr != null ? DateTime.tryParse(validoHastaStr) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SemanaHorarios {
|
||||
final DateTime weekStart;
|
||||
final Map<String, DiaHorarios> _byKey;
|
||||
|
||||
SemanaHorarios({required this.weekStart, required Map<String, DiaHorarios> byKey})
|
||||
: _byKey = byKey;
|
||||
|
||||
factory SemanaHorarios.fromResponse(DateTime weekStart, Map<String, dynamic> raw) {
|
||||
final byKey = <String, DiaHorarios>{};
|
||||
for (final entry in raw.entries) {
|
||||
final fecha = DateTime.tryParse(entry.key);
|
||||
if (fecha == null) continue;
|
||||
final diaMap = entry.value;
|
||||
if (diaMap is Map<String, dynamic>) {
|
||||
byKey[entry.key] = DiaHorarios.fromMap(fecha, diaMap);
|
||||
}
|
||||
}
|
||||
return SemanaHorarios(weekStart: weekStart, byKey: byKey);
|
||||
}
|
||||
|
||||
String _key(DateTime d) =>
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
DiaHorarios? diaPara(DateTime fecha) => _byKey[_key(fecha)];
|
||||
|
||||
List<DiaHorarios> get dias => _byKey.values.toList()
|
||||
..sort((a, b) => a.fecha.compareTo(b.fecha));
|
||||
|
||||
bool contieneFecha(DateTime fecha) => _byKey.containsKey(_key(fecha));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
|
||||
|
||||
/// Plantilla regular futura para un día de la semana, tal como la devuelve
|
||||
/// `fc_obtener_planificaciones_futuras`. Sólo se incluyen plantillas cuyo
|
||||
/// `valido_desde` es estrictamente posterior al `p_desde` consultado.
|
||||
class PlanificacionFutura {
|
||||
final DateTime validoDesde;
|
||||
final DateTime? validoHasta;
|
||||
final List<BloqueHorario> rangos;
|
||||
|
||||
const PlanificacionFutura({
|
||||
required this.validoDesde,
|
||||
this.validoHasta,
|
||||
required this.rangos,
|
||||
});
|
||||
|
||||
factory PlanificacionFutura.fromMap(Map<String, dynamic> m) {
|
||||
final vh = m['valido_hasta'] as String?;
|
||||
return PlanificacionFutura(
|
||||
validoDesde: DateTime.parse(m['valido_desde'] as String),
|
||||
validoHasta: vh != null ? DateTime.tryParse(vh) : null,
|
||||
rangos: (m['rangos'] as List<dynamic>? ?? [])
|
||||
.map((e) => BloqueHorario.fromMap(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:gimnasio_soma/features/horarios/domain/entities/alcance.dart';
|
||||
import 'package:gimnasio_soma/features/horarios/domain/entities/dia_especial.dart';
|
||||
import 'package:gimnasio_soma/features/horarios/domain/entities/horario_semana.dart';
|
||||
import 'package:gimnasio_soma/features/horarios/domain/entities/planificacion_futura.dart';
|
||||
|
||||
abstract class HorariosRepository {
|
||||
/// Obtener horarios de una semana (7 días desde [weekStart]).
|
||||
Future<SemanaHorarios> obtenerSemana(DateTime weekStart);
|
||||
|
||||
/// Guardar horario para un día (PUT: reemplaza todos los bloques).
|
||||
/// [esEspecial]=false → SCD upsert en horario regular.
|
||||
/// [esEspecial]=true + bloques vacíos → marca el día como cerrado.
|
||||
/// [esEspecial]=true + bloques → crea horario_diferente.
|
||||
///
|
||||
/// [validoDesde] y [alcance] **sólo aplican a día regular**; el backend
|
||||
/// los ignora cuando [esEspecial] es true. Si vienen `null`, no se mandan
|
||||
/// y el backend usa sus defaults (`valido_desde = hoy`,
|
||||
/// `alcance = hasta_proximo`).
|
||||
Future<void> guardarDia({
|
||||
required DateTime fecha,
|
||||
required bool esEspecial,
|
||||
String? motivo,
|
||||
required List<Map<String, dynamic>> bloques,
|
||||
DateTime? validoDesde,
|
||||
Alcance? alcance,
|
||||
});
|
||||
|
||||
/// Eliminar día especial (restaura el horario normal para esa fecha).
|
||||
Future<void> eliminarDiaEspecial(DateTime fecha);
|
||||
|
||||
/// Listar los días especiales programados en una ventana hacia adelante.
|
||||
/// [dias] = cantidad de días a inspeccionar desde hoy (default 90).
|
||||
Future<List<DiaEspecialResumen>> listarDiasEspeciales({int dias = 90});
|
||||
|
||||
/// Plantillas regulares con `valido_desde` estrictamente posterior a [desde]
|
||||
/// para el [diaSemana] dado (1=lunes ... 7=domingo, ISODOW). Ventana de
|
||||
/// inspección controlada por [meses] (default 6, máx 24).
|
||||
Future<List<PlanificacionFutura>> futurosParaDiaSemana({
|
||||
required int diaSemana,
|
||||
required DateTime desde,
|
||||
int meses = 6,
|
||||
});
|
||||
|
||||
/// Cuenta las reservas huérfanas generadas desde [instante] (UTC).
|
||||
/// Usado para informar al admin cuántas reservas quedaron sin turno
|
||||
/// tras una operación de escritura en horarios.
|
||||
Future<int> contarHuerfanasDesde(DateTime instante);
|
||||
}
|
||||
Reference in New Issue
Block a user