This commit is contained in:
2026-02-22 14:38:53 -06:00
parent bec656b105
commit a73de4a4fa
47 changed files with 4290 additions and 3 deletions

15
RS_system/.idea/.idea.RS_system/.idea/.gitignore generated vendored Normal file
View File

@@ -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/

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -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<IActionResult> TiposSalida()
{
var lista = await _context.DiezmoTiposSalida
.Where(x => !x.Eliminado)
.OrderBy(x => x.Nombre)
.ToListAsync();
return View(lista);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Beneficiarios()
{
var lista = await _context.DiezmoBeneficiarios
.Where(x => !x.Eliminado)
.OrderBy(x => x.Nombre)
.ToListAsync();
return View(lista);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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));
}
}

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<SelectListItem> GetAniosSelectList()
{
var anioActual = DateTime.Today.Year;
return Enumerable.Range(anioActual - 3, 5)
.Select(a => new SelectListItem(a.ToString(), a.ToString()))
.ToList();
}
private async Task<IActionResult> 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
});
}
}

View File

@@ -57,6 +57,13 @@ public class ApplicationDbContext : DbContext
public DbSet<Colaboracion> Colaboraciones { get; set; }
public DbSet<DetalleColaboracion> DetalleColaboraciones { get; set; }
// Diezmos module
public DbSet<DiezmoCierre> DiezmoCierres { get; set; }
public DbSet<DiezmoDetalle> DiezmoDetalles { get; set; }
public DbSet<DiezmoSalida> DiezmoSalidas { get; set; }
public DbSet<DiezmoBeneficiario> DiezmoBeneficiarios { get; set; }
public DbSet<DiezmoTipoSalida> DiezmoTiposSalida { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -217,6 +224,81 @@ public class ApplicationDbContext : DbContext
.IsUnique();
});
// ── Diezmos module configuration ──────────────────────────────────
modelBuilder.Entity<DiezmoCierre>(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<DiezmoDetalle>(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<DiezmoSalida>(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<DiezmoTipoSalida>(entity =>
{
entity.ToTable("diezmo_tipos_salida", "public");
entity.HasKey(e => e.Id);
entity.Property(e => e.Nombre).HasMaxLength(100).IsRequired();
});
modelBuilder.Entity<DiezmoBeneficiario>(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())

View File

@@ -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');

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
/// <summary>
/// Personas o entidades que pueden recibir salidas de diezmos
/// (pastor, tesorero, organismos externos, etc.)
/// </summary>
[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<DiezmoSalida> Salidas { get; set; } = new List<DiezmoSalida>();
}

View File

@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
/// <summary>
/// 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).
/// </summary>
[Table("diezmo_cierres")]
public class DiezmoCierre
{
[Key]
[Column("id")]
public long Id { get; set; }
/// <summary>Fecha del cierre. UNIQUE — no pueden existir dos cierres para el mismo día.</summary>
[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<DiezmoDetalle> Detalles { get; set; } = new List<DiezmoDetalle>();
public virtual ICollection<DiezmoSalida> Salidas { get; set; } = new List<DiezmoSalida>();
}

View File

@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
/// <summary>
/// Diezmo individual aportado por un miembro dentro de un cierre.
/// MontoNeto = MontoEntregado - CambioEntregado (calculado por el sistema).
/// </summary>
[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; }
/// <summary>Monto físico que el miembro entregó (puede incluir cambio).</summary>
[Column("monto_entregado", TypeName = "numeric(12,2)")]
[Required]
public decimal MontoEntregado { get; set; }
/// <summary>Cambio devuelto al miembro.</summary>
[Column("cambio_entregado", TypeName = "numeric(12,2)")]
public decimal CambioEntregado { get; set; } = 0;
/// <summary>Diezmo neto real = MontoEntregado - CambioEntregado. Calculado por el sistema.</summary>
[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!;
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
/// <summary>
/// Salida de fondos registrada contra un cierre de diezmos.
/// Incluye entregas al pastor, gastos administrativos, misiones, etc.
/// </summary>
[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;
/// <summary>Correlativo de recibo asignado al momento de generar el comprobante.</summary>
[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; }
}

View File

@@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
/// <summary>
/// Catálogo de tipos de salida del módulo de diezmos
/// (Entrega al Pastor, Gastos Administrativos, Misiones, etc.)
/// </summary>
[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; }
/// <summary>
/// Marca este tipo como la entrega oficial al pastor.
/// Permite sugerirlo/forzarlo automáticamente al cerrar con saldo pendiente.
/// </summary>
[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<DiezmoSalida> Salidas { get; set; } = new List<DiezmoSalida>();
}

View File

@@ -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; }
}

View File

@@ -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<DiezmoDetalleRowViewModel> Detalles { get; set; } = new();
// Datos de salidas
public List<DiezmoSalidaRowViewModel> 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<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> MiembrosSelect { get; set; } = new();
public List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> TiposSalidaSelect { get; set; } = new();
public List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> 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; }
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace Rs_system.Models.ViewModels;
/// <summary>Fila del listado de cierres de diezmos.</summary>
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";
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace Rs_system.Models.ViewModels;
/// <summary>Formulario modal para agregar un diezmo de un miembro.</summary>
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; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Rs_system.Models.ViewModels;
/// <summary>Formulario modal para registrar una salida/entrega de fondos.</summary>
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;
}

View File

@@ -0,0 +1,14 @@
namespace Rs_system.Models.ViewModels;
public class PaginatedViewModel<T>
{
public List<T> 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;
}

View File

@@ -50,6 +50,11 @@ builder.Services.AddScoped<IContabilidadGeneralService, ContabilidadGeneralServi
builder.Services.AddScoped<IPrestamoService, PrestamoService>();
builder.Services.AddScoped<IColaboracionService, ColaboracionService>();
builder.Services.AddSingleton<IQueryCacheService, QueryCacheService>();
// Diezmos module services
builder.Services.AddScoped<IDiezmoCalculoService, DiezmoCalculoService>();
builder.Services.AddScoped<IDiezmoCierreService, DiezmoCierreService>();
builder.Services.AddScoped<IDiezmoReciboService, DiezmoReciboService>();
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // 1024 cache entries max

View File

@@ -0,0 +1,25 @@
using Rs_system.Models;
namespace Rs_system.Services;
public class DiezmoCalculoService : IDiezmoCalculoService
{
/// <inheritdoc/>
public decimal CalcularMontoNeto(decimal montoEntregado, decimal cambioEntregado)
=> montoEntregado - cambioEntregado;
/// <inheritdoc/>
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;
}
}

