Compare commits

...

2 Commits

Author SHA1 Message Date
a73de4a4fa cinu 2026-02-22 14:39:11 -06:00
bec656b105 service worker 2026-02-22 09:06:44 -06:00
77 changed files with 120174 additions and 229 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

@@ -17,10 +17,10 @@ public class MiembroController : Controller
}
// GET: Miembro
public async Task<IActionResult> Index()
public async Task<IActionResult> Index(int page = 1, int pageSize = 10, string? search = null)
{
var miembros = await _miembroService.GetAllAsync();
return View(miembros);
var paginatedMembers = await _miembroService.GetPaginatedAsync(page, pageSize, search);
return View(paginatedMembers);
}
// GET: Miembro/Details/5
@@ -129,4 +129,61 @@ public class MiembroController : Controller
var grupos = await _miembroService.GetGruposTrabajoAsync();
ViewBag.GruposTrabajo = new SelectList(grupos.Select(g => new { g.Id, g.Nombre }), "Id", "Nombre");
}
// GET: Miembro/Importar
public IActionResult Importar()
{
return View();
}
// POST: Miembro/Importar
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Importar(IFormFile? file)
{
if (file == null || file.Length == 0)
{
ModelState.AddModelError("", "Por favor seleccione un archivo CSV.");
return View();
}
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError("", "El archivo debe ser un CSV.");
return View();
}
try
{
using var stream = file.OpenReadStream();
var createdBy = User.Identity?.Name ?? "Sistema";
var result = await _miembroService.ImportarMiembrosAsync(stream, createdBy);
if (result.SuccessCount > 0)
{
TempData["SuccessMessage"] = $"Se importaron {result.SuccessCount} miembros exitosamente.";
}
if (result.Errors.Any())
{
ViewBag.Errors = result.Errors;
if (result.SuccessCount == 0)
{
ModelState.AddModelError("", "No se pudo importar ningún miembro. Revise los errores.");
}
else
{
TempData["WarningMessage"] = "Se importaron algunos miembros, pero hubo errores en otras filas.";
}
return View(); // Stay on page to show errors
}
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
ModelState.AddModelError("", $"Error al procesar el archivo: {ex.Message}");
return View();
}
}
}

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

@@ -34,4 +34,21 @@ public interface IMiembroService
/// Gets all active work groups for dropdown
/// </summary>
Task<IEnumerable<(long Id, string Nombre)>> GetGruposTrabajoAsync();
/// <summary>
/// Imports members from a CSV stream
/// </summary>
/// <param name="csvStream">The stream of the CSV file</param>
/// <param name="createdBy">The user creating the members</param>
/// <returns>A tuple with success count and a list of error messages</returns>
Task<(int SuccessCount, List<string> Errors)> ImportarMiembrosAsync(Stream csvStream, string createdBy);
/// <summary>
/// Gets paginated members with optional search
/// </summary>
/// <param name="page">Current page number (1-based)</param>
/// <param name="pageSize">Number of items per page</param>
/// <param name="searchQuery">Optional search query to filter by name</param>
/// <returns>Paginated result with members</returns>
Task<PaginatedViewModel<MiembroViewModel>> GetPaginatedAsync(int page, int pageSize, string? searchQuery = null);
}

View File

