diff --git a/RS_system/.idea/.idea.RS_system/.idea/.gitignore b/RS_system/.idea/.idea.RS_system/.idea/.gitignore new file mode 100644 index 0000000..ecf77fd --- /dev/null +++ b/RS_system/.idea/.idea.RS_system/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.RS_system.iml +/modules.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/RS_system/.idea/.idea.RS_system/.idea/encodings.xml b/RS_system/.idea/.idea.RS_system/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/RS_system/.idea/.idea.RS_system/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/RS_system/.idea/.idea.RS_system/.idea/indexLayout.xml b/RS_system/.idea/.idea.RS_system/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/RS_system/.idea/.idea.RS_system/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/RS_system/.idea/.idea.RS_system/.idea/vcs.xml b/RS_system/.idea/.idea.RS_system/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/RS_system/.idea/.idea.RS_system/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/RS_system/Controllers/DiezmoCatalogoController.cs b/RS_system/Controllers/DiezmoCatalogoController.cs new file mode 100644 index 0000000..9478115 --- /dev/null +++ b/RS_system/Controllers/DiezmoCatalogoController.cs @@ -0,0 +1,177 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Rs_system.Data; +using Rs_system.Filters; +using Rs_system.Models; +using Rs_system.Models.ViewModels.Catalogos; +using System.Security.Claims; + +namespace Rs_system.Controllers; + +[Authorize] +[Permission("Diezmo.Index")] // Requiere permisos base del módulo +public class DiezmoCatalogoController : Controller +{ + private readonly ApplicationDbContext _context; + + public DiezmoCatalogoController(ApplicationDbContext context) + { + _context = context; + } + + private string UsuarioActual() => User.FindFirst(ClaimTypes.Name)?.Value ?? User.Identity?.Name ?? "Sistema"; + + // ───────────────────────────────────────────────────────────────────────── + // Tipos de Salida + // ───────────────────────────────────────────────────────────────────────── + public async Task TiposSalida() + { + var lista = await _context.DiezmoTiposSalida + .Where(x => !x.Eliminado) + .OrderBy(x => x.Nombre) + .ToListAsync(); + return View(lista); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task GuardarTipoSalida(TipoSalidaViewModel vm) + { + if (!ModelState.IsValid) + { + TempData["ErrorMessage"] = "Datos inválidos: " + string.Join(", ", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)); + return RedirectToAction(nameof(TiposSalida)); + } + + if (vm.Id == 0) // Crear + { + var nuevo = new DiezmoTipoSalida + { + Nombre = vm.Nombre, + Descripcion = vm.Descripcion, + EsEntregaPastor = vm.EsEntregaPastor, + CreadoPor = UsuarioActual(), + CreadoEn = DateTime.UtcNow + }; + _context.DiezmoTiposSalida.Add(nuevo); + TempData["SuccessMessage"] = "Tipo de salida creado."; + } + else // Editar + { + var dbItem = await _context.DiezmoTiposSalida.FindAsync(vm.Id); + if (dbItem == null || dbItem.Eliminado) return NotFound(); + + dbItem.Nombre = vm.Nombre; + dbItem.Descripcion = vm.Descripcion; + dbItem.EsEntregaPastor = vm.EsEntregaPastor; + dbItem.ActualizadoEn = DateTime.UtcNow; + _context.Update(dbItem); + + TempData["SuccessMessage"] = "Tipo de salida actualizado."; + } + + await _context.SaveChangesAsync(); + return RedirectToAction(nameof(TiposSalida)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EliminarTipoSalida(long id) + { + var dbItem = await _context.DiezmoTiposSalida.FindAsync(id); + if (dbItem == null) return NotFound(); + + // Validación simple (si ya hay salidas con este tipo no borrar duro) + var enUso = await _context.DiezmoSalidas.AnyAsync(s => s.TipoSalidaId == id && !s.Eliminado); + if (enUso) + { + dbItem.Activo = false; // Desactivar en lugar de borrar + dbItem.Eliminado = true; + TempData["SuccessMessage"] = "Tipo de salida desactivado (Estaba en uso)."; + } + else + { + dbItem.Eliminado = true; + TempData["SuccessMessage"] = "Tipo de salida eliminado."; + } + + await _context.SaveChangesAsync(); + return RedirectToAction(nameof(TiposSalida)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Beneficiarios + // ───────────────────────────────────────────────────────────────────────── + public async Task Beneficiarios() + { + var lista = await _context.DiezmoBeneficiarios + .Where(x => !x.Eliminado) + .OrderBy(x => x.Nombre) + .ToListAsync(); + return View(lista); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task GuardarBeneficiario(BeneficiarioViewModel vm) + { + if (!ModelState.IsValid) + { + TempData["ErrorMessage"] = "Datos inválidos: " + string.Join(", ", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)); + return RedirectToAction(nameof(Beneficiarios)); + } + + if (vm.Id == 0) + { + var nuevo = new DiezmoBeneficiario + { + Nombre = vm.Nombre, + Descripcion = vm.Descripcion, + CreadoPor = UsuarioActual() + }; + _context.DiezmoBeneficiarios.Add(nuevo); + TempData["SuccessMessage"] = "Beneficiario creado."; + } + else + { + var dbItem = await _context.DiezmoBeneficiarios.FindAsync(vm.Id); + if (dbItem == null || dbItem.Eliminado) return NotFound(); + + dbItem.Nombre = vm.Nombre; + dbItem.Descripcion = vm.Descripcion; + dbItem.ActualizadoPor = UsuarioActual(); + dbItem.ActualizadoEn = DateTime.UtcNow; + _context.Update(dbItem); + + TempData["SuccessMessage"] = "Beneficiario actualizado."; + } + + await _context.SaveChangesAsync(); + return RedirectToAction(nameof(Beneficiarios)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EliminarBeneficiario(long id) + { + var dbItem = await _context.DiezmoBeneficiarios.FindAsync(id); + if (dbItem == null) return NotFound(); + + var enUso = await _context.DiezmoSalidas.AnyAsync(s => s.BeneficiarioId == id && !s.Eliminado); + if (enUso) + { + dbItem.Activo = false; + dbItem.Eliminado = true; + TempData["SuccessMessage"] = "Beneficiario desactivado (estaba en uso)."; + } + else + { + dbItem.Eliminado = true; + TempData["SuccessMessage"] = "Beneficiario eliminado."; + } + + await _context.SaveChangesAsync(); + return RedirectToAction(nameof(Beneficiarios)); + } +} diff --git a/RS_system/Controllers/DiezmoController.cs b/RS_system/Controllers/DiezmoController.cs new file mode 100644 index 0000000..f63f7bc --- /dev/null +++ b/RS_system/Controllers/DiezmoController.cs @@ -0,0 +1,356 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Rs_system.Filters; +using Rs_system.Models.ViewModels; +using Rs_system.Services; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace Rs_system.Controllers; + +[Authorize] +public class DiezmoController : Controller +{ + private readonly IDiezmoCierreService _cierreService; + private readonly IDiezmoReciboService _reciboService; + private readonly IMiembroService _miembroService; + + public DiezmoController( + IDiezmoCierreService cierreService, + IDiezmoReciboService reciboService, + IMiembroService miembroService) + { + _cierreService = cierreService; + _reciboService = reciboService; + _miembroService = miembroService; + } + + // ───────────────────────────────────────────────────────────────────────── + // GET: /Diezmo — Listado de cierres + // ───────────────────────────────────────────────────────────────────────── + [Permission("Diezmo.Index")] + public async Task Index(int? anio) + { + anio ??= DateTime.Today.Year; + var cierres = await _cierreService.GetCierresAsync(anio); + + var vm = cierres.Select(c => new DiezmoCierreListViewModel + { + Id = c.Id, + Fecha = c.Fecha, + Cerrado = c.Cerrado, + TotalRecibido = c.TotalRecibido, + TotalNeto = c.TotalNeto, + TotalSalidas = c.TotalSalidas, + SaldoFinal = c.SaldoFinal, + NumeroDetalles = c.Detalles?.Count ?? 0, + NumeroSalidas = c.Salidas?.Count ?? 0 + }).ToList(); + + ViewBag.AnioActual = anio; + ViewBag.Anios = GetAniosSelectList(); + return View(vm); + } + + // ───────────────────────────────────────────────────────────────────────── + // GET: /Diezmo/Create + // ───────────────────────────────────────────────────────────────────────── + [Permission("Diezmo.Create")] + public IActionResult Create() + => View(new DiezmoCierreCreateViewModel()); + + // POST: /Diezmo/Create + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.Create")] + public async Task Create(DiezmoCierreCreateViewModel vm) + { + if (!ModelState.IsValid) + return View(vm); + + try + { + var cierre = await _cierreService.CrearCierreAsync( + vm.Fecha, vm.Observaciones, UsuarioActual()); + + TempData["SuccessMessage"] = $"Cierre del {cierre.Fecha:dd/MM/yyyy} creado exitosamente."; + return RedirectToAction(nameof(Detail), new { id = cierre.Id }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError("Fecha", ex.Message); + return View(vm); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // GET: /Diezmo/Detail/{id} — Pantalla operativa + // ───────────────────────────────────────────────────────────────────────── + [Permission("Diezmo.Index")] + public async Task Detail(long id) + { + var cierre = await _cierreService.GetCierreByIdAsync(id); + if (cierre == null || cierre.Eliminado) return NotFound(); + + var tiposSalida = await _cierreService.GetTiposSalidaActivosAsync(); + var beneficiarios = await _cierreService.GetBeneficiariosActivosAsync(); + var todosMiembros = await _miembroService.GetAllAsync(); + var miembrosSelect = todosMiembros + .Where(m => m.Activo) + .OrderBy(m => m.NombreCompleto) + .Select(m => new SelectListItem(m.NombreCompleto, m.Id.ToString())) + .ToList(); + + var vm = new DiezmoCierreDetalleViewModel + { + Id = cierre.Id, + Fecha = cierre.Fecha, + Cerrado = cierre.Cerrado, + Observaciones = cierre.Observaciones, + CerradoPor = cierre.CerradoPor, + FechaCierre = cierre.FechaCierre, + TotalRecibido = cierre.TotalRecibido, + TotalCambio = cierre.TotalCambio, + TotalNeto = cierre.TotalNeto, + TotalSalidas = cierre.TotalSalidas, + SaldoFinal = cierre.SaldoFinal, + + Detalles = cierre.Detalles.Select(d => new DiezmoDetalleRowViewModel + { + Id = d.Id, + MiembroId = d.MiembroId, + NombreMiembro = d.Miembro?.Persona?.NombreCompleto ?? "—", + MontoEntregado = d.MontoEntregado, + CambioEntregado = d.CambioEntregado, + MontoNeto = d.MontoNeto, + Observaciones = d.Observaciones, + Fecha = d.Fecha + }).ToList(), + + Salidas = cierre.Salidas.Select(s => new DiezmoSalidaRowViewModel + { + Id = s.Id, + TipoSalidaNombre = s.TipoSalida?.Nombre ?? "—", + BeneficiarioNombre = s.Beneficiario?.Nombre, + Monto = s.Monto, + Concepto = s.Concepto, + NumeroRecibo = s.NumeroRecibo, + Fecha = s.Fecha + }).ToList(), + + MiembrosSelect = miembrosSelect, + TiposSalidaSelect = tiposSalida.Select(t => + new SelectListItem(t.Nombre, t.Id.ToString())).ToList(), + BeneficiariosSelect = beneficiarios.Select(b => + new SelectListItem(b.Nombre, b.Id.ToString())).ToList() + }; + + return View(vm); + } + + // ───────────────────────────────────────────────────────────────────────── + // POST: /Diezmo/AddDetalle + // ───────────────────────────────────────────────────────────────────────── + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.AddDetalle")] + public async Task AddDetalle(long cierreId, DiezmoDetalleFormViewModel vm) + { + if (!ModelState.IsValid) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest("Datos inválidos."); + + TempData["ErrorMessage"] = "Datos inválidos. Verifique el formulario."; + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + try + { + await _cierreService.AgregarDetalleAsync(cierreId, vm, UsuarioActual()); + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return await GetTotalesJsonAsync(cierreId); + + TempData["SuccessMessage"] = "Diezmo registrado correctamente."; + } + catch (InvalidOperationException ex) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest(ex.Message); + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + // POST: /Diezmo/DeleteDetalle + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.AddDetalle")] + public async Task DeleteDetalle(long detalleId, long cierreId) + { + try + { + await _cierreService.EliminarDetalleAsync(detalleId, UsuarioActual()); + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return await GetTotalesJsonAsync(cierreId); + + TempData["SuccessMessage"] = "Detalle eliminado."; + } + catch (InvalidOperationException ex) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest(ex.Message); + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + // ───────────────────────────────────────────────────────────────────────── + // POST: /Diezmo/AddSalida + // ───────────────────────────────────────────────────────────────────────── + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.AddSalida")] + public async Task AddSalida(long cierreId, DiezmoSalidaFormViewModel vm) + { + if (!ModelState.IsValid) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest("Datos inválidos."); + + TempData["ErrorMessage"] = "Datos inválidos. Verifique el formulario."; + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + try + { + await _cierreService.AgregarSalidaAsync(cierreId, vm, UsuarioActual()); + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return await GetTotalesJsonAsync(cierreId); + + TempData["SuccessMessage"] = "Salida registrada correctamente."; + } + catch (InvalidOperationException ex) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest(ex.Message); + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + // POST: /Diezmo/DeleteSalida + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.AddSalida")] + public async Task DeleteSalida(long salidaId, long cierreId) + { + try + { + await _cierreService.EliminarSalidaAsync(salidaId, UsuarioActual()); + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return await GetTotalesJsonAsync(cierreId); + + TempData["SuccessMessage"] = "Salida eliminada."; + } + catch (InvalidOperationException ex) + { + if (Request.Headers["X-Requested-With"] == "XMLHttpRequest") + return BadRequest(ex.Message); + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id = cierreId }); + } + + // ───────────────────────────────────────────────────────────────────────── + // POST: /Diezmo/Close/{id} + // ───────────────────────────────────────────────────────────────────────── + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.Close")] + public async Task Close(long id) + { + try + { + await _cierreService.CerrarCierreAsync(id, UsuarioActual()); + TempData["SuccessMessage"] = "Cierre sellado exitosamente."; + } + catch (InvalidOperationException ex) + { + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id }); + } + + // ───────────────────────────────────────────────────────────────────────── + // POST: /Diezmo/Reopen/{id} — Solo Administrador + // ───────────────────────────────────────────────────────────────────────── + [HttpPost] + [ValidateAntiForgeryToken] + [Permission("Diezmo.Reopen")] + public async Task Reopen(long id) + { + try + { + await _cierreService.ReabrirCierreAsync(id, UsuarioActual()); + TempData["SuccessMessage"] = "Cierre reabierto."; + } + catch (InvalidOperationException ex) + { + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Detail), new { id }); + } + + // ───────────────────────────────────────────────────────────────────────── + // GET: /Diezmo/Recibo/{salidaId} + // ───────────────────────────────────────────────────────────────────────── + [Permission("Diezmo.Index")] + public async Task Recibo(long salidaId) + { + var numero = await _reciboService.GenerarNumeroReciboAsync(salidaId); + var salida = await _reciboService.GetSalidaParaReciboAsync(salidaId); + + if (salida == null) return NotFound(); + + ViewBag.NumeroRecibo = numero; + ViewBag.Emisor = UsuarioActual(); + return View(salida); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers privados + // ───────────────────────────────────────────────────────────────────────── + private string UsuarioActual() + => User.FindFirst(ClaimTypes.Name)?.Value + ?? User.Identity?.Name + ?? "Sistema"; + + private static List GetAniosSelectList() + { + var anioActual = DateTime.Today.Year; + return Enumerable.Range(anioActual - 3, 5) + .Select(a => new SelectListItem(a.ToString(), a.ToString())) + .ToList(); + } + + private async Task GetTotalesJsonAsync(long id) + { + var cierre = await _cierreService.GetCierreByIdAsync(id); + if (cierre == null) return NotFound(); + + return Json(new { + totalRecibido = cierre.TotalRecibido, + totalCambio = cierre.TotalCambio, + totalNeto = cierre.TotalNeto, + totalSalidas = cierre.TotalSalidas, + saldoFinal = cierre.SaldoFinal + }); + } +} diff --git a/RS_system/Data/ApplicationDbContext.cs b/RS_system/Data/ApplicationDbContext.cs index 6a47e55..0dedc7d 100644 --- a/RS_system/Data/ApplicationDbContext.cs +++ b/RS_system/Data/ApplicationDbContext.cs @@ -57,6 +57,13 @@ public class ApplicationDbContext : DbContext public DbSet Colaboraciones { get; set; } public DbSet DetalleColaboraciones { get; set; } + // Diezmos module + public DbSet DiezmoCierres { get; set; } + public DbSet DiezmoDetalles { get; set; } + public DbSet DiezmoSalidas { get; set; } + public DbSet DiezmoBeneficiarios { get; set; } + public DbSet DiezmoTiposSalida { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -217,6 +224,81 @@ public class ApplicationDbContext : DbContext .IsUnique(); }); + // ── Diezmos module configuration ────────────────────────────────── + modelBuilder.Entity(entity => + { + entity.ToTable("diezmo_cierres", "public"); + entity.HasKey(e => e.Id); + entity.Property(e => e.TotalRecibido).HasColumnType("numeric(12,2)"); + entity.Property(e => e.TotalCambio).HasColumnType("numeric(12,2)"); + entity.Property(e => e.TotalNeto).HasColumnType("numeric(12,2)"); + entity.Property(e => e.TotalSalidas).HasColumnType("numeric(12,2)"); + entity.Property(e => e.SaldoFinal).HasColumnType("numeric(12,2)"); + + // One closure per date + entity.HasIndex(e => e.Fecha).IsUnique(); + + entity.HasMany(e => e.Detalles) + .WithOne(d => d.DiezmoCierre) + .HasForeignKey(d => d.DiezmoCierreId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(e => e.Salidas) + .WithOne(s => s.DiezmoCierre) + .HasForeignKey(s => s.DiezmoCierreId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("diezmo_detalles", "public"); + entity.HasKey(e => e.Id); + entity.Property(e => e.MontoEntregado).HasColumnType("numeric(12,2)"); + entity.Property(e => e.CambioEntregado).HasColumnType("numeric(12,2)"); + entity.Property(e => e.MontoNeto).HasColumnType("numeric(12,2)"); + + entity.HasIndex(e => e.DiezmoCierreId); + entity.HasIndex(e => e.MiembroId); + + entity.HasOne(e => e.Miembro) + .WithMany() + .HasForeignKey(e => e.MiembroId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("diezmo_salidas", "public"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Monto).HasColumnType("numeric(12,2)"); + + entity.HasIndex(e => e.DiezmoCierreId); + + entity.HasOne(e => e.TipoSalida) + .WithMany(t => t.Salidas) + .HasForeignKey(e => e.TipoSalidaId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(e => e.Beneficiario) + .WithMany(b => b.Salidas) + .HasForeignKey(e => e.BeneficiarioId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("diezmo_tipos_salida", "public"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Nombre).HasMaxLength(100).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("diezmo_beneficiarios", "public"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Nombre).HasMaxLength(150).IsRequired(); + }); + // Global configuration: Convert all dates to UTC when saving foreach (var entityType in modelBuilder.Model.GetEntityTypes()) diff --git a/RS_system/Migrations/sql_diezmos.sql b/RS_system/Migrations/sql_diezmos.sql new file mode 100644 index 0000000..51c88cc --- /dev/null +++ b/RS_system/Migrations/sql_diezmos.sql @@ -0,0 +1,221 @@ +-- ============================================= +-- Módulo de Diezmos — Script SQL de migración +-- Ejecutar contra la BD de desarrollo/producción +-- Idempotente: usa IF NOT EXISTS / WHERE NOT EXISTS +-- ============================================= + +-- ───────────────────────────────────────────── +-- Tabla: diezmo_tipos_salida +-- Catálogo de tipos de salida (Entrega al Pastor, Gastos, etc.) +-- ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.diezmo_tipos_salida +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + nombre varchar(100) NOT NULL, + descripcion varchar(300), + es_entrega_pastor boolean NOT NULL DEFAULT false, + activo boolean NOT NULL DEFAULT true, + eliminado boolean NOT NULL DEFAULT false, + creado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por varchar(100), + actualizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT diezmo_tipos_salida_pkey PRIMARY KEY (id) +); + +COMMENT ON TABLE public.diezmo_tipos_salida IS 'Catálogo de tipos de salida del módulo de diezmos'; + +INSERT INTO public.diezmo_tipos_salida (nombre, descripcion, es_entrega_pastor, activo) +SELECT 'Entrega al Pastor', 'Entrega oficial de diezmos al pastor', true, true +WHERE NOT EXISTS (SELECT 1 FROM public.diezmo_tipos_salida WHERE es_entrega_pastor = true); + +INSERT INTO public.diezmo_tipos_salida (nombre, descripcion, es_entrega_pastor, activo) +SELECT 'Gastos Administrativos', 'Gastos operativos de la iglesia', false, true +WHERE NOT EXISTS (SELECT 1 FROM public.diezmo_tipos_salida WHERE nombre = 'Gastos Administrativos'); + +-- ───────────────────────────────────────────── +-- Tabla: diezmo_beneficiarios +-- Personas o entidades que reciben salidas +-- ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.diezmo_beneficiarios +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + nombre varchar(150) NOT NULL, + descripcion varchar(300), + activo boolean NOT NULL DEFAULT true, + eliminado boolean NOT NULL DEFAULT false, + creado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por varchar(100), + actualizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actualizado_por varchar(100), + CONSTRAINT diezmo_beneficiarios_pkey PRIMARY KEY (id) +); + +COMMENT ON TABLE public.diezmo_beneficiarios IS 'Personas o entidades beneficiarias de salidas de diezmos'; + +-- ───────────────────────────────────────────── +-- Tabla: diezmo_cierres +-- Agregado raíz — un cierre por fecha (UNIQUE) +-- ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.diezmo_cierres +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + fecha date NOT NULL, + cerrado boolean NOT NULL DEFAULT false, + fecha_cierre timestamptz, + cerrado_por varchar(100), + observaciones varchar(500), + total_recibido numeric(12,2) NOT NULL DEFAULT 0, + total_cambio numeric(12,2) NOT NULL DEFAULT 0, + total_neto numeric(12,2) NOT NULL DEFAULT 0, + total_salidas numeric(12,2) NOT NULL DEFAULT 0, + saldo_final numeric(12,2) NOT NULL DEFAULT 0, + creado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por varchar(100), + actualizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actualizado_por varchar(100), + eliminado boolean NOT NULL DEFAULT false, + CONSTRAINT diezmo_cierres_pkey PRIMARY KEY (id), + CONSTRAINT diezmo_cierres_fecha_uq UNIQUE (fecha) +); + +COMMENT ON TABLE public.diezmo_cierres IS 'Cierres periódicos del módulo de diezmos (un registro por fecha)'; + +CREATE INDEX IF NOT EXISTS idx_diezmo_cierres_fecha ON public.diezmo_cierres (fecha); +CREATE INDEX IF NOT EXISTS idx_diezmo_cierres_cerrado ON public.diezmo_cierres (cerrado); +CREATE INDEX IF NOT EXISTS idx_diezmo_cierres_eliminado ON public.diezmo_cierres (eliminado); + +-- ───────────────────────────────────────────── +-- Tabla: diezmo_detalles +-- Diezmo individual por miembro dentro de un cierre +-- ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.diezmo_detalles +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + diezmo_cierre_id bigint NOT NULL, + miembro_id bigint NOT NULL, + monto_entregado numeric(12,2) NOT NULL CHECK (monto_entregado >= 0), + cambio_entregado numeric(12,2) NOT NULL DEFAULT 0 CHECK (cambio_entregado >= 0), + monto_neto numeric(12,2) NOT NULL DEFAULT 0, + observaciones varchar(300), + fecha timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por varchar(100), + creado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actualizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actualizado_por varchar(100), + eliminado boolean NOT NULL DEFAULT false, + CONSTRAINT diezmo_detalles_pkey PRIMARY KEY (id), + CONSTRAINT fk_diezmo_detalles_cierre + FOREIGN KEY (diezmo_cierre_id) REFERENCES public.diezmo_cierres (id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_diezmo_detalles_miembro + FOREIGN KEY (miembro_id) REFERENCES public.miembros (id) + ON UPDATE CASCADE ON DELETE RESTRICT +); + +COMMENT ON TABLE public.diezmo_detalles IS 'Diezmos individuales aportados por miembro en cada cierre'; + +CREATE INDEX IF NOT EXISTS idx_diezmo_detalles_cierre_id ON public.diezmo_detalles (diezmo_cierre_id); +CREATE INDEX IF NOT EXISTS idx_diezmo_detalles_miembro_id ON public.diezmo_detalles (miembro_id); +CREATE INDEX IF NOT EXISTS idx_diezmo_detalles_eliminado ON public.diezmo_detalles (eliminado); + +-- ───────────────────────────────────────────── +-- Tabla: diezmo_salidas +-- Salida de fondos registrada contra un cierre +-- ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.diezmo_salidas +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + diezmo_cierre_id bigint NOT NULL, + tipo_salida_id bigint NOT NULL, + beneficiario_id bigint, + monto numeric(12,2) NOT NULL CHECK (monto > 0), + concepto varchar(300) NOT NULL, + numero_recibo varchar(30), + fecha timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por varchar(100), + creado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + actualizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + eliminado boolean NOT NULL DEFAULT false, + CONSTRAINT diezmo_salidas_pkey PRIMARY KEY (id), + CONSTRAINT fk_diezmo_salidas_cierre + FOREIGN KEY (diezmo_cierre_id) REFERENCES public.diezmo_cierres (id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_diezmo_salidas_tipo + FOREIGN KEY (tipo_salida_id) REFERENCES public.diezmo_tipos_salida (id) + ON UPDATE CASCADE ON DELETE RESTRICT, + CONSTRAINT fk_diezmo_salidas_beneficiario + FOREIGN KEY (beneficiario_id) REFERENCES public.diezmo_beneficiarios (id) + ON UPDATE CASCADE ON DELETE RESTRICT +); + +COMMENT ON TABLE public.diezmo_salidas IS 'Salidas de fondos (entregas, gastos) registradas en un cierre de diezmos'; + +CREATE INDEX IF NOT EXISTS idx_diezmo_salidas_cierre_id ON public.diezmo_salidas (diezmo_cierre_id); +CREATE INDEX IF NOT EXISTS idx_diezmo_salidas_eliminado ON public.diezmo_salidas (eliminado); + +-- ============================================= +-- Bitácora de operaciones críticas del módulo +-- ============================================= +CREATE TABLE IF NOT EXISTS public.diezmo_bitacora +( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY (INCREMENT 1 START 1 MINVALUE 1 CACHE 1), + diezmo_cierre_id bigint, + accion varchar(50) NOT NULL, -- 'CIERRE', 'REAPERTURA', 'ELIMINAR_DETALLE', 'ELIMINAR_SALIDA' + detalle varchar(500), + realizado_por varchar(100) NOT NULL, + realizado_en timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT diezmo_bitacora_pkey PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS idx_diezmo_bitacora_cierre_id ON public.diezmo_bitacora (diezmo_cierre_id); + +-- ============================================= +-- Permisos para el módulo +-- ============================================= + +-- Crear módulo si no existe +INSERT INTO public.modulos (nombre, descripcion, icono, orden, activo) +SELECT 'Diezmos', 'Módulo de gestión de diezmos', 'bi bi-cash-coin', 50, true +WHERE NOT EXISTS (SELECT 1 FROM public.modulos WHERE nombre = 'Diezmos'); + +-- Permiso: ver listado de cierres +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.Index', 'Ver Diezmos', 'Permite ver el listado de cierres de diezmos', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.Index'); + +-- Permiso: crear cierre +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.Create', 'Crear Cierre', 'Permite crear un nuevo cierre de diezmos', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.Create'); + +-- Permiso: registrar detalle (diezmo por miembro) +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.AddDetalle', 'Registrar Detalle', 'Permite agregar/eliminar diezmos de miembros en un cierre', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.AddDetalle'); + +-- Permiso: registrar salida +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.AddSalida', 'Registrar Salida', 'Permite registrar salidas/entregas en un cierre', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.AddSalida'); + +-- Permiso: cerrar cierre +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.Close', 'Cerrar Cierre', 'Permite cerrar un cierre de diezmos (bloquea edición)', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.Close'); + +-- Permiso: reabrir cierre (solo Administrador) +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.Reopen', 'Reabrir Cierre', 'Permite reabrir un cierre cerrado (solo Administrador)', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.Reopen'); + +-- Permiso: reportes y consultas +INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo) +SELECT 'Diezmo.Reportes', 'Reportes Diezmos', 'Permite ver reportes y estado de cuenta del módulo de diezmos', + (SELECT id FROM public.modulos WHERE nombre = 'Diezmos'), true +WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Diezmo.Reportes'); diff --git a/RS_system/Models/DiezmoBeneficiario.cs b/RS_system/Models/DiezmoBeneficiario.cs new file mode 100644 index 0000000..05774e3 --- /dev/null +++ b/RS_system/Models/DiezmoBeneficiario.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Rs_system.Models; + +/// +/// Personas o entidades que pueden recibir salidas de diezmos +/// (pastor, tesorero, organismos externos, etc.) +/// +[Table("diezmo_beneficiarios")] +public class DiezmoBeneficiario +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("nombre")] + [Required] + [StringLength(150)] + public string Nombre { get; set; } = string.Empty; + + [Column("descripcion")] + [StringLength(300)] + public string? Descripcion { get; set; } + + [Column("activo")] + public bool Activo { get; set; } = true; + + [Column("eliminado")] + public bool Eliminado { get; set; } = false; + + [Column("creado_en")] + public DateTime CreadoEn { get; set; } = DateTime.UtcNow; + + [Column("creado_por")] + [StringLength(100)] + public string? CreadoPor { get; set; } + + [Column("actualizado_en")] + public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow; + + [Column("actualizado_por")] + [StringLength(100)] + public string? ActualizadoPor { get; set; } + + // Navegación + public virtual ICollection Salidas { get; set; } = new List(); +} diff --git a/RS_system/Models/DiezmoCierre.cs b/RS_system/Models/DiezmoCierre.cs new file mode 100644 index 0000000..48ffecb --- /dev/null +++ b/RS_system/Models/DiezmoCierre.cs @@ -0,0 +1,74 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Rs_system.Models; + +/// +/// Agregado raíz del módulo de diezmos. +/// Representa un período/corte de diezmos (la fecha la elige el operador libremente). +/// Un cierre por fecha (UNIQUE en fecha). +/// +[Table("diezmo_cierres")] +public class DiezmoCierre +{ + [Key] + [Column("id")] + public long Id { get; set; } + + /// Fecha del cierre. UNIQUE — no pueden existir dos cierres para el mismo día. + [Column("fecha")] + [Required] + public DateOnly Fecha { get; set; } + + [Column("cerrado")] + public bool Cerrado { get; set; } = false; + + [Column("fecha_cierre")] + public DateTime? FechaCierre { get; set; } + + [Column("cerrado_por")] + [StringLength(100)] + public string? CerradoPor { get; set; } + + [Column("observaciones")] + [StringLength(500)] + public string? Observaciones { get; set; } + + // ── Totales calculados (persistidos para consulta rápida en el listado) ── + [Column("total_recibido", TypeName = "numeric(12,2)")] + public decimal TotalRecibido { get; set; } = 0; + + [Column("total_cambio", TypeName = "numeric(12,2)")] + public decimal TotalCambio { get; set; } = 0; + + [Column("total_neto", TypeName = "numeric(12,2)")] + public decimal TotalNeto { get; set; } = 0; + + [Column("total_salidas", TypeName = "numeric(12,2)")] + public decimal TotalSalidas { get; set; } = 0; + + [Column("saldo_final", TypeName = "numeric(12,2)")] + public decimal SaldoFinal { get; set; } = 0; + + // ── Auditoría ── + [Column("creado_en")] + public DateTime CreadoEn { get; set; } = DateTime.UtcNow; + + [Column("creado_por")] + [StringLength(100)] + public string? CreadoPor { get; set; } + + [Column("actualizado_en")] + public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow; + + [Column("actualizado_por")] + [StringLength(100)] + public string? ActualizadoPor { get; set; } + + [Column("eliminado")] + public bool Eliminado { get; set; } = false; + + // ── Navegación ── + public virtual ICollection Detalles { get; set; } = new List(); + public virtual ICollection Salidas { get; set; } = new List(); +} diff --git a/RS_system/Models/DiezmoDetalle.cs b/RS_system/Models/DiezmoDetalle.cs new file mode 100644 index 0000000..f2965bc --- /dev/null +++ b/RS_system/Models/DiezmoDetalle.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Rs_system.Models; + +/// +/// Diezmo individual aportado por un miembro dentro de un cierre. +/// MontoNeto = MontoEntregado - CambioEntregado (calculado por el sistema). +/// +[Table("diezmo_detalles")] +public class DiezmoDetalle +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("diezmo_cierre_id")] + public long DiezmoCierreId { get; set; } + + [Column("miembro_id")] + public long MiembroId { get; set; } + + /// Monto físico que el miembro entregó (puede incluir cambio). + [Column("monto_entregado", TypeName = "numeric(12,2)")] + [Required] + public decimal MontoEntregado { get; set; } + + /// Cambio devuelto al miembro. + [Column("cambio_entregado", TypeName = "numeric(12,2)")] + public decimal CambioEntregado { get; set; } = 0; + + /// Diezmo neto real = MontoEntregado - CambioEntregado. Calculado por el sistema. + [Column("monto_neto", TypeName = "numeric(12,2)")] + public decimal MontoNeto { get; set; } + + [Column("observaciones")] + [StringLength(300)] + public string? Observaciones { get; set; } + + [Column("fecha")] + public DateTime Fecha { get; set; } = DateTime.UtcNow; + + // ── Auditoría ── + [Column("creado_por")] + [StringLength(100)] + public string? CreadoPor { get; set; } + + [Column("creado_en")] + public DateTime CreadoEn { get; set; } = DateTime.UtcNow; + + [Column("actualizado_en")] + public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow; + + [Column("actualizado_por")] + [StringLength(100)] + public string? ActualizadoPor { get; set; } + + [Column("eliminado")] + public bool Eliminado { get; set; } = false; + + // ── Navegación ── + [ForeignKey("DiezmoCierreId")] + public virtual DiezmoCierre DiezmoCierre { get; set; } = null!; + + [ForeignKey("MiembroId")] + public virtual Miembro Miembro { get; set; } = null!; +} diff --git a/RS_system/Models/DiezmoSalida.cs b/RS_system/Models/DiezmoSalida.cs new file mode 100644 index 0000000..6a5890c --- /dev/null +++ b/RS_system/Models/DiezmoSalida.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Rs_system.Models; + +/// +/// Salida de fondos registrada contra un cierre de diezmos. +/// Incluye entregas al pastor, gastos administrativos, misiones, etc. +/// +[Table("diezmo_salidas")] +public class DiezmoSalida +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("diezmo_cierre_id")] + public long DiezmoCierreId { get; set; } + + [Column("tipo_salida_id")] + public long TipoSalidaId { get; set; } + + [Column("beneficiario_id")] + public long? BeneficiarioId { get; set; } + + [Column("monto", TypeName = "numeric(12,2)")] + [Required] + public decimal Monto { get; set; } + + [Column("concepto")] + [Required] + [StringLength(300)] + public string Concepto { get; set; } = string.Empty; + + /// Correlativo de recibo asignado al momento de generar el comprobante. + [Column("numero_recibo")] + [StringLength(30)] + public string? NumeroRecibo { get; set; } + + [Column("fecha")] + public DateTime Fecha { get; set; } = DateTime.UtcNow; + + // ── Auditoría ── + [Column("creado_por")] + [StringLength(100)] + public string? CreadoPor { get; set; } + + [Column("creado_en")] + public DateTime CreadoEn { get; set; } = DateTime.UtcNow; + + [Column("actualizado_en")] + public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow; + + [Column("eliminado")] + public bool Eliminado { get; set; } = false; + + // ── Navegación ── + [ForeignKey("DiezmoCierreId")] + public virtual DiezmoCierre DiezmoCierre { get; set; } = null!; + + [ForeignKey("TipoSalidaId")] + public virtual DiezmoTipoSalida TipoSalida { get; set; } = null!; + + [ForeignKey("BeneficiarioId")] + public virtual DiezmoBeneficiario? Beneficiario { get; set; } +} diff --git a/RS_system/Models/DiezmoTipoSalida.cs b/RS_system/Models/DiezmoTipoSalida.cs new file mode 100644 index 0000000..bbe78c2 --- /dev/null +++ b/RS_system/Models/DiezmoTipoSalida.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Rs_system.Models; + +/// +/// Catálogo de tipos de salida del módulo de diezmos +/// (Entrega al Pastor, Gastos Administrativos, Misiones, etc.) +/// +[Table("diezmo_tipos_salida")] +public class DiezmoTipoSalida +{ + [Key] + [Column("id")] + public long Id { get; set; } + + [Column("nombre")] + [Required] + [StringLength(100)] + public string Nombre { get; set; } = string.Empty; + + [Column("descripcion")] + [StringLength(300)] + public string? Descripcion { get; set; } + + /// + /// Marca este tipo como la entrega oficial al pastor. + /// Permite sugerirlo/forzarlo automáticamente al cerrar con saldo pendiente. + /// + [Column("es_entrega_pastor")] + public bool EsEntregaPastor { get; set; } = false; + + [Column("activo")] + public bool Activo { get; set; } = true; + + [Column("eliminado")] + public bool Eliminado { get; set; } = false; + + [Column("creado_en")] + public DateTime CreadoEn { get; set; } = DateTime.UtcNow; + + [Column("creado_por")] + [StringLength(100)] + public string? CreadoPor { get; set; } + + [Column("actualizado_en")] + public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow; + + // Navegación + public virtual ICollection Salidas { get; set; } = new List(); +} diff --git a/RS_system/Models/ViewModels/Catalogos/DiezmosCatalogosViewModels.cs b/RS_system/Models/ViewModels/Catalogos/DiezmosCatalogosViewModels.cs new file mode 100644 index 0000000..225b7ac --- /dev/null +++ b/RS_system/Models/ViewModels/Catalogos/DiezmosCatalogosViewModels.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rs_system.Models.ViewModels.Catalogos; + +public class TipoSalidaViewModel +{ + public long Id { get; set; } + + [Required(ErrorMessage = "El nombre es obligatorio")] + [StringLength(100)] + public string Nombre { get; set; } = string.Empty; + + [StringLength(300)] + public string? Descripcion { get; set; } + + [Display(Name = "Es Entrega Directa a Pastor")] + public bool EsEntregaPastor { get; set; } +} + +public class BeneficiarioViewModel +{ + public long Id { get; set; } + + [Required(ErrorMessage = "El nombre es obligatorio")] + [StringLength(150)] + public string Nombre { get; set; } = string.Empty; + + [StringLength(300)] + public string? Descripcion { get; set; } +} diff --git a/RS_system/Models/ViewModels/DiezmoCierreDetalleViewModel.cs b/RS_system/Models/ViewModels/DiezmoCierreDetalleViewModel.cs new file mode 100644 index 0000000..fc1e412 --- /dev/null +++ b/RS_system/Models/ViewModels/DiezmoCierreDetalleViewModel.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rs_system.Models.ViewModels; + +// ───────────────────────────────────────────────────────────────────────────── +// Formulario — Nuevo cierre +// ───────────────────────────────────────────────────────────────────────────── +public class DiezmoCierreCreateViewModel +{ + [Required(ErrorMessage = "La fecha es obligatoria.")] + [Display(Name = "Fecha del cierre")] + public DateOnly Fecha { get; set; } = DateOnly.FromDateTime(DateTime.Today); + + [Display(Name = "Observaciones")] + [StringLength(500)] + public string? Observaciones { get; set; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pantalla operativa de detalle del cierre +// ───────────────────────────────────────────────────────────────────────────── +public class DiezmoCierreDetalleViewModel +{ + public long Id { get; set; } + public DateOnly Fecha { get; set; } + public bool Cerrado { get; set; } + public string? Observaciones { get; set; } + public string? CerradoPor { get; set; } + public DateTime? FechaCierre { get; set; } + + // Totales + public decimal TotalRecibido { get; set; } + public decimal TotalCambio { get; set; } + public decimal TotalNeto { get; set; } + public decimal TotalSalidas { get; set; } + public decimal SaldoFinal { get; set; } + + // Datos de detalles + public List Detalles { get; set; } = new(); + + // Datos de salidas + public List Salidas { get; set; } = new(); + + // Formularios embebidos para modales + public DiezmoDetalleFormViewModel FormDetalle { get; set; } = new(); + public DiezmoSalidaFormViewModel FormSalida { get; set; } = new(); + + // Datos de selectores para los modales + public List MiembrosSelect { get; set; } = new(); + public List TiposSalidaSelect { get; set; } = new(); + public List BeneficiariosSelect { get; set; } = new(); + + public string EstadoBadge => Cerrado ? "badge bg-secondary" : "badge bg-success"; + public string EstadoTexto => Cerrado ? "Cerrado" : "Abierto"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fila de un detalle en la tabla +// ───────────────────────────────────────────────────────────────────────────── +public class DiezmoDetalleRowViewModel +{ + public long Id { get; set; } + public long MiembroId { get; set; } + public string NombreMiembro { get; set; } = string.Empty; + public decimal MontoEntregado { get; set; } + public decimal CambioEntregado { get; set; } + public decimal MontoNeto { get; set; } + public string? Observaciones { get; set; } + public DateTime Fecha { get; set; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fila de una salida en la tabla +// ───────────────────────────────────────────────────────────────────────────── +public class DiezmoSalidaRowViewModel +{ + public long Id { get; set; } + public string TipoSalidaNombre { get; set; } = string.Empty; + public string? BeneficiarioNombre { get; set; } + public decimal Monto { get; set; } + public string Concepto { get; set; } = string.Empty; + public string? NumeroRecibo { get; set; } + public DateTime Fecha { get; set; } +} diff --git a/RS_system/Models/ViewModels/DiezmoCierreListViewModel.cs b/RS_system/Models/ViewModels/DiezmoCierreListViewModel.cs new file mode 100644 index 0000000..f52c492 --- /dev/null +++ b/RS_system/Models/ViewModels/DiezmoCierreListViewModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rs_system.Models.ViewModels; + +/// Fila del listado de cierres de diezmos. +public class DiezmoCierreListViewModel +{ + public long Id { get; set; } + public DateOnly Fecha { get; set; } + public bool Cerrado { get; set; } + public decimal TotalRecibido { get; set; } + public decimal TotalNeto { get; set; } + public decimal TotalSalidas { get; set; } + public decimal SaldoFinal { get; set; } + public int NumeroDetalles { get; set; } + public int NumeroSalidas { get; set; } + public string EstadoBadge => Cerrado ? "badge bg-secondary" : "badge bg-success"; + public string EstadoTexto => Cerrado ? "Cerrado" : "Abierto"; +} diff --git a/RS_system/Models/ViewModels/DiezmoDetalleFormViewModel.cs b/RS_system/Models/ViewModels/DiezmoDetalleFormViewModel.cs new file mode 100644 index 0000000..aba7de0 --- /dev/null +++ b/RS_system/Models/ViewModels/DiezmoDetalleFormViewModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rs_system.Models.ViewModels; + +/// Formulario modal para agregar un diezmo de un miembro. +public class DiezmoDetalleFormViewModel +{ + [Required(ErrorMessage = "Seleccione un miembro.")] + [Display(Name = "Miembro")] + public long MiembroId { get; set; } + + [Required(ErrorMessage = "El monto entregado es obligatorio.")] + [Range(0.01, 999999.99, ErrorMessage = "El monto debe ser mayor a 0.")] + [Display(Name = "Monto entregado")] + public decimal MontoEntregado { get; set; } + + [Required(ErrorMessage = "El monto del diezmo (neto) es obligatorio.")] + [Range(0.01, 999999.99, ErrorMessage = "El diezmo debe ser mayor a 0.")] + [Display(Name = "Diezmo (Neto)")] + public decimal MontoNeto { get; set; } + + // Este campo ahora vendrá como solo-lectura desde el formulario + public decimal CambioEntregado { get; set; } = 0; + + [Display(Name = "Observaciones")] + [StringLength(300)] + public string? Observaciones { get; set; } +} diff --git a/RS_system/Models/ViewModels/DiezmoSalidaFormViewModel.cs b/RS_system/Models/ViewModels/DiezmoSalidaFormViewModel.cs new file mode 100644 index 0000000..9c38538 --- /dev/null +++ b/RS_system/Models/ViewModels/DiezmoSalidaFormViewModel.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rs_system.Models.ViewModels; + +/// Formulario modal para registrar una salida/entrega de fondos. +public class DiezmoSalidaFormViewModel +{ + [Required(ErrorMessage = "Seleccione el tipo de salida.")] + [Display(Name = "Tipo de salida")] + public long TipoSalidaId { get; set; } + + [Display(Name = "Beneficiario")] + public long? BeneficiarioId { get; set; } + + [Required(ErrorMessage = "El monto es obligatorio.")] + [Range(0.01, 999999.99, ErrorMessage = "El monto debe ser mayor a 0.")] + [Display(Name = "Monto")] + public decimal Monto { get; set; } + + [Required(ErrorMessage = "El concepto es obligatorio.")] + [StringLength(300)] + [Display(Name = "Concepto")] + public string Concepto { get; set; } = string.Empty; +} diff --git a/RS_system/Models/ViewModels/PaginatedViewModel.cs b/RS_system/Models/ViewModels/PaginatedViewModel.cs new file mode 100644 index 0000000..b54c55e --- /dev/null +++ b/RS_system/Models/ViewModels/PaginatedViewModel.cs @@ -0,0 +1,14 @@ +namespace Rs_system.Models.ViewModels; + +public class PaginatedViewModel +{ + public List Items { get; set; } = new(); + public int CurrentPage { get; set; } + public int PageSize { get; set; } + public int TotalItems { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalItems / PageSize); + public string? SearchQuery { get; set; } + + public bool HasPreviousPage => CurrentPage > 1; + public bool HasNextPage => CurrentPage < TotalPages; +} diff --git a/RS_system/Program.cs b/RS_system/Program.cs index ea63614..47512ee 100644 --- a/RS_system/Program.cs +++ b/RS_system/Program.cs @@ -50,6 +50,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + +// Diezmos module services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddMemoryCache(options => { options.SizeLimit = 1024; // 1024 cache entries max diff --git a/RS_system/Services/DiezmoCalculoService.cs b/RS_system/Services/DiezmoCalculoService.cs new file mode 100644 index 0000000..560b36a --- /dev/null +++ b/RS_system/Services/DiezmoCalculoService.cs @@ -0,0 +1,25 @@ +using Rs_system.Models; + +namespace Rs_system.Services; + +public class DiezmoCalculoService : IDiezmoCalculoService +{ + /// + public decimal CalcularMontoNeto(decimal montoEntregado, decimal cambioEntregado) + => montoEntregado - cambioEntregado; + + /// + public DiezmoCierre RecalcularTotales(DiezmoCierre cierre) + { + var detallesActivos = cierre.Detalles.Where(d => !d.Eliminado).ToList(); + var salidasActivas = cierre.Salidas.Where(s => !s.Eliminado).ToList(); + + cierre.TotalRecibido = detallesActivos.Sum(d => d.MontoEntregado); + cierre.TotalCambio = detallesActivos.Sum(d => d.CambioEntregado); + cierre.TotalNeto = detallesActivos.Sum(d => d.MontoNeto); + cierre.TotalSalidas = salidasActivas.Sum(s => s.Monto); + cierre.SaldoFinal = cierre.TotalNeto - cierre.TotalSalidas; + + return cierre; + } +} diff --git a/RS_system/Services/DiezmoCierreService.cs b/RS_system/Services/DiezmoCierreService.cs new file mode 100644 index 0000000..38bc4fa --- /dev/null +++ b/RS_system/Services/DiezmoCierreService.cs @@ -0,0 +1,262 @@ +using Microsoft.EntityFrameworkCore; +using Rs_system.Data; +using Rs_system.Models; +using Rs_system.Models.ViewModels; + +namespace Rs_system.Services; + +public class DiezmoCierreService : IDiezmoCierreService +{ + private readonly ApplicationDbContext _context; + private readonly IDiezmoCalculoService _calculo; + + public DiezmoCierreService(ApplicationDbContext context, IDiezmoCalculoService calculo) + { + _context = context; + _calculo = calculo; + } + + // ────────────────────────────────────────────────────────────────────────── + // Catálogos + // ────────────────────────────────────────────────────────────────────────── + + public async Task> GetTiposSalidaActivosAsync() + => await _context.DiezmoTiposSalida + .Where(t => t.Activo && !t.Eliminado) + .OrderBy(t => t.Nombre) + .ToListAsync(); + + public async Task> GetBeneficiariosActivosAsync() + => await _context.DiezmoBeneficiarios + .Where(b => b.Activo && !b.Eliminado) + .OrderBy(b => b.Nombre) + .ToListAsync(); + + // ────────────────────────────────────────────────────────────────────────── + // Cierres + // ────────────────────────────────────────────────────────────────────────── + + public async Task> GetCierresAsync(int? anio = null) + { + var query = _context.DiezmoCierres + .Where(c => !c.Eliminado); + + if (anio.HasValue) + query = query.Where(c => c.Fecha.Year == anio.Value); + + return await query + .OrderByDescending(c => c.Fecha) + .ToListAsync(); + } + + public async Task GetCierreByIdAsync(long id) + => await _context.DiezmoCierres + .Include(c => c.Detalles.Where(d => !d.Eliminado)) + .ThenInclude(d => d.Miembro) + .ThenInclude(m => m.Persona) + .Include(c => c.Salidas.Where(s => !s.Eliminado)) + .ThenInclude(s => s.TipoSalida) + .Include(c => c.Salidas.Where(s => !s.Eliminado)) + .ThenInclude(s => s.Beneficiario) + .FirstOrDefaultAsync(c => c.Id == id && !c.Eliminado); + + public async Task CrearCierreAsync(DateOnly fecha, string? observaciones, string creadoPor) + { + // Verificar que no exista ya un cierre para esa fecha + var yaExiste = await _context.DiezmoCierres + .AnyAsync(c => c.Fecha == fecha && !c.Eliminado); + + if (yaExiste) + throw new InvalidOperationException($"Ya existe un cierre para la fecha {fecha:dd/MM/yyyy}."); + + var cierre = new DiezmoCierre + { + Fecha = fecha, + Observaciones = observaciones, + CreadoPor = creadoPor, + CreadoEn = DateTime.UtcNow, + ActualizadoEn = DateTime.UtcNow + }; + + _context.DiezmoCierres.Add(cierre); + await _context.SaveChangesAsync(); + return cierre; + } + + // ────────────────────────────────────────────────────────────────────────── + // Detalles + // ────────────────────────────────────────────────────────────────────────── + + public async Task AgregarDetalleAsync(long cierreId, DiezmoDetalleFormViewModel vm, string usuario) + { + var cierre = await GetCierreOrThrowAsync(cierreId); + GuardarSiAbierto(cierre); + + // Se invierte la lógica: El net (Diezmo) se introduce manual. El cambio es derivado. + var neto = vm.MontoNeto; + var cambio = vm.MontoEntregado - neto; + if (cambio < 0) cambio = 0; // Prevenir errores; si entregó menos del neto se asume cambio 0 + + var detalle = new DiezmoDetalle + { + DiezmoCierreId = cierreId, + MiembroId = vm.MiembroId, + MontoEntregado = vm.MontoEntregado, + CambioEntregado = cambio, + MontoNeto = neto, + Observaciones = vm.Observaciones, + Fecha = DateTime.UtcNow, + CreadoPor = usuario, + CreadoEn = DateTime.UtcNow, + ActualizadoEn = DateTime.UtcNow + }; + + _context.DiezmoDetalles.Add(detalle); + await _context.SaveChangesAsync(); + await RecalcularTotalesAsync(cierreId); + } + + public async Task EliminarDetalleAsync(long detalleId, string usuario) + { + var detalle = await _context.DiezmoDetalles + .FirstOrDefaultAsync(d => d.Id == detalleId && !d.Eliminado) + ?? throw new InvalidOperationException("Detalle no encontrado."); + + var cierre = await GetCierreOrThrowAsync(detalle.DiezmoCierreId); + GuardarSiAbierto(cierre); + + detalle.Eliminado = true; + detalle.ActualizadoEn = DateTime.UtcNow; + detalle.ActualizadoPor = usuario; + await _context.SaveChangesAsync(); + + await RegistrarBitacoraAsync(detalle.DiezmoCierreId, "ELIMINAR_DETALLE", + $"Detalle #{detalleId} eliminado", usuario); + await RecalcularTotalesAsync(detalle.DiezmoCierreId); + } + + // ────────────────────────────────────────────────────────────────────────── + // Salidas + // ────────────────────────────────────────────────────────────────────────── + + public async Task AgregarSalidaAsync(long cierreId, DiezmoSalidaFormViewModel vm, string usuario) + { + var cierre = await GetCierreOrThrowAsync(cierreId); + GuardarSiAbierto(cierre); + + var salida = new DiezmoSalida + { + DiezmoCierreId = cierreId, + TipoSalidaId = vm.TipoSalidaId, + BeneficiarioId = vm.BeneficiarioId, + Monto = vm.Monto, + Concepto = vm.Concepto, + Fecha = DateTime.UtcNow, + CreadoPor = usuario, + CreadoEn = DateTime.UtcNow, + ActualizadoEn = DateTime.UtcNow + }; + + _context.DiezmoSalidas.Add(salida); + await _context.SaveChangesAsync(); + await RecalcularTotalesAsync(cierreId); + } + + public async Task EliminarSalidaAsync(long salidaId, string usuario) + { + var salida = await _context.DiezmoSalidas + .FirstOrDefaultAsync(s => s.Id == salidaId && !s.Eliminado) + ?? throw new InvalidOperationException("Salida no encontrada."); + + var cierre = await GetCierreOrThrowAsync(salida.DiezmoCierreId); + GuardarSiAbierto(cierre); + + salida.Eliminado = true; + salida.ActualizadoEn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + await RegistrarBitacoraAsync(salida.DiezmoCierreId, "ELIMINAR_SALIDA", + $"Salida #{salidaId} eliminada", usuario); + await RecalcularTotalesAsync(salida.DiezmoCierreId); + } + + // ────────────────────────────────────────────────────────────────────────── + // Flujo de cierre / reapertura + // ────────────────────────────────────────────────────────────────────────── + + public async Task CerrarCierreAsync(long cierreId, string usuario) + { + var cierre = await GetCierreByIdAsync(cierreId) + ?? throw new InvalidOperationException("Cierre no encontrado."); + + if (cierre.Cerrado) + throw new InvalidOperationException("El cierre ya se encuentra cerrado."); + + // Recalcular por si hay cambios recientes antes de sellar + _calculo.RecalcularTotales(cierre); + + cierre.Cerrado = true; + cierre.FechaCierre = DateTime.UtcNow; + cierre.CerradoPor = usuario; + cierre.ActualizadoEn = DateTime.UtcNow; + cierre.ActualizadoPor = usuario; + + await _context.SaveChangesAsync(); + await RegistrarBitacoraAsync(cierreId, "CIERRE", $"Cierre sellado. Saldo final: {cierre.SaldoFinal:C}", usuario); + } + + public async Task ReabrirCierreAsync(long cierreId, string usuario) + { + var cierre = await GetCierreOrThrowAsync(cierreId); + + if (!cierre.Cerrado) + throw new InvalidOperationException("El cierre ya se encuentra abierto."); + + cierre.Cerrado = false; + cierre.FechaCierre = null; + cierre.CerradoPor = null; + cierre.ActualizadoEn = DateTime.UtcNow; + cierre.ActualizadoPor = usuario; + + await _context.SaveChangesAsync(); + await RegistrarBitacoraAsync(cierreId, "REAPERTURA", "Cierre reabierto", usuario); + } + + // ────────────────────────────────────────────────────────────────────────── + // Totales + // ────────────────────────────────────────────────────────────────────────── + + public async Task RecalcularTotalesAsync(long cierreId) + { + var cierre = await GetCierreByIdAsync(cierreId) + ?? throw new InvalidOperationException("Cierre no encontrado."); + + _calculo.RecalcularTotales(cierre); + cierre.ActualizadoEn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers privados + // ────────────────────────────────────────────────────────────────────────── + + private async Task GetCierreOrThrowAsync(long id) + => await _context.DiezmoCierres.FirstOrDefaultAsync(c => c.Id == id && !c.Eliminado) + ?? throw new InvalidOperationException("Cierre no encontrado."); + + private static void GuardarSiAbierto(DiezmoCierre cierre) + { + if (cierre.Cerrado) + throw new InvalidOperationException("No se puede modificar un cierre que ya está cerrado."); + } + + private async Task RegistrarBitacoraAsync(long cierreId, string accion, string detalle, string usuario) + { + await _context.Database.ExecuteSqlRawAsync( + """ + INSERT INTO public.diezmo_bitacora (diezmo_cierre_id, accion, detalle, realizado_por, realizado_en) + VALUES ({0}, {1}, {2}, {3}, {4}) + """, + cierreId, accion, detalle, usuario, DateTime.UtcNow); + } +} diff --git a/RS_system/Services/DiezmoReciboService.cs b/RS_system/Services/DiezmoReciboService.cs new file mode 100644 index 0000000..5989d5e --- /dev/null +++ b/RS_system/Services/DiezmoReciboService.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using Rs_system.Data; +using Rs_system.Models; + +namespace Rs_system.Services; + +public class DiezmoReciboService : IDiezmoReciboService +{ + private readonly ApplicationDbContext _context; + + public DiezmoReciboService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task GenerarNumeroReciboAsync(long salidaId) + { + var salida = await _context.DiezmoSalidas + .FirstOrDefaultAsync(s => s.Id == salidaId && !s.Eliminado) + ?? throw new InvalidOperationException("Salida no encontrada."); + + // Si ya tiene correlativo, devolverlo + if (!string.IsNullOrEmpty(salida.NumeroRecibo)) + return salida.NumeroRecibo; + + var anio = salida.CreadoEn.Year; + var correlativo = $"RECDZ-{anio}-{salidaId:D6}"; + + salida.NumeroRecibo = correlativo; + salida.ActualizadoEn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + return correlativo; + } + + /// + public async Task GetSalidaParaReciboAsync(long salidaId) + => await _context.DiezmoSalidas + .Include(s => s.DiezmoCierre) + .Include(s => s.TipoSalida) + .Include(s => s.Beneficiario) + .FirstOrDefaultAsync(s => s.Id == salidaId && !s.Eliminado); +} diff --git a/RS_system/Services/IDiezmoCalculoService.cs b/RS_system/Services/IDiezmoCalculoService.cs new file mode 100644 index 0000000..e7d9e29 --- /dev/null +++ b/RS_system/Services/IDiezmoCalculoService.cs @@ -0,0 +1,16 @@ +using Rs_system.Models; +using Rs_system.Models.ViewModels; + +namespace Rs_system.Services; + +public interface IDiezmoCalculoService +{ + /// Calcula el monto neto de un detalle: MontoEntregado - CambioEntregado. + decimal CalcularMontoNeto(decimal montoEntregado, decimal cambioEntregado); + + /// + /// Recalcula todos los totales del cierre a partir de sus detalles y salidas activos. + /// Retorna el cierre con los valores actualizados (sin guardar en BD). + /// + DiezmoCierre RecalcularTotales(DiezmoCierre cierre); +} diff --git a/RS_system/Services/IDiezmoCierreService.cs b/RS_system/Services/IDiezmoCierreService.cs new file mode 100644 index 0000000..bea8cb1 --- /dev/null +++ b/RS_system/Services/IDiezmoCierreService.cs @@ -0,0 +1,31 @@ +using Rs_system.Models; +using Rs_system.Models.ViewModels; + +namespace Rs_system.Services; + +public interface IDiezmoCierreService +{ + // ── Catálogos ── + Task> GetTiposSalidaActivosAsync(); + Task> GetBeneficiariosActivosAsync(); + + // ── Cierres ── + Task> GetCierresAsync(int? anio = null); + Task GetCierreByIdAsync(long id); + Task CrearCierreAsync(DateOnly fecha, string? observaciones, string creadoPor); + + // ── Detalles ── + Task AgregarDetalleAsync(long cierreId, DiezmoDetalleFormViewModel vm, string usuario); + Task EliminarDetalleAsync(long detalleId, string usuario); + + // ── Salidas ── + Task AgregarSalidaAsync(long cierreId, DiezmoSalidaFormViewModel vm, string usuario); + Task EliminarSalidaAsync(long salidaId, string usuario); + + // ── Flujo de cierre ── + Task CerrarCierreAsync(long cierreId, string usuario); + Task ReabrirCierreAsync(long cierreId, string usuario); + + // ── Totales ── + Task RecalcularTotalesAsync(long cierreId); +} diff --git a/RS_system/Services/IDiezmoReciboService.cs b/RS_system/Services/IDiezmoReciboService.cs new file mode 100644 index 0000000..de8dfd2 --- /dev/null +++ b/RS_system/Services/IDiezmoReciboService.cs @@ -0,0 +1,16 @@ +using Rs_system.Models; + +namespace Rs_system.Services; + +public interface IDiezmoReciboService +{ + /// + /// Genera (o recupera) el correlativo de recibo para una salida. + /// Formato: RECDZ-{AAAA}-{id:D6} + /// Persiste el numero_recibo en la tabla diezmo_salidas. + /// + Task GenerarNumeroReciboAsync(long salidaId); + + /// Obtiene todos los datos necesarios para renderizar el recibo. + Task GetSalidaParaReciboAsync(long salidaId); +} diff --git a/RS_system/Views/Diezmo/Create.cshtml b/RS_system/Views/Diezmo/Create.cshtml new file mode 100644 index 0000000..247d2c9 --- /dev/null +++ b/RS_system/Views/Diezmo/Create.cshtml @@ -0,0 +1,49 @@ +@model Rs_system.Models.ViewModels.DiezmoCierreCreateViewModel +@{ + ViewData["Title"] = "Nuevo Cierre de Diezmos"; +} + +
+
+

