Compare commits

...

2 Commits

Author SHA1 Message Date
46bf68cb21 soporte sin conexion. modal para buscar articulos 2026-02-11 22:39:25 -06:00
0a4a3e86e6 antes de sin conexion 2026-02-11 20:56:32 -06:00
20 changed files with 596 additions and 38 deletions

View File

@@ -109,6 +109,84 @@ public class ContabilidadGeneralController : Controller
return Json(new { success = false, message = "Error al guardar los movimientos. Verifique que el mes no esté cerrado." }); return Json(new { success = false, message = "Error al guardar los movimientos. Verifique que el mes no esté cerrado." });
} }
// ==================== Sincronización Offline ====================
[HttpPost]
public async Task<IActionResult> SincronizarOffline([FromBody] List<BulkSaveRequest> transacciones)
{
if (transacciones == null || !transacciones.Any())
return BadRequest("No hay transacciones para sincronizar.");
var resultados = new List<object>();
foreach (var request in transacciones)
{
try
{
if (request.ReporteId <= 0)
{
resultados.Add(new {
success = false,
reporteId = request.ReporteId,
message = "ID de reporte inválido."
});
continue;
}
var movimientos = request.Movimientos.Select(m => new MovimientoGeneral
{
Id = m.Id,
Tipo = m.Tipo,
CategoriaIngresoId = m.CategoriaIngresoId,
CategoriaEgresoId = m.CategoriaEgresoId,
Monto = m.Monto,
Fecha = DateTime.SpecifyKind(m.Fecha, DateTimeKind.Utc),
Descripcion = m.Descripcion ?? "",
NumeroComprobante = m.NumeroComprobante
}).ToList();
var success = await _contabilidadService.GuardarMovimientosBulkAsync(request.ReporteId, movimientos);
if (success)
{
resultados.Add(new {
success = true,
reporteId = request.ReporteId,
message = "Sincronizado exitosamente"
});
}
else
{
resultados.Add(new {
success = false,
reporteId = request.ReporteId,
message = "Error al guardar. El mes puede estar cerrado."
});
}
}
catch (Exception ex)
{
resultados.Add(new {
success = false,
reporteId = request.ReporteId,
message = $"Error: {ex.Message}"
});
}
}
var exitosos = resultados.Count(r => (bool)((dynamic)r).success);
var fallidos = resultados.Count - exitosos;
return Json(new {
success = exitosos > 0,
total = transacciones.Count,
exitosos = exitosos,
fallidos = fallidos,
resultados = resultados
});
}
// ==================== Cerrar Mes ==================== // ==================== Cerrar Mes ====================
[HttpPost] [HttpPost]

View File

@@ -53,13 +53,44 @@ public class MovimientosInventarioController : Controller
ViewBag.CantidadGlobal = articulo.CantidadGlobal; // For LOTE validation? ViewBag.CantidadGlobal = articulo.CantidadGlobal; // For LOTE validation?
} }
ViewBag.Articulos = new SelectList((await _articuloService.GetAllAsync()).Select(x => new { x.Id, Nombre = $"{x.Codigo} - {x.Nombre}" }), "Id", "Nombre", articuloId); ViewBag.Articulos =
new SelectList(
(await _articuloService.GetAllAsync()).Select(x => new { x.Id, Nombre = $"{x.Codigo} - {x.Nombre}" }),
"Id", "Nombre", articuloId);
ViewBag.Ubicaciones = new SelectList(await _ubicacionService.GetAllAsync(), "Id", "Nombre"); ViewBag.Ubicaciones = new SelectList(await _ubicacionService.GetAllAsync(), "Id", "Nombre");
ViewBag.Estados = new SelectList(await _estadoService.GetAllAsync(), "Id", "Nombre"); ViewBag.Estados = new SelectList(await _estadoService.GetAllAsync(), "Id", "Nombre");
return View(); return View();
} }
[HttpGet]
public async Task<IActionResult> BuscarArticulos(string term)
{
var articulos = await _articuloService.GetAllAsync();
if (!string.IsNullOrWhiteSpace(term))
{
term = term.ToLower();
articulos = articulos
.Where(a =>
(a.Nombre != null && a.Nombre.ToLower().Contains(term)) ||
(a.Codigo != null && a.Codigo.ToLower().Contains(term)) ||
(a.Descripcion != null && a.Descripcion.ToLower().Contains(term)))
.ToList();
}
// Limit results
var resultados = articulos.Take(20).Select(a => new {
a.Id,
a.Codigo,
a.Nombre,
Ubicacion = a.UbicacionNombre ?? "Sin ubicación",
Stock = a.CantidadGlobal
});
return Json(resultados);
}
// POST: MovimientosInventario/RegistrarTraslado // POST: MovimientosInventario/RegistrarTraslado
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]