View File

@@ -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<List<DiezmoTipoSalida>> GetTiposSalidaActivosAsync()
=> await _context.DiezmoTiposSalida
.Where(t => t.Activo && !t.Eliminado)
.OrderBy(t => t.Nombre)
.ToListAsync();
public async Task<List<DiezmoBeneficiario>> GetBeneficiariosActivosAsync()
=> await _context.DiezmoBeneficiarios
.Where(b => b.Activo && !b.Eliminado)
.OrderBy(b => b.Nombre)
.ToListAsync();
// ──────────────────────────────────────────────────────────────────────────
// Cierres
// ──────────────────────────────────────────────────────────────────────────
public async Task<List<DiezmoCierre>> 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<DiezmoCierre?> 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<DiezmoCierre> 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<DiezmoCierre> 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);
}
}

View File

@@ -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;
}
/// <inheritdoc/>
public async Task<string> 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;
}
/// <inheritdoc/>
public async Task<DiezmoSalida?> 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);
}

View File

@@ -0,0 +1,16 @@
using Rs_system.Models;
using Rs_system.Models.ViewModels;
namespace Rs_system.Services;
public interface IDiezmoCalculoService
{
/// <summary>Calcula el monto neto de un detalle: MontoEntregado - CambioEntregado.</summary>
decimal CalcularMontoNeto(decimal montoEntregado, decimal cambioEntregado);
/// <summary>
/// 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).
/// </summary>
DiezmoCierre RecalcularTotales(DiezmoCierre cierre);
}

View File

@@ -0,0 +1,31 @@
using Rs_system.Models;
using Rs_system.Models.ViewModels;
namespace Rs_system.Services;
public interface IDiezmoCierreService
{
// ── Catálogos ──
Task<List<DiezmoTipoSalida>> GetTiposSalidaActivosAsync();
Task<List<DiezmoBeneficiario>> GetBeneficiariosActivosAsync();
// ── Cierres ──
Task<List<DiezmoCierre>> GetCierresAsync(int? anio = null);
Task<DiezmoCierre?> GetCierreByIdAsync(long id);
Task<DiezmoCierre> 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);
}

View File

@@ -0,0 +1,16 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IDiezmoReciboService
{
/// <summary>
/// Genera (o recupera) el correlativo de recibo para una salida.
/// Formato: RECDZ-{AAAA}-{id:D6}
/// Persiste el numero_recibo en la tabla diezmo_salidas.
/// </summary>
Task<string> GenerarNumeroReciboAsync(long salidaId);
/// <summary>Obtiene todos los datos necesarios para renderizar el recibo.</summary>
Task<DiezmoSalida?> GetSalidaParaReciboAsync(long salidaId);
}

View File