@@ -221,4 +221,265 @@ public class MiembroService : IMiembroService
.Select(g => new ValueTuple<long, string>(g.Id, g.Nombre))
.ToListAsync();
}
public async Task<(int SuccessCount, List<string> Errors)> ImportarMiembrosAsync(Stream csvStream, string createdBy)
{
int successCount = 0;
var errors = new List<string>();
int rowNumber = 1; // 1-based, starting at header
using var reader = new StreamReader(csvStream);
// Read valid groups for validation
var validGroupIds = await _context.GruposTrabajo
.Where(g => g.Activo)
.Select(g => g.Id)
.ToListAsync();
var validGroupIdsSet = new HashSet<long>(validGroupIds);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line)) continue;
rowNumber++;
// Skip header if it looks like one (simple check or just assume first row is header)
// The prompt implies a specific format, we'll assume the first row IS the header based on standard CSV practices,
// but if the user provides a file without header it might be an issue.
// However, usually "loading a csv" implies a header.
// I'll skip the first row (header) in the loop logic by adding a check.
if (rowNumber == 2) continue; // Skip header row (rowNumber started at 1, so first ReadLine is row 1 (header), loop increments to 2)
// Wait, if I increment rowNumber AFTER reading, then:
// Start: rowNumber=1.
// ReadLine (Header). rowNumber becomes 2.
// So if rowNumber == 2, it means we just read the header. Correct.
// Parse CSV line
var values = ParseCsvLine(line);
// Expected columns:
// 0: Nombres
// 1: Apellidos
// 2: Fecha Nacimiento
// 3: Fecha Ingreso Congregacion
// 4: Telefono
// 5: Telefono Emergencia
// 6: Direccion
// 7: Grupo de trabajo (ID)
// 8: Bautizado en El Espiritu Santo (Si/No or True/False)
// 9: Activo (Si/No or True/False)
if (values.Count < 10)
{
errors.Add($"Fila {rowNumber}: Número de columnas insuficiente. Se esperaban 10, se encontraron {values.Count}.");
continue;
}
try
{
// Validation and Parsing
var nombres = values[0].Trim();
var apellidos = values[1].Trim();
if (string.IsNullOrEmpty(nombres) || string.IsNullOrEmpty(apellidos))
{
errors.Add($"Fila {rowNumber}: Nombres y Apellidos son obligatorios.");
continue;
}
DateOnly? fechaNacimiento = ParseDate(values[2]);
DateOnly? fechaIngreso = ParseDate(values[3]);
var telefono = values[4].Trim();
var telefonoEmergencia = values[5].Trim();
var direccion = values[6].Trim();
if (!long.TryParse(values[7], out long grupoId))
{
errors.Add($"Fila {rowNumber}: ID de Grupo de trabajo inválido '{values[7]}'.");
continue;
}
if (!validGroupIdsSet.Contains(grupoId))
{
errors.Add($"Fila {rowNumber}: Grupo de trabajo con ID {grupoId} no existe o no está activo.");
continue;
}
bool bautizado = ParseBool(values[8]);
bool activo = ParseBool(values[9]);
// Create Logic
var strategy = _context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var persona = new Persona
{
Nombres = nombres,
Apellidos = apellidos,
FechaNacimiento = fechaNacimiento,
Direccion = string.IsNullOrEmpty(direccion) ? null : direccion,
Telefono = string.IsNullOrEmpty(telefono) ? null : telefono,
Activo = activo,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Personas.Add(persona);
await _context.SaveChangesAsync();
var miembro = new Miembro
{
PersonaId = persona.Id,
BautizadoEspirituSanto = bautizado,
FechaIngresoCongregacion = fechaIngreso,
TelefonoEmergencia = string.IsNullOrEmpty(telefonoEmergencia) ? null : telefonoEmergencia,
GrupoTrabajoId = grupoId,
Activo = activo,
CreadoPor = createdBy,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Miembros.Add(miembro);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
successCount++;
}
catch (Exception ex)
{
errors.Add($"Fila {rowNumber}: Error al guardar en base de datos: {ex.Message}");
// Transaction rolls back automatically on dispose if not committed
}
});
}
catch (Exception ex)
{
errors.Add($"Fila {rowNumber}: Error inesperado: {ex.Message}");
}
}
return (successCount, errors);
}
private List<string> ParseCsvLine(string line)
{
var values = new List<string>();
bool inQuotes = false;
string currentValue = "";
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
values.Add(currentValue);
currentValue = "";
}
else
{
currentValue += c;
}
}
values.Add(currentValue);
// Remove surrounding quotes if present
for (int i = 0; i < values.Count; i++)
{
var val = values[i].Trim();
if (val.StartsWith("\"") && val.EndsWith("\"") && val.Length >= 2)
{
values[i] = val.Substring(1, val.Length - 2).Replace("\"\"", "\"");
}
else
{
values[i] = val;
}
}
return values;
}
private DateOnly? ParseDate(string value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (DateOnly.TryParse(value, out var date)) return date;
if (DateTime.TryParse(value, out var dt)) return DateOnly.FromDateTime(dt);
return null; // Or throw depending on strictness, currently lenient
}
private bool ParseBool(string value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
var val = value.Trim().ToLower();
return val == "1" || val == "true" || val == "si" || val == "yes" || val == "s" || val == "verdadero";
}
public async Task<PaginatedViewModel<MiembroViewModel>> GetPaginatedAsync(int page, int pageSize, string? searchQuery = null)
{
// Ensure valid page and pageSize
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 10;
if (pageSize > 100) pageSize = 100; // Max limit
// Start with base query
var query = _context.Miembros
.Include(m => m.Persona)
.Include(m => m.GrupoTrabajo)
.Where(m => !m.Eliminado && m.Activo);
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var search = searchQuery.Trim().ToLower();
query = query.Where(m =>
m.Persona.Nombres.ToLower().Contains(search) ||
m.Persona.Apellidos.ToLower().Contains(search) ||
(m.Persona.Nombres + " " + m.Persona.Apellidos).ToLower().Contains(search)
);
}
// Get total count for pagination
var totalItems = await query.CountAsync();
// Get paginated items
var items = await query
.OrderBy(m => m.Persona.Apellidos)
.ThenBy(m => m.Persona.Nombres)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(m => new MiembroViewModel
{
Id = m.Id,
Nombres = m.Persona.Nombres,
Apellidos = m.Persona.Apellidos,
FechaNacimiento = m.Persona.FechaNacimiento,
BautizadoEspirituSanto = m.BautizadoEspirituSanto,
Direccion = m.Persona.Direccion,
FechaIngresoCongregacion = m.FechaIngresoCongregacion,
Telefono = m.Persona.Telefono,
TelefonoEmergencia = m.TelefonoEmergencia,
GrupoTrabajoId = m.GrupoTrabajoId,
GrupoTrabajoNombre = m.GrupoTrabajo != null ? m.GrupoTrabajo.Nombre : null,
Activo = m.Activo,
FotoUrl = m.Persona.FotoUrl
})
.ToListAsync();
return new PaginatedViewModel<MiembroViewModel>
{
Items = items,
CurrentPage = page,
PageSize = pageSize,
TotalItems = totalItems,
SearchQuery = searchQuery
};
}
}