View File

@@ -5,8 +5,8 @@ namespace Rs_system.Models;
public enum TipoMovimientoContable public enum TipoMovimientoContable
{ {
Ingreso, Ingreso = 1,
Egreso Egreso = 2
} }
[Table("contabilidad_registros")] [Table("contabilidad_registros")]

View File

@@ -67,7 +67,8 @@ public class ArticuloService : IArticuloService
EstadoColor = a.Estado.Color, EstadoColor = a.Estado.Color,
UbicacionId = a.UbicacionId, UbicacionId = a.UbicacionId,
UbicacionNombre = a.Ubicacion.Nombre, UbicacionNombre = a.Ubicacion.Nombre,
Activo = a.Activo Activo = a.Activo,
CantidadGlobal = a.CantidadGlobal
}) })
.ToListAsync(); .ToListAsync();
} }

View File

@@ -99,8 +99,8 @@
<td><input type="date" class="form-control form-control-sm row-fecha" value="@item.Fecha.ToString("yyyy-MM-dd")" @(Model.Cerrado ? "disabled" : "") /></td> <td><input type="date" class="form-control form-control-sm row-fecha" value="@item.Fecha.ToString("yyyy-MM-dd")" @(Model.Cerrado ? "disabled" : "") /></td>
<td> <td>
<select class="form-select form-select-sm row-tipo" @(Model.Cerrado ? "disabled" : "") onchange="updateTotals()"> <select class="form-select form-select-sm row-tipo" @(Model.Cerrado ? "disabled" : "") onchange="updateTotals()">
<!option value="1" @(item.Tipo == TipoMovimientoContable.Ingreso ? "selected" : "")>Ingreso (+)</!option> <option value="1" selected="@(item.Tipo == TipoMovimientoContable.Ingreso)">Ingreso (+)</option>
<!option value="0" @(item.Tipo == TipoMovimientoContable.Egreso ? "selected" : "")>Egreso (-)</!option> <option value="2" selected="@(item.Tipo == TipoMovimientoContable.Egreso)">Egreso (-)</option>
</select> </select>
</td> </td>
<td><input type="text" class="form-control form-control-sm row-descripcion" value="@item.Descripcion" placeholder="Motivo del movimiento..." @(Model.Cerrado ? "disabled" : "") /></td> <td><input type="text" class="form-control form-control-sm row-descripcion" value="@item.Descripcion" placeholder="Motivo del movimiento..." @(Model.Cerrado ? "disabled" : "") /></td>
@@ -151,7 +151,7 @@
<td> <td>
<select class="form-select form-select-sm row-tipo" onchange="updateTotals()"> <select class="form-select form-select-sm row-tipo" onchange="updateTotals()">
<option value="1">Ingreso (+)</option> <option value="1">Ingreso (+)</option>
<option value="0">Egreso (-)</option> <option value="2">Egreso (-)</option>
</select> </select>
</td> </td>
<td><input type="text" class="form-control form-control-sm row-descripcion" value="" placeholder="Motivo del movimiento..." /></td> <td><input type="text" class="form-control form-control-sm row-descripcion" value="" placeholder="Motivo del movimiento..." /></td>

View File