@@ -0,0 +1,49 @@
@model Rs_system.Models.ViewModels.DiezmoCierreCreateViewModel
@{
ViewData["Title"] = "Nuevo Cierre de Diezmos";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1"><i class="bi bi-plus-circle me-2"></i>Nuevo Cierre de Diezmos</h4>
<p class="text-muted mb-0">Registra un nuevo período de diezmos</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card-custom">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3" style="display:none;"></div>
<div class="mb-3">
<label asp-for="Fecha" class="form-label"></label>
<input asp-for="Fecha" type="date" class="form-control" />
<span asp-validation-for="Fecha" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="Observaciones" class="form-label"></label>
<textarea asp-for="Observaciones" class="form-control" rows="3"
placeholder="Opcional — notas o descripción del cierre"></textarea>
<span asp-validation-for="Observaciones" class="text-danger small"></span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-check-lg me-1"></i> Crear Cierre
</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,465 @@
@model Rs_system.Models.ViewModels.DiezmoCierreDetalleViewModel
@{
ViewData["Title"] = $"Cierre {Model.Fecha:dd/MM/yyyy}";
var cerrado = Model.Cerrado;
}
<!-- Cabecera -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-1">
<i class="bi bi-cash-coin me-2"></i>Cierre de Diezmos — @Model.Fecha.ToString("dd/MM/yyyy")
<span class="@Model.EstadoBadge ms-2">@Model.EstadoTexto</span>
</h4>
@if (!string.IsNullOrEmpty(Model.Observaciones))
{
<p class="text-muted mb-0">@Model.Observaciones</p>
}
@if (cerrado && Model.FechaCierre.HasValue)
{
<small class="text-muted">Cerrado por <strong>@Model.CerradoPor</strong> el @Model.FechaCierre.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</small>
}
</div>
<div class="d-flex gap-2 flex-wrap">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Volver
</a>
@if (!cerrado)
{
<button type="button" class="btn btn-success btn-sm" onclick="confirmClose(@Model.Id)">
<i class="bi bi-lock me-1"></i>Cerrar cierre
</button>
}
else
{
<button type="button" class="btn btn-outline-warning btn-sm" onclick="confirmReopen(@Model.Id)">
<i class="bi bi-unlock me-1"></i>Reabrir
</button>
}
</div>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-1"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (cerrado)
{
<div class="alert alert-secondary d-flex align-items-center mb-4">
<i class="bi bi-lock-fill me-2 fs-5"></i>
<strong>Este cierre está sellado.</strong>&nbsp;No se puede modificar. Para editarlo, un Administrador debe reabrirlo.
</div>
}
<!-- BLOQUE 1 — Resumen de totales -->
<div class="row mb-4 g-3">
<div class="col-6 col-md-2">
<div class="card-custom text-center py-3">
<h6 class="text-muted small mb-1">Recibido</h6>
<h5 class="mb-0" id="uiTotalRecibido">$ @Model.TotalRecibido.ToString("N2")</h5>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card-custom text-center py-3">
<h6 class="text-muted small mb-1">Cambio</h6>
<h5 class="text-warning mb-0" id="uiTotalCambio">$ @Model.TotalCambio.ToString("N2")</h5>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card-custom text-center py-3">
<h6 class="text-muted small mb-1">Neto</h6>
<h5 class="text-primary mb-0" id="uiTotalNeto">$ @Model.TotalNeto.ToString("N2")</h5>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card-custom text-center py-3">
<h6 class="text-muted small mb-1">Salidas</h6>
<h5 class="text-danger mb-0" id="uiTotalSalidas">$ @Model.TotalSalidas.ToString("N2")</h5>
</div>
</div>
<div class="col-12 col-md-4">
<div id="wrapperSaldoFinal" class="card-custom text-center py-3 border border-2 @(Model.SaldoFinal >= 0 ? "border-success" : "border-danger")">
<h6 class="text-muted small mb-1">Saldo Final</h6>
<h4 id="uiSaldoFinal" class="@(Model.SaldoFinal >= 0 ? "text-success" : "text-danger") mb-0 fw-bold">
$ @Model.SaldoFinal.ToString("N2")
</h4>
</div>
</div>
</div>
<!-- BLOQUE 2 — Diezmos por miembro -->
<div class="card-custom mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><i class="bi bi-people me-2"></i>Diezmos por Miembro (@Model.Detalles.Count)</h6>
@if (!cerrado)
{
<button type="button" class="btn btn-primary-custom btn-sm" data-bs-toggle="modal" data-bs-target="#modalAddDetalle">
<i class="bi bi-plus-lg me-1"></i>Agregar Diezmo
</button>
}
</div>
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Miembro</th>
<th class="text-end">Entregado</th>
<th class="text-end">Cambio</th>
<th class="text-end">Neto</th>
<th>Notas</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Detalles.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-inbox fs-2 d-block mb-1"></i>Sin diezmos registrados
</td>
</tr>
}
@{ var i = 1; }
@foreach (var d in Model.Detalles)
{
<tr>
<td class="text-muted">@i</td>
<td><strong>@d.NombreMiembro</strong></td>
<td class="text-end">$ @d.MontoEntregado.ToString("N2")</td>
<td class="text-end text-warning">$ @d.CambioEntregado.ToString("N2")</td>
<td class="text-end text-primary fw-bold">$ @d.MontoNeto.ToString("N2")</td>
<td><small class="text-muted">@d.Observaciones</small></td>
<td class="text-center">
@if (!cerrado)
{
<form asp-action="DeleteDetalle" method="post" class="d-inline formDelete" data-confirm-msg="¿Eliminar este diezmo?">
@Html.AntiForgeryToken()
<input type="hidden" name="detalleId" value="@d.Id" />
<input type="hidden" name="cierreId" value="@Model.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</form>
}
</td>
</tr>
i++;
}
</tbody>
</table>
</div>
</div>
<!-- BLOQUE 3 — Salidas / Entregas -->
<div class="card-custom mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0"><i class="bi bi-box-arrow-up me-2"></i>Salidas y Entregas (@Model.Salidas.Count)</h6>
@if (!cerrado)
{
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#modalAddSalida">
<i class="bi bi-plus-lg me-1"></i>Registrar Salida
</button>
}
</div>
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Tipo</th>
<th>Beneficiario</th>
<th>Concepto</th>
<th class="text-end">Monto</th>
<th>Recibo</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Salidas.Any())
{
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-inbox fs-2 d-block mb-1"></i>Sin salidas registradas
</td>
</tr>
}
@foreach (var s in Model.Salidas)
{
<tr>
<td><span class="badge bg-secondary">@s.TipoSalidaNombre</span></td>
<td>@(s.BeneficiarioNombre ?? "—")</td>
<td>@s.Concepto</td>
<td class="text-end text-danger fw-bold">$ @s.Monto.ToString("N2")</td>
<td>
@if (!string.IsNullOrEmpty(s.NumeroRecibo))
{
<a asp-action="Recibo" asp-route-salidaId="@s.Id" target="_blank"
class="badge bg-success text-decoration-none">
<i class="bi bi-receipt me-1"></i>@s.NumeroRecibo
</a>
}
else
{
<a asp-action="Recibo" asp-route-salidaId="@s.Id" target="_blank"
class="btn btn-sm btn-outline-secondary btn-sm py-0">
<i class="bi bi-receipt me-1"></i>Generar
</a>
}
</td>
<td class="text-center">
@if (!cerrado)
{
<form asp-action="DeleteSalida" method="post" class="d-inline formDelete" data-confirm-msg="¿Eliminar esta salida?">
@Html.AntiForgeryToken()
<input type="hidden" name="salidaId" value="@s.Id" />
<input type="hidden" name="cierreId" value="@Model.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
MODAL — Agregar Diezmo por Miembro
═══════════════════════════════════════════════════════════ -->
@if (!cerrado)
{
<div class="modal fade" id="modalAddDetalle" tabindex="-1" aria-labelledby="modalAddDetalleLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalAddDetalleLabel">
<i class="bi bi-person-plus me-2"></i>Registrar Diezmo
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="formAddDetalle" asp-action="AddDetalle" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="cierreId" value="@Model.Id" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Miembro <span class="text-danger">*</span></label>
<select name="MiembroId" class="form-select select2-miembros" required style="width: 100%;">
<option value="">— Seleccionar o Escribir —</option>
@foreach (var m in Model.MiembrosSelect)
{
<option value="@m.Value">@m.Text</option>
}
</select>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Monto entregado <span class="text-danger">*</span></label>
<input name="MontoEntregado" type="number" step="0.01" min="0.01"
class="form-control fw-bold text-success" id="montoEntregado" oninput="calcCambio()" required />
</div>
<div class="col-6">
<label class="form-label">Diezmo (Neto) <span class="text-danger">*</span></label>
<input name="MontoNeto" type="number" step="0.01" min="0.01"
class="form-control fw-bold text-primary" id="montoNeto" oninput="calcCambio()" required />
</div>
</div>
<div class="mt-3 bg-light border p-2 rounded text-end">
<small class="text-muted mb-0 d-block">Cambio a devolver:</small>
<strong id="cambioDisplay" class="text-warning fs-5">$ 0.00</strong>
<input type="hidden" name="CambioEntregado" id="cambioEntregado" value="0" />
</div>
<div class="mt-3">
<label class="form-label">Observaciones</label>
<input name="Observaciones" type="text" class="form-control"
placeholder="Opcional" maxlength="300" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
MODAL — Registrar Salida
═══════════════════════════════════════════════════════════ -->
<div class="modal fade" id="modalAddSalida" tabindex="-1" aria-labelledby="modalAddSalidaLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalAddSalidaLabel">
<i class="bi bi-box-arrow-up me-2"></i>Registrar Salida
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="formAddSalida" asp-action="AddSalida" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="cierreId" value="@Model.Id" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Tipo de salida <span class="text-danger">*</span></label>
<select name="TipoSalidaId" id="tipoSalidaSelect" class="form-select" required>
<option value="">— Seleccionar —</option>
@foreach (var t in Model.TiposSalidaSelect)
{
<option value="@t.Value">@t.Text</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Beneficiario</label>
<select name="BeneficiarioId" class="form-select">
<option value="">— Sin beneficiario —</option>
@foreach (var b in Model.BeneficiariosSelect)
{
<option value="@b.Value">@b.Text</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Monto <span class="text-danger">*</span></label>
<input name="Monto" type="number" step="0.01" min="0.01"
class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label">Concepto <span class="text-danger">*</span></label>
<input name="Concepto" id="s_concepto" type="text" class="form-control"
placeholder="Descripción de la salida" maxlength="300" required />
</div>
<div class="alert alert-info py-2 small mb-3 text-center">
Total del Diezmo Recibido (Neto): <strong>$ @Model.TotalNeto.ToString("N2")</strong>
</div>
@if (Model.SaldoFinal > 0)
{
<div class="alert alert-warning py-2 small mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
Saldo disponible: <strong>$ @Model.SaldoFinal.ToString("N2")</strong>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-check-lg me-1"></i>Registrar Salida
</button>
</div>
</form>
</div>
</div>
</div>
}
<!-- Formularios ocultos para cierre/reapertura -->
<form id="formCerrar" asp-action="Close" method="post" style="display:none;">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@Model.Id" />
</form>
<form id="formReabrir" asp-action="Reopen" method="post" style="display:none;">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@Model.Id" />
</form>
<!-- CSS para Select2 (asumimos que está en el layout o lo cargamos por CDN si no está) -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
@section Scripts {
<!-- JS para Select2 -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
// Inicializar Select2
$('.select2-miembros').select2({
theme: "bootstrap-5",
dropdownParent: $('#modalAddDetalle'),
placeholder: "Buscar por nombre, apellido o num...",
allowClear: true,
width: '100%',
language: {
noResults: function() { return "No se encontraron miembros"; },
searching: function() { return "Buscando..."; }
}
});
// Lógica para auto-llenar el Concepto de la salida según el Tipo seleccionado
$('#tipoSalidaSelect').on('change', function() {
var selectedText = $(this).find("option:selected").text();
var currentConcepto = $('#s_concepto').val();
if (selectedText && selectedText !== '— Seleccionar —') {
if (currentConcepto === '' || currentConcepto === '— Seleccionar —' || currentConcepto !== selectedText) {
$('#s_concepto').val(selectedText);
}
} else {
$('#s_concepto').val('');
}
});
// Lógica de validación Delete
$('.formDelete').on('submit', function (e) {
var msg = $(this).data('confirm-msg') || '¿Está seguro de eliminar este registro?';
if (!confirm(msg)) e.preventDefault();
});
});
// Calculo interactivo del cambio (Solo Lectura) en el formulario de Diezmo
function calcCambio() {
let entregado = parseFloat(document.getElementById('montoEntregado').value) || 0;
let neto = parseFloat(document.getElementById('montoNeto').value) || 0;
let cambio = entregado - neto;
if (cambio < 0) cambio = 0;
document.getElementById('cambioDisplay').textContent = '$ ' + cambio.toFixed(2);
document.getElementById('cambioEntregado').value = cambio.toFixed(2);
}
function confirmClose(id) {
Swal.fire({
title: '¿Cerrar este cierre?',
text: 'Una vez cerrado, no se podrán agregar ni modificar diezmos ni salidas.',
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#198754',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, cerrar',
cancelButtonText: 'Cancelar'
}).then(r => { if (r.isConfirmed) document.getElementById('formCerrar').submit(); });
}
function confirmReopen(id) {
Swal.fire({
title: '¿Reabrir este cierre?',
text: 'Esto permitirá nuevamente editar diezmos y salidas.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ffc107',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, reabrir',
cancelButtonText: 'Cancelar'
}).then(r => { if (r.isConfirmed) document.getElementById('formReabrir').submit(); });
}
</script>
}

View File

@@ -0,0 +1,138 @@
@model List<Rs_system.Models.ViewModels.DiezmoCierreListViewModel>
@{
ViewData["Title"] = "Registro de Diezmos";
}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-1"><i class="bi bi-cash-coin me-2"></i>Registro de Diezmos</h4>
<p class="text-muted mb-0">Gestión de cierres periódicos de diezmos</p>
</div>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownCatalogos" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Catálogos
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdownCatalogos">
<li><a class="dropdown-item" asp-controller="DiezmoCatalogo" asp-action="TiposSalida"><i class="bi bi-tags me-2 text-muted"></i>Tipos de Salida</a></li>
<li><a class="dropdown-item" asp-controller="DiezmoCatalogo" asp-action="Beneficiarios"><i class="bi bi-people me-2 text-muted"></i>Beneficiarios</a></li>
</ul>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Cierre
</a>
</div>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-1"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Filtro por año -->
<div class="card-custom mb-4">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Año</label>
<select name="anio" class="form-select" asp-items="@(ViewBag.Anios as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)">
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-funnel me-1"></i> Filtrar
</button>
</div>
</form>
</div>
<script>document.querySelector('select[name="anio"]').value = '@ViewBag.AnioActual';</script>
<!-- Tarjetas resumen del período -->
@{
var totalNeto = Model.Sum(c => c.TotalNeto);
var totalSalidas = Model.Sum(c => c.TotalSalidas);
var saldoTotal = Model.Sum(c => c.SaldoFinal);
}
<div class="row mb-4">
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Neto del Período</h6>
<h3 class="text-primary mb-0">$ @totalNeto.ToString("N2")</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Salidas</h6>
<h3 class="text-warning mb-0">$ @totalSalidas.ToString("N2")</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Saldo Acumulado</h6>
<h3 class="@(saldoTotal >= 0 ? "text-success" : "text-danger") mb-0">$ @saldoTotal.ToString("N2")</h3>
</div>
</div>
</div>
<!-- Tabla de cierres -->
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Fecha</th>
<th class="text-center">Estado</th>
<th class="text-end">Total Recibido</th>
<th class="text-end">Total Neto</th>
<th class="text-end">Salidas</th>
<th class="text-end">Saldo Final</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
No hay cierres registrados para el año seleccionado
</td>
</tr>
}
@foreach (var cierre in Model)
{
<tr>
<td>
<strong>@cierre.Fecha.ToString("dd/MM/yyyy")</strong>
<br><small class="text-muted">@cierre.Fecha.DayOfWeek</small>
</td>
<td class="text-center">
<span class="@cierre.EstadoBadge">@cierre.EstadoTexto</span>
</td>
<td class="text-end">$ @cierre.TotalRecibido.ToString("N2")</td>
<td class="text-end">$ @cierre.TotalNeto.ToString("N2")</td>
<td class="text-end text-warning">$ @cierre.TotalSalidas.ToString("N2")</td>
<td class="text-end @(cierre.SaldoFinal >= 0 ? "text-success" : "text-danger") fw-bold">
$ @cierre.SaldoFinal.ToString("N2")
</td>
<td class="text-center">
<a asp-action="Detail" asp-route-id="@cierre.Id"
class="btn btn-sm btn-outline-primary" title="Ver detalle">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,99 @@
@model Rs_system.Models.DiezmoSalida
@{
ViewData["Title"] = $"Recibo {ViewBag.NumeroRecibo}";
Layout = null; // layout propio para impresión
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Recibo @ViewBag.NumeroRecibo</title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 20px; color: #212529; }
.recibo { max-width: 600px; margin: auto; border: 2px solid #343a40; border-radius: 8px; padding: 30px; }
.recibo-header { text-align: center; border-bottom: 2px dashed #ced4da; padding-bottom: 16px; margin-bottom: 20px; }
.recibo-header h2 { margin: 0 0 4px; font-size: 1.6rem; }
.recibo-header p { margin: 0; color: #6c757d; font-size: .9rem; }
.recibo-nro { font-size: 1.1rem; font-weight: bold; color: #0d6efd; }
.recibo-body table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.recibo-body tr td { padding: 8px 12px; }
.recibo-body tr td:first-child { font-weight: 600; color: #6c757d; width: 40%; }
.recibo-monto { text-align: center; background: #f8f9fa; border-radius: 6px; padding: 16px; margin-bottom: 20px; }
.recibo-monto h3 { margin: 0; font-size: 2rem; color: #198754; }
.recibo-footer { border-top: 2px dashed #ced4da; padding-top: 16px; font-size: .8rem; color: #6c757d; display: flex; justify-content: space-between; }
.firma { margin-top: 40px; text-align: center; }
.firma-linea { border-top: 1px solid #343a40; width: 220px; margin: auto; padding-top: 6px; }
@@media print {
body { padding: 0; }
.no-print { display: none !important; }
}
</style>
</head>
<body>
<div class="no-print mb-3" style="text-align:center">
<button onclick="window.print()" style="padding:8px 20px;cursor:pointer">
🖨️ Imprimir / Guardar PDF
</button>
<button onclick="window.close()" style="padding:8px 20px;cursor:pointer;margin-left:8px">
✕ Cerrar
</button>
</div>
<div class="recibo">
<!-- Encabezado -->
<div class="recibo-header">
<h2>Recibo de Diezmos</h2>
<p>Iglesia — módulo de Diezmos</p>
<div class="recibo-nro mt-2">@ViewBag.NumeroRecibo</div>
</div>
<!-- Datos -->
<div class="recibo-body">
<table>
<tr>
<td>Fecha:</td>
<td>@Model.Fecha.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</td>
</tr>
<tr>
<td>Tipo:</td>
<td>@(Model.TipoSalida?.Nombre ?? "—")</td>
</tr>
<tr>
<td>Beneficiario:</td>
<td>@(Model.Beneficiario?.Nombre ?? "No especificado")</td>
</tr>
<tr>
<td>Concepto:</td>
<td>@Model.Concepto</td>
</tr>
<tr>
<td>Cierre:</td>
<td>@(Model.DiezmoCierre?.Fecha.ToString("dd/MM/yyyy") ?? "—")</td>
</tr>
<tr>
<td>Emitido por:</td>
<td>@ViewBag.Emisor</td>
</tr>
</table>
<!-- Monto destacado -->
<div class="recibo-monto">
<small style="color:#6c757d">MONTO</small>
<h3>$ @Model.Monto.ToString("N2")</h3>
</div>
<!-- Firma -->
<div class="firma">
<div class="firma-linea">Firma del receptor</div>
</div>
</div>
<!-- Pie -->
<div class="recibo-footer">
<span>Generado: @DateTime.Now.ToString("dd/MM/yyyy HH:mm")</span>
<span>@ViewBag.NumeroRecibo</span>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,136 @@
@model IEnumerable<Rs_system.Models.DiezmoBeneficiario>
@{
ViewData["Title"] = "Catálogo de Beneficiarios";
}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-1">
<i class="bi bi-people me-2"></i>Catálogo de Beneficiarios
</h4>
<p class="text-muted mb-0">Personas o entidades externas que reciben salidas de fondos.</p>
</div>
<div class="d-flex gap-2">
<a asp-controller="Diezmo" asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Volver a Diezmos
</a>
<button type="button" class="btn btn-primary-custom btn-sm" onclick="openModal(0, '', '')">
<i class="bi bi-plus-lg me-1"></i>Nuevo Beneficiario
</button>
</div>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-1"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom" id="tblBeneficiarios">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.Nombre</td>
<td><small class="text-muted">@item.Descripcion</small></td>
<td class="text-center">
@if (item.Activo)
{
<span class="badge bg-success">Activo</span>
}
else
{
<span class="badge bg-secondary">Inactivo</span>
}
</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary"
onclick="openModal(@item.Id, '@item.Nombre.Replace("'","\\'")', '@(item.Descripcion?.Replace("'","\\'") ?? "")')"
title="Editar">
<i class="bi bi-pencil"></i>
</button>
<form asp-action="EliminarBeneficiario" method="post" class="d-inline"
onsubmit="return confirm('¿Seguro que desea eliminar este beneficiario?')">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@item.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
}
@if (!Model.Any())
{
<tr>
<td colspan="4" class="text-center text-muted py-4">Sin registros.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Modal CRUD -->
<div class="modal fade" id="modalCrud" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Beneficiario</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form asp-action="GuardarBeneficiario" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Id" id="b_id" value="0" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre <span class="text-danger">*</span></label>
<input type="text" name="Nombre" id="b_nombre" class="form-control" required maxlength="150" />
</div>
<div class="mb-3">
<label class="form-label">Descripción</label>
<textarea name="Descripcion" id="b_desc" class="form-control" rows="3" maxlength="300"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary-custom">Guardar</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
function openModal(id, nombre, desc) {
document.getElementById('b_id').value = id;
document.getElementById('b_nombre').value = nombre;
document.getElementById('b_desc').value = desc;
document.getElementById('modalTitle').innerText = id === 0 ? 'Nuevo Beneficiario' : 'Editar Beneficiario';
var modal = new bootstrap.Modal(document.getElementById('modalCrud'));
modal.show();
}
</script>
}

View File

@@ -0,0 +1,142 @@
@model IEnumerable<Rs_system.Models.DiezmoTipoSalida>
@{
ViewData["Title"] = "Catálogo: Tipos de Salida";
}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h4 class="mb-1">
<i class="bi bi-tags me-2"></i>Tipos de Salida
</h4>
<p class="text-muted mb-0">Gestión de conceptos o clasificaciones para salidas de caja.</p>
</div>
<div class="d-flex gap-2">
<a asp-controller="Diezmo" asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Volver a Diezmos
</a>
<button type="button" class="btn btn-primary-custom btn-sm" onclick="openModal(0, '', '', false)">
<i class="bi bi-plus-lg me-1"></i>Nuevo Tipo
</button>
</div>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-1"></i> @TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i> @TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th class="text-center">Tipo Especial</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.Nombre</td>
<td><small class="text-muted">@item.Descripcion</small></td>
<td class="text-center">
@if (item.EsEntregaPastor)
{
<span class="badge bg-info text-dark"><i class="bi bi-person-check me-1"></i>Entrega Pastor</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary"
onclick="openModal(@item.Id, '@item.Nombre.Replace("'","\\'")', '@(item.Descripcion?.Replace("'","\\'") ?? "")', @(item.EsEntregaPastor.ToString().ToLower()))"
title="Editar">
<i class="bi bi-pencil"></i>
</button>
<form asp-action="EliminarTipoSalida" method="post" class="d-inline"
onsubmit="return confirm('¿Seguro que desea eliminar este tipo?')">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@item.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
}
@if (!Model.Any())
{
<tr>
<td colspan="4" class="text-center text-muted py-4">Sin registros.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Modal CRUD -->
<div class="modal fade" id="modalCrud" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Tipo de Salida</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form asp-action="GuardarTipoSalida" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Id" id="t_id" value="0" />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre <span class="text-danger">*</span></label>
<input type="text" name="Nombre" id="t_nombre" class="form-control" required maxlength="100" />
</div>
<div class="mb-3">
<label class="form-label">Descripción</label>
<textarea name="Descripcion" id="t_desc" class="form-control" rows="2" maxlength="300"></textarea>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" name="EsEntregaPastor" id="t_esPastor" value="true">
<label class="form-check-label" for="t_esPastor">Este tipo indica una "Entrega Directa al Pastor"</label>
<small class="d-block text-muted">Útil a nivel contable para identificar la obligación central del diezmo.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary-custom">Guardar</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
function openModal(id, nombre, desc, esPastor) {
document.getElementById('t_id').value = id;
document.getElementById('t_nombre').value = nombre;
document.getElementById('t_desc').value = desc;
document.getElementById('t_esPastor').checked = esPastor;
document.getElementById('modalTitle').innerText = id === 0 ? 'Nuevo Tipo de Salida' : 'Editar Tipo de Salida';
var modal = new bootstrap.Modal(document.getElementById('modalCrud'));
modal.show();
}
</script>
}

View File

@@ -0,0 +1,65 @@
@{
ViewData["Title"] = "Importar Miembros";
}
<div class="container-fluid">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Importar Miembros desde CSV</h1>
<a asp-action="Index" class="btn btn-sm btn-secondary shadow-sm">
<i class="fas fa-arrow-left fa-sm text-white-50"></i> Volver a la Lista
</a>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Cargar Archivo CSV</h6>
</div>
<div class="card-body">
@if (ViewBag.Errors != null)
{
<div class="alert alert-danger">
<h4 class="alert-heading">Errores encontrados:</h4>
<p>Por favor corrija los siguientes errores en el archivo CSV y vuelva a intentarlo:</p>
<hr>
<ul class="mb-0">
@foreach (var error in ViewBag.Errors)
{
<li>@error</li>
}
</ul>
</div>
}
<div class="alert alert-info">
<h5>Instrucciones:</h5>
<p>El archivo CSV debe tener las siguientes columnas en este orden exacto:</p>
<ol>
<li>Nombres</li>
<li>Apellidos</li>
<li>Fecha Nacimiento (formato aceptado por el sistema, e.g. YYYY-MM-DD)</li>
<li>Fecha Ingreso Congregación (formato aceptado por el sistema)</li>
<li>Teléfono</li>
<li>Teléfono de Emergencia</li>
<li>Dirección</li>
<li><strong>ID</strong> del Grupo de Trabajo (Número)</li>
<li>Bautizado en Espíritu Santo (Si/1/True)</li>
<li>Activo (Si/1/True)</li>
</ol>
</div>
<form asp-action="Importar" enctype="multipart/form-data" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label for="file">Seleccionar Archivo CSV</label>
<input type="file" name="file" class="form-control-file" id="file" required accept=".csv">
</div>
<div class="form-group mt-3">
<input type="submit" value="Importar" class="btn btn-primary" />
</div>
</form>
</div>
</div>
</div>

View File

@@ -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")]

View File

@@ -1 +1 @@
7e2d659fefb50453fa3da00dc55dfdaadf1c304f5a0fbc1389094f2c740f7326
207587d655de51326030a07a0184e67ca76d93743141a95cdbc5efdc3e4541f1

View File

@@ -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 =

View File

@@ -1 +1 @@
1e149c33148abe9fa6194941c5133295585bfc66f5204d7905e01b09846bdccc
cf5eb9535656c70fb44bcab29751e1b8ead46c4e4f39ae6fedbde6c1dae54744

View File

@@ -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
-- =====================================================

View File

@@ -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);
});

View File

@@ -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 = '<i class="bi bi-arrow-repeat"></i> Sincronizando';
this.statusIndicator.style.display = 'inline-block';
} else if (this.isOnline) {
this.statusIndicator.className = 'badge bg-success ms-2';
this.statusIndicator.innerHTML = '<i class="bi bi-wifi"></i> En línea';
this.statusIndicator.style.display = 'inline-block';
} else {
this.statusIndicator.className = 'badge bg-secondary ms-2';
this.statusIndicator.innerHTML = '<i class="bi bi-wifi-off"></i> 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();
}

View File

@@ -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');