View File

@@ -261,193 +261,284 @@
</div>
@section Scripts {
<script>
let timeoutBusqueda = null;
<!-- Offline Support Scripts -->
<script src="~/js/colaboraciones-offline-db.js"></script>
<script src="~/js/colaboraciones-sync.js"></script>
// Búsqueda de miembros
document.getElementById('buscarMiembro').addEventListener('input', function(e) {
const termino = e.target.value;
const resultadosDiv = document.getElementById('resultadosBusqueda');
<script>
let timeoutBusqueda = null;
clearTimeout(timeoutBusqueda);
// Búsqueda de miembros
document.getElementById('buscarMiembro').addEventListener('input', function(e) {
const termino = e.target.value;
const resultadosDiv = document.getElementById('resultadosBusqueda');
if (termino.length < 2) {
resultadosDiv.style.display = 'none';
return;
}
clearTimeout(timeoutBusqueda);
timeoutBusqueda = setTimeout(async () => {
try {
const response = await fetch('@Url.Action("BuscarMiembros", "Colaboracion")?termino=' + encodeURIComponent(termino));
const miembros = await response.json();
if (termino.length < 2) {
resultadosDiv.style.display = 'none';
return;
}
if (miembros.length === 0) {
resultadosDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
resultadosDiv.style.display = 'block';
return;
}
timeoutBusqueda = setTimeout(async () => {
try {
const response = await fetch('@Url.Action("BuscarMiembros", "Colaboracion")?termino=' + encodeURIComponent(termino));
const miembros = await response.json();
let html = '';
miembros.forEach(miembro => {
html += `
<button type="button" class="list-group-item list-group-item-action" onclick="seleccionarMiembro(${miembro.id}, '${miembro.text}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-person me-2"></i>
<strong>${miembro.text}</strong>
</div>
${miembro.telefono ? '<small class="text-muted">' + miembro.telefono + '</small>' : ''}
</div>
</button>
`;
});
if (miembros.length === 0) {
resultadosDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
resultadosDiv.style.display = 'block';
return;
}
resultadosDiv.innerHTML = html;
resultadosDiv.style.display = 'block';
} catch (error) {
console.error('Error al buscar miembros:', error);
}
}, 300);
});
let html = '';
miembros.forEach(miembro => {
html += `
<button type="button" class="list-group-item list-group-item-action" onclick="seleccionarMiembro(${miembro.id}, '${miembro.text}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-person me-2"></i>
<strong>${miembro.text}</strong>
</div>
${miembro.telefono ? '<small class="text-muted">' + miembro.telefono + '</small>' : ''}
</div>
</button>
`;
});
// Cerrar resultados cuando se hace clic fuera
document.addEventListener('click', function(e) {
const buscarInput = document.getElementById('buscarMiembro');
const resultadosDiv = document.getElementById('resultadosBusqueda');
resultadosDiv.innerHTML = html;
resultadosDiv.style.display = 'block';
} catch (error) {
console.error('Error al buscar miembros:', error);
}
}, 300);
});
if (!buscarInput.contains(e.target) && !resultadosDiv.contains(e.target)) {
resultadosDiv.style.display = 'none';
}
});
// Cerrar resultados cuando se hace clic fuera
document.addEventListener('click', function(e) {
const buscarInput = document.getElementById('buscarMiembro');
const resultadosDiv = document.getElementById('resultadosBusqueda');
function seleccionarMiembro(id, nombre) {
document.getElementById('miembroIdHidden').value = id;
document.getElementById('nombreMiembroSeleccionado').textContent = nombre;
document.getElementById('miembroSeleccionado').style.display = 'block';
document.getElementById('buscarMiembro').value = '';
document.getElementById('buscarMiembro').style.display = 'none';
document.getElementById('resultadosBusqueda').style.display = 'none';
if (!buscarInput.contains(e.target) && !resultadosDiv.contains(e.target)) {
resultadosDiv.style.display = 'none';
}
});
// Cargar historial de pagos
cargarHistorialPagos(id);
}
function seleccionarMiembro(id, nombre) {
document.getElementById('miembroIdHidden').value = id;
document.getElementById('nombreMiembroSeleccionado').textContent = nombre;
document.getElementById('miembroSeleccionado').style.display = 'block';
document.getElementById('buscarMiembro').value = '';
document.getElementById('buscarMiembro').style.display = 'none';
document.getElementById('resultadosBusqueda').style.display = 'none';
function limpiarMiembro() {
document.getElementById('miembroIdHidden').value = '';
document.getElementById('miembroSeleccionado').style.display = 'none';
document.getElementById('buscarMiembro').style.display = 'block';
document.getElementById('buscarMiembro').focus();
// Ocultar historial
document.getElementById('infoUltimosPagos').style.display = 'none';
document.getElementById('listaUltimosPagos').innerHTML = '';
}
async function cargarHistorialPagos(miembroId) {
const contenedor = document.getElementById('infoUltimosPagos');
const lista = document.getElementById('listaUltimosPagos');
lista.innerHTML = '<div class="spinner-border spinner-border-sm text-info" role="status"></div> Cargando historial...';
contenedor.style.display = 'block';
try {
const response = await fetch('@Url.Action("ObtenerUltimosPagos", "Colaboracion")?miembroId=' + miembroId);
const pagos = await response.json();
if (pagos && pagos.length > 0) {
let html = '';
pagos.forEach(p => {
const colorClass = p.ultimoMes > 0 ? 'bg-white text-info border border-info' : 'bg-secondary text-white';
html += `
<span class="badge ${colorClass} fw-normal p-2">
<strong>${p.nombreTipo}:</strong> ${p.ultimoPeriodoTexto}
</span>
`;
});
lista.innerHTML = html;
} else {
lista.innerHTML = '<span class="text-muted small">No hay historial de pagos registrado.</span>';
}
} catch (error) {
console.error('Error al cargar historial:', error);
lista.innerHTML = '<span class="text-danger small"><i class="bi bi-exclamation-circle"></i> Error al cargar historial</span>';
}
}
function calcularSugerido() {
try {
// Obtener valores
const mesInicial = parseInt(document.getElementById('mesInicial').value);
const anioInicial = parseInt(document.getElementById('anioInicial').value);
const mesFinal = parseInt(document.getElementById('mesFinal').value);
const anioFinal = parseInt(document.getElementById('anioFinal').value);
// Calcular total de meses
const fechaInicial = new Date(anioInicial, mesInicial - 1, 1);
const fechaFinal = new Date(anioFinal, mesFinal - 1, 1);
let totalMeses = 0;
if (fechaFinal >= fechaInicial) {
totalMeses = ((anioFinal - anioInicial) * 12) + (mesFinal - mesInicial) + 1;
}
// Obtener tipos seleccionados y sus montos
const tiposCheckboxes = document.querySelectorAll('.tipo-checkbox:checked');
const totalTipos = tiposCheckboxes.length;
// Calcular monto sugerido total
let montoSugeridoTotal = 0;
tiposCheckboxes.forEach(checkbox => {
const montoPorMes = parseFloat(checkbox.getAttribute('data-monto')) || 0;
montoSugeridoTotal += montoPorMes * totalMeses;
});
// Actualizar UI
document.getElementById('totalMeses').textContent = totalMeses;
document.getElementById('totalTipos').textContent = totalTipos;
document.getElementById('montoSugerido').textContent = montoSugeridoTotal.toFixed(2);
// Comparar con monto ingresado
const montoIngresado = parseFloat(document.getElementById('montoTotal').value) || 0;
const alertaDiv = document.getElementById('alertaDiferencia');
const mensajeSpan = document.getElementById('mensajeDiferencia');
if (montoIngresado > 0) {
if (Math.abs(montoIngresado - montoSugeridoTotal) > 0.01) {
const diferencia = montoIngresado - montoSugeridoTotal;
if (diferencia > 0) {
mensajeSpan.textContent = `Sobra: $${diferencia.toFixed(2)}`;
alertaDiv.className = 'alert alert-info py-1 px-2 mb-0 mt-2';
} else {
mensajeSpan.textContent = `Falta: $${Math.abs(diferencia).toFixed(2)}`;
alertaDiv.className = 'alert alert-warning py-1 px-2 mb-0 mt-2';
}
alertaDiv.style.display = 'block';
} else {
alertaDiv.style.display = 'none';
}
} else {
alertaDiv.style.display = 'none';
}
} catch (error) {
console.error('Error al calcular sugerido:', error);
}
}
// Calcular cuando cambia el monto total
document.getElementById('montoTotal')?.addEventListener('input', calcularSugerido);
// Calcular al cargar la página
document.addEventListener('DOMContentLoaded', function() {
calcularSugerido();
});
// Show error messages
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
// Cargar historial de pagos
cargarHistorialPagos(id);
}
</script>
function limpiarMiembro() {
document.getElementById('miembroIdHidden').value = '';
document.getElementById('miembroSeleccionado').style.display = 'none';
document.getElementById('buscarMiembro').style.display = 'block';
document.getElementById('buscarMiembro').focus();
// Ocultar historial
document.getElementById('infoUltimosPagos').style.display = 'none';
document.getElementById('listaUltimosPagos').innerHTML = '';
}
async function cargarHistorialPagos(miembroId) {
const contenedor = document.getElementById('infoUltimosPagos');
const lista = document.getElementById('listaUltimosPagos');
lista.innerHTML = '<div class="spinner-border spinner-border-sm text-info" role="status"></div> Cargando historial...';
contenedor.style.display = 'block';
try {
const response = await fetch('@Url.Action("ObtenerUltimosPagos", "Colaboracion")?miembroId=' + miembroId);
const pagos = await response.json();
if (pagos && pagos.length > 0) {
let html = '';
pagos.forEach(p => {
const colorClass = p.ultimoMes > 0 ? 'bg-white text-info border border-info' : 'bg-secondary text-white';
html += `
<span class="badge ${colorClass} fw-normal p-2">
<strong>${p.nombreTipo}:</strong> ${p.ultimoPeriodoTexto}
</span>
`;
});
lista.innerHTML = html;
} else {
lista.innerHTML = '<span class="text-muted small">No hay historial de pagos registrado.</span>';
}
} catch (error) {
console.error('Error al cargar historial:', error);
lista.innerHTML = '<span class="text-danger small"><i class="bi bi-exclamation-circle"></i> Error al cargar historial</span>';
}
}
function calcularSugerido() {
try {
// Obtener valores
const mesInicial = parseInt(document.getElementById('mesInicial').value);
const anioInicial = parseInt(document.getElementById('anioInicial').value);
const mesFinal = parseInt(document.getElementById('mesFinal').value);
const anioFinal = parseInt(document.getElementById('anioFinal').value);
// Calcular total de meses
const fechaInicial = new Date(anioInicial, mesInicial - 1, 1);
const fechaFinal = new Date(anioFinal, mesFinal - 1, 1);
let totalMeses = 0;
if (fechaFinal >= fechaInicial) {
totalMeses = ((anioFinal - anioInicial) * 12) + (mesFinal - mesInicial) + 1;
}
// Obtener tipos seleccionados y sus montos
const tiposCheckboxes = document.querySelectorAll('.tipo-checkbox:checked');
const totalTipos = tiposCheckboxes.length;
// Calcular monto sugerido total
let montoSugeridoTotal = 0;
tiposCheckboxes.forEach(checkbox => {
const montoPorMes = parseFloat(checkbox.getAttribute('data-monto')) || 0;
montoSugeridoTotal += montoPorMes * totalMeses;
});
// Actualizar UI
document.getElementById('totalMeses').textContent = totalMeses;
document.getElementById('totalTipos').textContent = totalTipos;
document.getElementById('montoSugerido').textContent = montoSugeridoTotal.toFixed(2);
// Comparar con monto ingresado
const montoIngresado = parseFloat(document.getElementById('montoTotal').value) || 0;
const alertaDiv = document.getElementById('alertaDiferencia');
const mensajeSpan = document.getElementById('mensajeDiferencia');
if (montoIngresado > 0) {
if (Math.abs(montoIngresado - montoSugeridoTotal) > 0.01) {
const diferencia = montoSugeridoTotal - montoIngresado; // Corrected calculation for difference
if (diferencia > 0) {
mensajeSpan.textContent = `Falta: $${diferencia.toFixed(2)}`;
alertaDiv.className = 'alert alert-warning py-1 px-2 mb-0 mt-2';
} else {
mensajeSpan.textContent = `Sobra: $${Math.abs(diferencia).toFixed(2)}`;
alertaDiv.className = 'alert alert-info py-1 px-2 mb-0 mt-2';
}
alertaDiv.style.display = 'block';
} else {
alertaDiv.style.display = 'none';
}
} else {
alertaDiv.style.display = 'none';
}
} catch (error) {
console.error('Error al calcular sugerido:', error);
}
}
// Calcular cuando cambia el monto total
document.getElementById('montoTotal')?.addEventListener('input', calcularSugerido);
// ===== OFFLINE-FIRST FORM SUBMISSION =====
document.getElementById('colaboracionForm')?.addEventListener('submit', async function(e) {
e.preventDefault(); // Prevent default form submission
// Gather form data
const miembroId = document.getElementById('miembroIdHidden').value;
const mesInicial = document.getElementById('mesInicial').value;
const anioInicial = document.getElementById('anioInicial').value;
const mesFinal = document.getElementById('mesFinal').value;
const anioFinal = document.getElementById('anioFinal').value;
const montoTotal = document.getElementById('montoTotal').value;
const observaciones = document.querySelector('[name="Observaciones"]').value;
const tipoPrioritario = document.getElementById('tipoPrioritario').value;
// Get selected tipos
const tiposSeleccionados = Array.from(document.querySelectorAll('.tipo-checkbox:checked'))
.map(cb => cb.value);
// Validate
if (!miembroId) {
toastr.error('Por favor seleccione un miembro');
return;
}
if (tiposSeleccionados.length === 0) {
toastr.error('Por favor seleccione al menos un tipo de colaboración');
return;
}
if (!montoTotal || parseFloat(montoTotal) <= 0) {
toastr.error('Por favor ingrese un monto válido');
return;
}
// Prepare data object
const colaboracionData = {
miembroId: parseInt(miembroId),
mesInicial: parseInt(mesInicial),
anioInicial: parseInt(anioInicial),
mesFinal: parseInt(mesFinal),
anioFinal: parseInt(anioFinal),
montoTotal: parseFloat(montoTotal),
observaciones: observaciones,
tiposSeleccionados: tiposSeleccionados,
tipoPrioritario: tipoPrioritario || null,
registradoPor: '@User.Identity?.Name'
};
// Disable submit button
const submitBtn = this.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Guardando...';
try {
// Use sync manager to save (handles online/offline automatically)
const result = await ColaboracionesSyncManager.saveColaboracion(colaboracionData);
if (result.success) {
if (result.offline) {
toastr.warning(result.message);
// Clear form
setTimeout(() => {
window.location.href = '@Url.Action("Index", "Colaboracion")';
}, 2000);
} else {
toastr.success(result.message);
// Redirect to index
setTimeout(() => {
window.location.href = '@Url.Action("Index", "Colaboracion")';
}, 1500);
}
} else {
toastr.error(result.message || 'Error al guardar');
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
} catch (error) {
console.error('Form submission error:', error);
toastr.error('Error inesperado: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
});
// Calcular al cargar la página
document.addEventListener('DOMContentLoaded', function() {
calcularSugerido();
});
// Show error messages
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
}
</script>
}

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

@@ -1,4 +1,4 @@
@model IEnumerable<Rs_system.Models.ViewModels.MiembroViewModel>
@model Rs_system.Models.ViewModels.PaginatedViewModel<Rs_system.Models.ViewModels.MiembroViewModel>
@{
ViewData["Title"] = "Miembros de la Iglesia";
}
@@ -8,9 +8,48 @@
<h4 class="mb-1">Miembros en Propiedad</h4>
<p class="text-muted mb-0">Gestión de miembros de la congregación</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Miembro
</a>
<div>
<a asp-action="Importar" class="btn btn-outline-success me-2">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Importar CSV
</a>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Miembro
</a>
</div>
</div>
<!-- Search and Page Size Controls -->
<div class="card-custom mb-3">
<form method="get" asp-action="Index" id="searchForm" class="row g-3 align-items-end">
<div class="col-md-6">
<label for="search" class="form-label">Buscar Miembro</label>
<input type="text"
class="form-control"
id="search"
name="search"
value="@Model.SearchQuery"
placeholder="Buscar por nombre o apellido...">
</div>
<div class="col-md-3">
<label for="pageSize" class="form-label">Registros por página</label>
<select class="form-select"
id="pageSize"
asp-for="PageSize"
onchange="document.getElementById('searchForm').submit();">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-search me-1"></i> Buscar
</button>
</div>
<input type="hidden" name="page" value="1" />
</form>
</div>
<!-- Summary Cards -->
@@ -18,19 +57,19 @@
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Miembros</h6>
<h3 class="text-primary mb-0">@Model.Count()</h3>
<h3 class="text-primary mb-0">@Model.TotalItems</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Bautizados en el Espíritu Santo</h6>
<h3 class="text-success mb-0">@Model.Count(m => m.BautizadoEspirituSanto)</h3>
<h3 class="text-success mb-0">@Model.Items.Count(m => m.BautizadoEspirituSanto)</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Grupos de Trabajo</h6>
<h3 class="text-info mb-0">@Model.Where(m => m.GrupoTrabajoId.HasValue).GroupBy(m => m.GrupoTrabajoId).Count()</h3>
<h3 class="text-info mb-0">@Model.Items.Where(m => m.GrupoTrabajoId.HasValue).GroupBy(m => m.GrupoTrabajoId).Count()</h3>
</div>
</div>
</div>
@@ -52,16 +91,23 @@
</tr>
</thead>
<tbody>
@if (!Model.Any())
@if (!Model.Items.Any())
{
<tr>
<td colspan="8" class="text-center text-muted py-4">
<i class="bi bi-people fs-1 d-block mb-2"></i>
No hay miembros registrados
@if (!string.IsNullOrWhiteSpace(Model.SearchQuery))
{
<text>No se encontraron miembros con el criterio de búsqueda "@Model.SearchQuery"</text>
}
else
{
<text>No hay miembros registrados</text>
}
</td>
</tr>
}
@foreach (var miembro in Model)
@foreach (var miembro in Model.Items)
{
<tr>
<td class="text-center">
@@ -152,6 +198,71 @@
</tbody>
</table>
</div>
<!-- Pagination Controls -->
@if (Model.TotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center mt-3 px-3 pb-3">
<div class="text-muted">
Mostrando @((Model.CurrentPage - 1) * Model.PageSize + 1) a @(Math.Min(Model.CurrentPage * Model.PageSize, Model.TotalItems)) de @Model.TotalItems registros
</div>
<nav aria-label="Paginación de miembros">
<ul class="pagination mb-0">
<!-- Previous Button -->
<li class="page-item @(!Model.HasPreviousPage ? "disabled" : "")">
<a class="page-link"
href="@Url.Action("Index", new { page = Model.CurrentPage - 1, pageSize = Model.PageSize, search = Model.SearchQuery })"
aria-label="Anterior">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@{
var startPage = Math.Max(1, Model.CurrentPage - 2);
var endPage = Math.Min(Model.TotalPages, Model.CurrentPage + 2);
if (startPage > 1)
{
<li class="page-item">
<a class="page-link" href="@Url.Action("Index", new { page = 1, pageSize = Model.PageSize, search = Model.SearchQuery })">1</a>
</li>
if (startPage > 2)
{
<li class="page-item disabled"><span class="page-link">...</span></li>
}
}
for (int i = startPage; i <= endPage; i++)
{
<li class="page-item @(i == Model.CurrentPage ? "active" : "")">
<a class="page-link" href="@Url.Action("Index", new { page = i, pageSize = Model.PageSize, search = Model.SearchQuery })">@i</a>
</li>
}
if (endPage < Model.TotalPages)
{
if (endPage < Model.TotalPages - 1)
{
<li class="page-item disabled"><span class="page-link">...</span></li>
}
<li class="page-item">
<a class="page-link" href="@Url.Action("Index", new { page = Model.TotalPages, pageSize = Model.PageSize, search = Model.SearchQuery })">@Model.TotalPages</a>
</li>
}
}
<!-- Next Button -->
<li class="page-item @(!Model.HasNextPage ? "disabled" : "")">
<a class="page-link"
href="@Url.Action("Index", new { page = Model.CurrentPage + 1, pageSize = Model.PageSize, search = Model.SearchQuery })"
aria-label="Siguiente">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
}
</div>
<!-- Delete Form -->

View File

@@ -22,6 +22,22 @@
<!--<link rel="stylesheet" href="~/Rs_system.styles.css" asp-append-version="true"/>-->
<link rel="manifest" href="~/manifest.json">
<meta name="theme-color" content="#1e293b">
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered successfully:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
}
</script>
@RenderSection("Styles", required: false)
</head>
<body>
@@ -53,6 +69,10 @@
<h5 class="mb-0 fw-semibold">@ViewData["Title"]</h5>
</div>
<div class="header-right">
<span id="offlineStatus" class="badge bg-success" style="display: none;">
<i class="bi bi-wifi"></i> En línea
</span>
<span id="pendingBadge" class="badge bg-warning ms-2" style="display: none;">0</span>
<partial name="_LoginPartial"/>
</div>
</header>

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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+1784131456f11aa7351eef9061c1354519f67545")]
[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 @@
0c9f7ccc95584dd75dafc3cf1c7a4bb9a06235cda706237be23d01c5759bf731
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 =
@@ -184,6 +208,10 @@ build_metadata.AdditionalFiles.CssScope =
build_metadata.AdditionalFiles.TargetPath = Vmlld3MvTWllbWJyby9FZGl0LmNzaHRtbA==
build_metadata.AdditionalFiles.CssScope =
[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Miembro/Importar.cshtml]
build_metadata.AdditionalFiles.TargetPath = Vmlld3MvTWllbWJyby9JbXBvcnRhci5jc2h0bWw=
build_metadata.AdditionalFiles.CssScope =
[/home/adalberto/RiderProjects/RS_system/RS_system/Views/Miembro/Index.cshtml]
build_metadata.AdditionalFiles.TargetPath = Vmlld3MvTWllbWJyby9JbmRleC5jc2h0bWw=
build_metadata.AdditionalFiles.CssScope =

View File

@@ -1 +1 @@
8269c6b6fd373b3f46013b10aa9c68fa849af0f8b9f46b71c9398c8b327dec83
cf5eb9535656c70fb44bcab29751e1b8ead46c4e4f39ae6fedbde6c1dae54744

View File

@@ -266,7 +266,6 @@
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/tlmqwhkg3d-ifse5yxmqk.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/b6c3bvqukf-ifse5yxmqk.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets.build.json
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets.build.json.cache
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets.development.json
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets.build.endpoints.json
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/RS_system.csproj.Up2Date
@@ -277,3 +276,14 @@
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/ref/RS_system.dll
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/lc3k1q6eo4-cr0snyzw1m.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/m7f2490r97-cr0snyzw1m.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/e79wfobnuv-lc8ee02c5q.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/z2cv867s5m-ga728ncyli.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/dpe32h769j-rise9grasc.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/ubjjtv0x1g-4bsvp4jd9h.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/compressed/tlvbvx8n5g-pr0jyv6zw7.gz
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets/msbuild.RS_system.Microsoft.AspNetCore.StaticWebAssets.props
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets/msbuild.RS_system.Microsoft.AspNetCore.StaticWebAssetEndpoints.props
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets/msbuild.build.RS_system.props
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets/msbuild.buildMultiTargeting.RS_system.props
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets/msbuild.buildTransitive.RS_system.props
/home/adalberto/RiderProjects/RS_system/RS_system/obj/Debug/net9.0/staticwebassets.pack.json

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
FIiThG6o4LKnL0aAIanau0zkgrgpEoNVs6Tge42QuR8=

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@
"net9.0"
],
"sources": {
"/home/adalberto/.dotnet/library-packs": {},
"/usr/lib64/dotnet/library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -39,7 +39,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "9.0.300"
"SdkAnalysisLevel": "9.0.100"
},
"frameworks": {
"net9.0": {
@@ -95,7 +95,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "/home/adalberto/.dotnet/sdk/9.0.300/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "/usr/lib64/dotnet/sdk/9.0.113/PortableRuntimeIdentifierGraph.json"
}
}
}

View File

@@ -7,7 +7,7 @@
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/adalberto/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/adalberto/.nuget/packages/</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.13.2</NuGetToolVersion>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="/home/adalberto/.nuget/packages/" />

View File

@@ -3332,7 +3332,7 @@
"net9.0"
],
"sources": {
"/home/adalberto/.dotnet/library-packs": {},
"/usr/lib64/dotnet/library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -3351,7 +3351,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "9.0.300"
"SdkAnalysisLevel": "9.0.100"
},
"frameworks": {
"net9.0": {
@@ -3407,7 +3407,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "/home/adalberto/.dotnet/sdk/9.0.300/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "/usr/lib64/dotnet/sdk/9.0.113/PortableRuntimeIdentifierGraph.json"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"version": 2,
"dgSpecHash": "mE5kunE1L6A=",
"dgSpecHash": "3JUQhcRdx7k=",
"success": true,
"projectFilePath": "/home/adalberto/RiderProjects/RS_system/RS_system/RS_system.csproj",
"expectedPackageFiles": [

View File

@@ -1 +1 @@
"restore":{"projectUniqueName":"/home/adalberto/RiderProjects/RS_system/RS_system/RS_system.csproj","projectName":"RS_system","projectPath":"/home/adalberto/RiderProjects/RS_system/RS_system/RS_system.csproj","outputPath":"/home/adalberto/RiderProjects/RS_system/RS_system/obj/","projectStyle":"PackageReference","originalTargetFrameworks":["net9.0"],"sources":{"/home/adalberto/.dotnet/library-packs":{},"https://api.nuget.org/v3/index.json":{}},"frameworks":{"net9.0":{"targetAlias":"net9.0","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"9.0.300"}"frameworks":{"net9.0":{"targetAlias":"net9.0","dependencies":{"BCrypt.Net-Next":{"target":"Package","version":"[4.0.3, )"},"Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore":{"target":"Package","version":"[9.0.5, )"},"Microsoft.AspNetCore.Identity.EntityFrameworkCore":{"target":"Package","version":"[9.0.5, )"},"Microsoft.AspNetCore.Identity.UI":{"target":"Package","version":"[9.0.5, )"},"Microsoft.EntityFrameworkCore.Design":{"include":"Runtime, Build, Native, ContentFiles, Analyzers, BuildTransitive","suppressParent":"All","target":"Package","version":"[9.0.5, )"},"Microsoft.EntityFrameworkCore.Tools":{"target":"Package","version":"[9.0.5, )"},"Npgsql.EntityFrameworkCore.PostgreSQL":{"target":"Package","version":"[9.0.3, )"}},"imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"frameworkReferences":{"Microsoft.AspNetCore.App":{"privateAssets":"none"},"Microsoft.NETCore.App":{"privateAssets":"all"}},"runtimeIdentifierGraphPath":"/home/adalberto/.dotnet/sdk/9.0.300/PortableRuntimeIdentifierGraph.json"}}
"restore":{"projectUniqueName":"/home/adalberto/RiderProjects/RS_system/RS_system/RS_system.csproj","projectName":"RS_system","projectPath":"/home/adalberto/RiderProjects/RS_system/RS_system/RS_system.csproj","outputPath":"/home/adalberto/RiderProjects/RS_system/RS_system/obj/","projectStyle":"PackageReference","originalTargetFrameworks":["net9.0"],"sources":{"/usr/lib64/dotnet/library-packs":{},"https://api.nuget.org/v3/index.json":{}},"frameworks":{"net9.0":{"targetAlias":"net9.0","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"9.0.100"}"frameworks":{"net9.0":{"targetAlias":"net9.0","dependencies":{"BCrypt.Net-Next":{"target":"Package","version":"[4.0.3, )"},"Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore":{"target":"Package","version":"[9.0.5, )"},"Microsoft.AspNetCore.Identity.EntityFrameworkCore":{"target":"Package","version":"[9.0.5, )"},"Microsoft.AspNetCore.Identity.UI":{"target":"Package","version":"[9.0.5, )"},"Microsoft.EntityFrameworkCore.Design":{"include":"Runtime, Build, Native, ContentFiles, Analyzers, BuildTransitive","suppressParent":"All","target":"Package","version":"[9.0.5, )"},"Microsoft.EntityFrameworkCore.Tools":{"target":"Package","version":"[9.0.5, )"},"Npgsql.EntityFrameworkCore.PostgreSQL":{"target":"Package","version":"[9.0.3, )"}},"imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"frameworkReferences":{"Microsoft.AspNetCore.App":{"privateAssets":"none"},"Microsoft.NETCore.App":{"privateAssets":"all"}},"runtimeIdentifierGraphPath":"/usr/lib64/dotnet/sdk/9.0.113/PortableRuntimeIdentifierGraph.json"}}

View File

@@ -1 +1 @@
17677521665296480
17717199317703256

View File

@@ -1 +1 @@
17677521665296480
17717199317703256

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