@@ -12,6 +12,10 @@
<h5 class="text-secondary"> <h5 class="text-secondary">
Saldo Actual: <span class="font-weight-bold @(ViewBag.SaldoActual >= 0 ? "text-success" : "text-danger")">@ViewBag.SaldoActual?.ToString("C")</span> Saldo Actual: <span class="font-weight-bold @(ViewBag.SaldoActual >= 0 ? "text-success" : "text-danger")">@ViewBag.SaldoActual?.ToString("C")</span>
</h5> </h5>
<div class="mt-2">
<span id="connectionStatus" class="badge bg-secondary"><i class="fas fa-wifi"></i> Verificando...</span>
<span id="pendingCount" class="badge bg-warning ml-2" style="display:none;">0 pendientes</span>
</div>
</div> </div>
<div> <div>
<a asp-action="Index" class="btn btn-secondary btn-sm"> <a asp-action="Index" class="btn btn-secondary btn-sm">
@@ -139,6 +143,8 @@
</div> </div>
@section Scripts { @section Scripts {
<script src="~/js/offline-db.js"></script>
<script src="~/js/offline-manager.js"></script>
<script> <script>
const esCerrado = @Model.Cerrado.ToString().ToLower(); const esCerrado = @Model.Cerrado.ToString().ToLower();
const reporteId = @Model.Id; const reporteId = @Model.Id;
@@ -329,26 +335,23 @@
})); }));
try { try {
const response = await fetch('@Url.Action("GuardarBulk")', { // Use offline manager for save operation
method: 'POST', const result = await OfflineManager.saveTransaction(
headers: { reporteId,
'Content-Type': 'application/json' payloadMovimientos,
}, '@Url.Action("GuardarBulk")'
body: JSON.stringify({ );
ReporteId: reporteId,
Movimientos: payloadMovimientos
})
});
// ... rest of logic
const result = await response.json();
if (result.success) { if (result.success) {
// Show toast or alert if (result.offline) {
alert('Guardado exitosamente'); alert(result.message);
location.reload(); await OfflineManager.updatePendingCount();
} else {
alert('Guardado exitosamente');
location.reload();
}
} else { } else {
alert('Error: ' + result.message); alert('Error: ' + (result.message || 'Error desconocido'));
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -23,14 +23,107 @@
<div class="card-body"> <div class="card-body">
<form method="get" asp-action="Create"> <form method="get" asp-action="Create">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Buscar Artículo</label> <div class="input-group">
<select name="articuloId" class="form-select" asp-items="ViewBag.Articulos" onchange="this.form.submit()"> <input type="text" class="form-control" value="@ViewBag.ArticuloNombre" placeholder="Ningún artículo seleccionado" readonly />
<option value="">-- Seleccione un artículo --</option> <input type="hidden" name="articuloId" id="articuloIdInput" value="@ViewBag.ArticuloId" />
</select> <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalBuscarArticulo">
<div class="form-text">Seleccione para cargar datos actuales.</div> <i class="bi bi-search"></i> Buscar
</button>
</div>
<div class="form-text">Busque y seleccione el artículo para cargar sus datos.</div>
</div> </div>
</form> </form>
<!-- Modal Buscador -->
<div class="modal fade" id="modalBuscarArticulo" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Buscar Artículo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<input type="text" id="inputBusqueda" class="form-control" placeholder="Nombre, código o descripción...">
<button class="btn btn-outline-secondary" type="button" onclick="buscarArticulos()">
<i class="bi bi-search"></i>
</button>
</div>
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Ubicación</th>
<th>Stock</th>
<th>Acción</th>
</tr>
</thead>
<tbody id="resultadoBusqueda">
<tr><td colspan="5" class="text-center text-muted">Ingrese un término para buscar...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('inputBusqueda').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
buscarArticulos();
}
});
async function buscarArticulos() {
const term = document.getElementById('inputBusqueda').value;
if (!term || term.length < 2) {
alert("Ingrese al menos 2 caracteres.");
return;
}
const tbody = document.getElementById('resultadoBusqueda');
tbody.innerHTML = '<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm text-primary"></div> Buscando...</td></tr>';
try {
const response = await fetch(`@Url.Action("BuscarArticulos")?term=${encodeURIComponent(term)}`);
const data = await response.json();
tbody.innerHTML = '';
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No se encontraron resultados.</td></tr>';
return;
}
data.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><small>${item.codigo || '-'}</small></td>
<td>${item.nombre}</td>
<td><small>${item.ubicacion}</small></td>
<td><span class="badge bg-secondary">${item.stock}</span></td>
<td>
<button type="button" class="btn btn-sm btn-primary" onclick="seleccionarArticulo(${item.id})">
Seleccionar
</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (error) {
console.error(error);
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger">Error al buscar.</td></tr>';
}
}
function seleccionarArticulo(id) {
// Redirect to same page with id parameter to load details
window.location.href = '@Url.Action("Create")?articuloId=' + id;
}
</script>
@if (ViewBag.ArticuloId != null) @if (ViewBag.ArticuloId != null)
{ {
<div class="alert alert-light border mt-3"> <div class="alert alert-light border mt-3">

View File

@@ -15,7 +15,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("RS_system")] [assembly: System.Reflection.AssemblyCompanyAttribute("RS_system")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0a4c756479ff6d53fcf26ddccd18fdd64a07c2f6")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1784131456f11aa7351eef9061c1354519f67545")]
[assembly: System.Reflection.AssemblyProductAttribute("RS_system")] [assembly: System.Reflection.AssemblyProductAttribute("RS_system")]
[assembly: System.Reflection.AssemblyTitleAttribute("RS_system")] [assembly: System.Reflection.AssemblyTitleAttribute("RS_system")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
a52affd2506f3cdbc9cb36fe76b525426724cc22cc4e60ef2470282155247555 0c9f7ccc95584dd75dafc3cf1c7a4bb9a06235cda706237be23d01c5759bf731

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

@@ -0,0 +1,123 @@
/**
* IndexedDB Wrapper for Offline Contabilidad
* Stores pending transactions when offline
*/
const OfflineDB = {
dbName: 'ContabilidadOfflineDB',
version: 1,
storeName: 'pendingTransactions',
/**
* 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',
autoIncrement: true
});
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
objectStore.createIndex('reporteId', 'reporteId', { unique: false });
}
};
});
},
/**
* Add a pending transaction to the queue
*/
async addPending(reporteId, movimientos) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const record = {
reporteId: reporteId,
movimientos: movimientos,
timestamp: new Date().toISOString()
};
const request = store.add(record);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
/**
* Get all pending transactions
*/
async getAllPending() {
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);
});
},
/**
* Remove a specific pending transaction by id
*/
async removePending(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 = () => resolve();
request.onerror = () => reject(request.error);
});
},
/**
* Clear all pending transactions
*/
async clearPending() {
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 = () => resolve();
request.onerror = () => reject(request.error);
});
},
/**
* Get count of pending transactions
*/
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 request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
};