Nuevo Cierre de Diezmos

+

Registra un nuevo período de diezmos

+
+ + Volver + +
+ +
+
+
+
+ @Html.AntiForgeryToken() + + + +
+ + + +
+ +
+ + + +
+ +
+ +
+
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/RS_system/Views/Diezmo/Detail.cshtml b/RS_system/Views/Diezmo/Detail.cshtml new file mode 100644 index 0000000..9298417 --- /dev/null +++ b/RS_system/Views/Diezmo/Detail.cshtml @@ -0,0 +1,465 @@ +@model Rs_system.Models.ViewModels.DiezmoCierreDetalleViewModel +@{ + ViewData["Title"] = $"Cierre {Model.Fecha:dd/MM/yyyy}"; + var cerrado = Model.Cerrado; +} + + +
+
+

+ Cierre de Diezmos — @Model.Fecha.ToString("dd/MM/yyyy") + @Model.EstadoTexto +

+ @if (!string.IsNullOrEmpty(Model.Observaciones)) + { +

@Model.Observaciones

+ } + @if (cerrado && Model.FechaCierre.HasValue) + { + Cerrado por @Model.CerradoPor el @Model.FechaCierre.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm") + } +
+
+ + Volver + + @if (!cerrado) + { + + } + else + { + + } +
+
+ +@if (TempData["SuccessMessage"] != null) +{ + +} +@if (TempData["ErrorMessage"] != null) +{ + +} + +@if (cerrado) +{ +
+ + Este cierre está sellado. No se puede modificar. Para editarlo, un Administrador debe reabrirlo. +
+} + + +
+
+
+
Recibido
+
$ @Model.TotalRecibido.ToString("N2")
+
+
+
+
+
Cambio
+
$ @Model.TotalCambio.ToString("N2")
+
+
+
+
+
Neto
+
$ @Model.TotalNeto.ToString("N2")
+
+
+
+
+
Salidas
+
$ @Model.TotalSalidas.ToString("N2")
+
+
+
+
+
Saldo Final
+

+ $ @Model.SaldoFinal.ToString("N2") +

+
+
+
+ + +
+
+
Diezmos por Miembro (@Model.Detalles.Count)
+ @if (!cerrado) + { + + } +
+
+ + + + + + + + + + + + + + @if (!Model.Detalles.Any()) + { + + + + } + @{ var i = 1; } + @foreach (var d in Model.Detalles) + { + + + + + + + + + + i++; + } + +
#MiembroEntregadoCambioNetoNotasAcciones
+ Sin diezmos registrados +
@i@d.NombreMiembro$ @d.MontoEntregado.ToString("N2")$ @d.CambioEntregado.ToString("N2")$ @d.MontoNeto.ToString("N2")@d.Observaciones + @if (!cerrado) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+
+ + +
+
+
Salidas y Entregas (@Model.Salidas.Count)
+ @if (!cerrado) + { + + } +
+
+ + + + + + + + + + + + + @if (!Model.Salidas.Any()) + { + + + + } + @foreach (var s in Model.Salidas) + { + + + + + + + + + } + +
TipoBeneficiarioConceptoMontoReciboAcciones
+ Sin salidas registradas +
@s.TipoSalidaNombre@(s.BeneficiarioNombre ?? "—")@s.Concepto$ @s.Monto.ToString("N2") + @if (!string.IsNullOrEmpty(s.NumeroRecibo)) + { + + @s.NumeroRecibo + + } + else + { + + Generar + + } + + @if (!cerrado) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+
+ + +@if (!cerrado) +{ + + + + +} + + + + + + + + + +@section Scripts { + + + + +} diff --git a/RS_system/Views/Diezmo/Index.cshtml b/RS_system/Views/Diezmo/Index.cshtml new file mode 100644 index 0000000..78de2c9 --- /dev/null +++ b/RS_system/Views/Diezmo/Index.cshtml @@ -0,0 +1,138 @@ +@model List +@{ + ViewData["Title"] = "Registro de Diezmos"; +} + +
+
+

Registro de Diezmos

+

Gestión de cierres periódicos de diezmos

+
+
+ + + Nuevo Cierre + +
+
+ +@if (TempData["SuccessMessage"] != null) +{ + +} +@if (TempData["ErrorMessage"] != null) +{ + +} + + +
+
+
+ + +
+
+ +
+
+
+ + + +@{ + var totalNeto = Model.Sum(c => c.TotalNeto); + var totalSalidas = Model.Sum(c => c.TotalSalidas); + var saldoTotal = Model.Sum(c => c.SaldoFinal); +} +
+
+
+
Total Neto del Período
+

$ @totalNeto.ToString("N2")

+
+
+
+
+
Total Salidas
+

$ @totalSalidas.ToString("N2")

+
+
+
+
+
Saldo Acumulado
+

$ @saldoTotal.ToString("N2")

+
+
+
+ + +
+
+ + + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + @foreach (var cierre in Model) + { + + + + + + + + + + } + +
FechaEstadoTotal RecibidoTotal NetoSalidasSaldo FinalAcciones
+ + No hay cierres registrados para el año seleccionado +
+ @cierre.Fecha.ToString("dd/MM/yyyy") +
@cierre.Fecha.DayOfWeek +
+ @cierre.EstadoTexto + $ @cierre.TotalRecibido.ToString("N2")$ @cierre.TotalNeto.ToString("N2")$ @cierre.TotalSalidas.ToString("N2") + $ @cierre.SaldoFinal.ToString("N2") + + + + +
+
+
diff --git a/RS_system/Views/Diezmo/Recibo.cshtml b/RS_system/Views/Diezmo/Recibo.cshtml new file mode 100644 index 0000000..45a08fa --- /dev/null +++ b/RS_system/Views/Diezmo/Recibo.cshtml @@ -0,0 +1,99 @@ +@model Rs_system.Models.DiezmoSalida +@{ + ViewData["Title"] = $"Recibo {ViewBag.NumeroRecibo}"; + Layout = null; // layout propio para impresión +} + + + + + Recibo @ViewBag.NumeroRecibo + + + +
+ + +
+ +
+ +
+

Recibo de Diezmos

+

Iglesia — módulo de Diezmos

+
@ViewBag.NumeroRecibo
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Fecha:@Model.Fecha.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
Tipo:@(Model.TipoSalida?.Nombre ?? "—")
Beneficiario:@(Model.Beneficiario?.Nombre ?? "No especificado")
Concepto:@Model.Concepto
Cierre:@(Model.DiezmoCierre?.Fecha.ToString("dd/MM/yyyy") ?? "—")
Emitido por:@ViewBag.Emisor
+ + +
+ MONTO +

$ @Model.Monto.ToString("N2")

+
+ + +
+
Firma del receptor
+
+
+ + + +
+ + diff --git a/RS_system/Views/DiezmoCatalogo/Beneficiarios.cshtml b/RS_system/Views/DiezmoCatalogo/Beneficiarios.cshtml new file mode 100644 index 0000000..9f69dc6 --- /dev/null +++ b/RS_system/Views/DiezmoCatalogo/Beneficiarios.cshtml @@ -0,0 +1,136 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Catálogo de Beneficiarios"; +} + +
+
+

+ Catálogo de Beneficiarios +

+

Personas o entidades externas que reciben salidas de fondos.

+
+
+ + Volver a Diezmos + + +
+
+ +@if (TempData["SuccessMessage"] != null) +{ + +} +@if (TempData["ErrorMessage"] != null) +{ + +} + +
+
+ + + + + + + + + + + @foreach (var item in Model) + { + + + + + + + } + @if (!Model.Any()) + { + + + + } + +
NombreDescripciónEstadoAcciones
@item.Nombre@item.Descripcion + @if (item.Activo) + { + Activo + } + else + { + Inactivo + } + + +
+ @Html.AntiForgeryToken() + + +
+
Sin registros.
+
+
+ + + + +@section Scripts { + +} diff --git a/RS_system/Views/DiezmoCatalogo/TiposSalida.cshtml b/RS_system/Views/DiezmoCatalogo/TiposSalida.cshtml new file mode 100644 index 0000000..25194c2 --- /dev/null +++ b/RS_system/Views/DiezmoCatalogo/TiposSalida.cshtml @@ -0,0 +1,142 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Catálogo: Tipos de Salida"; +} + +
+
+

+ Tipos de Salida +

+

Gestión de conceptos o clasificaciones para salidas de caja.

+
+
+ + Volver a Diezmos + + +
+
+ +@if (TempData["SuccessMessage"] != null) +{ + +} +@if (TempData["ErrorMessage"] != null) +{ + +} + +
+
+ + + + + + + + + + + @foreach (var item in Model) + { + + + + + + + } + @if (!Model.Any()) + { + + + + } + +
NombreDescripciónTipo EspecialAcciones
@item.Nombre@item.Descripcion + @if (item.EsEntregaPastor) + { + Entrega Pastor + } + else + { + + } + + +
+ @Html.AntiForgeryToken() + + +
+
Sin registros.
+
+
+ + + + +@section Scripts { + +} diff --git a/RS_system/Views/Miembro/Importar.cshtml b/RS_system/Views/Miembro/Importar.cshtml new file mode 100644 index 0000000..7c1391f --- /dev/null +++ b/RS_system/Views/Miembro/Importar.cshtml @@ -0,0 +1,65 @@ +@{ + ViewData["Title"] = "Importar Miembros"; +} + +
+
+

Importar Miembros desde CSV

+ + Volver a la Lista + +
+ +
+
+
Cargar Archivo CSV
+
+
+ + @if (ViewBag.Errors != null) + { +
+

Errores encontrados:

+

Por favor corrija los siguientes errores en el archivo CSV y vuelva a intentarlo:

+
+
    + @foreach (var error in ViewBag.Errors) + { +
  • @error
  • + } +
+
+ } + +
+
Instrucciones:
+

El archivo CSV debe tener las siguientes columnas en este orden exacto:

+
    +
  1. Nombres
  2. +
  3. Apellidos
  4. +
  5. Fecha Nacimiento (formato aceptado por el sistema, e.g. YYYY-MM-DD)
  6. +
  7. Fecha Ingreso Congregación (formato aceptado por el sistema)
  8. +
  9. Teléfono
  10. +
  11. Teléfono de Emergencia
  12. +
  13. Dirección
  14. +
  15. ID del Grupo de Trabajo (Número)
  16. +
  17. Bautizado en Espíritu Santo (Si/1/True)
  18. +
  19. Activo (Si/1/True)
  20. +
+
+ +
+
+ +
+ + +
+ +
+ +
+
+
+
+
diff --git a/RS_system/bin/Debug/net9.0/RS_system.dll b/RS_system/bin/Debug/net9.0/RS_system.dll index 7f48f53..b1bfede 100644 Binary files a/RS_system/bin/Debug/net9.0/RS_system.dll and b/RS_system/bin/Debug/net9.0/RS_system.dll differ diff --git a/RS_system/bin/Debug/net9.0/RS_system.pdb b/RS_system/bin/Debug/net9.0/RS_system.pdb index 832c9f8..c9d1386 100644 Binary files a/RS_system/bin/Debug/net9.0/RS_system.pdb and b/RS_system/bin/Debug/net9.0/RS_system.pdb differ diff --git a/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfo.cs b/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfo.cs index b75afd2..333b209 100644 --- a/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfo.cs +++ b/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfo.cs @@ -15,7 +15,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("RS_system")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+46bf68cb21fcad11c3f8b5ebbeb6ec4b6567d6c9")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+bec656b105cc858404ace22d1d46cd053a5d0fd7")] [assembly: System.Reflection.AssemblyProductAttribute("RS_system")] [assembly: System.Reflection.AssemblyTitleAttribute("RS_system")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfoInputs.cache b/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfoInputs.cache index 540e963..35ba033 100644 --- a/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfoInputs.cache +++ b/RS_system/obj/Debug/net9.0/RS_system.AssemblyInfoInputs.cache @@ -1 +1 @@ -7e2d659fefb50453fa3da00dc55dfdaadf1c304f5a0fbc1389094f2c740f7326 +207587d655de51326030a07a0184e67ca76d93743141a95cdbc5efdc3e4541f1 diff --git a/RS_system/obj/Debug/net9.0/RS_system.GeneratedMSBuildEditorConfig.editorconfig b/RS_system/obj/Debug/net9.0/RS_system.GeneratedMSBuildEditorConfig.editorconfig index aaf9696..1164742 100644 --- a/RS_system/obj/Debug/net9.0/RS_system.GeneratedMSBuildEditorConfig.editorconfig +++ b/RS_system/obj/Debug/net9.0/RS_system.GeneratedMSBuildEditorConfig.editorconfig @@ -152,6 +152,30 @@ build_metadata.AdditionalFiles.CssScope = build_metadata.AdditionalFiles.TargetPath = Vmlld3MvQ29udGFiaWxpZGFkR2VuZXJhbC9SZWdpc3Ryb01lbnN1YWwuY3NodG1s build_metadata.AdditionalFiles.CssScope = +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Diezmo/Create.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vL0NyZWF0ZS5jc2h0bWw= +build_metadata.AdditionalFiles.CssScope = + +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Diezmo/Detail.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vL0RldGFpbC5jc2h0bWw= +build_metadata.AdditionalFiles.CssScope = + +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Diezmo/Index.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vL0luZGV4LmNzaHRtbA== +build_metadata.AdditionalFiles.CssScope = + +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Diezmo/Recibo.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vL1JlY2liby5jc2h0bWw= +build_metadata.AdditionalFiles.CssScope = + +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/DiezmoCatalogo/Beneficiarios.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vQ2F0YWxvZ28vQmVuZWZpY2lhcmlvcy5jc2h0bWw= +build_metadata.AdditionalFiles.CssScope = + +[/home/adalberto/RiderProjects/RS_system/RS_system/Views/DiezmoCatalogo/TiposSalida.cshtml] +build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRGllem1vQ2F0YWxvZ28vVGlwb3NTYWxpZGEuY3NodG1s +build_metadata.AdditionalFiles.CssScope = + [/home/adalberto/RiderProjects/RS_system/RS_system/Views/Estados/Create.cshtml] build_metadata.AdditionalFiles.TargetPath = Vmlld3MvRXN0YWRvcy9DcmVhdGUuY3NodG1s build_metadata.AdditionalFiles.CssScope = diff --git a/RS_system/obj/Debug/net9.0/RS_system.csproj.CoreCompileInputs.cache b/RS_system/obj/Debug/net9.0/RS_system.csproj.CoreCompileInputs.cache index 530e9af..029e874 100644 --- a/RS_system/obj/Debug/net9.0/RS_system.csproj.CoreCompileInputs.cache +++ b/RS_system/obj/Debug/net9.0/RS_system.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -1e149c33148abe9fa6194941c5133295585bfc66f5204d7905e01b09846bdccc +cf5eb9535656c70fb44bcab29751e1b8ead46c4e4f39ae6fedbde6c1dae54744 diff --git a/RS_system/obj/Debug/net9.0/RS_system.dll b/RS_system/obj/Debug/net9.0/RS_system.dll index 7f48f53..b1bfede 100644 Binary files a/RS_system/obj/Debug/net9.0/RS_system.dll and b/RS_system/obj/Debug/net9.0/RS_system.dll differ diff --git a/RS_system/obj/Debug/net9.0/RS_system.pdb b/RS_system/obj/Debug/net9.0/RS_system.pdb index 832c9f8..c9d1386 100644 Binary files a/RS_system/obj/Debug/net9.0/RS_system.pdb and b/RS_system/obj/Debug/net9.0/RS_system.pdb differ diff --git a/RS_system/obj/Debug/net9.0/ref/RS_system.dll b/RS_system/obj/Debug/net9.0/ref/RS_system.dll index 5f27a06..e884d8e 100644 Binary files a/RS_system/obj/Debug/net9.0/ref/RS_system.dll and b/RS_system/obj/Debug/net9.0/ref/RS_system.dll differ diff --git a/RS_system/obj/Debug/net9.0/refint/RS_system.dll b/RS_system/obj/Debug/net9.0/refint/RS_system.dll index 5f27a06..e884d8e 100644 Binary files a/RS_system/obj/Debug/net9.0/refint/RS_system.dll and b/RS_system/obj/Debug/net9.0/refint/RS_system.dll differ diff --git a/RS_system/sql_migration_to_guid.sql b/RS_system/sql_migration_to_guid.sql new file mode 100644 index 0000000..1a67d59 --- /dev/null +++ b/RS_system/sql_migration_to_guid.sql @@ -0,0 +1,615 @@ +-- ===================================================== +-- SQL Migration Script: Auto-increment IDs to UUIDs +-- ===================================================== +-- WARNING: This is a BREAKING CHANGE. Make a backup first! +-- This script converts all primary keys from BIGSERIAL to UUID +-- and updates all foreign key references accordingly. +-- +-- INSTRUCTIONS: +-- 1. BACKUP your database first: pg_dump -U postgres rs_system > backup.sql +-- 2. Test on a copy of the database first +-- 3. Run during maintenance window (minimal user activity) +-- 4. Verify all data after migration +-- ===================================================== + +BEGIN; + +-- ===================================================== +-- STEP 1: Enable UUID extension +-- ===================================================== +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ===================================================== +-- STEP 2: Drop all foreign key constraints +-- ===================================================== +-- (We'll recreate them later with UUID types) + +-- Colaboraciones relationships +ALTER TABLE IF EXISTS detalle_colaboracion DROP CONSTRAINT IF EXISTS fk_detalle_colaboracion_colaboracion; +ALTER TABLE IF EXISTS detalle_colaboracion DROP CONSTRAINT IF EXISTS fk_detalle_colaboracion_tipo; +ALTER TABLE IF EXISTS colaboraciones DROP CONSTRAINT IF EXISTS fk_colaboracion_miembro; + +-- Miembros relationships +ALTER TABLE IF EXISTS colaboraciones DROP CONSTRAINT IF EXISTS colaboraciones_miembro_id_fkey; +ALTER TABLE IF EXISTS miembros DROP CONSTRAINT IF EXISTS fk_miembro_grupo_trabajo; +ALTER TABLE IF EXISTS asistencia_culto DROP CONSTRAINT IF EXISTS fk_asistencia_miembro; + +-- Prestamos relationships +ALTER TABLE IF EXISTS prestamos DROP CONSTRAINT IF EXISTS fk_prestamo_miembro; +ALTER TABLE IF EXISTS pagos_prestamo DROP CONSTRAINT IF EXISTS fk_pago_prestamo; + +-- Inventory relationships +ALTER TABLE IF EXISTS existencias DROP CONSTRAINT IF EXISTS fk_existencia_articulo; +ALTER TABLE IF EXISTS existencias DROP CONSTRAINT IF EXISTS fk_existencia_ubicacion; +ALTER TABLE IF EXISTS movimientos_inventario DROP CONSTRAINT IF EXISTS fk_movimiento_articulo; +ALTER TABLE IF EXISTS movimientos_inventario DROP CONSTRAINT IF EXISTS fk_movimiento_ubicacion_origen; +ALTER TABLE IF EXISTS movimientos_inventario DROP CONSTRAINT IF EXISTS fk_movimiento_ubicacion_destino; +ALTER TABLE IF EXISTS movimientos_inventario DROP CONSTRAINT IF EXISTS fk_movimiento_usuario; + +-- Contabilidad relationships +ALTER TABLE IF EXISTS movimiento_general DROP CONSTRAINT IF EXISTS fk_movimiento_categoria_ingreso; +ALTER TABLE IF EXISTS movimiento_general DROP CONSTRAINT IF EXISTS fk_movimiento_categoria_egreso; +ALTER TABLE IF EXISTS movimiento_general DROP CONSTRAINT IF EXISTS fk_movimiento_reporte_mensual; +ALTER TABLE IF EXISTS movimiento_general_adjunto DROP CONSTRAINT IF EXISTS fk_adjunto_movimiento; +ALTER TABLE IF EXISTS contabilidad_registro DROP CONSTRAINT IF EXISTS fk_contabilidad_reporte; + +-- ===================================================== +-- STEP 3: Add UUID columns and migrate data +-- ===================================================== + +-- GRUPOS_TRABAJO +ALTER TABLE grupos_trabajo ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE grupos_trabajo SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE grupos_trabajo ALTER COLUMN id_uuid SET NOT NULL; + +-- MIEMBROS +ALTER TABLE miembros ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE miembros ADD COLUMN grupo_trabajo_id_uuid UUID; +UPDATE miembros SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE miembros ALTER COLUMN id_uuid SET NOT NULL; + +-- Update foreign key references +UPDATE miembros m +SET grupo_trabajo_id_uuid = gt.id_uuid +FROM grupos_trabajo gt +WHERE m.grupo_trabajo_id = gt.id; + +-- TIPOS_COLABORACION +ALTER TABLE tipos_colaboracion ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE tipos_colaboracion SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE tipos_colaboracion ALTER COLUMN id_uuid SET NOT NULL; + +-- COLABORACIONES +ALTER TABLE colaboraciones ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE colaboraciones ADD COLUMN miembro_id_uuid UUID; +ALTER TABLE colaboraciones ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; +UPDATE colaboraciones SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE colaboraciones ALTER COLUMN id_uuid SET NOT NULL; + +-- Update foreign key references +UPDATE colaboraciones c +SET miembro_id_uuid = m.id_uuid +FROM miembros m +WHERE c.miembro_id = m.id; + +-- DETALLE_COLABORACION +ALTER TABLE detalle_colaboracion ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE detalle_colaboracion ADD COLUMN colaboracion_id_uuid UUID; +ALTER TABLE detalle_colaboracion ADD COLUMN tipo_colaboracion_id_uuid UUID; +UPDATE detalle_colaboracion SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE detalle_colaboracion ALTER COLUMN id_uuid SET NOT NULL; + +-- Update foreign key references +UPDATE detalle_colaboracion dc +SET colaboracion_id_uuid = c.id_uuid +FROM colaboraciones c +WHERE dc.colaboracion_id = c.id; + +UPDATE detalle_colaboracion dc +SET tipo_colaboracion_id_uuid = tc.id_uuid +FROM tipos_colaboracion tc +WHERE dc.tipo_colaboracion_id = tc.id; + +-- PRESTAMOS +ALTER TABLE prestamos ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE prestamos ADD COLUMN miembro_id_uuid UUID; +UPDATE prestamos SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE prestamos ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE prestamos p +SET miembro_id_uuid = m.id_uuid +FROM miembros m +WHERE p.miembro_id = m.id; + +-- PAGOS_PRESTAMO +ALTER TABLE pagos_prestamo ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE pagos_prestamo ADD COLUMN prestamo_id_uuid UUID; +UPDATE pagos_prestamo SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE pagos_prestamo ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE pagos_prestamo pp +SET prestamo_id_uuid = p.id_uuid +FROM prestamos p +WHERE pp.prestamo_id = p.id; + +-- ASISTENCIA_CULTO +ALTER TABLE asistencia_culto ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE asistencia_culto ADD COLUMN miembro_id_uuid UUID; +UPDATE asistencia_culto SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE asistencia_culto ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE asistencia_culto ac +SET miembro_id_uuid = m.id_uuid +FROM miembros m +WHERE ac.miembro_id = m.id; + +-- USUARIOS +ALTER TABLE usuarios ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE usuarios SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE usuarios ALTER COLUMN id_uuid SET NOT NULL; + +-- OFERNDAS +ALTER TABLE oferndas ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE oferndas SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE oferndas ALTER COLUMN id_uuid SET NOT NULL; + +-- CATEGORIA_INGRESO +ALTER TABLE categoria_ingreso ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE categoria_ingreso SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE categoria_ingreso ALTER COLUMN id_uuid SET NOT NULL; + +-- CATEGORIA_EGRESO +ALTER TABLE categoria_egreso ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE categoria_egreso SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE categoria_egreso ALTER COLUMN id_uuid SET NOT NULL; + +-- REPORTE_MENSUAL_GENERAL +ALTER TABLE reporte_mensual_general ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE reporte_mensual_general SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE reporte_mensual_general ALTER COLUMN id_uuid SET NOT NULL; + +-- REPORTE_MENSUAL_CONTABLE +ALTER TABLE reporte_mensual_contable ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE reporte_mensual_contable SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE reporte_mensual_contable ALTER COLUMN id_uuid SET NOT NULL; + +-- MOVIMIENTO_GENERAL +ALTER TABLE movimiento_general ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE movimiento_general ADD COLUMN categoria_ingreso_id_uuid UUID; +ALTER TABLE movimiento_general ADD COLUMN categoria_egreso_id_uuid UUID; +ALTER TABLE movimiento_general ADD COLUMN reporte_mensual_id_uuid UUID; +UPDATE movimiento_general SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE movimiento_general ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE movimiento_general mg +SET categoria_ingreso_id_uuid = ci.id_uuid +FROM categoria_ingreso ci +WHERE mg.categoria_ingreso_id = ci.id; + +UPDATE movimiento_general mg +SET categoria_egreso_id_uuid = ce.id_uuid +FROM categoria_egreso ce +WHERE mg.categoria_egreso_id = ce.id; + +UPDATE movimiento_general mg +SET reporte_mensual_id_uuid = rm.id_uuid +FROM reporte_mensual_general rm +WHERE mg.reporte_mensual_id = rm.id; + +-- MOVIMIENTO_GENERAL_ADJUNTO +ALTER TABLE movimiento_general_adjunto ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE movimiento_general_adjunto ADD COLUMN movimiento_id_uuid UUID; +UPDATE movimiento_general_adjunto SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE movimiento_general_adjunto ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE movimiento_general_adjunto mga +SET movimiento_id_uuid = mg.id_uuid +FROM movimiento_general mg +WHERE mga.movimiento_id = mg.id; + +-- CONTABILIDAD_REGISTRO +ALTER TABLE contabilidad_registro ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE contabilidad_registro ADD COLUMN reporte_id_uuid UUID; +UPDATE contabilidad_registro SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE contabilidad_registro ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE contabilidad_registro cr +SET reporte_id_uuid = rm.id_uuid +FROM reporte_mensual_contable rm +WHERE cr.reporte_id = rm.id; + +-- ARTICULOS (assuming you have this table) +ALTER TABLE IF EXISTS articulos ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE articulos SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE IF EXISTS articulos ALTER COLUMN id_uuid SET NOT NULL; + +-- UBICACIONES (assuming you have this table) +ALTER TABLE IF EXISTS ubicaciones ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +UPDATE ubicaciones SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE IF EXISTS ubicaciones ALTER COLUMN id_uuid SET NOT NULL; + +-- EXISTENCIAS +ALTER TABLE IF EXISTS existencias ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE IF EXISTS existencias ADD COLUMN articulo_id_uuid UUID; +ALTER TABLE IF EXISTS existencias ADD COLUMN ubicacion_id_uuid UUID; +UPDATE existencias SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE IF EXISTS existencias ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE existencias e +SET articulo_id_uuid = a.id_uuid +FROM articulos a +WHERE e.articulo_id = a.id; + +UPDATE existencias e +SET ubicacion_id_uuid = u.id_uuid +FROM ubicaciones u +WHERE e.ubicacion_id = u.id; + +-- MOVIMIENTOS_INVENTARIO +ALTER TABLE IF EXISTS movimientos_inventario ADD COLUMN id_uuid UUID DEFAULT uuid_generate_v4(); +ALTER TABLE IF EXISTS movimientos_inventario ADD COLUMN articulo_id_uuid UUID; +ALTER TABLE IF EXISTS movimientos_inventario ADD COLUMN ubicacion_origen_id_uuid UUID; +ALTER TABLE IF EXISTS movimientos_inventario ADD COLUMN ubicacion_destino_id_uuid UUID; +ALTER TABLE IF EXISTS movimientos_inventario ADD COLUMN usuario_id_uuid UUID; +UPDATE movimientos_inventario SET id_uuid = uuid_generate_v4() WHERE id_uuid IS NULL; +ALTER TABLE IF EXISTS movimientos_inventario ALTER COLUMN id_uuid SET NOT NULL; + +UPDATE movimientos_inventario mi +SET articulo_id_uuid = a.id_uuid +FROM articulos a +WHERE mi.articulo_id = a.id; + +UPDATE movimientos_inventario mi +SET ubicacion_origen_id_uuid = u.id_uuid +FROM ubicaciones u +WHERE mi.ubicacion_origen_id = u.id; + +UPDATE movimientos_inventario mi +SET ubicacion_destino_id_uuid = u.id_uuid +FROM ubicaciones u +WHERE mi.ubicacion_destino_id = u.id; + +UPDATE movimientos_inventario mi +SET usuario_id_uuid = usr.id_uuid +FROM usuarios usr +WHERE mi.usuario_id = usr.id; + +-- ===================================================== +-- STEP 4: Drop old ID columns and rename UUID columns +-- ===================================================== + +-- GRUPOS_TRABAJO +ALTER TABLE grupos_trabajo DROP CONSTRAINT IF EXISTS grupos_trabajo_pkey; +ALTER TABLE grupos_trabajo DROP COLUMN id; +ALTER TABLE grupos_trabajo RENAME COLUMN id_uuid TO id; +ALTER TABLE grupos_trabajo ADD PRIMARY KEY (id); + +-- MIEMBROS +ALTER TABLE miembros DROP CONSTRAINT IF EXISTS miembros_pkey; +ALTER TABLE miembros DROP COLUMN id; +ALTER TABLE miembros DROP COLUMN grupo_trabajo_id; +ALTER TABLE miembros RENAME COLUMN id_uuid TO id; +ALTER TABLE miembros RENAME COLUMN grupo_trabajo_id_uuid TO grupo_trabajo_id; +ALTER TABLE miembros ADD PRIMARY KEY (id); + +-- TIPOS_COLABORACION +ALTER TABLE tipos_colaboracion DROP CONSTRAINT IF EXISTS tipos_colaboracion_pkey; +ALTER TABLE tipos_colaboracion DROP COLUMN id; +ALTER TABLE tipos_colaboracion RENAME COLUMN id_uuid TO id; +ALTER TABLE tipos_colaboracion ADD PRIMARY KEY (id); + +-- COLABORACIONES +ALTER TABLE colaboraciones DROP CONSTRAINT IF EXISTS colaboraciones_pkey; +ALTER TABLE colaboraciones DROP COLUMN id; +ALTER TABLE colaboraciones DROP COLUMN miembro_id; +ALTER TABLE colaboraciones RENAME COLUMN id_uuid TO id; +ALTER TABLE colaboraciones RENAME COLUMN miembro_id_uuid TO miembro_id; +ALTER TABLE colaboraciones ADD PRIMARY KEY (id); + +-- DETALLE_COLABORACION +ALTER TABLE detalle_colaboracion DROP CONSTRAINT IF EXISTS detalle_colaboracion_pkey; +ALTER TABLE detalle_colaboracion DROP COLUMN id; +ALTER TABLE detalle_colaboracion DROP COLUMN colaboracion_id; +ALTER TABLE detalle_colaboracion DROP COLUMN tipo_colaboracion_id; +ALTER TABLE detalle_colaboracion RENAME COLUMN id_uuid TO id; +ALTER TABLE detalle_colaboracion RENAME COLUMN colaboracion_id_uuid TO colaboracion_id; +ALTER TABLE detalle_colaboracion RENAME COLUMN tipo_colaboracion_id_uuid TO tipo_colaboracion_id; +ALTER TABLE detalle_colaboracion ADD PRIMARY KEY (id); + +-- PRESTAMOS +ALTER TABLE prestamos DROP CONSTRAINT IF EXISTS prestamos_pkey; +ALTER TABLE prestamos DROP COLUMN id; +ALTER TABLE prestamos DROP COLUMN miembro_id; +ALTER TABLE prestamos RENAME COLUMN id_uuid TO id; +ALTER TABLE prestamos RENAME COLUMN miembro_id_uuid TO miembro_id; +ALTER TABLE prestamos ADD PRIMARY KEY (id); + +-- PAGOS_PRESTAMO +ALTER TABLE pagos_prestamo DROP CONSTRAINT IF EXISTS pagos_prestamo_pkey; +ALTER TABLE pagos_prestamo DROP COLUMN id; +ALTER TABLE pagos_prestamo DROP COLUMN prestamo_id; +ALTER TABLE pagos_prestamo RENAME COLUMN id_uuid TO id; +ALTER TABLE pagos_prestamo RENAME COLUMN prestamo_id_uuid TO prestamo_id; +ALTER TABLE pagos_prestamo ADD PRIMARY KEY (id); + +-- ASISTENCIA_CULTO +ALTER TABLE asistencia_culto DROP CONSTRAINT IF EXISTS asistencia_culto_pkey; +ALTER TABLE asistencia_culto DROP COLUMN id; +ALTER TABLE asistencia_culto DROP COLUMN miembro_id; +ALTER TABLE asistencia_culto RENAME COLUMN id_uuid TO id; +ALTER TABLE asistencia_culto RENAME COLUMN miembro_id_uuid TO miembro_id; +ALTER TABLE asistencia_culto ADD PRIMARY KEY (id); + +-- USUARIOS +ALTER TABLE usuarios DROP CONSTRAINT IF EXISTS usuarios_pkey; +ALTER TABLE usuarios DROP COLUMN id; +ALTER TABLE usuarios RENAME COLUMN id_uuid TO id; +ALTER TABLE usuarios ADD PRIMARY KEY (id); + +-- OFERNDAS +ALTER TABLE oferndas DROP CONSTRAINT IF EXISTS oferndas_pkey; +ALTER TABLE oferndas DROP COLUMN id; +ALTER TABLE oferndas RENAME COLUMN id_uuid TO id; +ALTER TABLE oferndas ADD PRIMARY KEY (id); + +-- CATEGORIA_INGRESO +ALTER TABLE categoria_ingreso DROP CONSTRAINT IF EXISTS categoria_ingreso_pkey; +ALTER TABLE categoria_ingreso DROP COLUMN id; +ALTER TABLE categoria_ingreso RENAME COLUMN id_uuid TO id; +ALTER TABLE categoria_ingreso ADD PRIMARY KEY (id); + +-- CATEGORIA_EGRESO +ALTER TABLE categoria_egreso DROP CONSTRAINT IF EXISTS categoria_egreso_pkey; +ALTER TABLE categoria_egreso DROP COLUMN id; +ALTER TABLE categoria_egreso RENAME COLUMN id_uuid TO id; +ALTER TABLE categoria_egreso ADD PRIMARY KEY (id); + +-- REPORTE_MENSUAL_GENERAL +ALTER TABLE reporte_mensual_general DROP CONSTRAINT IF EXISTS reporte_mensual_general_pkey; +ALTER TABLE reporte_mensual_general DROP COLUMN id; +ALTER TABLE reporte_mensual_general RENAME COLUMN id_uuid TO id; +ALTER TABLE reporte_mensual_general ADD PRIMARY KEY (id); + +-- REPORTE_MENSUAL_CONTABLE +ALTER TABLE reporte_mensual_contable DROP CONSTRAINT IF EXISTS reporte_mensual_contable_pkey; +ALTER TABLE reporte_mensual_contable DROP COLUMN id; +ALTER TABLE reporte_mensual_contable RENAME COLUMN id_uuid TO id; +ALTER TABLE reporte_mensual_contable ADD PRIMARY KEY (id); + +-- MOVIMIENTO_GENERAL +ALTER TABLE movimiento_general DROP CONSTRAINT IF EXISTS movimiento_general_pkey; +ALTER TABLE movimiento_general DROP COLUMN id; +ALTER TABLE movimiento_general DROP COLUMN categoria_ingreso_id; +ALTER TABLE movimiento_general DROP COLUMN categoria_egreso_id; +ALTER TABLE movimiento_general DROP COLUMN reporte_mensual_id; +ALTER TABLE movimiento_general RENAME COLUMN id_uuid TO id; +ALTER TABLE movimiento_general RENAME COLUMN categoria_ingreso_id_uuid TO categoria_ingreso_id; +ALTER TABLE movimiento_general RENAME COLUMN categoria_egreso_id_uuid TO categoria_egreso_id; +ALTER TABLE movimiento_general RENAME COLUMN reporte_mensual_id_uuid TO reporte_mensual_id; +ALTER TABLE movimiento_general ADD PRIMARY KEY (id); + +-- MOVIMIENTO_GENERAL_ADJUNTO +ALTER TABLE movimiento_general_adjunto DROP CONSTRAINT IF EXISTS movimiento_general_adjunto_pkey; +ALTER TABLE movimiento_general_adjunto DROP COLUMN id; +ALTER TABLE movimiento_general_adjunto DROP COLUMN movimiento_id; +ALTER TABLE movimiento_general_adjunto RENAME COLUMN id_uuid TO id; +ALTER TABLE movimiento_general_adjunto RENAME COLUMN movimiento_id_uuid TO movimiento_id; +ALTER TABLE movimiento_general_adjunto ADD PRIMARY KEY (id); + +-- CONTABILIDAD_REGISTRO +ALTER TABLE contabilidad_registro DROP CONSTRAINT IF EXISTS contabilidad_registro_pkey; +ALTER TABLE contabilidad_registro DROP COLUMN id; +ALTER TABLE contabilidad_registro DROP COLUMN reporte_id; +ALTER TABLE contabilidad_registro RENAME COLUMN id_uuid TO id; +ALTER TABLE contabilidad_registro RENAME COLUMN reporte_id_uuid TO reporte_id; +ALTER TABLE contabilidad_registro ADD PRIMARY KEY (id); + +-- ARTICULOS +ALTER TABLE IF EXISTS articulos DROP CONSTRAINT IF EXISTS articulos_pkey; +ALTER TABLE IF EXISTS articulos DROP COLUMN id; +ALTER TABLE IF EXISTS articulos RENAME COLUMN id_uuid TO id; +ALTER TABLE IF EXISTS articulos ADD PRIMARY KEY (id); + +-- UBICACIONES +ALTER TABLE IF EXISTS ubicaciones DROP CONSTRAINT IF EXISTS ubicaciones_pkey; +ALTER TABLE IF EXISTS ubicaciones DROP COLUMN id; +ALTER TABLE IF EXISTS ubicaciones RENAME COLUMN id_uuid TO id; +ALTER TABLE IF EXISTS ubicaciones ADD PRIMARY KEY (id); + +-- EXISTENCIAS +ALTER TABLE IF EXISTS existencias DROP CONSTRAINT IF EXISTS existencias_pkey; +ALTER TABLE IF EXISTS existencias DROP COLUMN id; +ALTER TABLE IF EXISTS existencias DROP COLUMN articulo_id; +ALTER TABLE IF EXISTS existencias DROP COLUMN ubicacion_id; +ALTER TABLE IF EXISTS existencias RENAME COLUMN id_uuid TO id; +ALTER TABLE IF EXISTS existencias RENAME COLUMN articulo_id_uuid TO articulo_id; +ALTER TABLE IF EXISTS existencias RENAME COLUMN ubicacion_id_uuid TO ubicacion_id; +ALTER TABLE IF EXISTS existencias ADD PRIMARY KEY (id); + +-- MOVIMIENTOS_INVENTARIO +ALTER TABLE IF EXISTS movimientos_inventario DROP CONSTRAINT IF EXISTS movimientos_inventario_pkey; +ALTER TABLE IF EXISTS movimientos_inventario DROP COLUMN id; +ALTER TABLE IF EXISTS movimientos_inventario DROP COLUMN articulo_id; +ALTER TABLE IF EXISTS movimientos_inventario DROP COLUMN ubicacion_origen_id; +ALTER TABLE IF EXISTS movimientos_inventario DROP COLUMN ubicacion_destino_id; +ALTER TABLE IF EXISTS movimientos_inventario DROP COLUMN usuario_id; +ALTER TABLE IF EXISTS movimientos_inventario RENAME COLUMN id_uuid TO id; +ALTER TABLE IF EXISTS movimientos_inventario RENAME COLUMN articulo_id_uuid TO articulo_id; +ALTER TABLE IF EXISTS movimientos_inventario RENAME COLUMN ubicacion_origen_id_uuid TO ubicacion_origen_id; +ALTER TABLE IF EXISTS movimientos_inventario RENAME COLUMN ubicacion_destino_id_uuid TO ubicacion_destino_id; +ALTER TABLE IF EXISTS movimientos_inventario RENAME COLUMN usuario_id_uuid TO usuario_id; +ALTER TABLE IF EXISTS movimientos_inventario ADD PRIMARY KEY (id); + +-- ===================================================== +-- STEP 5: Recreate foreign key constraints +-- ===================================================== + +-- Miembros -> Grupos_trabajo +ALTER TABLE miembros + ADD CONSTRAINT fk_miembro_grupo_trabajo + FOREIGN KEY (grupo_trabajo_id) + REFERENCES grupos_trabajo(id) + ON DELETE SET NULL; + +-- Colaboraciones -> Miembros +ALTER TABLE colaboraciones + ADD CONSTRAINT fk_colaboracion_miembro + FOREIGN KEY (miembro_id) + REFERENCES miembros(id) + ON DELETE CASCADE; + +-- Detalle_colaboracion -> Colaboraciones +ALTER TABLE detalle_colaboracion + ADD CONSTRAINT fk_detalle_colaboracion_colaboracion + FOREIGN KEY (colaboracion_id) + REFERENCES colaboraciones(id) + ON DELETE CASCADE; + +-- Detalle_colaboracion -> Tipos_colaboracion +ALTER TABLE detalle_colaboracion + ADD CONSTRAINT fk_detalle_colaboracion_tipo + FOREIGN KEY (tipo_colaboracion_id) + REFERENCES tipos_colaboracion(id) + ON DELETE CASCADE; + +-- Prestamos -> Miembros +ALTER TABLE prestamos + ADD CONSTRAINT fk_prestamo_miembro + FOREIGN KEY (miembro_id) + REFERENCES miembros(id) + ON DELETE CASCADE; + +-- Pagos_prestamo -> Prestamos +ALTER TABLE pagos_prestamo + ADD CONSTRAINT fk_pago_prestamo + FOREIGN KEY (prestamo_id) + REFERENCES prestamos(id) + ON DELETE CASCADE; + +-- Asistencia_culto -> Miembros +ALTER TABLE asistencia_culto + ADD CONSTRAINT fk_asistencia_miembro + FOREIGN KEY (miembro_id) + REFERENCES miembros(id) + ON DELETE CASCADE; + +-- Movimiento_general -> Categoria_ingreso +ALTER TABLE movimiento_general + ADD CONSTRAINT fk_movimiento_categoria_ingreso + FOREIGN KEY (categoria_ingreso_id) + REFERENCES categoria_ingreso(id) + ON DELETE SET NULL; + +-- Movimiento_general -> Categoria_egreso +ALTER TABLE movimiento_general + ADD CONSTRAINT fk_movimiento_categoria_egreso + FOREIGN KEY (categoria_egreso_id) + REFERENCES categoria_egreso(id) + ON DELETE SET NULL; + +-- Movimiento_general -> Reporte_mensual_general +ALTER TABLE movimiento_general + ADD CONSTRAINT fk_movimiento_reporte_mensual + FOREIGN KEY (reporte_mensual_id) + REFERENCES reporte_mensual_general(id) + ON DELETE CASCADE; + +-- Movimiento_general_adjunto -> Movimiento_general +ALTER TABLE movimiento_general_adjunto + ADD CONSTRAINT fk_adjunto_movimiento + FOREIGN KEY (movimiento_id) + REFERENCES movimiento_general(id) + ON DELETE CASCADE; + +-- Contabilidad_registro -> Reporte_mensual_contable +ALTER TABLE contabilidad_registro + ADD CONSTRAINT fk_contabilidad_reporte + FOREIGN KEY (reporte_id) + REFERENCES reporte_mensual_contable(id) + ON DELETE CASCADE; + +-- Existencias -> Articulos +ALTER TABLE IF EXISTS existencias + ADD CONSTRAINT fk_existencia_articulo + FOREIGN KEY (articulo_id) + REFERENCES articulos(id) + ON DELETE CASCADE; + +-- Existencias -> Ubicaciones +ALTER TABLE IF EXISTS existencias + ADD CONSTRAINT fk_existencia_ubicacion + FOREIGN KEY (ubicacion_id) + REFERENCES ubicaciones(id) + ON DELETE CASCADE; + +-- Movimientos_inventario -> Articulos +ALTER TABLE IF EXISTS movimientos_inventario + ADD CONSTRAINT fk_movimiento_articulo + FOREIGN KEY (articulo_id) + REFERENCES articulos(id) + ON DELETE CASCADE; + +-- Movimientos_inventario -> Ubicaciones (origen) +ALTER TABLE IF EXISTS movimientos_inventario + ADD CONSTRAINT fk_movimiento_ubicacion_origen + FOREIGN KEY (ubicacion_origen_id) + REFERENCES ubicaciones(id) + ON DELETE SET NULL; + +-- Movimientos_inventario -> Ubicaciones (destino) +ALTER TABLE IF EXISTS movimientos_inventario + ADD CONSTRAINT fk_movimiento_ubicacion_destino + FOREIGN KEY (ubicacion_destino_id) + REFERENCES ubicaciones(id) + ON DELETE SET NULL; + +-- Movimientos_inventario -> Usuarios +ALTER TABLE IF EXISTS movimientos_inventario + ADD CONSTRAINT fk_movimiento_usuario + FOREIGN KEY (usuario_id) + REFERENCES usuarios(id) + ON DELETE SET NULL; + +-- ===================================================== +-- STEP 6: Create indexes for better performance +-- ===================================================== + +CREATE INDEX IF NOT EXISTS idx_colaboraciones_miembro_id ON colaboraciones(miembro_id); +CREATE INDEX IF NOT EXISTS idx_detalle_colaboracion_id ON detalle_colaboracion(colaboracion_id); +CREATE INDEX IF NOT EXISTS idx_detalle_tipo_id ON detalle_colaboracion(tipo_colaboracion_id); +CREATE INDEX IF NOT EXISTS idx_colaboraciones_updated_at ON colaboraciones(updated_at); +CREATE INDEX IF NOT EXISTS idx_prestamos_miembro_id ON prestamos(miembro_id); +CREATE INDEX IF NOT EXISTS idx_asistencia_miembro_id ON asistencia_culto(miembro_id); +CREATE INDEX IF NOT EXISTS idx_movimiento_reporte_id ON movimiento_general(reporte_mensual_id); + +-- ===================================================== +-- VERIFICATION QUERIES +-- ===================================================== +-- Run these after migration to verify success: + +-- SELECT 'colaboraciones' as table_name, COUNT(*) as count FROM colaboraciones; +-- SELECT 'miembros' as table_name, COUNT(*) as count FROM miembros; +-- SELECT 'detalle_colaboracion' as table_name, COUNT(*) as count FROM detalle_colaboracion; +-- +-- -- Check that all IDs are now UUIDs: +-- SELECT id, miembro_id FROM colaboraciones LIMIT 5; +-- SELECT id, grupo_trabajo_id FROM miembros LIMIT 5; + +-- ===================================================== +-- COMMIT or ROLLBACK +-- ===================================================== +-- If everything looks good, COMMIT: +COMMIT; + +-- If there are errors, ROLLBACK: +-- ROLLBACK; + +-- ===================================================== +-- POST-MIGRATION NOTES +-- ===================================================== +-- After running this script successfully: +-- 1. Update your C# models to use Guid instead of long +-- 2. Rebuild your application +-- 3. Test thoroughly before deploying to production +-- 4. Monitor for any issues with existing records +-- ===================================================== diff --git a/RS_system/wwwroot/js/colaboraciones-offline-db.js b/RS_system/wwwroot/js/colaboraciones-offline-db.js new file mode 100644 index 0000000..8aaa3ab --- /dev/null +++ b/RS_system/wwwroot/js/colaboraciones-offline-db.js @@ -0,0 +1,230 @@ +/** + * IndexedDB Wrapper for Offline Colaboraciones + * Stores pending colaboraciones when offline using GUID-based IDs + */ + +const ColaboracionesOfflineDB = { + dbName: 'ColaboracionesOfflineDB', + version: 1, + storeName: 'colaboraciones', + + /** + * Initialize the database + */ + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create object store if it doesn't exist + if (!db.objectStoreNames.contains(this.storeName)) { + const objectStore = db.createObjectStore(this.storeName, { + keyPath: 'id' // GUID generated client-side + }); + + // Indexes for querying + objectStore.createIndex('syncStatus', 'syncStatus', { unique: false }); + objectStore.createIndex('timestamp', 'timestamp', { unique: false }); + objectStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + objectStore.createIndex('miembroId', 'miembroId', { unique: false }); + } + }; + }); + }, + + /** + * Generate a GUID (v4 UUID) + */ + generateGuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }, + + /** + * Add a new colaboracion to offline queue + */ + async addColaboracion(colaboracionData) { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + + // Prepare record with GUID and sync metadata + const record = { + id: this.generateGuid(), // GUID generated client-side + miembroId: colaboracionData.miembroId, + mesInicial: colaboracionData.mesInicial, + anioInicial: colaboracionData.anioInicial, + mesFinal: colaboracionData.mesFinal, + anioFinal: colaboracionData.anioFinal, + montoTotal: colaboracionData.montoTotal, + observaciones: colaboracionData.observaciones || '', + tiposSeleccionados: colaboracionData.tiposSeleccionados || [], + tipoPrioritario: colaboracionData.tipoPrioritario || null, + registradoPor: colaboracionData.registradoPor || 'Usuario', + syncStatus: 'pending', // pending, syncing, synced, failed + timestamp: new Date().toISOString(), + updatedAt: new Date().toISOString(), + retryCount: 0 + }; + + const request = store.add(record); + + request.onsuccess = () => { + console.log('[OfflineDB] Colaboración guardada con ID:', record.id); + resolve(record); + }; + request.onerror = () => { + console.error('[OfflineDB] Error al guardar:', request.error); + reject(request.error); + }; + }); + }, + + /** + * Get all pending colaboraciones + */ + async getPending() { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('syncStatus'); + const request = index.getAll('pending'); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + }, + + /** + * Get all colaboraciones (any status) + */ + async getAll() { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + }, + + /** + * Update sync status of a colaboracion + */ + async updateSyncStatus(id, status, retryCount = 0) { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const getRequest = store.get(id); + + getRequest.onsuccess = () => { + const record = getRequest.result; + if (record) { + record.syncStatus = status; + record.retryCount = retryCount; + record.lastSyncAttempt = new Date().toISOString(); + + const updateRequest = store.put(record); + updateRequest.onsuccess = () => resolve(record); + updateRequest.onerror = () => reject(updateRequest.error); + } else { + reject(new Error('Record not found')); + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); + }, + + /** + * Remove a colaboracion by ID (after successful sync) + */ + async remove(id) { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.delete(id); + + request.onsuccess = () => { + console.log('[OfflineDB] Colaboración eliminada:', id); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + }, + + /** + * Get count of pending colaboraciones + */ + async getPendingCount() { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const index = store.index('syncStatus'); + const request = index.count('pending'); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + }, + + /** + * Clear all records (use with caution) + */ + async clearAll() { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const request = store.clear(); + + request.onsuccess = () => { + console.log('[OfflineDB] All records cleared'); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + }, + + /** + * Get a specific colaboracion by ID + */ + async getById(id) { + const db = await this.init(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], 'readonly'); + const store = transaction.objectStore(this.storeName); + const request = store.get(id); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } +}; + +// Initialize database when script loads +ColaboracionesOfflineDB.init().catch(error => { + console.error('[OfflineDB] Initialization failed:', error); +}); diff --git a/RS_system/wwwroot/js/colaboraciones-sync.js b/RS_system/wwwroot/js/colaboraciones-sync.js new file mode 100644 index 0000000..dd27bac --- /dev/null +++ b/RS_system/wwwroot/js/colaboraciones-sync.js @@ -0,0 +1,310 @@ +/** + * Sync Manager for Colaboraciones + * Handles connection monitoring, offline queue, and synchronization + */ + +const ColaboracionesSyncManager = { + isOnline: navigator.onLine, + isSyncing: false, + syncInProgress: false, + statusIndicator: null, + pendingBadge: null, + maxRetries: 3, + retryDelay: 2000, // milliseconds + + /** + * Initialize the sync manager + */ + init() { + console.log('[SyncManager] Initializing...'); + + // Get UI elements + this.statusIndicator = document.getElementById('offlineStatus'); + this.pendingBadge = document.getElementById('pendingBadge'); + + // Listen for online/offline events + window.addEventListener('online', () => this.handleOnline()); + window.addEventListener('offline', () => this.handleOffline()); + + // Set initial status + this.updateStatusUI(); + this.updatePendingBadge(); + + // If online, try to sync pending items + if (this.isOnline) { + setTimeout(() => this.syncPending(), 1000); + } + + console.log('[SyncManager] Initialized. Status:', this.isOnline ? 'Online' : 'Offline'); + }, + + /** + * Handle online event + */ + async handleOnline() { + console.log('[SyncManager] Connection restored'); + this.isOnline = true; + this.updateStatusUI(); + + // Auto-sync after short delay + setTimeout(() => this.syncPending(), 500); + }, + + /** + * Handle offline event + */ + handleOffline() { + console.log('[SyncManager] Connection lost'); + this.isOnline = false; + this.updateStatusUI(); + }, + + /** + * Update status indicator UI + */ + updateStatusUI() { + if (!this.statusIndicator) return; + + if (this.isSyncing) { + this.statusIndicator.className = 'badge bg-warning ms-2'; + this.statusIndicator.innerHTML = ' Sincronizando'; + this.statusIndicator.style.display = 'inline-block'; + } else if (this.isOnline) { + this.statusIndicator.className = 'badge bg-success ms-2'; + this.statusIndicator.innerHTML = ' En línea'; + this.statusIndicator.style.display = 'inline-block'; + } else { + this.statusIndicator.className = 'badge bg-secondary ms-2'; + this.statusIndicator.innerHTML = ' Sin conexión'; + this.statusIndicator.style.display = 'inline-block'; + } + }, + + /** + * Update pending items badge + */ + async updatePendingBadge() { + if (!this.pendingBadge) return; + + try { + const count = await ColaboracionesOfflineDB.getPendingCount(); + + if (count > 0) { + this.pendingBadge.textContent = count; + this.pendingBadge.style.display = 'inline-block'; + } else { + this.pendingBadge.style.display = 'none'; + } + } catch (error) { + console.error('[SyncManager] Error updating badge:', error); + } + }, + + /** + * Save colaboracion (online or offline) + */ + async saveColaboracion(colaboracionData) { + if (this.isOnline) { + try { + // Try to save directly to server + const result = await this.sendToServer(colaboracionData); + + if (result.success) { + return { + success: true, + message: 'Colaboración registrada exitosamente', + online: true + }; + } else { + throw new Error(result.message || 'Error al guardar'); + } + } catch (error) { + console.warn('[SyncManager] Online save failed, using offline mode:', error); + // Fall back to offline save + return await this.saveOffline(colaboracionData); + } + } else { + // Save offline + return await this.saveOffline(colaboracionData); + } + }, + + /** + * Save to offline queue + */ + async saveOffline(colaboracionData) { + try { + const record = await ColaboracionesOfflineDB.addColaboracion(colaboracionData); + await this.updatePendingBadge(); + + return { + success: true, + offline: true, + message: 'Guardado offline. Se sincronizará automáticamente cuando haya conexión.', + id: record.id + }; + } catch (error) { + console.error('[SyncManager] Offline save failed:', error); + return { + success: false, + message: 'Error al guardar en modo offline: ' + error.message + }; + } + }, + + /** + * Send colaboracion to server + */ + async sendToServer(colaboracionData) { + const formData = new FormData(); + + formData.append('MiembroId', colaboracionData.miembroId); + formData.append('MesInicial', colaboracionData.mesInicial); + formData.append('AnioInicial', colaboracionData.anioInicial); + formData.append('MesFinal', colaboracionData.mesFinal); + formData.append('AnioFinal', colaboracionData.anioFinal); + formData.append('MontoTotal', colaboracionData.montoTotal); + formData.append('Observaciones', colaboracionData.observaciones || ''); + + if (colaboracionData.tipoPrioritario) { + formData.append('TipoPrioritario', colaboracionData.tipoPrioritario); + } + + if (colaboracionData.tiposSeleccionados && colaboracionData.tiposSeleccionados.length > 0) { + colaboracionData.tiposSeleccionados.forEach(tipo => { + formData.append('TiposSeleccionados', tipo); + }); + } + + const response = await fetch('/Colaboracion/Create', { + method: 'POST', + body: formData + }); + + if (response.redirected) { + // Success - ASP.NET redirected to Index + return { success: true }; + } + + const text = await response.text(); + + // Check if response contains success indicators + if (text.includes('exitosamente') || response.ok) { + return { success: true }; + } + + throw new Error('Error en la respuesta del servidor'); + }, + + /** + * Synchronize all pending colaboraciones + */ + async syncPending() { + if (!this.isOnline || this.syncInProgress) { + console.log('[SyncManager] Sync skipped. Online:', this.isOnline, 'InProgress:', this.syncInProgress); + return; + } + + try { + this.syncInProgress = true; + this.isSyncing = true; + this.updateStatusUI(); + + const pending = await ColaboracionesOfflineDB.getPending(); + + if (pending.length === 0) { + console.log('[SyncManager] No pending items to sync'); + return; + } + + console.log(`[SyncManager] Syncing ${pending.length} pending item(s)...`); + + let successCount = 0; + let failCount = 0; + + for (const item of pending) { + try { + // Update status to syncing + await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'syncing', item.retryCount); + + // Try to send to server + const result = await this.sendToServer(item); + + if (result.success) { + // Remove from offline DB + await ColaboracionesOfflineDB.remove(item.id); + successCount++; + console.log(`[SyncManager] Item ${item.id} synced successfully`); + } else { + throw new Error(result.message || 'Unknown error'); + } + } catch (error) { + console.error(`[SyncManager] Sync failed for item ${item.id}:`, error); + + // Update retry count + const newRetryCount = (item.retryCount || 0) + 1; + + if (newRetryCount >= this.maxRetries) { + await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'failed', newRetryCount); + failCount++; + } else { + await ColaboracionesOfflineDB.updateSyncStatus(item.id, 'pending', newRetryCount); + } + } + } + + await this.updatePendingBadge(); + + // Show results + if (successCount > 0) { + toastr.success(`${successCount} colaboración(es) sincronizada(s) exitosamente`); + + // Reload page to show updated data + setTimeout(() => { + window.location.reload(); + }, 1500); + } + + if (failCount > 0) { + toastr.error(`${failCount} colaboración(es) no se pudieron sincronizar. Se reintentará automáticamente.`); + } + + } catch (error) { + console.error('[SyncManager] Sync error:', error); + toastr.error('Error durante la sincronización'); + } finally { + this.syncInProgress = false; + this.isSyncing = false; + this.updateStatusUI(); + } + }, + + /** + * Manually trigger sync + */ + async manualSync() { + if (!this.isOnline) { + toastr.warning('No hay conexión a internet'); + return; + } + + toastr.info('Iniciando sincronización...'); + await this.syncPending(); + }, + + /** + * Check if currently online + */ + checkOnlineStatus() { + return this.isOnline; + } +}; + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + ColaboracionesSyncManager.init(); + }); +} else { + ColaboracionesSyncManager.init(); +} diff --git a/RS_system/wwwroot/service-worker.js b/RS_system/wwwroot/service-worker.js new file mode 100644 index 0000000..67b47bd --- /dev/null +++ b/RS_system/wwwroot/service-worker.js @@ -0,0 +1,241 @@ +/** + * Service Worker for RS_system PWA + * Implements offline-first architecture with strategic caching + * Version: 1.0.0 + */ + +const CACHE_VERSION = 'rs-system-v1.0.0'; +const STATIC_CACHE = `${CACHE_VERSION}-static`; +const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`; +const API_CACHE = `${CACHE_VERSION}-api`; + +// Critical resources to cache on install +const STATIC_ASSETS = [ + '/', + '/Home/Index', + '/Colaboracion/Create', + '/Colaboracion/Index', + '/css/site.css', + '/css/bootstrap.min.css', + '/css/bootstrap-icons.min.css', + '/js/site.js', + '/js/colaboraciones-offline-db.js', + '/js/colaboraciones-sync.js', + '/lib/jquery/dist/jquery.min.js', + '/lib/bootstrap/dist/js/bootstrap.bundle.min.js', + '/manifest.json', + '/Assets/icon-192x192.png', + '/Assets/icon-512x512.png' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[Service Worker] Installing...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[Service Worker] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => { + console.log('[Service Worker] Installation complete'); + return self.skipWaiting(); // Activate immediately + }) + .catch((error) => { + console.error('[Service Worker] Installation failed:', error); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[Service Worker] Activating...'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => { + // Delete old version caches + return name.startsWith('rs-system-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE && name !== API_CACHE; + }) + .map((name) => { + console.log('[Service Worker] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + .then(() => { + console.log('[Service Worker] Activation complete'); + return self.clients.claim(); // Take control immediately + }) + ); +}); + +// Fetch event - implement caching strategies +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip chrome extension and non-HTTP requests + if (!url.protocol.startsWith('http')) { + return; + } + + // API requests - Network First, fallback to offline indicator + if (url.pathname.includes('/api/') || + url.pathname.includes('/Colaboracion/Sync') || + url.pathname.includes('/Colaboracion/BuscarMiembros') || + url.pathname.includes('/Colaboracion/ObtenerUltimosPagos')) { + + event.respondWith(networkFirstStrategy(request, API_CACHE)); + return; + } + + // POST requests - Network Only (never cache) + if (request.method === 'POST') { + event.respondWith( + fetch(request).catch(() => { + return new Response( + JSON.stringify({ + success: false, + offline: true, + message: 'Sin conexión. Por favor intente más tarde.' + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 503 + } + ); + }) + ); + return; + } + + // Static assets - Cache First, fallback to Network + if (isStaticAsset(url.pathname)) { + event.respondWith(cacheFirstStrategy(request, STATIC_CACHE)); + return; + } + + // Dynamic content (HTML pages) - Network First, fallback to Cache + event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE)); +}); + +/** + * Cache First Strategy + * Try cache first, fallback to network, then cache the response + */ +function cacheFirstStrategy(request, cacheName) { + return caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(request) + .then((networkResponse) => { + // Clone the response + const responseToCache = networkResponse.clone(); + + caches.open(cacheName) + .then((cache) => { + cache.put(request, responseToCache); + }); + + return networkResponse; + }) + .catch((error) => { + console.error('[Service Worker] Fetch failed:', error); + // Return offline page if available + return caches.match('/offline.html') || new Response('Offline'); + }); + }); +} + +/** + * Network First Strategy + * Try network first, fallback to cache + */ +function networkFirstStrategy(request, cacheName) { + return fetch(request) + .then((networkResponse) => { + // Clone and cache the response + const responseToCache = networkResponse.clone(); + + caches.open(cacheName) + .then((cache) => { + cache.put(request, responseToCache); + }); + + return networkResponse; + }) + .catch((error) => { + console.log('[Service Worker] Network failed, trying cache:', error); + + return caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + // If API request and no cache, return offline indicator + if (request.url.includes('/api/') || request.url.includes('/Colaboracion/')) { + return new Response( + JSON.stringify({ offline: true }), + { + headers: { 'Content-Type': 'application/json' }, + status: 503 + } + ); + } + + throw error; + }); + }); +} + +/** + * Check if request is for a static asset + */ +function isStaticAsset(pathname) { + const staticExtensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.ico']; + return staticExtensions.some(ext => pathname.endsWith(ext)); +} + +// Background Sync for future enhancement +self.addEventListener('sync', (event) => { + console.log('[Service Worker] Background sync triggered:', event.tag); + + if (event.tag === 'sync-colaboraciones') { + event.waitUntil( + // This will be handled by colaboraciones-sync.js + self.registration.showNotification('Sincronización completada', { + body: 'Las colaboraciones offline se han sincronizado exitosamente.', + icon: '/Assets/icon-192x192.png', + badge: '/Assets/icon-192x192.png' + }) + ); + } +}); + +// Message handler for cache updates +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +}); + +console.log('[Service Worker] Loaded and ready');