service worker

This commit is contained in:
2026-02-22 09:06:44 -06:00
parent 46bf68cb21
commit bec656b105
40 changed files with 115887 additions and 229 deletions

View File

@@ -261,193 +261,284 @@
</div>
@section Scripts {
<script>
let timeoutBusqueda = null;
// Búsqueda de miembros
document.getElementById('buscarMiembro').addEventListener('input', function(e) {
const termino = e.target.value;
const resultadosDiv = document.getElementById('resultadosBusqueda');
clearTimeout(timeoutBusqueda);
if (termino.length < 2) {
resultadosDiv.style.display = 'none';
return;
}
timeoutBusqueda = setTimeout(async () => {
try {
const response = await fetch('@Url.Action("BuscarMiembros", "Colaboracion")?termino=' + encodeURIComponent(termino));
const miembros = await response.json();
if (miembros.length === 0) {
resultadosDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
resultadosDiv.style.display = 'block';
return;
}
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>
`;
});
resultadosDiv.innerHTML = html;
resultadosDiv.style.display = 'block';
} catch (error) {
console.error('Error al buscar miembros:', error);
}
}, 300);
});
// Cerrar resultados cuando se hace clic fuera
document.addEventListener('click', function(e) {
const buscarInput = document.getElementById('buscarMiembro');
const resultadosDiv = document.getElementById('resultadosBusqueda');
if (!buscarInput.contains(e.target) && !resultadosDiv.contains(e.target)) {
resultadosDiv.style.display = 'none';
}
});
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';
// Cargar historial de pagos
cargarHistorialPagos(id);
}
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>
<!-- Offline Support Scripts -->
<script src="~/js/colaboraciones-offline-db.js"></script>
<script src="~/js/colaboraciones-sync.js"></script>
<script>
let timeoutBusqueda = null;
// Búsqueda de miembros
document.getElementById('buscarMiembro').addEventListener('input', function(e) {
const termino = e.target.value;
const resultadosDiv = document.getElementById('resultadosBusqueda');
clearTimeout(timeoutBusqueda);
if (termino.length < 2) {
resultadosDiv.style.display = 'none';
return;
}
timeoutBusqueda = setTimeout(async () => {
try {
const response = await fetch('@Url.Action("BuscarMiembros", "Colaboracion")?termino=' + encodeURIComponent(termino));
const miembros = await response.json();
if (miembros.length === 0) {
resultadosDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
resultadosDiv.style.display = 'block';
return;
}
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>
`;
});
resultadosDiv.innerHTML = html;
resultadosDiv.style.display = 'block';
} catch (error) {
console.error('Error al buscar miembros:', error);
}
}, 300);
});
// Cerrar resultados cuando se hace clic fuera
document.addEventListener('click', function(e) {
const buscarInput = document.getElementById('buscarMiembro');
const resultadosDiv = document.getElementById('resultadosBusqueda');
if (!buscarInput.contains(e.target) && !resultadosDiv.contains(e.target)) {
resultadosDiv.style.display = 'none';
}
});
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';
// 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

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