View File

@@ -0,0 +1,229 @@
/**
* Offline Manager for Contabilidad
* Handles connection monitoring, offline queue, and synchronization
*/
const OfflineManager = {
isOnline: navigator.onLine,
isSyncing: false,
statusBadge: null,
pendingCounter: null,
syncInProgress: false,
/**
* Initialize the offline manager
*/
init() {
this.statusBadge = document.getElementById('connectionStatus');
this.pendingCounter = document.getElementById('pendingCount');
// Listen for online/offline events
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// Initial status
this.updateStatus();
this.updatePendingCount();
// Check for pending transactions on load
if (this.isOnline) {
this.syncPending();
}
},
/**
* Handle online event
*/
async handleOnline() {
this.isOnline = true;
this.updateStatus();
console.log('Connection restored - starting sync...');
await this.syncPending();
},
/**
* Handle offline event
*/
handleOffline() {
this.isOnline = false;
this.updateStatus();
console.log('Connection lost - offline mode enabled');
},
/**
* Update connection status badge
*/
updateStatus() {
if (!this.statusBadge) return;
if (this.isSyncing) {
this.statusBadge.className = 'badge bg-warning';
this.statusBadge.innerHTML = '<i class="fas fa-sync fa-spin"></i> Sincronizando';
} else if (this.isOnline) {
this.statusBadge.className = 'badge bg-success';
this.statusBadge.innerHTML = '<i class="fas fa-wifi"></i> En línea';
} else {
this.statusBadge.className = 'badge bg-secondary';
this.statusBadge.innerHTML = '<i class="fas fa-wifi-slash"></i> Sin conexión';
}
},
/**
* Update pending transaction counter
*/
async updatePendingCount() {
if (!this.pendingCounter) return;
try {
const count = await OfflineDB.getPendingCount();
if (count > 0) {
this.pendingCounter.textContent = `${count} pendiente${count > 1 ? 's' : ''}`;
this.pendingCounter.style.display = 'inline-block';
} else {
this.pendingCounter.style.display = 'none';
}
} catch (error) {
console.error('Error updating pending count:', error);
}
},
/**
* Save transaction (online or offline)
*/
async saveTransaction(reporteId, movimientos, url) {
if (this.isOnline) {
// Try to save directly
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ReporteId: reporteId,
Movimientos: movimientos
})
});
const result = await response.json();
if (result.success) {
return { success: true, message: 'Guardado exitosamente', data: result };
} else {
throw new Error(result.message || 'Error desconocido');
}
} catch (error) {
// If online but request failed, save to queue
console.warn('Request failed, saving to offline queue:', error);
await this.saveOffline(reporteId, movimientos);
return {
success: true,
offline: true,
message: 'Guardado en cola offline. Se sincronizará automáticamente.'
};
}
} else {
// Save to offline queue
await this.saveOffline(reporteId, movimientos);
return {
success: true,
offline: true,
message: 'Sin conexión. Guardado en cola offline.'
};
}
},
/**
* Save to offline queue
*/
async saveOffline(reporteId, movimientos) {
await OfflineDB.addPending(reporteId, movimientos);
await this.updatePendingCount();
console.log('Transaction saved to offline queue');
},
/**
* Synchronize pending transactions
*/
async syncPending() {
if (!this.isOnline || this.syncInProgress) return;
try {
this.syncInProgress = true;
this.isSyncing = true;
this.updateStatus();
const pending = await OfflineDB.getAllPending();
if (pending.length === 0) {
console.log('No pending transactions to sync');
return;
}
console.log(`Syncing ${pending.length} pending transaction(s)...`);
let successCount = 0;
let failCount = 0;
for (const transaction of pending) {
try {
const response = await fetch('/ContabilidadGeneral/GuardarBulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ReporteId: transaction.reporteId,
Movimientos: transaction.movimientos
})
});
const result = await response.json();
if (result.success) {
await OfflineDB.removePending(transaction.id);
successCount++;
console.log(`Transaction ${transaction.id} synced successfully`);
} else {
failCount++;
console.error(`Transaction ${transaction.id} failed:`, result.message);
}
} catch (error) {
failCount++;
console.error(`Error syncing transaction ${transaction.id}:`, error);
}
}
await this.updatePendingCount();
if (successCount > 0) {
alert(`Sincronización completa: ${successCount} registro(s) guardado(s).${failCount > 0 ? ` ${failCount} fallido(s).` : ''}`);
location.reload(); // Refresh to show updated data
} else if (failCount > 0) {
alert(`Error en sincronización: ${failCount} registro(s) no se pudieron guardar.`);
}
} catch (error) {
console.error('Sync error:', error);
} finally {
this.syncInProgress = false;
this.isSyncing = false;
this.updateStatus();
}
},
/**
* Manually trigger sync
*/
async manualSync() {
if (!this.isOnline) {
alert('No hay conexión a internet. La sincronización se realizará automáticamente cuando se restaure la conexión.');
return;
}
await this.syncPending();
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => OfflineManager.init());
} else {
OfflineManager.init();
}