This commit is contained in:
2026-02-01 14:28:17 -06:00
parent 700af7ea60
commit 1784131456
109 changed files with 19894 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Rs_system.Models.ViewModels;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class ArticulosController : Controller
{
private readonly IArticuloService _service;
public ArticulosController(IArticuloService service)
{
_service = service;
}
// GET: Articulos
public async Task<IActionResult> Index(string? search, int? categoriaId, int? ubicacionId, int? estadoId)
{
// Load filter lists
var categorias = await _service.GetCategoriasAsync();
ViewBag.Categorias = new SelectList(categorias.Select(c => new { c.Id, c.Nombre }), "Id", "Nombre", categoriaId);
var ubicaciones = await _service.GetUbicacionesAsync();
ViewBag.Ubicaciones = new SelectList(ubicaciones.Select(u => new { u.Id, u.Nombre }), "Id", "Nombre", ubicacionId);
// Custom Estado SelectList
var estados = await _service.GetEstadosAsync();
ViewBag.Estados = new SelectList(estados.Select(e => new { e.Id, e.Nombre }), "Id", "Nombre", estadoId);
// Keep Search params
ViewBag.CurrentSearch = search ?? "";
ViewBag.CurrentCategoria = categoriaId;
ViewBag.CurrentUbicacion = ubicacionId;
ViewBag.CurrentEstado = estadoId;
var list = await _service.GetAllAsync(search, categoriaId, ubicacionId, estadoId);
return View(list);
}
// GET: Articulos/Details/5
public async Task<IActionResult> Details(int? id)
{
if (id == null) return NotFound();
var articulo = await _service.GetByIdAsync(id.Value);
if (articulo == null) return NotFound();
return View(articulo);
}
// GET: Articulos/Create
public async Task<IActionResult> Create()
{
await LoadDropdownsAsync();
return View(new ArticuloViewModel());
}
// POST: Articulos/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ArticuloViewModel viewModel)
{
if (ModelState.IsValid)
{
if (await _service.ExistsCodigoAsync(viewModel.Codigo))
{
ModelState.AddModelError("Codigo", "Ya existe un artículo con este código.");
}
else
{
var createdBy = User.Identity?.Name ?? "Sistema";
var result = await _service.CreateAsync(viewModel, createdBy);
if (result)
{
TempData["SuccessMessage"] = "Artículo registrado exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "Ocurrió un error al guardar el artículo.");
}
}
await LoadDropdownsAsync();
return View(viewModel);
}
// GET: Articulos/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var articulo = await _service.GetByIdAsync(id.Value);
if (articulo == null) return NotFound();
await LoadDropdownsAsync();
return View(articulo);
}
// POST: Articulos/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, ArticuloViewModel viewModel)
{
if (id != viewModel.Id) return NotFound();
if (ModelState.IsValid)
{
if (await _service.ExistsCodigoAsync(viewModel.Codigo, id))
{
ModelState.AddModelError("Codigo", "Ya existe otro artículo con este código.");
}
else
{
var result = await _service.UpdateAsync(viewModel);
if (result)
{
TempData["SuccessMessage"] = "Artículo actualizado exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "No se pudo actualizar el artículo.");
}
}
await LoadDropdownsAsync();
return View(viewModel);
}
// POST: Articulos/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var result = await _service.DeleteAsync(id);
if (result)
{
TempData["SuccessMessage"] = "Artículo eliminado exitosamente.";
}
else
{
TempData["ErrorMessage"] = "No se pudo eliminado el artículo.";
}
return RedirectToAction(nameof(Index));
}
private async Task LoadDropdownsAsync()
{
var categorias = await _service.GetCategoriasAsync();
ViewBag.Categorias = new SelectList(categorias.Select(c => new { c.Id, c.Nombre }), "Id", "Nombre");
var ubicaciones = await _service.GetUbicacionesAsync();
ViewBag.Ubicaciones = new SelectList(ubicaciones.Select(u => new { u.Id, u.Nombre }), "Id", "Nombre");
var estados = await _service.GetEstadosAsync();
ViewBag.Estados = new SelectList(estados.Select(e => new { e.Id, e.Nombre }), "Id", "Nombre");
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Rs_system.Models;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class CategoriasController : Controller
{
private readonly ICategoriaService _service;
public CategoriasController(ICategoriaService service)
{
_service = service;
}
// GET: Categorias
public async Task<IActionResult> Index()
{
var list = await _service.GetAllAsync();
return View(list);
}
// GET: Categorias/Create
public IActionResult Create()
{
return View();
}
// POST: Categorias/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Nombre,Descripcion,Activo")] Categoria categoria)
{
if (string.IsNullOrWhiteSpace(categoria.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(categoria.Nombre))
{
ModelState.AddModelError("Nombre", "Ya existe una categoría con ese nombre.");
return View(categoria);
}
categoria.CreadoPor = User.Identity?.Name ?? "Sistema";
var result = await _service.CreateAsync(categoria);
if (result)
{
TempData["SuccessMessage"] = "Categoría creada exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "Ocurrió un error al guardar los datos.");
}
return View(categoria);
}
// GET: Categorias/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var categoria = await _service.GetByIdAsync(id.Value);
if (categoria == null) return NotFound();
return View(categoria);
}
// POST: Categorias/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,Descripcion,Activo")] Categoria categoria)
{
if (id != categoria.Id) return NotFound();
if (string.IsNullOrWhiteSpace(categoria.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(categoria.Nombre, id))
{
ModelState.AddModelError("Nombre", "Ya existe otra categoría con ese nombre.");
return View(categoria);
}
var result = await _service.UpdateAsync(categoria);
if (result)
{
TempData["SuccessMessage"] = "Categoría actualizada exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "No se pudo actualizar la categoría o no fue encontrada.");
}
return View(categoria);
}
// POST: Categorias/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var result = await _service.DeleteAsync(id);
if (result)
{
TempData["SuccessMessage"] = "Categoría eliminada exitosamente.";
}
else
{
TempData["ErrorMessage"] = "No se pudo eliminar la categoría.";
}
return RedirectToAction(nameof(Index));
}
}

View File

@@ -0,0 +1,221 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Rs_system.Models.ViewModels;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class ColaboracionController : Controller
{
private readonly IColaboracionService _colaboracionService;
private readonly IMiembroService _miembroService;
public ColaboracionController(
IColaboracionService colaboracionService,
IMiembroService miembroService)
{
_colaboracionService = colaboracionService;
_miembroService = miembroService;
}
// GET: Colaboracion
public async Task<IActionResult> Index()
{
try
{
var colaboraciones = await _colaboracionService.GetColaboracionesRecientesAsync();
return View(colaboraciones);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al cargar colaboraciones: {ex.Message}";
return View(new List<Models.Colaboracion>());
}
}
// GET: Colaboracion/Create
public async Task<IActionResult> Create()
{
try
{
var viewModel = new RegistrarColaboracionViewModel
{
MesInicial = DateTime.Now.Month,
AnioInicial = DateTime.Now.Year,
MesFinal = DateTime.Now.Month,
AnioFinal = DateTime.Now.Year,
MontoTotal = 0,
TiposDisponibles = await _colaboracionService.GetTiposActivosAsync()
};
await CargarMiembrosAsync();
return View(viewModel);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al cargar formulario: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
// POST: Colaboracion/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(RegistrarColaboracionViewModel model)
{
if (ModelState.IsValid)
{
try
{
var registradoPor = User.Identity?.Name ?? "Sistema";
await _colaboracionService.RegistrarColaboracionAsync(model, registradoPor);
TempData["Success"] = "Colaboración registrada exitosamente";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
ModelState.AddModelError("", $"Error al registrar: {ex.Message}");
}
}
// Recargar datos para la vista
model.TiposDisponibles = await _colaboracionService.GetTiposActivosAsync();
await CargarMiembrosAsync();
return View(model);
}
// GET: Colaboracion/Details/5
public async Task<IActionResult> Details(long id)
{
try
{
var colaboracion = await _colaboracionService.GetColaboracionByIdAsync(id);
if (colaboracion == null)
{
TempData["Error"] = "Colaboración no encontrada";
return RedirectToAction(nameof(Index));
}
return View(colaboracion);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al cargar detalle: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
// GET: Colaboracion/Reportes
public IActionResult Reportes()
{
ViewBag.FechaInicio = DateTime.Now.Date;
ViewBag.FechaFin = DateTime.Now.Date;
return View();
}
// POST: Colaboracion/GenerarReporte
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerarReporte(DateTime fechaInicio, DateTime fechaFin)
{
try
{
// Ajustar fecha fin para incluir todo el día
var fechaFinAjustada = fechaFin.Date.AddDays(1).AddSeconds(-1);
var reporte = await _colaboracionService.GenerarReportePorFechasAsync(
fechaInicio.Date,
fechaFinAjustada);
return View("Reporte", reporte);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al generar reporte: {ex.Message}";
return RedirectToAction(nameof(Reportes));
}
}
// GET: Colaboracion/EstadoCuenta/5
public async Task<IActionResult> EstadoCuenta(long id)
{
try
{
var estado = await _colaboracionService.GenerarEstadoCuentaAsync(id);
return View(estado);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al generar estado de cuenta: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
// GET: Colaboracion/BuscarMiembros?termino=juan
[HttpGet]
public async Task<IActionResult> BuscarMiembros(string termino)
{
if (string.IsNullOrWhiteSpace(termino) || termino.Length < 2)
{
return Json(new List<object>());
}
try
{
var miembros = await _miembroService.GetAllAsync();
var resultados = miembros
.Where(m =>
m.Nombres.Contains(termino, StringComparison.OrdinalIgnoreCase) ||
m.Apellidos.Contains(termino, StringComparison.OrdinalIgnoreCase) ||
$"{m.Nombres} {m.Apellidos}".Contains(termino, StringComparison.OrdinalIgnoreCase))
.Take(10)
.Select(m => new
{
id = m.Id,
text = $"{m.Nombres} {m.Apellidos}",
telefono = m.Telefono
})
.ToList();
return Json(resultados);
}
catch (Exception ex)
{
return Json(new List<object>());
}
}
// GET: Colaboracion/ObtenerUltimosPagos?miembroId=5
[HttpGet]
public async Task<IActionResult> ObtenerUltimosPagos(long miembroId)
{
try
{
var ultimosPagos = await _colaboracionService.GetUltimosPagosPorMiembroAsync(miembroId);
return Json(ultimosPagos);
}
catch (Exception ex)
{
return Json(new List<object>());
}
}
// Helper methods
private async Task CargarMiembrosAsync()
{
var miembros = await _miembroService.GetAllAsync();
ViewBag.Miembros = new SelectList(
miembros.Select(m => new
{
Id = m.Id,
NombreCompleto = $"{m.Nombres} {m.Apellidos}"
}),
"Id",
"NombreCompleto"
);
}
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Rs_system.Models;
using Rs_system.Services;
using Rs_system.Data;
using Microsoft.EntityFrameworkCore;
namespace Rs_system.Controllers;
[Authorize]
public class ContabilidadController : Controller
{
private readonly IContabilidadService _contabilidadService;
private readonly IMiembroService _miembroService;
private readonly ApplicationDbContext _context;
public ContabilidadController(IContabilidadService contabilidadService, IMiembroService miembroService, ApplicationDbContext context)
{
_contabilidadService = contabilidadService;
_miembroService = miembroService;
_context = context;
}
[HttpGet]
public async Task<IActionResult> Index(long? grupoId)
{
var grupos = await _miembroService.GetGruposTrabajoAsync();
ViewBag.Grupos = new SelectList(grupos.Select(g => new { g.Id, g.Nombre }), "Id", "Nombre", grupoId);
List<ReporteMensualContable> reportes = new();
if (grupoId.HasValue)
{
reportes = await _contabilidadService.ListarReportesPorGrupoAsync(grupoId.Value);
}
ViewBag.GrupoId = grupoId;
return View(reportes);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AbrirMes(long grupoId, int mes, int anio)
{
try
{
var reporte = await _contabilidadService.ObtenerOCrearReporteMensualAsync(grupoId, mes, anio);
TempData["Success"] = $"Reporte de {reporte.NombreMes} {anio} abierto correctamente.";
return RedirectToAction(nameof(RegistroMensual), new { id = reporte.Id });
}
catch (Exception ex)
{
TempData["Error"] = "Error al abrir el mes: " + ex.Message;
return RedirectToAction(nameof(Index), new { grupoId });
}
}
[HttpGet]
public async Task<IActionResult> RegistroMensual(long id)
{
var reporte = await _context.ReportesMensualesContables
.Include(r => r.GrupoTrabajo)
.Include(r => r.Registros)
.FirstOrDefaultAsync(r => r.Id == id);
if (reporte == null) return NotFound();
ViewBag.SaldoActual = await _contabilidadService.CalcularSaldoActualAsync(id);
return View(reporte);
}
[HttpPost]
public async Task<IActionResult> GuardarBulk([FromBody] BulkSaveRequest request)
{
if (request == null || request.ReporteId <= 0) return BadRequest("Solicitud inválida.");
var registros = request.Registros.Select(r => new ContabilidadRegistro
{
Id = r.Id,
Tipo = r.Tipo,
Monto = r.Monto,
Fecha = DateTime.SpecifyKind(r.Fecha, DateTimeKind.Utc),
Descripcion = r.Descripcion ?? ""
}).ToList();
var success = await _contabilidadService.GuardarRegistrosBulkAsync(request.ReporteId, registros);
if (success)
{
var nuevoSaldo = await _contabilidadService.CalcularSaldoActualAsync(request.ReporteId);
return Json(new { success = true, saldo = nuevoSaldo });
}
return Json(new { success = false, message = "Error al guardar los registros. Verifique que el mes no esté cerrado." });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CerrarMes(long id)
{
var success = await _contabilidadService.CerrarReporteAsync(id);
if (success)
{
TempData["Success"] = "El reporte ha sido cerrado. Ya no se pueden realizar cambios.";
}
else
{
TempData["Error"] = "No se pudo cerrar el reporte.";
}
return RedirectToAction(nameof(RegistroMensual), new { id });
}
// Helper classes for AJAX
public class BulkSaveRequest
{
public long ReporteId { get; set; }
public List<RegistroInput> Registros { get; set; } = new();
}
public class RegistroInput
{
public long Id { get; set; }
public TipoMovimientoContable Tipo { get; set; }
public decimal Monto { get; set; }
public DateTime Fecha { get; set; }
public string? Descripcion { get; set; }
}
}

View File

@@ -0,0 +1,319 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Rs_system.Models;
using Rs_system.Services;
using Rs_system.Data;
using Microsoft.EntityFrameworkCore;
namespace Rs_system.Controllers;
[Authorize]
public class ContabilidadGeneralController : Controller
{
private readonly IContabilidadGeneralService _contabilidadService;
private readonly ApplicationDbContext _context;
private readonly IFileStorageService _fileStorageService;
public ContabilidadGeneralController(IContabilidadGeneralService contabilidadService, ApplicationDbContext context, IFileStorageService fileStorageService)
{
_contabilidadService = contabilidadService;
_context = context;
_fileStorageService = fileStorageService;
}
// ==================== Vista Principal ====================
[HttpGet]
public async Task<IActionResult> Index(int? anio)
{
var anioActual = anio ?? DateTime.Now.Year;
ViewBag.Anio = anioActual;
// Generar lista de años disponibles
var anios = Enumerable.Range(DateTime.Now.Year - 5, 10).Reverse();
ViewBag.Anios = new SelectList(anios);
var reportes = await _contabilidadService.ListarReportesAsync(anioActual);
return View(reportes);
}
// ==================== Abrir/Crear Reporte Mensual ====================
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AbrirMes(int mes, int anio)
{
try
{
var reporte = await _contabilidadService.ObtenerOCrearReporteMensualAsync(mes, anio);
TempData["Success"] = $"Reporte de {reporte.NombreMes} {anio} abierto correctamente.";
return RedirectToAction(nameof(RegistroMensual), new { id = reporte.Id });
}
catch (Exception ex)
{
TempData["Error"] = "Error al abrir el mes: " + ex.Message;
return RedirectToAction(nameof(Index));
}
}
// ==================== Registro Mensual (Excel-like) ====================
[HttpGet]
public async Task<IActionResult> RegistroMensual(long id)
{
var reporte = await _context.ReportesMensualesGenerales
.Include(r => r.Movimientos)
.ThenInclude(m => m.CategoriaIngreso)
.Include(r => r.Movimientos)
.ThenInclude(m => m.CategoriaEgreso)
.FirstOrDefaultAsync(r => r.Id == id);
if (reporte == null) return NotFound();
ViewBag.SaldoActual = await _contabilidadService.CalcularSaldoActualAsync(id);
ViewBag.CategoriasIngreso = await _contabilidadService.ObtenerCategoriasIngresoAsync();
ViewBag.CategoriasEgreso = await _contabilidadService.ObtenerCategoriasEgresoAsync();
return View(reporte);
}
// ==================== Guardar Movimientos Bulk (AJAX) ====================
[HttpPost]
public async Task<IActionResult> GuardarBulk([FromBody] BulkSaveRequest request)
{
if (request == null || request.ReporteId <= 0)
return BadRequest("Solicitud inválida.");
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)
{
var nuevoSaldo = await _contabilidadService.CalcularSaldoActualAsync(request.ReporteId);
return Json(new { success = true, saldo = nuevoSaldo });
}
return Json(new { success = false, message = "Error al guardar los movimientos. Verifique que el mes no esté cerrado." });
}
// ==================== Cerrar Mes ====================
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CerrarMes(long id)
{
var success = await _contabilidadService.CerrarReporteAsync(id);
if (success)
{
TempData["Success"] = "El reporte ha sido cerrado. Ya no se pueden realizar cambios.";
}
else
{
TempData["Error"] = "No se pudo cerrar el reporte.";
}
return RedirectToAction(nameof(RegistroMensual), new { id });
}
// ==================== Consolidado Mensual ====================
[HttpGet]
public async Task<IActionResult> Consolidado(long id)
{
var reporte = await _context.ReportesMensualesGenerales
.FirstOrDefaultAsync(r => r.Id == id);
if (reporte == null) return NotFound();
ViewBag.ConsolidadoIngresos = await _contabilidadService.ObtenerConsolidadoIngresosAsync(id);
ViewBag.ConsolidadoEgresos = await _contabilidadService.ObtenerConsolidadoEgresosAsync(id);
ViewBag.SaldoActual = await _contabilidadService.CalcularSaldoActualAsync(id);
return View(reporte);
}
// ==================== Gestión de Categorías ====================
[HttpGet]
public async Task<IActionResult> GestionCategorias()
{
var categoriasIngreso = await _context.CategoriasIngreso
.OrderBy(c => c.Nombre)
.ToListAsync();
var categoriasEgreso = await _context.CategoriasEgreso
.OrderBy(c => c.Nombre)
.ToListAsync();
ViewBag.CategoriasIngreso = categoriasIngreso;
ViewBag.CategoriasEgreso = categoriasEgreso;
return View();
}
// ==================== CRUD Categorías Ingreso ====================
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CrearCategoriaIngreso(CategoriaIngreso categoria)
{
if (ModelState.IsValid)
{
await _contabilidadService.CrearCategoriaIngresoAsync(categoria);
TempData["Success"] = "Categoría de ingreso creada exitosamente.";
}
else
{
TempData["Error"] = "Error al crear la categoría.";
}
return RedirectToAction(nameof(GestionCategorias));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditarCategoriaIngreso(CategoriaIngreso categoria)
{
if (ModelState.IsValid)
{
var success = await _contabilidadService.ActualizarCategoriaIngresoAsync(categoria);
TempData[success ? "Success" : "Error"] = success
? "Categoría actualizada exitosamente."
: "Error al actualizar la categoría.";
}
return RedirectToAction(nameof(GestionCategorias));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EliminarCategoriaIngreso(long id)
{
var success = await _contabilidadService.EliminarCategoriaIngresoAsync(id);
TempData[success ? "Success" : "Error"] = success
? "Categoría eliminada exitosamente."
: "Error al eliminar la categoría.";
return RedirectToAction(nameof(GestionCategorias));
}
// ==================== CRUD Categorías Egreso ====================
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CrearCategoriaEgreso(CategoriaEgreso categoria)
{
if (ModelState.IsValid)
{
await _contabilidadService.CrearCategoriaEgresoAsync(categoria);
TempData["Success"] = "Categoría de egreso creada exitosamente.";
}
else
{
TempData["Error"] = "Error al crear la categoría.";
}
return RedirectToAction(nameof(GestionCategorias));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditarCategoriaEgreso(CategoriaEgreso categoria)
{
if (ModelState.IsValid)
{
var success = await _contabilidadService.ActualizarCategoriaEgresoAsync(categoria);
TempData[success ? "Success" : "Error"] = success
? "Categoría actualizada exitosamente."
: "Error al actualizar la categoría.";
}
return RedirectToAction(nameof(GestionCategorias));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EliminarCategoriaEgreso(long id)
{
var success = await _contabilidadService.EliminarCategoriaEgresoAsync(id);
TempData[success ? "Success" : "Error"] = success
? "Categoría eliminada exitosamente."
: "Error al eliminar la categoría.";
return RedirectToAction(nameof(GestionCategorias));
}
// ==================== Helper Classes for AJAX ====================
public class BulkSaveRequest
{
public long ReporteId { get; set; }
public List<MovimientoInput> Movimientos { get; set; } = new();
}
public class MovimientoInput
{
public long Id { get; set; }
public int Tipo { get; set; }
public long? CategoriaIngresoId { get; set; }
public long? CategoriaEgresoId { get; set; }
public decimal Monto { get; set; }
public DateTime Fecha { get; set; }
public string? Descripcion { get; set; }
public string? NumeroComprobante { get; set; }
}
// ==================== Adjuntos ====================
[HttpGet]
public async Task<IActionResult> ObtenerAdjuntos(long movimientoId)
{
var adjuntos = await _contabilidadService.ObtenerAdjuntosMovimientoAsync(movimientoId);
return Json(adjuntos.Select(a => new {
id = a.Id,
nombre = a.NombreArchivo,
url = _fileStorageService.GetFileUrl(a.RutaArchivo),
tipo = a.TipoContenido,
fecha = a.FechaSubida.ToLocalTime().ToString("g")
}));
}
[HttpPost]
public async Task<IActionResult> SubirAdjunto(long movimientoId, List<IFormFile> archivos)
{
if (movimientoId <= 0 || archivos == null || !archivos.Any())
return BadRequest("Datos inválidos.");
int count = 0;
foreach (var archivo in archivos)
{
if (archivo.Length > 0)
{
// El usuario solicitó guardar en uploads/miembros
var ruta = await _fileStorageService.SaveFileAsync(archivo, "miembros");
if (!string.IsNullOrEmpty(ruta))
{
await _contabilidadService.CrearAdjuntoAsync(movimientoId, archivo.FileName, ruta, archivo.ContentType);
count++;
}
}
}
return Json(new { success = true, count = count, message = $"{count} archivos subidos correctamente." });
}
[HttpPost]
public async Task<IActionResult> EliminarAdjunto(long id)
{
// Primero obtener para borrar el archivo físico si es necesario (opcional, aquí solo borramos registro BD)
// O idealmente el servicio se encarga. Por ahora solo borramos BD.
var success = await _contabilidadService.EliminarAdjuntoAsync(id);
return Json(new { success = success });
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Rs_system.Models;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class EstadosController : Controller
{
private readonly IEstadoArticuloService _service;
public EstadosController(IEstadoArticuloService service)
{
_service = service;
}
// GET: Estados
public async Task<IActionResult> Index()
{
var list = await _service.GetAllAsync();
return View(list);
}
// GET: Estados/Create
public IActionResult Create()
{
return View();
}
// POST: Estados/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Nombre,Descripcion,Color,Activo")] EstadoArticulo estado)
{
if (string.IsNullOrWhiteSpace(estado.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(estado.Nombre))
{
ModelState.AddModelError("Nombre", "Ya existe un estado con ese nombre.");
return View(estado);
}
estado.CreadoPor = User.Identity?.Name ?? "Sistema";
var result = await _service.CreateAsync(estado);
if (result)
{
TempData["SuccessMessage"] = "Estado creado exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "Ocurrió un error al guardar los datos.");
}
return View(estado);
}
// GET: Estados/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var estado = await _service.GetByIdAsync(id.Value);
if (estado == null) return NotFound();
return View(estado);
}
// POST: Estados/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,Descripcion,Color,Activo")] EstadoArticulo estado)
{
if (id != estado.Id) return NotFound();
if (string.IsNullOrWhiteSpace(estado.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(estado.Nombre, id))
{
ModelState.AddModelError("Nombre", "Ya existe otro estado con ese nombre.");
return View(estado);
}
var result = await _service.UpdateAsync(estado);
if (result)
{
TempData["SuccessMessage"] = "Estado actualizado exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "No se pudo actualizar el estado o no fue encontrado.");
}
return View(estado);
}
// POST: Estados/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var result = await _service.DeleteAsync(id);
if (result)
{
TempData["SuccessMessage"] = "Estado eliminado exitosamente.";
}
else
{
TempData["ErrorMessage"] = "No se pudo eliminar el estado.";
}
return RedirectToAction(nameof(Index));
}
}

View File

@@ -0,0 +1,190 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Rs_system.Models;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class MovimientosInventarioController : Controller
{
private readonly IMovimientoService _movimientoService;
private readonly IArticuloService _articuloService;
private readonly IUbicacionService _ubicacionService;
private readonly IEstadoArticuloService _estadoService;
private readonly IPrestamoService _prestamoService;
public MovimientosInventarioController(
IMovimientoService movimientoService,
IArticuloService articuloService,
IUbicacionService ubicacionService,
IEstadoArticuloService estadoService,
IPrestamoService prestamoService)
{
_movimientoService = movimientoService;
_articuloService = articuloService;
_ubicacionService = ubicacionService;
_estadoService = estadoService;
_prestamoService = prestamoService;
}
// GET: MovimientosInventario
public async Task<IActionResult> Index()
{
var historial = await _movimientoService.GetHistorialGeneralAsync(50); // Limit 50 for performance
return View(historial);
}
// GET: MovimientosInventario/Create
// This is the "Wizard" or "Action Selector"
public async Task<IActionResult> Create(int? articuloId)
{
if (articuloId.HasValue)
{
var articulo = await _articuloService.GetByIdAsync(articuloId.Value);
if (articulo == null) return NotFound();
ViewBag.ArticuloId = articulo.Id;
ViewBag.ArticuloNombre = $"{articulo.Codigo} - {articulo.Nombre}";
ViewBag.UbicacionActual = articulo.UbicacionNombre;
ViewBag.EstadoActual = articulo.EstadoNombre;
ViewBag.TipoControl = articulo.TipoControl; // "UNITARIO" or "LOTE"
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.Ubicaciones = new SelectList(await _ubicacionService.GetAllAsync(), "Id", "Nombre");
ViewBag.Estados = new SelectList(await _estadoService.GetAllAsync(), "Id", "Nombre");
return View();
}
// POST: MovimientosInventario/RegistrarTraslado
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarTraslado(int articuloId, int nuevaUbicacionId, string observacion, int cantidad = 1)
{
var usuario = User.Identity?.Name ?? "Sistema";
// Use the new Quantity-Aware method
var result = await _movimientoService.RegistrarTrasladoCantidadAsync(articuloId, nuevaUbicacionId, cantidad, observacion, usuario);
if (result)
{
TempData["SuccessMessage"] = "Traslado registrado correctamente.";
return RedirectToAction(nameof(Index));
}
TempData["ErrorMessage"] = "Error al registrar el traslado. Verifique stock o campos.";
return RedirectToAction(nameof(Create), new { articuloId });
}
// POST: MovimientosInventario/RegistrarBaja
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarBaja(int articuloId, string motivo, int cantidad = 1)
{
if (string.IsNullOrWhiteSpace(motivo))
{
TempData["ErrorMessage"] = "Debe especificar un motivo para la baja.";
return RedirectToAction(nameof(Create), new { articuloId });
}
var usuario = User.Identity?.Name ?? "Sistema";
var result = await _movimientoService.RegistrarBajaCantidadAsync(articuloId, cantidad, motivo, usuario);
if (result)
{
TempData["SuccessMessage"] = "Baja registrada correctamente.";
return RedirectToAction(nameof(Index));
}
TempData["ErrorMessage"] = "Error al registrar la baja.";
return RedirectToAction(nameof(Create), new { articuloId });
}
// POST: MovimientosInventario/RegistrarCambioEstado
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarCambioEstado(int articuloId, int nuevoEstadoId, string observacion)
{
var usuario = User.Identity?.Name ?? "Sistema";
var result = await _movimientoService.RegistrarCambioEstadoAsync(articuloId, nuevoEstadoId, observacion, usuario);
if (result)
{
TempData["SuccessMessage"] = "Cambio de estado registrado correctamento.";
return RedirectToAction(nameof(Index));
}
TempData["ErrorMessage"] = "Error al registrar el cambio de estado. Verifique que el estado sea diferente al actual.";
return RedirectToAction(nameof(Create), new { articuloId });
}
// POST: MovimientosInventario/RegistrarPrestamo
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarPrestamo(int articuloId, int cantidad, string personaNombre, string personaIdentificacion, DateTime? fechaDevolucionEstimada, string observacion)
{
if (string.IsNullOrWhiteSpace(personaNombre))
{
TempData["ErrorMessage"] = "Debe especificar el nombre de la persona a quien se presta el artículo.";
return RedirectToAction(nameof(Create), new { articuloId });
}
var usuario = User.Identity?.Name ?? "Sistema";
var result = await _prestamoService.RegistrarPrestamoAsync(articuloId, cantidad, personaNombre, personaIdentificacion, fechaDevolucionEstimada, observacion, usuario);
if (result)
{
TempData["SuccessMessage"] = "Préstamo registrado correctamente.";
return RedirectToAction(nameof(Index));
}
TempData["ErrorMessage"] = "Error al registrar el préstamo. Verifique stock disponible.";
return RedirectToAction(nameof(Create), new { articuloId });
}
// GET: MovimientosInventario/PrestamosActivos
public async Task<IActionResult> PrestamosActivos()
{
var prestamosActivos = await _prestamoService.GetPrestamosActivosAsync();
return View(prestamosActivos);
}
// POST: MovimientosInventario/RegistrarDevolucion
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarDevolucion(long prestamoId, string observacion)
{
var usuario = User.Identity?.Name ?? "Sistema";
var result = await _prestamoService.RegistrarDevolucionAsync(prestamoId, observacion, usuario);
if (result)
{
TempData["SuccessMessage"] = "Devolución registrada correctamente.";
return RedirectToAction(nameof(PrestamosActivos));
}
TempData["ErrorMessage"] = "Error al registrar la devolución.";
return RedirectToAction(nameof(PrestamosActivos));
}
// POST: MovimientosInventario/RegistrarEntrada
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegistrarEntrada(int articuloId, int cantidad, string observacion)
{
var usuario = User.Identity?.Name ?? "Sistema";
var result = await _movimientoService.RegistrarEntradaCantidadAsync(articuloId, cantidad, observacion, usuario);
if (result)
{
TempData["SuccessMessage"] = "Entrada de inventario registrada correctamente.";
return RedirectToAction(nameof(Index));
}
TempData["ErrorMessage"] = "Error al registrar la entrada de inventario.";
return RedirectToAction(nameof(Create), new { articuloId });
}
}

View File

@@ -0,0 +1,168 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class TipoColaboracionController : Controller
{
private readonly IColaboracionService _colaboracionService;
private readonly ApplicationDbContext _context;
public TipoColaboracionController(IColaboracionService colaboracionService, ApplicationDbContext context)
{
_colaboracionService = colaboracionService;
_context = context;
}
// GET: TipoColaboracion
public async Task<IActionResult> Index()
{
try
{
var tipos = await _context.TiposColaboracion
.OrderBy(t => t.Orden)
.ToListAsync();
return View(tipos);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al cargar tipos: {ex.Message}";
return View(new List<TipoColaboracion>());
}
}
// GET: TipoColaboracion/Create
public IActionResult Create()
{
var model = new TipoColaboracion
{
MontoSugerido = 1.00m,
Activo = true
};
return View(model);
}
// POST: TipoColaboracion/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(TipoColaboracion model)
{
if (ModelState.IsValid)
{
try
{
model.CreadoEn = DateTime.UtcNow;
model.ActualizadoEn = DateTime.UtcNow;
_context.TiposColaboracion.Add(model);
await _context.SaveChangesAsync();
TempData["Success"] = "Tipo de colaboración creado exitosamente";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
ModelState.AddModelError("", $"Error al crear: {ex.Message}");
}
}
return View(model);
}
// GET: TipoColaboracion/Edit/5
public async Task<IActionResult> Edit(long id)
{
try
{
var tipo = await _context.TiposColaboracion.FindAsync(id);
if (tipo == null)
{
TempData["Error"] = "Tipo de colaboración no encontrado";
return RedirectToAction(nameof(Index));
}
return View(tipo);
}
catch (Exception ex)
{
TempData["Error"] = $"Error al cargar tipo: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
// POST: TipoColaboracion/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(long id, TipoColaboracion model)
{
if (id != model.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
var tipo = await _context.TiposColaboracion.FindAsync(id);
if (tipo == null)
{
TempData["Error"] = "Tipo de colaboración no encontrado";
return RedirectToAction(nameof(Index));
}
tipo.Nombre = model.Nombre;
tipo.Descripcion = model.Descripcion;
tipo.MontoSugerido = model.MontoSugerido;
tipo.Activo = model.Activo;
tipo.Orden = model.Orden;
tipo.ActualizadoEn = DateTime.UtcNow;
_context.TiposColaboracion.Update(tipo);
await _context.SaveChangesAsync();
TempData["Success"] = "Tipo de colaboración actualizado exitosamente";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
ModelState.AddModelError("", $"Error al actualizar: {ex.Message}");
}
}
return View(model);
}
// POST: TipoColaboracion/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(long id)
{
try
{
var tipo = await _context.TiposColaboracion.FindAsync(id);
if (tipo == null)
{
TempData["Error"] = "Tipo de colaboración no encontrado";
return RedirectToAction(nameof(Index));
}
// Soft delete - just deactivate
tipo.Activo = false;
tipo.ActualizadoEn = DateTime.UtcNow;
await _context.SaveChangesAsync();
TempData["Success"] = "Tipo de colaboración desactivado exitosamente";
}
catch (Exception ex)
{
TempData["Error"] = $"Error al desactivar: {ex.Message}";
}
return RedirectToAction(nameof(Index));
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Rs_system.Models;
using Rs_system.Services;
namespace Rs_system.Controllers;
[Authorize]
public class UbicacionesController : Controller
{
private readonly IUbicacionService _service;
public UbicacionesController(IUbicacionService service)
{
_service = service;
}
// GET: Ubicaciones
public async Task<IActionResult> Index()
{
var list = await _service.GetAllAsync();
return View(list);
}
// GET: Ubicaciones/Create
public IActionResult Create()
{
return View();
}
// POST: Ubicaciones/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Nombre,Descripcion,Responsable,Activo")] Ubicacion ubicacion)
{
if (string.IsNullOrWhiteSpace(ubicacion.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(ubicacion.Nombre))
{
ModelState.AddModelError("Nombre", "Ya existe una ubicación con ese nombre.");
return View(ubicacion);
}
ubicacion.CreadoPor = User.Identity?.Name ?? "Sistema";
var result = await _service.CreateAsync(ubicacion);
if (result)
{
TempData["SuccessMessage"] = "Ubicación creada exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "Ocurrió un error al guardar los datos.");
}
return View(ubicacion);
}
// GET: Ubicaciones/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var ubicacion = await _service.GetByIdAsync(id.Value);
if (ubicacion == null) return NotFound();
return View(ubicacion);
}
// POST: Ubicaciones/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,Descripcion,Responsable,Activo")] Ubicacion ubicacion)
{
if (id != ubicacion.Id) return NotFound();
if (string.IsNullOrWhiteSpace(ubicacion.Nombre))
{
ModelState.AddModelError("Nombre", "El nombre es obligatorio.");
}
if (ModelState.IsValid)
{
if (await _service.ExistsAsync(ubicacion.Nombre, id))
{
ModelState.AddModelError("Nombre", "Ya existe otra ubicación con ese nombre.");
return View(ubicacion);
}
var result = await _service.UpdateAsync(ubicacion);
if (result)
{
TempData["SuccessMessage"] = "Ubicación actualizada exitosamente.";
return RedirectToAction(nameof(Index));
}
ModelState.AddModelError("", "No se pudo actualizar la ubicación o no fue encontrada.");
}
return View(ubicacion);
}
// POST: Ubicaciones/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var result = await _service.DeleteAsync(id);
if (result)
{
TempData["SuccessMessage"] = "Ubicación eliminada exitosamente.";
}
else
{
TempData["ErrorMessage"] = "No se pudo eliminar la ubicación.";
}
return RedirectToAction(nameof(Index));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace RS_system.Migrations
{
/// <inheritdoc />
public partial class AddMonthlyAccounting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "reportes_mensuales_contables",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
grupo_trabajo_id = table.Column<long>(type: "bigint", nullable: false),
mes = table.Column<int>(type: "integer", nullable: false),
anio = table.Column<int>(type: "integer", nullable: false),
saldo_inicial = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
fecha_creacion = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
cerrado = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_reportes_mensuales_contables", x => x.id);
table.ForeignKey(
name: "FK_reportes_mensuales_contables_grupos_trabajo_grupo_trabajo_id",
column: x => x.grupo_trabajo_id,
principalTable: "grupos_trabajo",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "contabilidad_registros",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
reporte_mensual_id = table.Column<long>(type: "bigint", nullable: true),
grupo_trabajo_id = table.Column<long>(type: "bigint", nullable: false),
tipo = table.Column<int>(type: "integer", nullable: false),
monto = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
fecha = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
descripcion = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_contabilidad_registros", x => x.id);
table.ForeignKey(
name: "FK_contabilidad_registros_grupos_trabajo_grupo_trabajo_id",
column: x => x.grupo_trabajo_id,
principalTable: "grupos_trabajo",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_contabilidad_registros_reportes_mensuales_contables_reporte_mensual_id",
column: x => x.reporte_mensual_id,
principalTable: "reportes_mensuales_contables",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_contabilidad_registros_grupo_trabajo_id",
table: "contabilidad_registros",
column: "grupo_trabajo_id");
migrationBuilder.CreateIndex(
name: "IX_contabilidad_registros_reporte_mensual_id",
table: "contabilidad_registros",
column: "reporte_mensual_id");
migrationBuilder.CreateIndex(
name: "IX_reportes_mensuales_contables_grupo_trabajo_id",
table: "reportes_mensuales_contables",
column: "grupo_trabajo_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "contabilidad_registros");
migrationBuilder.DropTable(
name: "reportes_mensuales_contables");
}
}
}

View File

@@ -0,0 +1,143 @@
-- SQL para agregar funcionalidad de préstamos al sistema de inventario
-- PostgreSQL
-- 1. Agregar el nuevo tipo de movimiento PRESTAMO al enum existente
-- Nota: PostgreSQL no tiene ALTER ENUM, así que necesitamos recrear el enum
-- Primero creamos el nuevo enum
CREATE TYPE tipo_movimiento_new AS ENUM (
'ENTRADA',
'SALIDA',
'TRASLADO',
'BAJA',
'REPARACION',
'AJUSTE',
'CAMBIO_ESTADO',
'PRESTAMO'
);
-- Convertimos los datos existentes al nuevo enum
ALTER TABLE movimientos_inventario
ALTER COLUMN tipo_movimiento TYPE tipo_movimiento_new
USING tipo_movimiento::text::tipo_movimiento_new;
-- Eliminamos el enum viejo y renombramos el nuevo
DROP TYPE tipo_movimiento;
ALTER TYPE tipo_movimiento_new RENAME TO tipo_movimiento;
-- 2. Crear tabla de préstamos
CREATE TABLE prestamos (
id BIGSERIAL PRIMARY KEY,
articulo_id INTEGER NOT NULL,
cantidad INTEGER NOT NULL,
persona_nombre VARCHAR(200) NOT NULL,
persona_identificacion VARCHAR(50),
fecha_prestamo TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
fecha_devolucion_estimada TIMESTAMP WITH TIME ZONE,
fecha_devolucion_real TIMESTAMP WITH TIME ZONE,
estado VARCHAR(20) NOT NULL DEFAULT 'ACTIVO',
observacion VARCHAR(500),
usuario_id VARCHAR(100),
CONSTRAINT fk_prestamos_articulos FOREIGN KEY (articulo_id) REFERENCES articulos(id) ON DELETE CASCADE
);
-- 3. Crear tabla de detalles de préstamo (para códigos individuales)
CREATE TABLE prestamo_detalles (
id BIGSERIAL PRIMARY KEY,
prestamo_id BIGINT NOT NULL,
codigo_articulo_individual VARCHAR(100) NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'PRESTADO',
fecha_devolucion TIMESTAMP WITH TIME ZONE,
observacion VARCHAR(300),
CONSTRAINT fk_prestamo_detalles_prestamo FOREIGN KEY (prestamo_id) REFERENCES prestamos(id) ON DELETE CASCADE
);
-- 4. Crear índices para mejor rendimiento
CREATE INDEX idx_prestamos_articulo_id ON prestamos(articulo_id);
CREATE INDEX idx_prestamos_estado ON prestamos(estado);
CREATE INDEX idx_prestamos_fecha_prestamo ON prestamos(fecha_prestamo);
CREATE INDEX idx_prestamo_detalles_prestamo_id ON prestamo_detalles(prestamo_id);
CREATE INDEX idx_prestamo_detalles_codigo ON prestamo_detalles(codigo_articulo_individual);
-- 5. Crear función para generar códigos individuales automáticamente
CREATE OR REPLACE FUNCTION generar_codigo_individual(p_codigo_base VARCHAR, p_secuencia INTEGER)
RETURNS VARCHAR AS $$
BEGIN
RETURN p_codigo_base || '-' || LPAD(p_secuencia::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- 6. Crear trigger para actualizar estado de préstamo cuando todos los detalles están devueltos
CREATE OR REPLACE FUNCTION actualizar_estado_prestamo()
RETURNS TRIGGER AS $$
BEGIN
-- Si se actualiza un detalle a DEVUELTO, verificar si todos están devueltos
IF NEW.estado = 'DEVUELTO' THEN
UPDATE prestamos
SET estado = CASE
WHEN NOT EXISTS (
SELECT 1 FROM prestamo_detalles
WHERE prestamo_id = NEW.prestamo_id AND estado != 'DEVUELTO'
) THEN 'DEVUELTO'
ELSE estado
END,
fecha_devolucion_real = CASE
WHEN NOT EXISTS (
SELECT 1 FROM prestamo_detalles
WHERE prestamo_id = NEW.prestamo_id AND estado != 'DEVUELTO'
) THEN CURRENT_TIMESTAMP
ELSE fecha_devolucion_real
END
WHERE id = NEW.prestamo_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 7. Crear el trigger
CREATE TRIGGER trigger_actualizar_estado_prestamo
AFTER UPDATE ON prestamo_detalles
FOR EACH ROW
EXECUTE FUNCTION actualizar_estado_prestamo();
-- 8. Insertar datos de ejemplo (opcional)
-- INSERT INTO prestamos (articulo_id, cantidad, persona_nombre, persona_identificacion, fecha_devolucion_estimada, observacion, usuario_id)
-- VALUES (1, 2, 'Juan Pérez', '12345678', CURRENT_TIMESTAMP + INTERVAL '7 days', 'Préstamo para evento especial', 'admin');
-- Generar códigos individuales para el préstamo de ejemplo
-- INSERT INTO prestamo_detalles (prestamo_id, codigo_articulo_individual, estado)
-- VALUES
-- (1, generar_codigo_individual('sp-b20', 1), 'PRESTADO'),
-- (1, generar_codigo_individual('sp-b20', 2), 'PRESTADO');
-- 9. Crear vista para préstamos activos con detalles
CREATE OR REPLACE VIEW vista_prestamos_activos AS
SELECT
p.id,
p.articulo_id,
a.codigo as articulo_codigo,
a.nombre as articulo_nombre,
p.cantidad,
p.persona_nombre,
p.persona_identificacion,
p.fecha_prestamo,
p.fecha_devolucion_estimada,
p.estado,
p.observacion,
p.usuario_id,
COUNT(pd.id) as detalles_devueltos,
(p.cantidad - COUNT(pd.id)) as detalles_pendientes
FROM prestamos p
LEFT JOIN articulos a ON p.articulo_id = a.id
LEFT JOIN prestamo_detalles pd ON p.id = pd.prestamo_id AND pd.estado = 'DEVUELTO'
WHERE p.estado = 'ACTIVO'
GROUP BY p.id, p.articulo_id, a.codigo, a.nombre, p.cantidad, p.persona_nombre, p.persona_identificacion, p.fecha_prestamo, p.fecha_devolucion_estimada, p.estado, p.observacion, p.usuario_id;
-- 10. Conceder permisos (ajustar según tu usuario de base de datos)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON prestamos TO tu_usuario;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON prestamo_detalles TO tu_usuario;
-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO tu_usuario;
-- GRANT EXECUTE ON FUNCTION generar_codigo_individual TO tu_usuario;
-- GRANT EXECUTE ON FUNCTION actualizar_estado_prestamo TO tu_usuario;
-- GRANT SELECT ON vista_prestamos_activos TO tu_usuario;

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,67 @@
-- Table: public.articulos
CREATE TABLE IF NOT EXISTS public.articulos
(
id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
codigo character varying(50) COLLATE pg_catalog."default" NOT NULL,
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(500) COLLATE pg_catalog."default",
marca character varying(100) COLLATE pg_catalog."default",
modelo character varying(100) COLLATE pg_catalog."default",
numero_serie character varying(100) COLLATE pg_catalog."default",
precio numeric(10,2) NOT NULL DEFAULT 0,
fecha_adquisicion date,
imagen_url text COLLATE pg_catalog."default",
categoria_id integer NOT NULL,
estado_id integer NOT NULL,
ubicacion_id integer NOT NULL,
activo boolean NOT NULL DEFAULT true,
eliminado boolean NOT NULL DEFAULT false,
creado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
actualizado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
creado_por character varying(100) COLLATE pg_catalog."default",
CONSTRAINT articulos_pkey PRIMARY KEY (id),
CONSTRAINT fk_articulos_categorias FOREIGN KEY (categoria_id)
REFERENCES public.categorias (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT,
CONSTRAINT fk_articulos_estados FOREIGN KEY (estado_id)
REFERENCES public.estados_articulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT,
CONSTRAINT fk_articulos_ubicaciones FOREIGN KEY (ubicacion_id)
REFERENCES public.ubicaciones (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.articulos
OWNER to postgres;
-- Indexes
CREATE UNIQUE INDEX IF NOT EXISTS ix_articulos_codigo
ON public.articulos USING btree
(codigo COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_articulos_nombre
ON public.articulos USING btree
(nombre COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_articulos_categoria_id
ON public.articulos USING btree
(categoria_id ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_articulos_estado_id
ON public.articulos USING btree
(estado_id ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_articulos_ubicacion_id
ON public.articulos USING btree
(ubicacion_id ASC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,27 @@
-- Table: public.categorias
CREATE TABLE IF NOT EXISTS public.categorias
(
id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(500) COLLATE pg_catalog."default",
activo boolean NOT NULL DEFAULT true,
eliminado boolean NOT NULL DEFAULT false,
creado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
actualizado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
creado_por character varying(100) COLLATE pg_catalog."default",
CONSTRAINT categorias_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.categorias
OWNER to postgres;
-- Index: ix_categorias_nombre
-- DROP INDEX IF EXISTS public.ix_categorias_nombre;
CREATE INDEX IF NOT EXISTS ix_categorias_nombre
ON public.categorias USING btree
(nombre COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,28 @@
-- Table: public.estados_articulos
CREATE TABLE IF NOT EXISTS public.estados_articulos
(
id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
nombre character varying(50) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(200) COLLATE pg_catalog."default",
color character varying(20) COLLATE pg_catalog."default" DEFAULT 'secondary'::character varying,
activo boolean NOT NULL DEFAULT true,
eliminado boolean NOT NULL DEFAULT false,
creado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
actualizado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
creado_por character varying(100) COLLATE pg_catalog."default",
CONSTRAINT estados_articulos_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.estados_articulos
OWNER to postgres;
-- Index: ix_estados_articulos_nombre
-- DROP INDEX IF EXISTS public.ix_estados_articulos_nombre;
CREATE INDEX IF NOT EXISTS ix_estados_articulos_nombre
ON public.estados_articulos USING btree
(nombre COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,52 @@
-- =====================================================
-- Script: Church Members Module Database Schema (Refactored)
-- Description: Creates tables for work groups and members linked to personas
-- =====================================================
-- Table: grupos_trabajo
CREATE TABLE IF NOT EXISTS public.grupos_trabajo
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
nombre character varying(100) NOT NULL,
descripcion text,
activo boolean NOT NULL DEFAULT true,
creado_en timestamp with time zone NOT NULL DEFAULT NOW(),
actualizado_en timestamp with time zone NOT NULL DEFAULT NOW(),
CONSTRAINT grupos_trabajo_pkey PRIMARY KEY (id)
);
-- Table: miembros
-- Drop if exists to handle the schema change during development
DROP TABLE IF EXISTS public.miembros;
CREATE TABLE IF NOT EXISTS public.miembros
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
persona_id bigint NOT NULL,
bautizado_espiritu_santo boolean NOT NULL DEFAULT false,
fecha_ingreso_congregacion date,
telefono_emergencia character varying(20),
grupo_trabajo_id bigint,
activo boolean NOT NULL DEFAULT true,
eliminado boolean NOT NULL DEFAULT false,
creado_en timestamp with time zone NOT NULL DEFAULT NOW(),
actualizado_en timestamp with time zone NOT NULL DEFAULT NOW(),
creado_por character varying(100),
CONSTRAINT miembros_pkey PRIMARY KEY (id),
CONSTRAINT fk_miembros_persona FOREIGN KEY (persona_id)
REFERENCES public.personas (id),
CONSTRAINT fk_miembros_grupo_trabajo FOREIGN KEY (grupo_trabajo_id)
REFERENCES public.grupos_trabajo (id)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_miembros_persona ON public.miembros(persona_id);
CREATE INDEX IF NOT EXISTS idx_miembros_grupo_trabajo ON public.miembros(grupo_trabajo_id);
CREATE INDEX IF NOT EXISTS idx_miembros_activo ON public.miembros(activo, eliminado);
-- Initial work groups data
INSERT INTO public.grupos_trabajo (nombre, descripcion) VALUES
('Concilio Misionero Femenil', 'Grupo de trabajo de mujeres misioneras'),
('Fraternidad de Varones', 'Grupo de trabajo de varones de la iglesia'),
('Embajadores de Cristo', 'Grupo de jóvenes embajadores')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,52 @@
-- Table: public.movimientos_inventario
CREATE TABLE IF NOT EXISTS public.movimientos_inventario
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
articulo_id integer NOT NULL,
tipo_movimiento character varying(50) COLLATE pg_catalog."default" NOT NULL,
fecha timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
ubicacion_origen_id integer,
ubicacion_destino_id integer,
estado_anterior_id integer,
estado_nuevo_id integer,
observacion character varying(500) COLLATE pg_catalog."default",
usuario_id character varying(100) COLLATE pg_catalog."default",
CONSTRAINT movimientos_inventario_pkey PRIMARY KEY (id),
CONSTRAINT fk_movimientos_articulos FOREIGN KEY (articulo_id)
REFERENCES public.articulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT fk_movimientos_origen FOREIGN KEY (ubicacion_origen_id)
REFERENCES public.ubicaciones (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE SET NULL,
CONSTRAINT fk_movimientos_destino FOREIGN KEY (ubicacion_destino_id)
REFERENCES public.ubicaciones (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE SET NULL,
CONSTRAINT fk_movimientos_estado_ant FOREIGN KEY (estado_anterior_id)
REFERENCES public.estados_articulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE SET NULL,
CONSTRAINT fk_movimientos_estado_new FOREIGN KEY (estado_nuevo_id)
REFERENCES public.estados_articulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE SET NULL
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.movimientos_inventario
OWNER to postgres;
-- Indexes
CREATE INDEX IF NOT EXISTS ix_movimientos_articulo_id
ON public.movimientos_inventario USING btree
(articulo_id ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_movimientos_fecha
ON public.movimientos_inventario USING btree
(fecha DESC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,106 @@
-- =============================================
-- Módulo de Ofrendas - Script SQL
-- =============================================
-- Tabla: registros_culto
-- Almacena los registros de ofrendas por culto
CREATE TABLE IF NOT EXISTS public.registros_culto
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
fecha date NOT NULL,
observaciones character varying(500) COLLATE pg_catalog."default",
creado_por character varying(100) COLLATE pg_catalog."default",
creado_en timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
eliminado boolean NOT NULL DEFAULT false,
CONSTRAINT registros_culto_pkey PRIMARY KEY (id)
);
COMMENT ON TABLE public.registros_culto IS 'Registros de ofrendas por culto';
-- Índice para búsqueda por fecha
CREATE INDEX IF NOT EXISTS idx_registros_culto_fecha ON public.registros_culto(fecha);
CREATE INDEX IF NOT EXISTS idx_registros_culto_eliminado ON public.registros_culto(eliminado);
-- =============================================
-- Tabla: ofrendas
-- Almacena las ofrendas individuales de cada registro
CREATE TABLE IF NOT EXISTS public.ofrendas
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
registro_culto_id bigint NOT NULL,
monto numeric(10,2) NOT NULL CHECK (monto > 0),
concepto character varying(200) COLLATE pg_catalog."default" NOT NULL,
eliminado boolean NOT NULL DEFAULT false,
CONSTRAINT ofrendas_pkey PRIMARY KEY (id),
CONSTRAINT fk_ofrendas_registro_culto FOREIGN KEY (registro_culto_id)
REFERENCES public.registros_culto (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
COMMENT ON TABLE public.ofrendas IS 'Ofrendas individuales de cada registro de culto';
-- Índices
CREATE INDEX IF NOT EXISTS idx_ofrendas_registro_culto_id ON public.ofrendas(registro_culto_id);
CREATE INDEX IF NOT EXISTS idx_ofrendas_eliminado ON public.ofrendas(eliminado);
-- =============================================
-- Tabla: descuentos_ofrenda
-- Almacena los descuentos aplicados a cada ofrenda
CREATE TABLE IF NOT EXISTS public.descuentos_ofrenda
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
ofrenda_id bigint NOT NULL,
monto numeric(10,2) NOT NULL CHECK (monto > 0),
concepto character varying(200) COLLATE pg_catalog."default" NOT NULL,
eliminado boolean NOT NULL DEFAULT false,
CONSTRAINT descuentos_ofrenda_pkey PRIMARY KEY (id),
CONSTRAINT fk_descuentos_ofrenda_ofrenda FOREIGN KEY (ofrenda_id)
REFERENCES public.ofrendas (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
COMMENT ON TABLE public.descuentos_ofrenda IS 'Descuentos aplicados a las ofrendas (diezmo, asignaciones, etc.)';
-- Índices
CREATE INDEX IF NOT EXISTS idx_descuentos_ofrenda_ofrenda_id ON public.descuentos_ofrenda(ofrenda_id);
CREATE INDEX IF NOT EXISTS idx_descuentos_ofrenda_eliminado ON public.descuentos_ofrenda(eliminado);
-- =============================================
-- Permisos para el módulo
-- =============================================
-- Primero crear el módulo si no existe
INSERT INTO public.modulos (nombre, descripcion, icono, orden, activo)
SELECT 'Secretaría', 'Módulo de secretaría de la iglesia', 'bi bi-journal-bookmark', 40, true
WHERE NOT EXISTS (SELECT 1 FROM public.modulos WHERE nombre = 'Secretaría');
-- Crear permisos para el controlador Ofrenda
INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo)
SELECT 'Ofrenda.Index', 'Ver Ofrendas', 'Permite ver el listado de registros de ofrendas',
(SELECT id FROM public.modulos WHERE nombre = 'Secretaría'), true
WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Ofrenda.Index');
INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo)
SELECT 'Ofrenda.Create', 'Crear Ofrenda', 'Permite crear nuevos registros de ofrendas',
(SELECT id FROM public.modulos WHERE nombre = 'Secretaría'), true
WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Ofrenda.Create');
INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo)
SELECT 'Ofrenda.Edit', 'Editar Ofrenda', 'Permite editar registros de ofrendas',
(SELECT id FROM public.modulos WHERE nombre = 'Secretaría'), true
WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Ofrenda.Edit');
INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo)
SELECT 'Ofrenda.Delete', 'Eliminar Ofrenda', 'Permite eliminar registros de ofrendas',
(SELECT id FROM public.modulos WHERE nombre = 'Secretaría'), true
WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Ofrenda.Delete');
INSERT INTO public.permisos (codigo, nombre, descripcion, modulo_id, activo)
SELECT 'Ofrenda.Details', 'Ver Detalle Ofrenda', 'Permite ver el detalle de un registro de ofrendas',
(SELECT id FROM public.modulos WHERE nombre = 'Secretaría'), true
WHERE NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Ofrenda.Details');

View File

@@ -0,0 +1,28 @@
-- Table: public.ubicaciones
CREATE TABLE IF NOT EXISTS public.ubicaciones
(
id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(200) COLLATE pg_catalog."default",
responsable character varying(100) COLLATE pg_catalog."default",
activo boolean NOT NULL DEFAULT true,
eliminado boolean NOT NULL DEFAULT false,
creado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
actualizado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
creado_por character varying(100) COLLATE pg_catalog."default",
CONSTRAINT ubicaciones_pkey PRIMARY KEY (id)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.ubicaciones
OWNER to postgres;
-- Index: ix_ubicaciones_nombre
-- DROP INDEX IF EXISTS public.ix_ubicaciones_nombre;
CREATE INDEX IF NOT EXISTS ix_ubicaciones_nombre
ON public.ubicaciones USING btree
(nombre COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,45 @@
-- Add columns to articulos
ALTER TABLE public.articulos
ADD COLUMN IF NOT EXISTS tipo_control character varying(50) NOT NULL DEFAULT 'UNITARIO';
ALTER TABLE public.articulos
ADD COLUMN IF NOT EXISTS cantidad_global integer NOT NULL DEFAULT 1;
-- Add columns to movimientos_inventario
ALTER TABLE public.movimientos_inventario
ADD COLUMN IF NOT EXISTS cantidad integer NOT NULL DEFAULT 1;
-- Create existencias table
CREATE TABLE IF NOT EXISTS public.existencias
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
articulo_id integer NOT NULL,
ubicacion_id integer NOT NULL,
cantidad integer NOT NULL DEFAULT 0,
actualizado_en timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
CONSTRAINT existencias_pkey PRIMARY KEY (id),
CONSTRAINT fk_existencias_articulo FOREIGN KEY (articulo_id)
REFERENCES public.articulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT fk_existencias_ubicacion FOREIGN KEY (ubicacion_id)
REFERENCES public.ubicaciones (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT uq_existencias_articulo_ubicacion UNIQUE (articulo_id, ubicacion_id)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.existencias
OWNER to postgres;
-- Index for existencias
CREATE INDEX IF NOT EXISTS ix_existencias_articulo_id
ON public.existencias USING btree
(articulo_id ASC NULLS LAST)
TABLESPACE pg_default;
CREATE INDEX IF NOT EXISTS ix_existencias_ubicacion_id
ON public.existencias USING btree
(ubicacion_id ASC NULLS LAST)
TABLESPACE pg_default;

View File

@@ -0,0 +1,101 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("articulos")]
public class Articulo
{
public enum TipoControlInventario
{
UNITARIO, // 1 record = 1 physical item (Laptop, Projector)
LOTE // 1 record = N items (Chairs, Cables)
}
[Key]
[Column("id")]
public int Id { get; set; }
[Column("tipo_control")]
[Required]
public string TipoControl { get; set; } = nameof(TipoControlInventario.UNITARIO);
[Column("cantidad_global")]
public int CantidadGlobal { get; set; } = 1; // Cache/Total for LOTE. Always 1 for UNITARIO.
[Column("codigo")]
[Required(ErrorMessage = "El código es obligatorio")]
[StringLength(50, ErrorMessage = "El código no puede exceder los 50 caracteres")]
public string Codigo { get; set; } = string.Empty;
[Column("nombre")]
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(100, ErrorMessage = "El nombre no puede exceder los 100 caracteres")]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(500, ErrorMessage = "La descripción no puede exceder los 500 caracteres")]
public string? Descripcion { get; set; }
[Column("marca")]
[StringLength(100)]
public string? Marca { get; set; }
[Column("modelo")]
[StringLength(100)]
public string? Modelo { get; set; }
[Column("numero_serie")]
[StringLength(100)]
public string? NumeroSerie { get; set; }
[Column("precio")]
[Range(0, 99999999.99)]
public decimal Precio { get; set; } = 0;
[Column("fecha_adquisicion")]
public DateOnly? FechaAdquisicion { get; set; }
[Column("imagen_url")]
public string? ImagenUrl { get; set; }
// Foreign Keys
[Column("categoria_id")]
public int CategoriaId { get; set; }
[Column("estado_id")]
public int EstadoId { get; set; }
[Column("ubicacion_id")]
public int UbicacionId { get; set; }
// Audit & Control
[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("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
[Column("creado_por")]
[StringLength(100)]
public string? CreadoPor { get; set; }
// Navigation Properties
[ForeignKey("CategoriaId")]
public virtual Categoria? Categoria { get; set; }
[ForeignKey("EstadoId")]
public virtual EstadoArticulo? Estado { get; set; }
[ForeignKey("UbicacionId")]
public virtual Ubicacion? Ubicacion { get; set; }
}

View File

@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("categorias")]
public class Categoria
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("nombre")]
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(100, ErrorMessage = "El nombre no puede exceder los 100 caracteres")]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(500, ErrorMessage = "La descripción no puede exceder los 500 caracteres")]
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("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
[Column("creado_por")]
[StringLength(100)]
public string? CreadoPor { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("categorias_egreso")]
public class CategoriaEgreso
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("nombre")]
[Required]
[StringLength(100)]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(255)]
public string? Descripcion { get; set; }
[Column("activa")]
public bool Activa { get; set; } = true;
[Column("fecha_creacion")]
public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
// Navigation property
public virtual ICollection<MovimientoGeneral> Movimientos { get; set; } = new List<MovimientoGeneral>();
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("categorias_ingreso")]
public class CategoriaIngreso
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("nombre")]
[Required]
[StringLength(100)]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(255)]
public string? Descripcion { get; set; }
[Column("activa")]
public bool Activa { get; set; } = true;
[Column("fecha_creacion")]
public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
// Navigation property
public virtual ICollection<MovimientoGeneral> Movimientos { get; set; } = new List<MovimientoGeneral>();
}

View File

@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("colaboraciones", Schema = "public")]
public class Colaboracion
{
[Key]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("miembro_id")]
public long MiembroId { get; set; }
[Column("fecha_registro")]
public DateTime FechaRegistro { get; set; } = DateTime.UtcNow;
[Required]
[Column("monto_total")]
public decimal MontoTotal { get; set; }
[Column("observaciones")]
public string? Observaciones { get; set; }
[MaxLength(100)]
[Column("registrado_por")]
public string? RegistradoPor { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("MiembroId")]
public Miembro Miembro { get; set; } = null!;
public ICollection<DetalleColaboracion> Detalles { get; set; } = new List<DetalleColaboracion>();
}

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
public enum TipoMovimientoContable
{
Ingreso,
Egreso
}
[Table("contabilidad_registros")]
public class ContabilidadRegistro
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("reporte_mensual_id")]
public long? ReporteMensualId { get; set; }
[ForeignKey("ReporteMensualId")]
public virtual ReporteMensualContable? ReporteMensual { get; set; }
[Column("grupo_trabajo_id")]
[Required]
public long GrupoTrabajoId { get; set; }
[ForeignKey("GrupoTrabajoId")]
public virtual GrupoTrabajo GrupoTrabajo { get; set; }
[Column("tipo")]
[Required]
public TipoMovimientoContable Tipo { get; set; }
[Column("monto", TypeName = "decimal(18,2)")]
[Required]
public decimal Monto { get; set; }
[Column("fecha")]
[Required]
public DateTime Fecha { get; set; }
[Column("descripcion")]
[StringLength(200)]
public string Descripcion { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("detalle_colaboraciones", Schema = "public")]
public class DetalleColaboracion
{
[Key]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("colaboracion_id")]
public long ColaboracionId { get; set; }
[Required]
[Column("tipo_colaboracion_id")]
public long TipoColaboracionId { get; set; }
[Required]
[Range(1, 12)]
[Column("mes")]
public int Mes { get; set; }
[Required]
[Range(2000, 2100)]
[Column("anio")]
public int Anio { get; set; }
[Required]
[Column("monto")]
public decimal Monto { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("ColaboracionId")]
public Colaboracion Colaboracion { get; set; } = null!;
[ForeignKey("TipoColaboracionId")]
public TipoColaboracion TipoColaboracion { get; set; } = null!;
}

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("estados_articulos")]
public class EstadoArticulo
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("nombre")]
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres")]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(200, ErrorMessage = "La descripción no puede exceder los 200 caracteres")]
public string? Descripcion { get; set; }
[Column("color")]
[StringLength(20)]
public string? Color { get; set; } = "secondary"; // success, warning, danger, info, primary, secondary
[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("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
[Column("creado_por")]
[StringLength(100)]
public string? CreadoPor { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("existencias")]
public class Existencia
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("articulo_id")]
public int ArticuloId { get; set; }
[Column("ubicacion_id")]
public int UbicacionId { get; set; }
[Column("cantidad")]
public int Cantidad { get; set; } = 0;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Navigation
[ForeignKey("ArticuloId")]
public virtual Articulo? Articulo { get; set; }
[ForeignKey("UbicacionId")]
public virtual Ubicacion? Ubicacion { get; set; }
}

View File

@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
public enum TipoMovimientoGeneral
{
Ingreso = 1,
Egreso = 2
}
[Table("movimientos_generales")]
public class MovimientoGeneral
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("reporte_mensual_general_id")]
public long? ReporteMensualGeneralId { get; set; }
[ForeignKey("ReporteMensualGeneralId")]
public virtual ReporteMensualGeneral? ReporteMensualGeneral { get; set; }
[Column("tipo")]
[Required]
public int Tipo { get; set; }
[Column("categoria_ingreso_id")]
public long? CategoriaIngresoId { get; set; }
[ForeignKey("CategoriaIngresoId")]
public virtual CategoriaIngreso? CategoriaIngreso { get; set; }
[Column("categoria_egreso_id")]
public long? CategoriaEgresoId { get; set; }
[ForeignKey("CategoriaEgresoId")]
public virtual CategoriaEgreso? CategoriaEgreso { get; set; }
[Column("monto", TypeName = "decimal(18,2)")]
[Required]
public decimal Monto { get; set; }
[Column("fecha")]
[Required]
public DateTime Fecha { get; set; }
[Column("descripcion")]
[StringLength(200)]
public string Descripcion { get; set; } = string.Empty;
[Column("numero_comprobante")]
[StringLength(50)]
public string? NumeroComprobante { get; set; }
// Navigation property
public virtual ICollection<MovimientoGeneralAdjunto> Adjuntos { get; set; } = new List<MovimientoGeneralAdjunto>();
}

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("movimientos_generales_adjuntos")]
public class MovimientoGeneralAdjunto
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("movimiento_general_id")]
[Required]
public long MovimientoGeneralId { get; set; }
[ForeignKey("MovimientoGeneralId")]
public virtual MovimientoGeneral MovimientoGeneral { get; set; }
[Column("nombre_archivo")]
[Required]
[StringLength(255)]
public string NombreArchivo { get; set; } = string.Empty;
[Column("ruta_archivo")]
[Required]
[StringLength(500)]
public string RutaArchivo { get; set; } = string.Empty;
[Column("tipo_contenido")]
[StringLength(100)]
public string? TipoContenido { get; set; }
[Column("fecha_subida")]
public DateTime FechaSubida { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
public enum TipoMovimiento
{
ENTRADA, // Nueva adquisición (aunque se crea al crear art, podría usarse para reingresos)
SALIDA, // Salida temporal
TRASLADO, // Cambio de ubicación
BAJA, // Retiro permanente (daño, robo, venta)
REPARACION, // Envío a taller
AJUSTE, // Corrección de inventario
CAMBIO_ESTADO, // Cambio de condición física
PRESTAMO, // Préstamo a persona
DEVOLUCION
}
[Table("movimientos_inventario")]
public class MovimientoInventario
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("articulo_id")]
[Required]
public int ArticuloId { get; set; }
[Column("tipo_movimiento")]
[Required]
public string TipoMovimiento { get; set; } = string.Empty;
[Column("cantidad")]
public int Cantidad { get; set; } = 1; // Default 1 for UNITARIO
[Column("fecha")]
public DateTime Fecha { get; set; } = DateTime.UtcNow;
// Ubicaciones
[Column("ubicacion_origen_id")]
public int? UbicacionOrigenId { get; set; }
[Column("ubicacion_destino_id")]
public int? UbicacionDestinoId { get; set; }
// Estados
[Column("estado_anterior_id")]
public int? EstadoAnteriorId { get; set; }
[Column("estado_nuevo_id")]
public int? EstadoNuevoId { get; set; }
[Column("TipMov")]
public int? TipMov { get; set; }
[Column("observacion")]
[StringLength(500)]
public string? Observacion { get; set; }
[Column("usuario_id")]
[StringLength(100)]
public string? UsuarioId { get; set; } // Username or User ID
// Navigation Properties
[ForeignKey("ArticuloId")]
public virtual Articulo? Articulo { get; set; }
[ForeignKey("UbicacionOrigenId")]
public virtual Ubicacion? UbicacionOrigen { get; set; }
[ForeignKey("UbicacionDestinoId")]
public virtual Ubicacion? UbicacionDestino { get; set; }
[ForeignKey("EstadoAnteriorId")]
public virtual EstadoArticulo? EstadoAnterior { get; set; }
[ForeignKey("EstadoNuevoId")]
public virtual EstadoArticulo? EstadoNuevo { get; set; }
}

View File

@@ -0,0 +1,89 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("prestamos")]
public class Prestamo
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("articulo_id")]
[Required]
public int ArticuloId { get; set; }
[Column("cantidad")]
[Required]
public int Cantidad { get; set; }
[Column("persona_nombre")]
[Required]
[StringLength(200)]
public string PersonaNombre { get; set; } = string.Empty;
[Column("persona_identificacion")]
[StringLength(50)]
public string? PersonaIdentificacion { get; set; }
[Column("fecha_prestamo")]
public DateTime FechaPrestamo { get; set; } = DateTime.UtcNow;
[Column("fecha_devolucion_estimada")]
public DateTime? FechaDevolucionEstimada { get; set; }
[Column("fecha_devolucion_real")]
public DateTime? FechaDevolucionReal { get; set; }
[Column("estado")]
[Required]
public string Estado { get; set; } = "ACTIVO"; // ACTIVO, DEVUELTO, ATRASADO
[Column("observacion")]
[StringLength(500)]
public string? Observacion { get; set; }
[Column("usuario_id")]
[StringLength(100)]
public string? UsuarioId { get; set; }
// Navigation Properties
[ForeignKey("ArticuloId")]
public virtual Articulo? Articulo { get; set; }
// Navigation Property for detailed items
public virtual ICollection<PrestamoDetalle> Detalles { get; set; } = new List<PrestamoDetalle>();
}
[Table("prestamo_detalles")]
public class PrestamoDetalle
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("prestamo_id")]
[Required]
public long PrestamoId { get; set; }
[Column("codigo_articulo_individual")]
[Required]
[StringLength(100)]
public string CodigoArticuloIndividual { get; set; } = string.Empty;
[Column("estado")]
[Required]
public string Estado { get; set; } = "PRESTADO"; // PRESTADO, DEVUELTO
[Column("fecha_devolucion")]
public DateTime? FechaDevolucion { get; set; }
[Column("observacion")]
[StringLength(300)]
public string? Observacion { get; set; }
// Navigation Properties
[ForeignKey("PrestamoId")]
public virtual Prestamo? Prestamo { get; set; }
}

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("reportes_mensuales_contables")]
public class ReporteMensualContable
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("grupo_trabajo_id")]
[Required]
public long GrupoTrabajoId { get; set; }
[ForeignKey("GrupoTrabajoId")]
public virtual GrupoTrabajo GrupoTrabajo { get; set; }
[Column("mes")]
[Required]
public int Mes { get; set; }
[Column("anio")]
[Required]
public int Anio { get; set; }
[Column("saldo_inicial", TypeName = "decimal(18,2)")]
public decimal SaldoInicial { get; set; }
[Column("fecha_creacion")]
public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
[Column("cerrado")]
public bool Cerrado { get; set; } = false;
// Navigation property for details
public virtual ICollection<ContabilidadRegistro> Registros { get; set; } = new List<ContabilidadRegistro>();
// Helper properties for display
[NotMapped]
public string NombreMes => new DateTime(Anio, Mes, 1).ToString("MMMM", new System.Globalization.CultureInfo("es-ES"));
}

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("reportes_mensuales_generales")]
public class ReporteMensualGeneral
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("mes")]
[Required]
public int Mes { get; set; }
[Column("anio")]
[Required]
public int Anio { get; set; }
[Column("saldo_inicial", TypeName = "decimal(18,2)")]
public decimal SaldoInicial { get; set; }
[Column("fecha_creacion")]
public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
[Column("cerrado")]
public bool Cerrado { get; set; } = false;
// Navigation property for details
public virtual ICollection<MovimientoGeneral> Movimientos { get; set; } = new List<MovimientoGeneral>();
// Helper properties for display
[NotMapped]
public string NombreMes => new DateTime(Anio, Mes, 1).ToString("MMMM", new System.Globalization.CultureInfo("es-ES"));
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("tipos_colaboracion", Schema = "public")]
public class TipoColaboracion
{
[Key]
[Column("id")]
public long Id { get; set; }
[Required]
[MaxLength(100)]
[Column("nombre")]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
public string? Descripcion { get; set; }
[Column("monto_sugerido")]
[Required]
public decimal MontoSugerido { get; set; }
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("orden")]
public int Orden { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
public ICollection<DetalleColaboracion> Detalles { get; set; } = new List<DetalleColaboracion>();
}

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Rs_system.Models;
[Table("ubicaciones")]
public class Ubicacion
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("nombre")]
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(100, ErrorMessage = "El nombre no puede exceder los 100 caracteres")]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
[StringLength(200, ErrorMessage = "La descripción no puede exceder los 200 caracteres")]
public string? Descripcion { get; set; }
[Column("responsable")]
[StringLength(100, ErrorMessage = "El nombre del responsable no puede exceder los 100 caracteres")]
public string? Responsable { 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("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
[Column("creado_por")]
[StringLength(100)]
public string? CreadoPor { get; set; }
}

View File

@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
using Rs_system.Models;
namespace Rs_system.Models.ViewModels;
public class ArticuloViewModel
{
public int Id { get; set; }
[Required(ErrorMessage = "El código es obligatorio")]
[StringLength(50, ErrorMessage = "El código no puede exceder los 50 caracteres")]
public string Codigo { get; set; } = string.Empty;
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(100, ErrorMessage = "El nombre no puede exceder los 100 caracteres")]
public string Nombre { get; set; } = string.Empty;
[StringLength(500, ErrorMessage = "La descripción no puede exceder los 500 caracteres")]
public string? Descripcion { get; set; }
[StringLength(100)]
public string? Marca { get; set; }
[StringLength(100)]
public string? Modelo { get; set; }
[Display(Name = "Número de Serie")]
[StringLength(100)]
public string? NumeroSerie { get; set; }
[Range(0, 99999999.99)]
public decimal Precio { get; set; } = 0;
[Display(Name = "Fecha de Adquisición")]
public DateOnly? FechaAdquisicion { get; set; }
public string? ImagenUrl { get; set; }
[Display(Name = "Imagen")]
public IFormFile? ImagenFile { get; set; }
[Display(Name = "Tipo de Control")]
public string? TipoControl { get; set; } = "UNITARIO"; // Default for View
[Display(Name = "Cantidad Inicial")]
[Range(1, 100000)]
public int CantidadInicial { get; set; } = 1;
public int CategoriaId { get; set; }
[Display(Name = "Estado")]
[Required(ErrorMessage = "El estado es obligatorio")]
public int EstadoId { get; set; }
[Display(Name = "Ubicación")]
[Required(ErrorMessage = "La ubicación es obligatoria")]
public int UbicacionId { get; set; }
public bool Activo { get; set; } = true;
// Display properties for lists/details
public string? CategoriaNombre { get; set; }
public string? EstadoNombre { get; set; }
public string? EstadoColor { get; set; }
public string? UbicacionNombre { get; set; }
public int CantidadGlobal { get; set; }
}

View File

@@ -0,0 +1,41 @@
namespace Rs_system.Models.ViewModels;
public class EstadoCuentaViewModel
{
public long MiembroId { get; set; }
public string NombreMiembro { get; set; } = string.Empty;
public DateTime FechaConsulta { get; set; }
public List<HistorialPorTipo> HistorialPorTipos { get; set; } = new();
public decimal TotalAportado { get; set; }
}
public class HistorialPorTipo
{
public string TipoNombre { get; set; } = string.Empty;
public List<RegistroMensual> Registros { get; set; } = new();
public decimal TotalTipo { get; set; }
}
public class RegistroMensual
{
public int Mes { get; set; }
public int Anio { get; set; }
public decimal Monto { get; set; }
public DateTime FechaRegistro { get; set; }
public string MesTexto => ObtenerMesTexto();
private string ObtenerMesTexto()
{
try
{
var fecha = new DateTime(Anio, Mes, 1);
return fecha.ToString("MMMM yyyy", new System.Globalization.CultureInfo("es-ES"));
}
catch
{
return $"{Mes}/{Anio}";
}
}
}

View File

@@ -0,0 +1,87 @@
using System.ComponentModel.DataAnnotations;
using Rs_system.Models;
namespace Rs_system.Models.ViewModels;
public class RegistrarColaboracionViewModel
{
[Required(ErrorMessage = "Debe seleccionar un miembro")]
public long MiembroId { get; set; }
[Display(Name = "Mes Inicial")]
[Required(ErrorMessage = "Debe seleccionar el mes inicial")]
[Range(1, 12, ErrorMessage = "Mes debe estar entre 1 y 12")]
public int MesInicial { get; set; }
[Display(Name = "Año Inicial")]
[Required(ErrorMessage = "Debe seleccionar el año inicial")]
[Range(2000, 2100, ErrorMessage = "Año debe estar entre 2000 y 2100")]
public int AnioInicial { get; set; }
[Display(Name = "Mes Final")]
[Required(ErrorMessage = "Debe seleccionar el mes final")]
[Range(1, 12, ErrorMessage = "Mes debe estar entre 1 y 12")]
public int MesFinal { get; set; }
[Display(Name = "Año Final")]
[Required(ErrorMessage = "Debe seleccionar el año final")]
[Range(2000, 2100, ErrorMessage = "Año debe estar entre 2000 y 2100")]
public int AnioFinal { get; set; }
[Required(ErrorMessage = "Debe seleccionar al menos un tipo de colaboración")]
public List<long> TiposSeleccionados { get; set; } = new();
[Required(ErrorMessage = "Debe ingresar el monto total")]
[Range(0.01, 999999.99, ErrorMessage = "El monto total debe ser mayor a 0")]
[Display(Name = "Monto Total Entregado")]
public decimal MontoTotal { get; set; }
[Display(Name = "Tipo de Colaboración Prioritaria")]
public long? TipoPrioritario { get; set; }
[MaxLength(500, ErrorMessage = "Las observaciones no pueden exceder 500 caracteres")]
[Display(Name = "Observaciones")]
public string? Observaciones { get; set; }
// Para cargar en el formulario
public List<TipoColaboracion> TiposDisponibles { get; set; } = new();
// Propiedad calculada: Total de meses
public int TotalMeses
{
get
{
try
{
var fechaInicial = new DateTime(AnioInicial, MesInicial, 1);
var fechaFinal = new DateTime(AnioFinal, MesFinal, 1);
if (fechaFinal < fechaInicial)
return 0;
return ((AnioFinal - AnioInicial) * 12) + (MesFinal - MesInicial) + 1;
}
catch
{
return 0;
}
}
}
// Propiedad calculada: Monto sugerido total basado en los tipos seleccionados
public decimal MontoSugeridoTotal
{
get
{
if (TiposDisponibles == null || !TiposSeleccionados.Any())
return 0;
var tiposSeleccionadosData = TiposDisponibles
.Where(t => TiposSeleccionados.Contains(t.Id))
.ToList();
var montoSugeridoPorMes = tiposSeleccionadosData.Sum(t => t.MontoSugerido);
return montoSugeridoPorMes * TotalMeses;
}
}
}

View File

@@ -0,0 +1,28 @@
namespace Rs_system.Models.ViewModels;
public class ReporteColaboracionesViewModel
{
public DateTime FechaInicio { get; set; }
public DateTime FechaFin { get; set; }
public decimal TotalRecaudado { get; set; }
public List<DesglosePorTipo> DesglosePorTipos { get; set; } = new();
public List<DetalleMovimiento> Movimientos { get; set; } = new();
}
public class DesglosePorTipo
{
public string TipoNombre { get; set; } = string.Empty;
public int CantidadMeses { get; set; }
public decimal TotalRecaudado { get; set; }
}
public class DetalleMovimiento
{
public long ColaboracionId { get; set; }
public DateTime Fecha { get; set; }
public string NombreMiembro { get; set; } = string.Empty;
public string TiposColaboracion { get; set; } = string.Empty;
public string PeriodoCubierto { get; set; } = string.Empty;
public decimal Monto { get; set; }
}

View File

@@ -0,0 +1,39 @@
namespace Rs_system.Models.ViewModels;
public class UltimoPagoViewModel
{
public long TipoId { get; set; }
public string NombreTipo { get; set; }
public int UltimoMes { get; set; }
public int UltimoAnio { get; set; }
public DateTime FechaUltimoPago { get; set; }
public string UltimoPeriodoTexto
{
get
{
if (UltimoMes == 0 || UltimoAnio == 0) return "Sin pagos registrados";
return $"{ObtenerNombreMes(UltimoMes)} {UltimoAnio}";
}
}
private string ObtenerNombreMes(int mes)
{
return mes switch
{
1 => "Enero",
2 => "Febrero",
3 => "Marzo",
4 => "Abril",
5 => "Mayo",
6 => "Junio",
7 => "Julio",
8 => "Agosto",
9 => "Septiembre",
10 => "Octubre",
11 => "Noviembre",
12 => "Diciembre",
_ => ""
};
}
}

View File

@@ -0,0 +1,276 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
using Rs_system.Models.ViewModels;
namespace Rs_system.Services;
public class ArticuloService : IArticuloService
{
private readonly ApplicationDbContext _context;
private readonly IFileStorageService _fileStorageService;
public ArticuloService(ApplicationDbContext context, IFileStorageService fileStorageService)
{
_context = context;
_fileStorageService = fileStorageService;
}
public async Task<IEnumerable<ArticuloViewModel>> GetAllAsync(string? search = null, int? categoriaId = null, int? ubicacionId = null, int? estadoId = null)
{
var query = _context.Articulos
.Include(a => a.Categoria)
.Include(a => a.Estado)
.Include(a => a.Ubicacion)
.Where(a => !a.Eliminado)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.ToLower();
query = query.Where(a =>
a.Nombre.ToLower().Contains(term) ||
a.Codigo.ToLower().Contains(term) ||
a.Modelo.ToLower().Contains(term) ||
a.Marca.ToLower().Contains(term) ||
a.NumeroSerie.ToLower().Contains(term));
}
if (categoriaId.HasValue)
query = query.Where(a => a.CategoriaId == categoriaId);
if (ubicacionId.HasValue)
query = query.Where(a => a.UbicacionId == ubicacionId);
if (estadoId.HasValue)
query = query.Where(a => a.EstadoId == estadoId);
return await query
.OrderByDescending(a => a.CreadoEn)
.Select(a => new ArticuloViewModel
{
Id = a.Id,
Codigo = a.Codigo,
Nombre = a.Nombre,
Descripcion = a.Descripcion,
Marca = a.Marca,
Modelo = a.Modelo,
NumeroSerie = a.NumeroSerie,
Precio = a.Precio,
FechaAdquisicion = a.FechaAdquisicion,
ImagenUrl = a.ImagenUrl,
CategoriaId = a.CategoriaId,
CategoriaNombre = a.Categoria.Nombre,
EstadoId = a.EstadoId,
EstadoNombre = a.Estado.Nombre,
EstadoColor = a.Estado.Color,
UbicacionId = a.UbicacionId,
UbicacionNombre = a.Ubicacion.Nombre,
Activo = a.Activo
})
.ToListAsync();
}
public async Task<ArticuloViewModel?> GetByIdAsync(int id)
{
var a = await _context.Articulos
.Include(a => a.Categoria)
.Include(a => a.Estado)
.Include(a => a.Ubicacion)
.FirstOrDefaultAsync(x => x.Id == id && !x.Eliminado);
if (a == null) return null;
return new ArticuloViewModel
{
Id = a.Id,
Codigo = a.Codigo,
Nombre = a.Nombre,
Descripcion = a.Descripcion,
Marca = a.Marca,
Modelo = a.Modelo,
NumeroSerie = a.NumeroSerie,
Precio = a.Precio,
FechaAdquisicion = a.FechaAdquisicion,
ImagenUrl = a.ImagenUrl,
CategoriaId = a.CategoriaId,
CategoriaNombre = a.Categoria.Nombre,
EstadoId = a.EstadoId,
EstadoNombre = a.Estado.Nombre,
EstadoColor = a.Estado.Color,
UbicacionId = a.UbicacionId,
UbicacionNombre = a.Ubicacion.Nombre,
Activo = a.Activo,
CantidadGlobal = a.CantidadGlobal,
// New Fields
TipoControl = a.TipoControl,
CantidadInicial = a.CantidadGlobal // Map Global Qty to CantidadInicial for Display
};
}
public async Task<bool> CreateAsync(ArticuloViewModel viewModel, string createdBy)
{
var strategy = _context.Database.CreateExecutionStrategy();
try
{
await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
string? imagenUrl = null;
if (viewModel.ImagenFile != null)
{
imagenUrl = await _fileStorageService.SaveFileAsync(viewModel.ImagenFile, "articulos");
}
var articulo = new Articulo
{
Codigo = viewModel.Codigo,
Nombre = viewModel.Nombre,
Descripcion = viewModel.Descripcion,
Marca = viewModel.Marca,
Modelo = viewModel.Modelo,
NumeroSerie = viewModel.NumeroSerie,
Precio = viewModel.Precio,
FechaAdquisicion = viewModel.FechaAdquisicion,
ImagenUrl = imagenUrl,
CategoriaId = viewModel.CategoriaId,
EstadoId = viewModel.EstadoId,
UbicacionId = viewModel.UbicacionId,
Activo = viewModel.Activo,
Eliminado = false,
CreadoPor = createdBy,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow,
// New Fields
TipoControl = viewModel.TipoControl ?? nameof(Articulo.TipoControlInventario.UNITARIO),
CantidadGlobal = (viewModel.TipoControl == nameof(Articulo.TipoControlInventario.LOTE)) ? viewModel.CantidadInicial : 1
};
_context.Articulos.Add(articulo);
await _context.SaveChangesAsync();
// If LOTE, initialize Existencia
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
var existencia = new Existencia
{
ArticuloId = articulo.Id,
UbicacionId = articulo.UbicacionId,
Cantidad = articulo.CantidadGlobal,
ActualizadoEn = DateTime.UtcNow
};
_context.Existencias.Add(existencia);
await _context.SaveChangesAsync();
}
await transaction.CommitAsync();
});
return true;
}
catch
{
return false;
}
}
public async Task<bool> UpdateAsync(ArticuloViewModel viewModel)
{
try
{
var articulo = await _context.Articulos.FindAsync(viewModel.Id);
if (articulo == null || articulo.Eliminado) return false;
if (viewModel.ImagenFile != null)
{
if (!string.IsNullOrEmpty(articulo.ImagenUrl))
{
await _fileStorageService.DeleteFileAsync(articulo.ImagenUrl);
}
articulo.ImagenUrl = await _fileStorageService.SaveFileAsync(viewModel.ImagenFile, "articulos");
}
articulo.Codigo = viewModel.Codigo;
articulo.Nombre = viewModel.Nombre;
articulo.Descripcion = viewModel.Descripcion;
articulo.Marca = viewModel.Marca;
articulo.Modelo = viewModel.Modelo;
articulo.NumeroSerie = viewModel.NumeroSerie;
articulo.Precio = viewModel.Precio;
articulo.FechaAdquisicion = viewModel.FechaAdquisicion;
articulo.CategoriaId = viewModel.CategoriaId;
articulo.EstadoId = viewModel.EstadoId;
articulo.UbicacionId = viewModel.UbicacionId;
articulo.Activo = viewModel.Activo;
articulo.ActualizadoEn = DateTime.UtcNow;
_context.Articulos.Update(articulo);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> DeleteAsync(int id)
{
try
{
var articulo = await _context.Articulos.FindAsync(id);
if (articulo == null || articulo.Eliminado) return false;
articulo.Eliminado = true;
articulo.ActualizadoEn = DateTime.UtcNow;
_context.Articulos.Update(articulo);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> ExistsCodigoAsync(string codigo, int? excludeId = null)
{
var query = _context.Articulos.AsQueryable();
if (excludeId.HasValue)
{
query = query.Where(a => a.Id != excludeId.Value);
}
return await query.AnyAsync(a => a.Codigo.ToLower() == codigo.ToLower() && !a.Eliminado);
}
public async Task<IEnumerable<(int Id, string Nombre)>> GetCategoriasAsync()
{
return await _context.Categorias
.Where(x => x.Activo && !x.Eliminado)
.OrderBy(x => x.Nombre)
.Select(x => new ValueTuple<int, string>(x.Id, x.Nombre))
.ToListAsync();
}
public async Task<IEnumerable<(int Id, string Nombre, string Color)>> GetEstadosAsync()
{
return await _context.EstadosArticulos
.Where(x => x.Activo && !x.Eliminado)
.OrderBy(x => x.Nombre)
.Select(x => new ValueTuple<int, string, string>(x.Id, x.Nombre, x.Color ?? "secondary"))
.ToListAsync();
}
public async Task<IEnumerable<(int Id, string Nombre)>> GetUbicacionesAsync()
{
return await _context.Ubicaciones
.Where(x => x.Activo && !x.Eliminado)
.OrderBy(x => x.Nombre)
.Select(x => new ValueTuple<int, string>(x.Id, x.Nombre))
.ToListAsync();
}
}

View File

@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class CategoriaService : ICategoriaService
{
private readonly ApplicationDbContext _context;
public CategoriaService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Categoria>> GetAllAsync()
{
return await _context.Categorias
.Where(c => !c.Eliminado)
.OrderBy(c => c.Nombre)
.ToListAsync();
}
public async Task<Categoria?> GetByIdAsync(int id)
{
return await _context.Categorias
.FirstOrDefaultAsync(c => c.Id == id && !c.Eliminado);
}
public async Task<bool> CreateAsync(Categoria categoria)
{
try
{
categoria.CreadoEn = DateTime.UtcNow;
categoria.ActualizadoEn = DateTime.UtcNow;
// Eliminado and Activo defaults are set in the model/DB, ensuring here just in case
categoria.Eliminado = false;
_context.Categorias.Add(categoria);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> UpdateAsync(Categoria categoria)
{
try
{
var existing = await _context.Categorias.FindAsync(categoria.Id);
if (existing == null || existing.Eliminado) return false;
existing.Nombre = categoria.Nombre;
existing.Descripcion = categoria.Descripcion;
existing.Activo = categoria.Activo;
existing.ActualizadoEn = DateTime.UtcNow;
// CreadoPor and CreadoEn should not change
_context.Categorias.Update(existing);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> DeleteAsync(int id)
{
try
{
var categoria = await _context.Categorias.FindAsync(id);
if (categoria == null || categoria.Eliminado) return false;
categoria.Eliminado = true;
categoria.ActualizadoEn = DateTime.UtcNow;
_context.Categorias.Update(categoria);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> ExistsAsync(string nombre, int? excludeId = null)
{
var query = _context.Categorias.AsQueryable();
if (excludeId.HasValue)
{
query = query.Where(c => c.Id != excludeId.Value);
}
return await query.AnyAsync(c => c.Nombre.ToLower() == nombre.ToLower() && !c.Eliminado);
}
}

View File

@@ -0,0 +1,373 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
using Rs_system.Models.ViewModels;
using System.Globalization;
namespace Rs_system.Services;
public class ColaboracionService : IColaboracionService
{
private readonly ApplicationDbContext _context;
public ColaboracionService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<TipoColaboracion>> GetTiposActivosAsync()
{
return await _context.TiposColaboracion
.Where(t => t.Activo)
.OrderBy(t => t.Orden)
.AsNoTracking()
.ToListAsync();
}
public async Task<TipoColaboracion?> GetTipoByIdAsync(long id)
{
return await _context.TiposColaboracion
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<Colaboracion> RegistrarColaboracionAsync(
RegistrarColaboracionViewModel model,
string registradoPor)
{
// Validar que el rango de fechas sea válido
var fechaInicial = new DateTime(model.AnioInicial, model.MesInicial, 1);
var fechaFinal = new DateTime(model.AnioFinal, model.MesFinal, 1);
if (fechaFinal < fechaInicial)
{
throw new ArgumentException("La fecha final no puede ser anterior a la fecha inicial");
}
// Obtener información de los tipos seleccionados
var tiposColaboracion = await _context.TiposColaboracion
.Where(t => model.TiposSeleccionados.Contains(t.Id))
.ToListAsync();
// Generar todos los meses en el rango
var mesesAPagar = GenerarRangoMeses(
model.AnioInicial, model.MesInicial,
model.AnioFinal, model.MesFinal);
// Crear colaboración principal
var colaboracion = new Colaboracion
{
MiembroId = model.MiembroId,
FechaRegistro = DateTime.UtcNow,
MontoTotal = model.MontoTotal,
Observaciones = model.Observaciones,
RegistradoPor = registradoPor,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
// Distribuir el monto total entre los meses y tipos
var detalles = DistribuirMonto(
model.MontoTotal,
tiposColaboracion,
mesesAPagar,
model.TipoPrioritario);
foreach (var detalle in detalles)
{
colaboracion.Detalles.Add(detalle);
}
_context.Colaboraciones.Add(colaboracion);
await _context.SaveChangesAsync();
return colaboracion;
}
private List<DetalleColaboracion> DistribuirMonto(
decimal montoTotal,
List<TipoColaboracion> tipos,
List<(int anio, int mes)> meses,
long? tipoPrioritario)
{
var detalles = new List<DetalleColaboracion>();
var montoRestante = montoTotal;
// Estrategia: Mes a Mes
// Para cada mes, intentamos cubrir los tipos (Prioritario primero)
foreach (var (anio, mes) in meses)
{
if (montoRestante <= 0) break;
// Ordenar tipos para este mes: Prioritario al inicio
var tiposOrdenados = new List<TipoColaboracion>();
if (tipoPrioritario.HasValue)
{
var prio = tipos.FirstOrDefault(t => t.Id == tipoPrioritario.Value);
if (prio != null)
{
tiposOrdenados.Add(prio);
tiposOrdenados.AddRange(tipos.Where(t => t.Id != tipoPrioritario.Value));
}
else
{
tiposOrdenados.AddRange(tipos);
}
}
else
{
tiposOrdenados.AddRange(tipos);
}
foreach (var tipo in tiposOrdenados)
{
if (montoRestante <= 0) break;
// Determinar cuánto asignar
// Intentamos cubrir el monto sugerido completo
var montoAAsignar = Math.Min(tipo.MontoSugerido, montoRestante);
// Si es un monto muy pequeño (ej: residuo), igual lo asignamos para no perderlo,
// salvo que queramos reglas estrictas de "solo completos".
// Por ahora asignamos lo que haya.
if (montoAAsignar > 0)
{
detalles.Add(new DetalleColaboracion
{
TipoColaboracionId = tipo.Id,
Mes = mes,
Anio = anio,
Monto = montoAAsignar,
CreadoEn = DateTime.UtcNow
});
montoRestante -= montoAAsignar;
}
}
}
return detalles;
}
public async Task<List<UltimoPagoViewModel>> GetUltimosPagosPorMiembroAsync(long miembroId)
{
// Obtener todos los detalles agrupados por tipo para encontrar la fecha máxima
var detalles = await _context.DetalleColaboraciones
.Include(d => d.Colaboracion)
.Include(d => d.TipoColaboracion)
.Where(d => d.Colaboracion.MiembroId == miembroId)
.ToListAsync();
var resultado = detalles
.GroupBy(d => d.TipoColaboracion)
.Select(g =>
{
// Encontrar el registro con el mes/año más reciente
var ultimo = g.OrderByDescending(d => d.Anio).ThenByDescending(d => d.Mes).FirstOrDefault();
if (ultimo == null) return null;
return new UltimoPagoViewModel
{
TipoId = g.Key.Id,
NombreTipo = g.Key.Nombre,
UltimoMes = ultimo.Mes,
UltimoAnio = ultimo.Anio,
FechaUltimoPago = ultimo.Colaboracion.FechaRegistro
};
})
.Where(x => x != null)
.ToList();
// Asegurar que retornamos todos los tipos activos, incluso si no tienen pagos
var tiposActivos = await GetTiposActivosAsync();
var listaFinal = new List<UltimoPagoViewModel>();
foreach (var tipo in tiposActivos)
{
var pago = resultado.FirstOrDefault(r => r.TipoId == tipo.Id);
if (pago != null)
{
listaFinal.Add(pago);
}
else
{
listaFinal.Add(new UltimoPagoViewModel
{
TipoId = tipo.Id,
NombreTipo = tipo.Nombre,
UltimoMes = 0, // No hay pagos
UltimoAnio = 0
});
}
}
return listaFinal;
}
private List<(int anio, int mes)> GenerarRangoMeses(
int anioInicial, int mesInicial,
int anioFinal, int mesFinal)
{
var meses = new List<(int, int)>();
var fecha = new DateTime(anioInicial, mesInicial, 1);
var fechaFin = new DateTime(anioFinal, mesFinal, 1);
while (fecha <= fechaFin)
{
meses.Add((fecha.Year, fecha.Month));
fecha = fecha.AddMonths(1);
}
return meses;
}
public async Task<List<Colaboracion>> GetColaboracionesRecientesAsync(int cantidad = 50)
{
return await _context.Colaboraciones
.Include(c => c.Miembro)
.ThenInclude(m => m.Persona)
.Include(c => c.Detalles)
.ThenInclude(d => d.TipoColaboracion)
.OrderByDescending(c => c.FechaRegistro)
.Take(cantidad)
.AsNoTracking()
.ToListAsync();
}
public async Task<Colaboracion?> GetColaboracionByIdAsync(long id)
{
return await _context.Colaboraciones
.Include(c => c.Miembro)
.ThenInclude(m => m.Persona)
.Include(c => c.Detalles)
.ThenInclude(d => d.TipoColaboracion)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<ReporteColaboracionesViewModel> GenerarReportePorFechasAsync(
DateTime fechaInicio,
DateTime fechaFin)
{
var colaboraciones = await _context.Colaboraciones
.Include(c => c.Miembro)
.ThenInclude(m => m.Persona)
.Include(c => c.Detalles)
.ThenInclude(d => d.TipoColaboracion)
.Where(c => c.FechaRegistro >= fechaInicio && c.FechaRegistro <= fechaFin)
.OrderByDescending(c => c.FechaRegistro)
.AsNoTracking()
.ToListAsync();
var reporte = new ReporteColaboracionesViewModel
{
FechaInicio = fechaInicio,
FechaFin = fechaFin,
TotalRecaudado = colaboraciones.Sum(c => c.MontoTotal)
};
// Desglose por tipo
var desglosePorTipo = colaboraciones
.SelectMany(c => c.Detalles)
.GroupBy(d => d.TipoColaboracion.Nombre)
.Select(g => new DesglosePorTipo
{
TipoNombre = g.Key,
CantidadMeses = g.Count(),
TotalRecaudado = g.Sum(d => d.Monto)
})
.OrderBy(d => d.TipoNombre)
.ToList();
reporte.DesglosePorTipos = desglosePorTipo;
// Detalle de movimientos
var movimientos = colaboraciones.Select(c => new DetalleMovimiento
{
ColaboracionId = c.Id,
Fecha = c.FechaRegistro,
NombreMiembro = $"{c.Miembro.Persona.Nombres} {c.Miembro.Persona.Apellidos}",
TiposColaboracion = string.Join(", ", c.Detalles.Select(d => d.TipoColaboracion.Nombre).Distinct()),
PeriodoCubierto = ObtenerPeriodoCubierto(c.Detalles.ToList()),
Monto = c.MontoTotal
}).ToList();
reporte.Movimientos = movimientos;
return reporte;
}
private string ObtenerPeriodoCubierto(List<DetalleColaboracion> detalles)
{
if (!detalles.Any()) return "";
var ordenados = detalles.OrderBy(d => d.Anio).ThenBy(d => d.Mes).ToList();
var primero = ordenados.First();
var ultimo = ordenados.Last();
var cultura = new CultureInfo("es-ES");
if (primero.Anio == ultimo.Anio && primero.Mes == ultimo.Mes)
{
return new DateTime(primero.Anio, primero.Mes, 1).ToString("MMMM yyyy", cultura);
}
return $"{new DateTime(primero.Anio, primero.Mes, 1).ToString("MMM yyyy", cultura)} - " +
$"{new DateTime(ultimo.Anio, ultimo.Mes, 1).ToString("MMM yyyy", cultura)}";
}
public async Task<EstadoCuentaViewModel> GenerarEstadoCuentaAsync(long miembroId)
{
var miembro = await _context.Miembros
.Include(m => m.Persona)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.Id == miembroId);
if (miembro == null)
throw new Exception("Miembro no encontrado");
var colaboraciones = await _context.Colaboraciones
.Include(c => c.Detalles)
.ThenInclude(d => d.TipoColaboracion)
.Where(c => c.MiembroId == miembroId)
.AsNoTracking()
.ToListAsync();
var estado = new EstadoCuentaViewModel
{
MiembroId = miembroId,
NombreMiembro = $"{miembro.Persona.Nombres} {miembro.Persona.Apellidos}",
FechaConsulta = DateTime.Now,
TotalAportado = colaboraciones.Sum(c => c.MontoTotal)
};
// Agrupar por tipo
var historialPorTipo = colaboraciones
.SelectMany(c => c.Detalles.Select(d => new { Detalle = d, FechaRegistro = c.FechaRegistro }))
.GroupBy(x => x.Detalle.TipoColaboracion.Nombre)
.Select(g => new HistorialPorTipo
{
TipoNombre = g.Key,
TotalTipo = g.Sum(x => x.Detalle.Monto),
Registros = g.Select(x => new RegistroMensual
{
Mes = x.Detalle.Mes,
Anio = x.Detalle.Anio,
Monto = x.Detalle.Monto,
FechaRegistro = x.FechaRegistro
})
.OrderBy(r => r.Anio)
.ThenBy(r => r.Mes)
.ToList()
})
.OrderBy(h => h.TipoNombre)
.ToList();
estado.HistorialPorTipos = historialPorTipo;
return estado;
}
}

View File

@@ -0,0 +1,337 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class ContabilidadGeneralService : IContabilidadGeneralService
{
private readonly ApplicationDbContext _context;
public ContabilidadGeneralService(ApplicationDbContext context)
{
_context = context;
}
// ==================== Categorías de Ingreso ====================
public async Task<List<CategoriaIngreso>> ObtenerCategoriasIngresoAsync()
{
return await _context.CategoriasIngreso
.Where(c => c.Activa)
.OrderBy(c => c.Nombre)
.AsNoTracking()
.ToListAsync();
}
public async Task<CategoriaIngreso?> ObtenerCategoriaIngresoPorIdAsync(long id)
{
return await _context.CategoriasIngreso.FindAsync(id);
}
public async Task<CategoriaIngreso> CrearCategoriaIngresoAsync(CategoriaIngreso categoria)
{
categoria.FechaCreacion = DateTime.UtcNow;
_context.CategoriasIngreso.Add(categoria);
await _context.SaveChangesAsync();
return categoria;
}
public async Task<bool> ActualizarCategoriaIngresoAsync(CategoriaIngreso categoria)
{
try
{
_context.CategoriasIngreso.Update(categoria);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> EliminarCategoriaIngresoAsync(long id)
{
try
{
var categoria = await _context.CategoriasIngreso.FindAsync(id);
if (categoria == null) return false;
// Soft delete - marcar como inactiva en lugar de eliminar
categoria.Activa = false;
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
// ==================== Categorías de Egreso ====================
public async Task<List<CategoriaEgreso>> ObtenerCategoriasEgresoAsync()
{
return await _context.CategoriasEgreso
.Where(c => c.Activa)
.OrderBy(c => c.Nombre)
.AsNoTracking()
.ToListAsync();
}
public async Task<CategoriaEgreso?> ObtenerCategoriaEgresoPorIdAsync(long id)
{
return await _context.CategoriasEgreso.FindAsync(id);
}
public async Task<CategoriaEgreso> CrearCategoriaEgresoAsync(CategoriaEgreso categoria)
{
categoria.FechaCreacion = DateTime.UtcNow;
_context.CategoriasEgreso.Add(categoria);
await _context.SaveChangesAsync();
return categoria;
}
public async Task<bool> ActualizarCategoriaEgresoAsync(CategoriaEgreso categoria)
{
try
{
_context.CategoriasEgreso.Update(categoria);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> EliminarCategoriaEgresoAsync(long id)
{
try
{
var categoria = await _context.CategoriasEgreso.FindAsync(id);
if (categoria == null) return false;
// Soft delete - marcar como inactiva en lugar de eliminar
categoria.Activa = false;
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
// ==================== Reportes Mensuales ====================
public async Task<ReporteMensualGeneral?> ObtenerReporteMensualAsync(int mes, int anio)
{
return await _context.ReportesMensualesGenerales
.Include(r => r.Movimientos)
.ThenInclude(m => m.CategoriaIngreso)
.Include(r => r.Movimientos)
.ThenInclude(m => m.CategoriaEgreso)
.FirstOrDefaultAsync(r => r.Mes == mes && r.Anio == anio);
}
public async Task<ReporteMensualGeneral> ObtenerOCrearReporteMensualAsync(int mes, int anio)
{
var reporteExistente = await ObtenerReporteMensualAsync(mes, anio);
if (reporteExistente != null)
return reporteExistente;
// Obtener el saldo final del mes anterior
var mesAnterior = mes == 1 ? 12 : mes - 1;
var anioAnterior = mes == 1 ? anio - 1 : anio;
var reporteAnterior = await ObtenerReporteMensualAsync(mesAnterior, anioAnterior);
var saldoInicial = reporteAnterior != null
? await CalcularSaldoActualAsync(reporteAnterior.Id)
: 0;
var nuevoReporte = new ReporteMensualGeneral
{
Mes = mes,
Anio = anio,
SaldoInicial = saldoInicial,
FechaCreacion = DateTime.UtcNow,
Cerrado = false
};
_context.ReportesMensualesGenerales.Add(nuevoReporte);
await _context.SaveChangesAsync();
return nuevoReporte;
}
public async Task<List<ReporteMensualGeneral>> ListarReportesAsync(int? anio = null)
{
var query = _context.ReportesMensualesGenerales.AsQueryable();
if (anio.HasValue)
query = query.Where(r => r.Anio == anio.Value);
return await query
.OrderByDescending(r => r.Anio)
.ThenByDescending(r => r.Mes)
.AsNoTracking()
.ToListAsync();
}
public async Task<bool> CerrarReporteAsync(long reporteId)
{
try
{
var reporte = await _context.ReportesMensualesGenerales.FindAsync(reporteId);
if (reporte == null) return false;
_context.Entry(reporte).Property(x => x.Cerrado).CurrentValue = true;
_context.Entry(reporte).Property(x => x.Cerrado).IsModified = true;
await _context.SaveChangesAsync();
return true;
}
catch(Exception ex)
{
return false;
}
}
// ==================== Movimientos ====================
public async Task<bool> GuardarMovimientosBulkAsync(long reporteId, List<MovimientoGeneral> movimientos)
{
try
{
var reporte = await _context.ReportesMensualesGenerales.FindAsync(reporteId);
if (reporte == null || reporte.Cerrado)
return false;
foreach (var movimiento in movimientos)
{
movimiento.ReporteMensualGeneralId = reporteId;
movimiento.Fecha = DateTime.SpecifyKind(movimiento.Fecha, DateTimeKind.Utc);
if (movimiento.Id > 0)
{
// Update existing
var existente = await _context.MovimientosGenerales.FindAsync(movimiento.Id);
if (existente != null)
{
existente.Tipo = movimiento.Tipo;
existente.CategoriaIngresoId = movimiento.CategoriaIngresoId;
existente.CategoriaEgresoId = movimiento.CategoriaEgresoId;
existente.Monto = movimiento.Monto;
existente.Fecha = movimiento.Fecha;
existente.Descripcion = movimiento.Descripcion;
existente.NumeroComprobante = movimiento.NumeroComprobante;
}
}
else
{
// Insert new
_context.MovimientosGenerales.Add(movimiento);
}
}
await _context.SaveChangesAsync();
return true;
}
catch(Exception ex)
{
return false;
}
}
public async Task<decimal> CalcularSaldoActualAsync(long reporteId)
{
var reporte = await _context.ReportesMensualesGenerales
.Include(r => r.Movimientos)
.FirstOrDefaultAsync(r => r.Id == reporteId);
if (reporte == null) return 0;
var totalIngresos = reporte.Movimientos
.Where(m => m.Tipo == (int) TipoMovimientoGeneral.Ingreso)
.Sum(m => m.Monto);
var totalEgresos = reporte.Movimientos
.Where(m => m.Tipo == (int)TipoMovimientoGeneral.Egreso)
.Sum(m => m.Monto);
return reporte.SaldoInicial + totalIngresos - totalEgresos;
}
// ==================== Consolidados ====================
public async Task<Dictionary<string, decimal>> ObtenerConsolidadoIngresosAsync(long reporteId)
{
var movimientos = await _context.MovimientosGenerales
.Include(m => m.CategoriaIngreso)
.Where(m => m.ReporteMensualGeneralId == reporteId
&& m.Tipo == (int)TipoMovimientoGeneral.Ingreso)
.AsNoTracking()
.ToListAsync();
return movimientos
.GroupBy(m => m.CategoriaIngreso?.Nombre ?? "Sin Categoría")
.ToDictionary(g => g.Key, g => g.Sum(m => m.Monto));
}
public async Task<Dictionary<string, decimal>> ObtenerConsolidadoEgresosAsync(long reporteId)
{
var movimientos = await _context.MovimientosGenerales
.Include(m => m.CategoriaEgreso)
.Where(m => m.ReporteMensualGeneralId == reporteId
&& m.Tipo == (int)TipoMovimientoGeneral.Egreso)
.AsNoTracking()
.ToListAsync();
return movimientos
.GroupBy(m => m.CategoriaEgreso?.Nombre ?? "Sin Categoría")
.ToDictionary(g => g.Key, g => g.Sum(m => m.Monto));
}
// ==================== Adjuntos ====================
public async Task<List<MovimientoGeneralAdjunto>> ObtenerAdjuntosMovimientoAsync(long movimientoId)
{
return await _context.MovimientosGeneralesAdjuntos
.Where(a => a.MovimientoGeneralId == movimientoId)
.OrderByDescending(a => a.FechaSubida)
.AsNoTracking()
.ToListAsync();
}
public async Task<MovimientoGeneralAdjunto?> CrearAdjuntoAsync(long movimientoId, string nombreArchivo, string rutaArchivo, string tipoContenido)
{
var movimiento = await _context.MovimientosGenerales.FindAsync(movimientoId);
if (movimiento == null) return null;
var adjunto = new MovimientoGeneralAdjunto
{
MovimientoGeneralId = movimientoId,
NombreArchivo = nombreArchivo,
RutaArchivo = rutaArchivo,
TipoContenido = tipoContenido,
FechaSubida = DateTime.UtcNow
};
_context.MovimientosGeneralesAdjuntos.Add(adjunto);
await _context.SaveChangesAsync();
return adjunto;
}
public async Task<bool> EliminarAdjuntoAsync(long adjuntoId)
{
var adjunto = await _context.MovimientosGeneralesAdjuntos.FindAsync(adjuntoId);
if (adjunto == null) return false;
_context.MovimientosGeneralesAdjuntos.Remove(adjunto);
await _context.SaveChangesAsync();
return true;
}
}

View File

@@ -0,0 +1,169 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class ContabilidadService : IContabilidadService
{
private readonly ApplicationDbContext _context;
public ContabilidadService(ApplicationDbContext context)
{
_context = context;
}
public async Task<ContabilidadRegistro> CrearRegistroAsync(ContabilidadRegistro registro)
{
// Ensure Group exists
var groupExists = await _context.GruposTrabajo.AnyAsync(g => g.Id == registro.GrupoTrabajoId);
if (!groupExists)
{
throw new ArgumentException($"Grupo de trabajo con ID {registro.GrupoTrabajoId} no existe.");
}
_context.ContabilidadRegistros.Add(registro);
await _context.SaveChangesAsync();
return registro;
}
public async Task<IReadOnlyList<ContabilidadRegistro>> ObtenerRegistrosAsync(long grupoId, DateTime desde, DateTime hasta)
{
return await _context.ContabilidadRegistros
.Include(c => c.GrupoTrabajo)
.Where(c => c.GrupoTrabajoId == grupoId && c.Fecha.Date >= desde.Date && c.Fecha.Date <= hasta.Date)
.OrderByDescending(c => c.Fecha)
.ToListAsync();
}
public async Task<ReporteMensualContable?> ObtenerReporteMensualAsync(long grupoId, int mes, int anio)
{
return await _context.ReportesMensualesContables
.Include(r => r.Registros)
.FirstOrDefaultAsync(r => r.GrupoTrabajoId == grupoId && r.Mes == mes && r.Anio == anio);
}
public async Task<ReporteMensualContable> ObtenerOCrearReporteMensualAsync(long grupoId, int mes, int anio)
{
var reporte = await ObtenerReporteMensualAsync(grupoId, mes, anio);
if (reporte != null) return reporte;
// Calculate Saldo Inicial based on previous month
decimal saldoInicial = 0;
var prevMes = mes == 1 ? 12 : mes - 1;
var prevAnio = mes == 1 ? anio - 1 : anio;
var reportePrevio = await ObtenerReporteMensualAsync(grupoId, prevMes, prevAnio);
if (reportePrevio != null)
{
saldoInicial = await CalcularSaldoActualAsync(reportePrevio.Id);
}
reporte = new ReporteMensualContable
{
GrupoTrabajoId = grupoId,
Mes = mes,
Anio = anio,
SaldoInicial = saldoInicial,
FechaCreacion = DateTime.UtcNow,
Cerrado = false
};
_context.ReportesMensualesContables.Add(reporte);
await _context.SaveChangesAsync();
return reporte;
}
public async Task<List<ReporteMensualContable>> ListarReportesPorGrupoAsync(long grupoId)
{
return await _context.ReportesMensualesContables
.Where(r => r.GrupoTrabajoId == grupoId)
.OrderByDescending(r => r.Anio)
.ThenByDescending(r => r.Mes)
.ToListAsync();
}
public async Task<bool> GuardarRegistrosBulkAsync(long reporteId, List<ContabilidadRegistro> registros)
{
var reporte = await _context.ReportesMensualesContables
.Include(r => r.Registros)
.FirstOrDefaultAsync(r => r.Id == reporteId);
if (reporte == null || reporte.Cerrado) return false;
try
{
// Remove existing records for this report (or handle updates carefully)
// For a simple bulk entry system, we might replace all or upsert by ID.
// Let's go with UPSERT based on ID.
var existingIds = reporte.Registros.Select(r => r.Id).ToList();
var incomingIds = registros.Where(r => r.Id > 0).Select(r => r.Id).ToList();
// Delete records that are no longer in the list
var toDelete = reporte.Registros.Where(r => !incomingIds.Contains(r.Id)).ToList();
_context.ContabilidadRegistros.RemoveRange(toDelete);
foreach (var registro in registros)
{
if (registro.Id > 0)
{
// Update
var existing = reporte.Registros.FirstOrDefault(r => r.Id == registro.Id);
if (existing != null)
{
existing.Tipo = registro.Tipo;
existing.Monto = registro.Monto;
existing.Fecha = registro.Fecha;
existing.Descripcion = registro.Descripcion;
_context.Entry(existing).State = EntityState.Modified;
}
}
else
{
// Add
registro.ReporteMensualId = reporteId;
registro.GrupoTrabajoId = reporte.GrupoTrabajoId;
_context.ContabilidadRegistros.Add(registro);
}
}
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<decimal> CalcularSaldoActualAsync(long reporteId)
{
var reporte = await _context.ReportesMensualesContables
.Include(r => r.Registros)
.FirstOrDefaultAsync(r => r.Id == reporteId);
if (reporte == null) return 0;
decimal ingresos = reporte.Registros
.Where(r => r.Tipo == TipoMovimientoContable.Ingreso)
.Sum(r => r.Monto);
decimal egresos = reporte.Registros
.Where(r => r.Tipo == TipoMovimientoContable.Egreso)
.Sum(r => r.Monto);
return reporte.SaldoInicial + ingresos - egresos;
}
public async Task<bool> CerrarReporteAsync(long reporteId)
{
var reporte = await _context.ReportesMensualesContables.FindAsync(reporteId);
if (reporte == null || reporte.Cerrado) return false;
reporte.Cerrado = true;
_context.ReportesMensualesContables.Update(reporte);
await _context.SaveChangesAsync();
return true;
}
}

View File

@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class EstadoArticuloService : IEstadoArticuloService
{
private readonly ApplicationDbContext _context;
public EstadoArticuloService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<EstadoArticulo>> GetAllAsync()
{
return await _context.EstadosArticulos
.Where(e => !e.Eliminado)
.OrderBy(e => e.Nombre)
.ToListAsync();
}
public async Task<EstadoArticulo?> GetByIdAsync(int id)
{
return await _context.EstadosArticulos
.FirstOrDefaultAsync(e => e.Id == id && !e.Eliminado);
}
public async Task<bool> CreateAsync(EstadoArticulo estado)
{
try
{
estado.CreadoEn = DateTime.UtcNow;
estado.ActualizadoEn = DateTime.UtcNow;
estado.Eliminado = false;
_context.EstadosArticulos.Add(estado);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> UpdateAsync(EstadoArticulo estado)
{
try
{
var existing = await _context.EstadosArticulos.FindAsync(estado.Id);
if (existing == null || existing.Eliminado) return false;
existing.Nombre = estado.Nombre;
existing.Descripcion = estado.Descripcion;
existing.Color = estado.Color;
existing.Activo = estado.Activo;
existing.ActualizadoEn = DateTime.UtcNow;
_context.EstadosArticulos.Update(existing);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> DeleteAsync(int id)
{
try
{
var estado = await _context.EstadosArticulos.FindAsync(id);
if (estado == null || estado.Eliminado) return false;
estado.Eliminado = true;
estado.ActualizadoEn = DateTime.UtcNow;
_context.EstadosArticulos.Update(estado);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> ExistsAsync(string nombre, int? excludeId = null)
{
var query = _context.EstadosArticulos.AsQueryable();
if (excludeId.HasValue)
{
query = query.Where(e => e.Id != excludeId.Value);
}
return await query.AnyAsync(e => e.Nombre.ToLower() == nombre.ToLower() && !e.Eliminado);
}
}

View File

@@ -0,0 +1,18 @@
using Rs_system.Models.ViewModels;
namespace Rs_system.Services;
public interface IArticuloService
{
Task<IEnumerable<ArticuloViewModel>> GetAllAsync(string? search = null, int? categoriaId = null, int? ubicacionId = null, int? estadoId = null);
Task<ArticuloViewModel?> GetByIdAsync(int id);
Task<bool> CreateAsync(ArticuloViewModel viewModel, string createdBy);
Task<bool> UpdateAsync(ArticuloViewModel viewModel);
Task<bool> DeleteAsync(int id);
Task<bool> ExistsCodigoAsync(string codigo, int? excludeId = null);
// Dropdown helpers
Task<IEnumerable<(int Id, string Nombre)>> GetCategoriasAsync();
Task<IEnumerable<(int Id, string Nombre, string Color)>> GetEstadosAsync();
Task<IEnumerable<(int Id, string Nombre)>> GetUbicacionesAsync();
}

View File

@@ -0,0 +1,13 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface ICategoriaService
{
Task<IEnumerable<Categoria>> GetAllAsync();
Task<Categoria?> GetByIdAsync(int id);
Task<bool> CreateAsync(Categoria categoria);
Task<bool> UpdateAsync(Categoria categoria);
Task<bool> DeleteAsync(int id);
Task<bool> ExistsAsync(string nombre, int? excludeId = null);
}

View File

@@ -0,0 +1,21 @@
using Rs_system.Models;
using Rs_system.Models.ViewModels;
namespace Rs_system.Services;
public interface IColaboracionService
{
// Tipos de colaboración
Task<List<TipoColaboracion>> GetTiposActivosAsync();
Task<TipoColaboracion?> GetTipoByIdAsync(long id);
// Colaboraciones
Task<Colaboracion> RegistrarColaboracionAsync(RegistrarColaboracionViewModel model, string registradoPor);
Task<List<Colaboracion>> GetColaboracionesRecientesAsync(int cantidad = 50);
Task<Colaboracion?> GetColaboracionByIdAsync(long id);
// Reportes
Task<ReporteColaboracionesViewModel> GenerarReportePorFechasAsync(DateTime fechaInicio, DateTime fechaFin);
Task<EstadoCuentaViewModel> GenerarEstadoCuentaAsync(long miembroId);
Task<List<UltimoPagoViewModel>> GetUltimosPagosPorMiembroAsync(long miembroId);
}

View File

@@ -0,0 +1,39 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IContabilidadGeneralService
{
// Categorías de Ingreso
Task<List<CategoriaIngreso>> ObtenerCategoriasIngresoAsync();
Task<CategoriaIngreso?> ObtenerCategoriaIngresoPorIdAsync(long id);
Task<CategoriaIngreso> CrearCategoriaIngresoAsync(CategoriaIngreso categoria);
Task<bool> ActualizarCategoriaIngresoAsync(CategoriaIngreso categoria);
Task<bool> EliminarCategoriaIngresoAsync(long id);
// Categorías de Egreso
Task<List<CategoriaEgreso>> ObtenerCategoriasEgresoAsync();
Task<CategoriaEgreso?> ObtenerCategoriaEgresoPorIdAsync(long id);
Task<CategoriaEgreso> CrearCategoriaEgresoAsync(CategoriaEgreso categoria);
Task<bool> ActualizarCategoriaEgresoAsync(CategoriaEgreso categoria);
Task<bool> EliminarCategoriaEgresoAsync(long id);
// Reportes Mensuales
Task<ReporteMensualGeneral?> ObtenerReporteMensualAsync(int mes, int anio);
Task<ReporteMensualGeneral> ObtenerOCrearReporteMensualAsync(int mes, int anio);
Task<List<ReporteMensualGeneral>> ListarReportesAsync(int? anio = null);
Task<bool> CerrarReporteAsync(long reporteId);
// Movimientos
Task<bool> GuardarMovimientosBulkAsync(long reporteId, List<MovimientoGeneral> movimientos);
Task<decimal> CalcularSaldoActualAsync(long reporteId);
// Consolidados
Task<Dictionary<string, decimal>> ObtenerConsolidadoIngresosAsync(long reporteId);
Task<Dictionary<string, decimal>> ObtenerConsolidadoEgresosAsync(long reporteId);
// Adjuntos
Task<List<MovimientoGeneralAdjunto>> ObtenerAdjuntosMovimientoAsync(long movimientoId);
Task<MovimientoGeneralAdjunto?> CrearAdjuntoAsync(long movimientoId, string nombreArchivo, string rutaArchivo, string tipoContenido);
Task<bool> EliminarAdjuntoAsync(long adjuntoId);
}

View File

@@ -0,0 +1,18 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IContabilidadService
{
Task<ContabilidadRegistro> CrearRegistroAsync(ContabilidadRegistro registro);
Task<IReadOnlyList<ContabilidadRegistro>> ObtenerRegistrosAsync(long grupoId, DateTime desde, DateTime hasta);
// Monthly Report Methods
Task<ReporteMensualContable?> ObtenerReporteMensualAsync(long grupoId, int mes, int anio);
Task<ReporteMensualContable> ObtenerOCrearReporteMensualAsync(long grupoId, int mes, int anio);
Task<List<ReporteMensualContable>> ListarReportesPorGrupoAsync(long grupoId);
Task<bool> GuardarRegistrosBulkAsync(long reporteId, List<ContabilidadRegistro> registros);
Task<decimal> CalcularSaldoActualAsync(long reporteId);
Task<bool> CerrarReporteAsync(long reporteId);
}

View File

@@ -0,0 +1,13 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IEstadoArticuloService
{
Task<IEnumerable<EstadoArticulo>> GetAllAsync();
Task<EstadoArticulo?> GetByIdAsync(int id);
Task<bool> CreateAsync(EstadoArticulo estado);
Task<bool> UpdateAsync(EstadoArticulo estado);
Task<bool> DeleteAsync(int id);
Task<bool> ExistsAsync(string nombre, int? excludeId = null);
}

View File

@@ -0,0 +1,21 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IMovimientoService
{
Task<IEnumerable<MovimientoInventario>> GetHistorialGeneralAsync(int limit = 100);
Task<IEnumerable<MovimientoInventario>> GetHistorialPorArticuloAsync(int articuloId);
// Legacy wrappers (Quantity = 1)
Task<bool> RegistrarTrasladoAsync(int articuloId, int nuevaUbicacionId, string observacion, string usuario);
Task<bool> RegistrarBajaAsync(int articuloId, string motivo, string usuario);
// New Quantity-Aware Methods
Task<bool> RegistrarTrasladoCantidadAsync(int articuloId, int nuevaUbicacionId, int cantidad, string observacion, string usuario);
Task<bool> RegistrarBajaCantidadAsync(int articuloId, int cantidad, string motivo, string usuario);
Task<bool> RegistrarCambioEstadoAsync(int articuloId, int nuevoEstadoId, string observacion, string usuario);
Task<bool> RegistrarPrestamoAsync(int articuloId, int cantidad, string personaNombre, string? personaIdentificacion, DateTime? fechaDevolucionEstimada, string observacion, string usuario);
Task<bool> RegistrarEntradaCantidadAsync(int articuloId, int cantidad, string observacion, string usuario);
}

View File

@@ -0,0 +1,13 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IPrestamoService
{
Task<IEnumerable<Prestamo>> GetHistorialPrestamosAsync(int limit = 100);
Task<IEnumerable<Prestamo>> GetPrestamosActivosAsync();
Task<Prestamo?> GetPrestamoByIdAsync(long id);
Task<bool> RegistrarPrestamoAsync(int articuloId, int cantidad, string personaNombre, string? personaIdentificacion, DateTime? fechaDevolucionEstimada, string observacion, string usuario);
Task<bool> RegistrarDevolucionAsync(long prestamoId, string observacion, string usuario);
Task<bool> RegistrarDevolucionParcialAsync(long prestamoId, List<string> codigosDevolucion, string observacion, string usuario);
}

View File

@@ -0,0 +1,13 @@
using Rs_system.Models;
namespace Rs_system.Services;
public interface IUbicacionService
{
Task<IEnumerable<Ubicacion>> GetAllAsync();
Task<Ubicacion?> GetByIdAsync(int id);
Task<bool> CreateAsync(Ubicacion ubicacion);
Task<bool> UpdateAsync(Ubicacion ubicacion);
Task<bool> DeleteAsync(int id);
Task<bool> ExistsAsync(string nombre, int? excludeId = null);
}

View File

@@ -0,0 +1,450 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class MovimientoService : IMovimientoService
{
private readonly ApplicationDbContext _context;
public MovimientoService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<MovimientoInventario>> GetHistorialGeneralAsync(int limit = 100)
{
return await _context.MovimientosInventario
.Include(m => m.Articulo)
.Include(m => m.UbicacionOrigen)
.Include(m => m.UbicacionDestino)
.Include(m => m.EstadoAnterior)
.Include(m => m.EstadoNuevo)
.OrderByDescending(m => m.Fecha)
.Take(limit)
.ToListAsync();
}
public async Task<IEnumerable<MovimientoInventario>> GetHistorialPorArticuloAsync(int articuloId)
{
return await _context.MovimientosInventario
.Include(m => m.UbicacionOrigen)
.Include(m => m.UbicacionDestino)
.Include(m => m.EstadoAnterior)
.Include(m => m.EstadoNuevo)
.Where(m => m.ArticuloId == articuloId)
.OrderByDescending(m => m.Fecha)
.ToListAsync();
}
public async Task<bool> RegistrarTrasladoAsync(int articuloId, int nuevaUbicacionId, string observacion, string usuario)
{
return await RegistrarTrasladoCantidadAsync(articuloId, nuevaUbicacionId, 1, observacion, usuario);
}
public async Task<bool> RegistrarTrasladoCantidadAsync(
int articuloId,
int nuevaUbicacionId,
int cantidad,
string observacion,
string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
var fecha = DateTime.UtcNow;
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
// ===== LOTE =====
var origenExistencia = await _context.Existencias
.FirstOrDefaultAsync(e =>
e.ArticuloId == articuloId &&
e.UbicacionId == articulo.UbicacionId);
if (origenExistencia == null || origenExistencia.Cantidad < cantidad)
return false;
origenExistencia.Cantidad -= cantidad;
origenExistencia.ActualizadoEn = fecha;
_context.Existencias.Update(origenExistencia);
var destinoExistencia = await _context.Existencias
.FirstOrDefaultAsync(e =>
e.ArticuloId == articuloId &&
e.UbicacionId == nuevaUbicacionId);
if (destinoExistencia == null)
{
destinoExistencia = new Existencia
{
ArticuloId = articuloId,
UbicacionId = nuevaUbicacionId,
Cantidad = 0,
ActualizadoEn = fecha
};
_context.Existencias.Add(destinoExistencia);
}
destinoExistencia.Cantidad += cantidad;
destinoExistencia.ActualizadoEn = fecha;
// 📉 MOVIMIENTO SALIDA
var movSalida = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.TRASLADO),
Fecha = fecha,
UbicacionOrigenId = articulo.UbicacionId,
Cantidad = cantidad,
TipMov = 2,
Observacion = observacion,
UsuarioId = usuario,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId
};
// 📈 MOVIMIENTO ENTRADA
var movEntrada = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.TRASLADO),
Fecha = fecha,
UbicacionDestinoId = nuevaUbicacionId,
Cantidad = cantidad,
TipMov = 1,
Observacion = observacion,
UsuarioId = usuario,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId
};
_context.MovimientosInventario.AddRange(movSalida, movEntrada);
}
else
{
// ===== UNITARIO =====
if (articulo.UbicacionId == nuevaUbicacionId)
return false;
// 📉 SALIDA
var movSalida = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.TRASLADO),
Fecha = fecha,
UbicacionOrigenId = articulo.UbicacionId,
Cantidad = 1,
TipMov = 2,
Observacion = observacion,
UsuarioId = usuario,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId
};
// 📈 ENTRADA
var movEntrada = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.TRASLADO),
Fecha = fecha,
UbicacionDestinoId = nuevaUbicacionId,
Cantidad = 1,
TipMov = 1,
Observacion = observacion,
UsuarioId = usuario,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId
};
articulo.UbicacionId = nuevaUbicacionId;
articulo.ActualizadoEn = fecha;
_context.Articulos.Update(articulo);
_context.MovimientosInventario.AddRange(movSalida, movEntrada);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
return false;
}
});
}
public async Task<bool> RegistrarCambioEstadoAsync(int articuloId, int nuevoEstadoId, string observacion, string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
if (articulo.EstadoId == nuevoEstadoId) return false;
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.CAMBIO_ESTADO),
Fecha = DateTime.UtcNow,
UbicacionOrigenId = articulo.UbicacionId,
UbicacionDestinoId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = nuevoEstadoId,
Cantidad = (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE)) ? articulo.CantidadGlobal : 1,
Observacion = observacion,
UsuarioId = usuario
};
articulo.EstadoId = nuevoEstadoId;
articulo.ActualizadoEn = DateTime.UtcNow;
_context.MovimientosInventario.Add(movimiento);
_context.Articulos.Update(articulo);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
return false;
}
});
}
public async Task<bool> RegistrarPrestamoAsync(int articuloId, int cantidad, string personaNombre, string? personaIdentificacion,
DateTime? fechaDevolucionEstimada, string observacion, string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
// Validar stock disponible
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
var existencia = await _context.Existencias
.FirstOrDefaultAsync(e => e.ArticuloId == articuloId && e.UbicacionId == articulo.UbicacionId);
if (existencia == null || existencia.Cantidad < cantidad) return false;
// Reducir stock existente
existencia.Cantidad -= cantidad;
articulo.CantidadGlobal -= cantidad;
if (articulo.CantidadGlobal < 0) articulo.CantidadGlobal = 0;
if (existencia.Cantidad < 0) existencia.Cantidad = 0;
_context.Existencias.Update(existencia);
_context.Articulos.Update(articulo);
}
else
{
// UNITARIO - Solo se puede prestar 1
if (cantidad != 1) return false;
if (!articulo.Activo) return false;
}
// Crear movimiento de inventario
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.PRESTAMO),
TipMov = 2,
Fecha = DateTime.UtcNow,
UbicacionOrigenId = articulo.UbicacionId,
UbicacionDestinoId = articulo.UbicacionId, // Mismo lugar, solo está prestado
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId,
Cantidad = cantidad,
Observacion = $"Préstamo a {personaNombre}. {observacion}",
UsuarioId = usuario
};
_context.MovimientosInventario.Add(movimiento);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
return false;
}
});
}
public async Task<bool> RegistrarBajaAsync(int articuloId, string motivo, string usuario)
{
return await RegistrarBajaCantidadAsync(articuloId, 1, motivo, usuario);
}
public async Task<bool> RegistrarBajaCantidadAsync(int articuloId, int cantidad, string motivo, string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
var existencia = await _context.Existencias
.FirstOrDefaultAsync(e => e.ArticuloId == articuloId && e.UbicacionId == articulo.UbicacionId);
if (existencia == null || existencia.Cantidad < cantidad) return false;
existencia.Cantidad -= cantidad;
articulo.CantidadGlobal -= cantidad;
if (articulo.CantidadGlobal <= 0) articulo.Activo = false;
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.BAJA),
TipMov = 2,
Fecha = DateTime.UtcNow,
UbicacionOrigenId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
Cantidad = cantidad,
Observacion = motivo,
UsuarioId = usuario
};
_context.Existencias.Update(existencia);
_context.MovimientosInventario.Add(movimiento);
_context.Articulos.Update(articulo);
}
else
{
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.BAJA),
TipMov = 2,
Fecha = DateTime.UtcNow,
UbicacionOrigenId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
Cantidad = 1,
Observacion = motivo,
UsuarioId = usuario
};
articulo.Activo = false;
_context.MovimientosInventario.Add(movimiento);
_context.Articulos.Update(articulo);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
return false;
}
});
}
public async Task<bool> RegistrarEntradaCantidadAsync(int articuloId, int cantidad, string observacion, string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
var existencia = await _context.Existencias
.FirstOrDefaultAsync(e => e.ArticuloId == articuloId && e.UbicacionId == articulo.UbicacionId);
if (existencia == null)
{
existencia = new Existencia
{
ArticuloId = articuloId,
UbicacionId = articulo.UbicacionId,
Cantidad = 0,
ActualizadoEn = DateTime.UtcNow
};
_context.Existencias.Add(existencia);
}
existencia.Cantidad += cantidad;
articulo.CantidadGlobal += cantidad;
articulo.ActualizadoEn = DateTime.UtcNow;
_context.Existencias.Update(existencia);
_context.Articulos.Update(articulo);
}
else
{
// UNITARIO - Si está inactivo por baja, lo reactivamos?
// En unitario, una entrada suele ser que el objeto "vuelve" a estar disponible.
articulo.Activo = true;
articulo.ActualizadoEn = DateTime.UtcNow;
_context.Articulos.Update(articulo);
}
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.ENTRADA),
TipMov = 1,
Fecha = DateTime.UtcNow,
UbicacionDestinoId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId,
Cantidad = cantidad,
Observacion = observacion,
UsuarioId = usuario
};
_context.MovimientosInventario.Add(movimiento);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
});
}
}

View File

@@ -0,0 +1,226 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class PrestamoService : IPrestamoService
{
private readonly ApplicationDbContext _context;
public PrestamoService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Prestamo>> GetHistorialPrestamosAsync(int limit = 100)
{
return await _context.Prestamos
.Include(p => p.Articulo)
.OrderByDescending(p => p.FechaPrestamo)
.Take(limit)
.ToListAsync();
}
public async Task<IEnumerable<Prestamo>> GetPrestamosActivosAsync()
{
return await _context.Prestamos
.Include(p => p.Articulo)
.Where(p => p.Estado == "ACTIVO" || p.Estado == "ATRASADO")
.OrderByDescending(p => p.FechaPrestamo)
.ToListAsync();
}
public async Task<Prestamo?> GetPrestamoByIdAsync(long id)
{
return await _context.Prestamos
.Include(p => p.Articulo)
.Include(p => p.Detalles)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<bool> RegistrarPrestamoAsync(int articuloId, int cantidad, string personaNombre, string? personaIdentificacion, DateTime? fechaDevolucionEstimada, string observacion, string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var articulo = await _context.Articulos.FindAsync(articuloId);
if (articulo == null) return false;
// 1. Validar y actualizar stock
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
var existencia = await _context.Existencias
.FirstOrDefaultAsync(e => e.ArticuloId == articuloId && e.UbicacionId == articulo.UbicacionId);
if (existencia == null || existencia.Cantidad < cantidad) return false;
existencia.Cantidad -= cantidad;
articulo.CantidadGlobal -= cantidad;
_context.Existencias.Update(existencia);
_context.Articulos.Update(articulo);
}
else
{
// Unitario
if (cantidad != 1 || !articulo.Activo) return false;
// En unitario, podrías marcar como inactivo o simplemente registrar el préstamo
// Para este sistema, asumiremos que prestado sigue siendo "Activo" pero en una ubicación de préstamo (vía movimiento)
}
// 2. Crear el registro de préstamo
var prestamo = new Prestamo
{
ArticuloId = articuloId,
Cantidad = cantidad,
PersonaNombre = personaNombre,
PersonaIdentificacion = personaIdentificacion,
FechaPrestamo = DateTime.UtcNow,
FechaDevolucionEstimada = fechaDevolucionEstimada,
Estado = "ACTIVO",
Observacion = observacion,
UsuarioId = usuario
};
_context.Prestamos.Add(prestamo);
await _context.SaveChangesAsync(); // Guardamos para tener el ID del préstamo
// 3. Crear movimiento de inventario (auditoría)
var movimiento = new MovimientoInventario
{
ArticuloId = articuloId,
TipoMovimiento = nameof(TipoMovimiento.PRESTAMO),
TipMov = 2,
Fecha = DateTime.UtcNow,
UbicacionOrigenId = articulo.UbicacionId,
UbicacionDestinoId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId,
Cantidad = cantidad,
Observacion = $"Préstamo #{prestamo.Id} a {personaNombre}. {observacion}",
UsuarioId = usuario
};
_context.MovimientosInventario.Add(movimiento);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
return false;
}
});
}
public async Task<bool> RegistrarDevolucionAsync(
long prestamoId,
string observacion,
string usuario)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
try
{
var prestamo = await _context.Prestamos.FindAsync(prestamoId);
if (prestamo == null)
return false;
if (prestamo.Estado == "DEVUELTO")
return false;
var articulo = await _context.Articulos.FindAsync(prestamo.ArticuloId);
if (articulo == null)
return false;
var fechaActual = DateTime.UtcNow;
if (articulo.TipoControl == nameof(Articulo.TipoControlInventario.LOTE))
{
// --- Buscar existencia ---
var existencia = await _context.Existencias
.FirstOrDefaultAsync(e =>
e.ArticuloId == articulo.Id &&
e.UbicacionId == articulo.UbicacionId);
// --- Crear existencia si no existe ---
if (existencia == null)
{
existencia = new Existencia
{
ArticuloId = articulo.Id,
UbicacionId = articulo.UbicacionId,
Cantidad = 0,
ActualizadoEn = fechaActual
};
_context.Existencias.Add(existencia);
}
// --- Actualizar cantidades ---
existencia.Cantidad += prestamo.Cantidad;
existencia.ActualizadoEn = fechaActual;
articulo.CantidadGlobal += prestamo.Cantidad;
articulo.ActualizadoEn = fechaActual;
_context.Existencias.Update(existencia);
_context.Articulos.Update(articulo);
}
else
{
articulo.Activo = true;
articulo.ActualizadoEn = fechaActual;
_context.Articulos.Update(articulo);
}
prestamo.Estado = "DEVUELTO";
prestamo.FechaDevolucionReal = fechaActual;
prestamo.Observacion =
$"{prestamo.Observacion}\nDevolución: {observacion}";
_context.Prestamos.Update(prestamo);
var movimiento = new MovimientoInventario
{
ArticuloId = articulo.Id,
TipoMovimiento = nameof(TipoMovimiento.DEVOLUCION),
TipMov = 1, // ENTRADA
Fecha = fechaActual,
UbicacionOrigenId = articulo.UbicacionId,
UbicacionDestinoId = articulo.UbicacionId,
EstadoAnteriorId = articulo.EstadoId,
EstadoNuevoId = articulo.EstadoId,
Cantidad = prestamo.Cantidad,
Observacion = $"Devolución de préstamo #{prestamo.Id}. {observacion}",
UsuarioId = usuario
};
_context.MovimientosInventario.Add(movimiento);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
});
}
public async Task<bool> RegistrarDevolucionParcialAsync(long prestamoId, List<string> codigosDevolucion, string observacion, string usuario)
{
// Implementación básica para seguir la interfaz, aunque el controlador actual no la usa directamente
// Esta lógica sería más para artículos unitarios con códigos específicos (PrestamoDetalle)
return await RegistrarDevolucionAsync(prestamoId, "Devolución parcial - " + observacion, usuario);
}
}

View File

@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Rs_system.Data;
using Rs_system.Models;
namespace Rs_system.Services;
public class UbicacionService : IUbicacionService
{
private readonly ApplicationDbContext _context;
public UbicacionService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Ubicacion>> GetAllAsync()
{
return await _context.Ubicaciones
.Where(u => !u.Eliminado)
.OrderBy(u => u.Nombre)
.ToListAsync();
}
public async Task<Ubicacion?> GetByIdAsync(int id)
{
return await _context.Ubicaciones
.FirstOrDefaultAsync(u => u.Id == id && !u.Eliminado);
}
public async Task<bool> CreateAsync(Ubicacion ubicacion)
{
try
{
ubicacion.CreadoEn = DateTime.UtcNow;
ubicacion.ActualizadoEn = DateTime.UtcNow;
ubicacion.Eliminado = false;
_context.Ubicaciones.Add(ubicacion);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> UpdateAsync(Ubicacion ubicacion)
{
try
{
var existing = await _context.Ubicaciones.FindAsync(ubicacion.Id);
if (existing == null || existing.Eliminado) return false;
existing.Nombre = ubicacion.Nombre;
existing.Descripcion = ubicacion.Descripcion;
existing.Responsable = ubicacion.Responsable;
existing.Activo = ubicacion.Activo;
existing.ActualizadoEn = DateTime.UtcNow;
_context.Ubicaciones.Update(existing);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> DeleteAsync(int id)
{
try
{
var ubicacion = await _context.Ubicaciones.FindAsync(id);
if (ubicacion == null || ubicacion.Eliminado) return false;
ubicacion.Eliminado = true;
ubicacion.ActualizadoEn = DateTime.UtcNow;
_context.Ubicaciones.Update(ubicacion);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
public async Task<bool> ExistsAsync(string nombre, int? excludeId = null)
{
var query = _context.Ubicaciones.AsQueryable();
if (excludeId.HasValue)
{
query = query.Where(u => u.Id != excludeId.Value);
}
return await query.AnyAsync(u => u.Nombre.ToLower() == nombre.ToLower() && !u.Eliminado);
}
}

View File

@@ -0,0 +1,208 @@
@model Rs_system.Models.ViewModels.ArticuloViewModel
@{
ViewData["Title"] = "Nuevo Artículo";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nuevo Artículo</h4>
<p class="text-muted mb-0">Registrar un nuevo activo en el inventario</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver a la lista
</a>
</div>
<form asp-action="Create" method="post" enctype="multipart/form-data">
<div class="row">
<!-- Left Column: Basic Info -->
<div class="col-lg-8">
<div class="card-custom mb-4">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-primary">Información General</h5>
</div>
<div class="card-body">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="row mb-3">
<div class="col-md-4">
<label asp-for="Codigo" class="form-label">Código <span class="text-danger">*</span></label>
<input asp-for="Codigo" class="form-control" placeholder="Ej: SILLA-001" autofocus />
<span asp-validation-for="Codigo" class="text-danger"></span>
</div>
<div class="col-md-8">
<label asp-for="Nombre" class="form-label">Nombre del Artículo <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Guitarra Acustica Yamaha" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="3" placeholder="Detalles adicionales, características, color..."></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="Marca" class="form-label">Marca</label>
<input asp-for="Marca" class="form-control" placeholder="Ej: Yamaha, Sony" />
<span asp-validation-for="Marca" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Modelo" class="form-label">Modelo</label>
<input asp-for="Modelo" class="form-control" placeholder="Ej: C-40" />
<span asp-validation-for="Modelo" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="card-custom">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-info">Detalles del Activo</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="NumeroSerie" class="form-label">Número de Serie</label>
<input asp-for="NumeroSerie" class="form-control" placeholder="S/N..." />
<span asp-validation-for="NumeroSerie" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Precio" class="form-label">Valor Estimado (Moneda)</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="Precio" class="form-control" type="number" step="0.01" />
</div>
<span asp-validation-for="Precio" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="FechaAdquisicion" class="form-label">Fecha de Adquisición</label>
<input asp-for="FechaAdquisicion" type="date" class="form-control" />
<span asp-validation-for="FechaAdquisicion" class="text-danger"></span>
</div>
</div>
</div>
</div>
<!-- Right Column: Classification & Image -->
<div class="col-lg-4">
<div class="card-custom mb-4">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-warning">Clasificación</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="TipoControl" class="form-label fw-bold">Tipo de Inventario <span class="text-danger">*</span></label>
<select asp-for="TipoControl" class="form-select" id="tipoControlSelect">
<option value="UNITARIO">Unitario (Laptops, Proyectores)</option>
<option value="LOTE">Por Lote (Sillas, Cables)</option>
</select>
<div class="form-text">Unitario: Control 1 a 1 por Serie. Lote: Control por Cantidad.</div>
<span asp-validation-for="TipoControl" class="text-danger"></span>
</div>
<div class="mb-3" id="cantidadContainer" style="display:none;">
<label asp-for="CantidadInicial" class="form-label fw-bold text-primary">Cantidad Inicial <span class="text-danger">*</span></label>
<input asp-for="CantidadInicial" class="form-control" type="number" min="1" value="1" />
<span asp-validation-for="CantidadInicial" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="CategoriaId" class="form-label">Categoría <span class="text-danger">*</span></label>
<select asp-for="CategoriaId" class="form-select" asp-items="ViewBag.Categorias">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="CategoriaId" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="UbicacionId" class="form-label">Ubicación Inicial <span class="text-danger">*</span></label>
<select asp-for="UbicacionId" class="form-select" asp-items="ViewBag.Ubicaciones">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="UbicacionId" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="EstadoId" class="form-label">Estado Físico <span class="text-danger">*</span></label>
<select asp-for="EstadoId" class="form-select" asp-items="ViewBag.Estados">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="EstadoId" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" checked />
<label asp-for="Activo" class="form-check-label">Artículo Activo</label>
</div>
</div>
</div>
</div>
<div class="card-custom">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-secondary">Imagen</h5>
</div>
<div class="card-body text-center">
<div class="mb-3">
<div class="image-preview-container bg-light rounded d-flex align-items-center justify-content-center mb-2" style="height: 200px; border: 2px dashed #ccc;">
<img id="imagePreview" src="#" alt="Vista Previa" style="max-height: 100%; max-width: 100%; display: none;" />
<span id="placeholderText" class="text-muted">Sin Imagen</span>
</div>
<input asp-for="ImagenFile" class="form-control" type="file" accept="image/*" onchange="previewImage(this)" />
<span asp-validation-for="ImagenFile" class="text-danger"></span>
</div>
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary-custom btn-lg">
<i class="bi bi-save me-2"></i> Guardar Artículo
</button>
<a asp-action="Index" class="btn btn-light border">Cancelar</a>
</div>
</div>
</div>
</form>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
function previewImage(input) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
$('#imagePreview').attr('src', e.target.result).show();
$('#placeholderText').hide();
}
reader.readAsDataURL(input.files[0]);
} else {
$('#imagePreview').hide();
$('#placeholderText').show();
}
}
$(document).ready(function() {
function toggleFields() {
var tipo = $('#tipoControlSelect').val();
if (tipo === 'LOTE') {
$('#cantidadContainer').show();
$('#NumeroSerie').parent().hide(); // Hide Serial for Lote
$('#NumeroSerie').val('');
} else {
$('#cantidadContainer').hide();
$('#NumeroSerie').parent().show(); // Show Serial for Unitario
$('#CantidadInicial').val(1);
}
}
$('#tipoControlSelect').change(toggleFields);
toggleFields(); // Init
});
</script>
}

View File

@@ -0,0 +1,128 @@
@model Rs_system.Models.ViewModels.ArticuloViewModel
@{
ViewData["Title"] = "Ficha Técnica";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Ficha Técnica</h4>
<p class="text-muted mb-0">Detalles del activo: @Model.Codigo</p>
</div>
<div>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning text-white me-2">
<i class="bi bi-pencil me-1"></i> Editar
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
</div>
<div class="row">
<!-- Left Column: Image and Status -->
<div class="col-md-4 mb-4">
<div class="card-custom text-center mb-3">
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.ImagenUrl))
{
<img src="@Model.ImagenUrl" class="img-fluid rounded mb-3" style="max-height: 300px;" alt="Imagen del Artículo">
}
else
{
<div class="bg-light rounded d-flex align-items-center justify-content-center mx-auto mb-3" style="height: 250px; width: 100%;">
<div class="text-center text-muted">
<i class="bi bi-image fs-1 d-block mb-2"></i>
<span>Sin Imagen</span>
</div>
</div>
}
<h5 class="card-title fw-bold text-primary">@Model.Nombre</h5>
<p class="card-text text-muted mb-3">@Model.Codigo</p>
<div class="d-flex justify-content-center gap-2">
<span class="badge bg-@(Model.EstadoColor ?? "secondary") fs-6 px-3 py-2">
Estado: @Model.EstadoNombre
</span>
@if (Model.Activo)
{
<span class="badge bg-success fs-6 px-3 py-2">Activo</span>
}
else
{
<span class="badge bg-danger fs-6 px-3 py-2">Inactivo</span>
}
</div>
</div>
</div>
</div>
<!-- Right Column: Details -->
<div class="col-md-8">
<div class="card-custom">
<div class="card-header bg-transparent py-3 border-bottom">
<h5 class="card-title mb-0">Información Detallada</h5>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-sm-4 text-muted">Descripción</div>
<div class="col-sm-8 fw-semibold">
@(!string.IsNullOrEmpty(Model.Descripcion) ? Model.Descripcion : "-")
</div>
</div>
<div class="row mb-4">
<div class="col-sm-4 text-muted">Ubicación Actual</div>
<div class="col-sm-8">
<span class="d-flex align-items-center">
<i class="bi bi-geo-alt-fill text-danger me-2"></i>
<span class="fw-bold">@Model.UbicacionNombre</span>
</span>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-4 text-muted">Categoría</div>
<div class="col-sm-8 fw-semibold">@Model.CategoriaNombre</div>
</div>
<hr class="my-4 text-muted" />
<div class="row mb-3">
<div class="col-sm-6">
<small class="text-muted d-block mb-1">Marca</small>
<span class="fs-5">@(!string.IsNullOrEmpty(Model.Marca) ? Model.Marca : "-")</span>
</div>
<div class="col-sm-6">
<small class="text-muted d-block mb-1">Modelo</small>
<span class="fs-5">@(!string.IsNullOrEmpty(Model.Modelo) ? Model.Modelo : "-")</span>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-6">
<small class="text-muted d-block mb-1">Número de Serie</small>
<span class="font-monospace bg-light px-2 py-1 rounded">
@(!string.IsNullOrEmpty(Model.NumeroSerie) ? Model.NumeroSerie : "N/A")
</span>
</div>
<div class="col-sm-6">
<small class="text-muted d-block mb-1">Precio Estimado</small>
<span class="text-success fw-bold">
@Model.Precio.ToString("C")
</span>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<small class="text-muted d-block mb-1">Fecha Adquisición</small>
<span>
@(Model.FechaAdquisicion.HasValue ? Model.FechaAdquisicion.Value.ToString("dd/MM/yyyy") : "-")
</span>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,215 @@
@model Rs_system.Models.ViewModels.ArticuloViewModel
@{
ViewData["Title"] = "Editar Artículo";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Editar Artículo</h4>
<p class="text-muted mb-0">Modificar información del activo</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<form asp-action="Edit" method="post" enctype="multipart/form-data">
<input type="hidden" asp-for="Id" />
<div class="row">
<!-- Left Column: Basic Info -->
<div class="col-lg-8">
<div class="card-custom mb-4">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-primary">Información General</h5>
</div>
<div class="card-body">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="row mb-3">
<div class="col-md-4">
<label asp-for="Codigo" class="form-label">Código <span class="text-danger">*</span></label>
<input asp-for="Codigo" class="form-control" />
<span asp-validation-for="Codigo" class="text-danger"></span>
</div>
<div class="col-md-8">
<label asp-for="Nombre" class="form-label">Nombre del Artículo <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="3"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="Marca" class="form-label">Marca</label>
<input asp-for="Marca" class="form-control" />
<span asp-validation-for="Marca" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Modelo" class="form-label">Modelo</label>
<input asp-for="Modelo" class="form-control" />
<span asp-validation-for="Modelo" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="card-custom">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-info">Detalles del Activo</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="NumeroSerie" class="form-label">Número de Serie</label>
<input asp-for="NumeroSerie" class="form-control" />
<span asp-validation-for="NumeroSerie" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Precio" class="form-label">Valor Estimado (Moneda)</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="Precio" class="form-control" type="number" step="0.01" />
</div>
<span asp-validation-for="Precio" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="FechaAdquisicion" class="form-label">Fecha de Adquisición</label>
<input asp-for="FechaAdquisicion" type="date" class="form-control" />
<span asp-validation-for="FechaAdquisicion" class="text-danger"></span>
</div>
</div>
</div>
</div>
<!-- Right Column: Classification & Image -->
<div class="col-lg-4">
<div class="card-custom mb-4">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-warning">Clasificación</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label fw-bold">Tipo de Inventario</label>
<input type="text" class="form-control" value="@Model.TipoControl" readonly disabled />
<input type="hidden" asp-for="TipoControl" />
</div>
<div class="col-md-6">
@if (Model.TipoControl == "LOTE")
{
<label class="form-label fw-bold text-primary">Cantidad Global</label>
<input type="number" class="form-control" value="@Model.CantidadInicial" readonly disabled />
<div class="form-text">Gestionar cantidad vía Movimientos.</div>
}
else
{
<label class="form-label fw-bold">Cantidad</label>
<input type="text" class="form-control" value="1 (Unitario)" readonly disabled />
}
</div>
</div>
<div class="mb-3">
<label asp-for="CategoriaId" class="form-label">Categoría <span class="text-danger">*</span></label>
<select asp-for="CategoriaId" class="form-select" asp-items="ViewBag.Categorias">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="CategoriaId" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="UbicacionId" class="form-label">Ubicación Actual <span class="text-danger">*</span></label>
@if (Model.TipoControl == "LOTE")
{
<select asp-for="UbicacionId" class="form-select" asp-items="ViewBag.Ubicaciones" disabled>
<option value="">-- Seleccionar --</option>
</select>
<input type="hidden" asp-for="UbicacionId" />
<div class="form-text">La ubicación se gestiona por Existencias en Lotes.</div>
}
else
{
<select asp-for="UbicacionId" class="form-select" asp-items="ViewBag.Ubicaciones">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="UbicacionId" class="text-danger"></span>
}
</div>
<div class="mb-3">
<label asp-for="EstadoId" class="form-label">Estado Físico <span class="text-danger">*</span></label>
<select asp-for="EstadoId" class="form-select" asp-items="ViewBag.Estados">
<option value="">-- Seleccionar --</option>
</select>
<span asp-validation-for="EstadoId" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="Activo" class="form-check-label">Artículo Activo</label>
</div>
</div>
</div>
</div>
<div class="card-custom">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0 ps-2 border-start border-4 border-secondary">Imagen</h5>
</div>
<div class="card-body text-center">
<div class="mb-3">
<div class="image-preview-container bg-light rounded d-flex align-items-center justify-content-center mb-2" style="height: 200px; border: 2px dashed #ccc;">
@if (!string.IsNullOrEmpty(Model.ImagenUrl))
{
<img id="imagePreview" src="@Model.ImagenUrl" alt="Vista Previa" style="max-height: 100%; max-width: 100%;" />
<span id="placeholderText" class="text-muted" style="display: none;">Sin Imagen</span>
}
else
{
<img id="imagePreview" src="#" alt="Vista Previa" style="max-height: 100%; max-width: 100%; display: none;" />
<span id="placeholderText" class="text-muted">Sin Imagen</span>
}
</div>
<input asp-for="ImagenFile" class="form-control" type="file" accept="image/*" onchange="previewImage(this)" />
<span asp-validation-for="ImagenFile" class="text-danger"></span>
</div>
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary-custom btn-lg">
<i class="bi bi-save me-2"></i> Guardar Cambios
</button>
<a asp-action="Index" class="btn btn-light border">Cancelar</a>
</div>
</div>
</div>
</form>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
function previewImage(input) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function(e) {
$('#imagePreview').attr('src', e.target.result).show();
$('#placeholderText').hide();
}
reader.readAsDataURL(input.files[0]);
} else {
// If clearing, we might want to keep the old image or show placebo
// For simplicity here, if user cancels file dialog, it keeps previous
}
}
</script>
}

View File

@@ -0,0 +1,141 @@
@model IEnumerable<Rs_system.Models.ViewModels.ArticuloViewModel>
@{
ViewData["Title"] = "Inventario de Artículos";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Inventario de Artículos</h4>
<p class="text-muted mb-0">Gestión de bienes y activos fijos</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Artículo
</a>
</div>
<!-- Filters Card -->
<div class="card-custom mb-4">
<div class="card-body py-3">
<form asp-action="Index" method="get" class="row g-3">
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control border-start-0" placeholder="Buscar por nombre, código..." value="@ViewBag.CurrentSearch">
</div>
</div>
<div class="col-md-3">
<select name="categoriaId" class="form-select" asp-items="ViewBag.Categorias">
<option value="">Todas las Categorías</option>
</select>
</div>
<div class="col-md-3">
<select name="ubicacionId" class="form-select" asp-items="ViewBag.Ubicaciones">
<option value="">Todas las Ubicaciones</option>
</select>
</div>
<div class="col-md-2">
<select name="estadoId" class="form-select" asp-items="ViewBag.Estados">
<option value="">Todos los Estados</option>
</select>
</div>
<div class="col-md-1 d-grid">
<button type="submit" class="btn btn-secondary" title="Filtrar">
<i class="bi bi-funnel"></i>
</button>
</div>
</form>
</div>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom align-middle">
<thead>
<tr>
<th style="width: 60px;">Img</th>
<th>Código</th>
<th>Nombre / Marca</th>
<th>Ubicación</th>
<th>Categoría</th>
<th class="text-center">Estado</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-box-seam fs-1 d-block mb-2"></i>
No se encontraron artículos
</td>
</tr>
}
else
{
@foreach (var item in Model)
{
<tr>
<td class="text-center">
@if (!string.IsNullOrEmpty(item.ImagenUrl))
{
<img src="@item.ImagenUrl" alt="Img" class="rounded" style="width: 40px; height: 40px; object-fit: cover;" />
}
else
{
<div class="rounded bg-light d-flex align-items-center justify-content-center text-muted" style="width: 40px; height: 40px;">
<i class="bi bi-image"></i>
</div>
}
</td>
<td>
<span class="badge bg-light text-dark border">@item.Codigo</span>
</td>
<td>
<div class="fw-bold">@item.Nombre</div>
@if (!string.IsNullOrEmpty(item.Marca))
{
<small class="text-muted">@item.Marca @item.Modelo</small>
}
</td>
<td>
<i class="bi bi-geo-alt-fill text-muted me-1" style="font-size: 0.8rem;"></i>
@item.UbicacionNombre
</td>
<td>@item.CategoriaNombre</td>
<td class="text-center">
<span class="badge bg-@(item.EstadoColor ?? "secondary")">
@item.EstadoNombre
</span>
</td>
<td class="text-center">
<div class="btn-group">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-primary" title="Ver Ficha Técnica">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@section Scripts {
<script>
@if (TempData["SuccessMessage"] != null)
{
<text>toastr.success('@TempData["SuccessMessage"]');</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>toastr.error('@TempData["ErrorMessage"]');</text>
}
</script>
}

View File

@@ -0,0 +1,53 @@
@model Rs_system.Models.Categoria
@{
ViewData["Title"] = "Nueva Categoría";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nueva Categoría</h4>
<p class="text-muted mb-0">Registrar una nueva categoría de inventario</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Electrónica, Muebles, Papelería" autofocus />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="3" placeholder="Breve descripción de la categoría (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" checked />
<label asp-for="Activo" class="form-check-label">Categoría Activa</label>
</div>
<div class="form-text">Si está inactiva, no aparecerá en las selecciones de nuevos artículos.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,54 @@
@model Rs_system.Models.Categoria
@{
ViewData["Title"] = "Editar Categoría";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Editar Categoría</h4>
<p class="text-muted mb-0">Modificar información de la categoría</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Electrónica, Muebles, Papelería" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="3" placeholder="Breve descripción de la categoría (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="Activo" class="form-check-label">Categoría Activa</label>
</div>
<div class="form-text">Si está inactiva, no aparecerá en las selecciones de nuevos artículos.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar Cambios
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,117 @@
@model IEnumerable<Rs_system.Models.Categoria>
@{
ViewData["Title"] = "Categorías";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Categorías de Inventario</h4>
<p class="text-muted mb-0">Gestión de categorías para clasificación de artículos</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nueva Categoría
</a>
</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">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="4" class="text-center text-muted py-4">
<i class="bi bi-tags fs-1 d-block mb-2"></i>
No hay categorías registradas
</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td>
<strong>@item.Nombre</strong>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Descripcion))
{
<span>@item.Descripcion</span>
}
else
{
<span class="text-muted">-</span>
}
</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">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="confirmDelete(@item.Id)" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" asp-action="Delete" method="post" style="display: none;">
<input type="hidden" name="id" id="deleteId" />
</form>
@section Scripts {
<script>
function confirmDelete(id) {
Swal.fire({
title: '¿Eliminar categoría?',
text: 'Esta acción moverá la categoría a la papelera.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
});
}
// Show success/error messages
@if (TempData["SuccessMessage"] != null)
{
<text>
toastr.success('@TempData["SuccessMessage"]');
</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>
toastr.error('@TempData["ErrorMessage"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,453 @@
@model Rs_system.Models.ViewModels.RegistrarColaboracionViewModel
@{
ViewData["Title"] = "Nueva Colaboración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nueva Colaboración</h4>
<p class="text-muted mb-0">Registrar colaboración económica mensual</p>
</div>
<div>
<a asp-controller="TipoColaboracion" asp-action="Index" class="btn btn-outline-primary me-2">
<i class="bi bi-gear me-1"></i> Gestionar Tipos
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
</div>
<div class="card-custom">
<form asp-action="Create" method="post" id="colaboracionForm">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<!-- Selección de Miembro con Búsqueda -->
<div class="mb-4">
<h6 class="text-primary border-bottom pb-2">Información del Miembro</h6>
<div class="row">
<div class="col-md-12">
<label for="buscarMiembro" class="form-label">Buscar Miembro</label>
<input type="text"
id="buscarMiembro"
class="form-control"
placeholder="Escriba el nombre del miembro..."
autocomplete="off" />
<div id="resultadosBusqueda" class="list-group mt-2" style="display: none; position: absolute; z-index: 1000; max-height: 300px; overflow-y: auto; width: 90%;"></div>
<input type="hidden" asp-for="MiembroId" id="miembroIdHidden" />
<!-- Mostrar miembro seleccionado -->
<div id="miembroSeleccionado" class="mt-3" style="display: none;">
<div class="alert alert-success d-flex justify-content-between align-items-center mb-2">
<div>
<i class="bi bi-person-check me-2"></i>
<strong id="nombreMiembroSeleccionado"></strong>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="limpiarMiembro()">
<i class="bi bi-x"></i> Cambiar
</button>
</div>
<!-- Historial de Últimos Pagos -->
<div id="infoUltimosPagos" style="display: none;">
<div class="card border-info bg-light">
<div class="card-header bg-transparent border-info py-1 px-3">
<small class="text-info fw-bold"><i class="bi bi-clock-history me-1"></i> Últimos meses pagados</small>
</div>
<div class="card-body py-2 px-3">
<div id="listaUltimosPagos" class="d-flex flex-wrap gap-2"></div>
</div>
</div>
</div>
</div>
<span asp-validation-for="MiembroId" class="text-danger"></span>
</div>
</div>
</div>
<!-- Período de Colaboración -->
<div class="mb-4">
<h6 class="text-primary border-bottom pb-2">Período a Cubrir</h6>
<div class="row">
<div class="col-md-3">
<label asp-for="MesInicial" class="form-label"></label>
<select asp-for="MesInicial" class="form-select" id="mesInicial" onchange="calcularSugerido()">
<option value="1">Enero</option>
<option value="2">Febrero</option>
<option value="3">Marzo</option>
<option value="4">Abril</option>
<option value="5">Mayo</option>
<option value="6">Junio</option>
<option value="7">Julio</option>
<option value="8">Agosto</option>
<option value="9">Septiembre</option>
<option value="10">Octubre</option>
<option value="11">Noviembre</option>
<option value="12">Diciembre</option>
</select>
<span asp-validation-for="MesInicial" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="AnioInicial" class="form-label"></label>
<select asp-for="AnioInicial" class="form-select" id="anioInicial" onchange="calcularSugerido()">
@for (int i = DateTime.Now.Year; i >= DateTime.Now.Year - 5; i--)
{
<option value="@i">@i</option>
}
</select>
<span asp-validation-for="AnioInicial" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="MesFinal" class="form-label"></label>
<select asp-for="MesFinal" class="form-select" id="mesFinal" onchange="calcularSugerido()">
<option value="1">Enero</option>
<option value="2">Febrero</option>
<option value="3">Marzo</option>
<option value="4">Abril</option>
<option value="5">Mayo</option>
<option value="6">Junio</option>
<option value="7">Julio</option>
<option value="8">Agosto</option>
<option value="9">Septiembre</option>
<option value="10">Octubre</option>
<option value="11">Noviembre</option>
<option value="12">Diciembre</option>
</select>
<span asp-validation-for="MesFinal" class="text-danger"></span>
</div>
<div class="col-md-3">
<label asp-for="AnioFinal" class="form-label"></label>
<select asp-for="AnioFinal" class="form-select" id="anioFinal" onchange="calcularSugerido()">
@for (int i = DateTime.Now.Year + 1; i >= DateTime.Now.Year - 5; i--)
{
<option value="@i" selected="@(i == DateTime.Now.Year)">@i</option>
}
</select>
<span asp-validation-for="AnioFinal" class="text-danger"></span>
</div>
</div>
</div>
<!-- Tipos de Colaboración -->
<div class="mb-4">
<h6 class="text-primary border-bottom pb-2">Tipos de Colaboración</h6>
<div class="row">
@if (Model.TiposDisponibles != null && Model.TiposDisponibles.Any())
{
@foreach (var tipo in Model.TiposDisponibles)
{
var esTransporteOLimpieza = tipo.Nombre.Equals("Transporte", StringComparison.OrdinalIgnoreCase) ||
tipo.Nombre.Equals("Limpieza", StringComparison.OrdinalIgnoreCase);
<div class="col-md-6 mb-2">
<div class="form-check">
<input class="form-check-input tipo-checkbox"
type="checkbox"
name="TiposSeleccionados"
value="@tipo.Id"
id="tipo_@tipo.Id"
data-monto="@tipo.MontoSugerido"
@(esTransporteOLimpieza ? "checked" : "")
onchange="calcularSugerido()">
<label class="form-check-label" for="tipo_@tipo.Id">
<strong>@tipo.Nombre</strong>
<span class="badge bg-success ms-2">$@tipo.MontoSugerido.ToString("N2")/mes</span>
@if (!string.IsNullOrEmpty(tipo.Descripcion))
{
<br><small class="text-muted">@tipo.Descripcion</small>
}
</label>
</div>
</div>
}
}
else
{
<div class="col-12">
<div class="alert alert-warning">
No hay tipos de colaboración activos. <a asp-controller="TipoColaboracion" asp-action="Index">Gestionar tipos</a>
</div>
</div>
}
</div>
<span asp-validation-for="TiposSeleccionados" class="text-danger"></span>
</div>
<!-- Tipo Prioritario -->
<div class="mb-4">
<h6 class="text-primary border-bottom pb-2">Priorización (Opcional)</h6>
<div class="row">
<div class="col-md-12">
<label asp-for="TipoPrioritario" class="form-label"></label>
<select asp-for="TipoPrioritario" class="form-select" id="tipoPrioritario">
<option value="">Sin prioridad - Distribuir equitativamente</option>
@if (Model.TiposDisponibles != null)
{
@foreach (var tipo in Model.TiposDisponibles)
{
<option value="@tipo.Id">@tipo.Nombre (se pagará primero $@tipo.MontoSugerido por mes)</option>
}
}
</select>
<small class="form-text text-muted">
Si selecciona un tipo prioritario, el monto se asignará primero a ese tipo usando su monto sugerido,
y el resto se distribuirá equitativamente entre los demás tipos.
</small>
</div>
</div>
</div>
<!-- Monto Total -->
<div class="mb-4">
<h6 class="text-primary border-bottom pb-2">Monto de la Colaboración</h6>
<div class="row">
<div class="col-md-6">
<label asp-for="MontoTotal" class="form-label"></label>
<div class="input-group input-group-lg">
<span class="input-group-text">$</span>
<input asp-for="MontoTotal"
type="number"
step="0.01"
class="form-control"
id="montoTotal"
placeholder="0.00"
required />
</div>
<span asp-validation-for="MontoTotal" class="text-danger"></span>
<small class="form-text text-muted">Monto total que entrega el miembro</small>
</div>
<div class="col-md-6">
<label class="form-label">Resumen</label>
<div class="card bg-light">
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>Meses a cubrir:</span>
<strong id="totalMeses">0</strong>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Tipos seleccionados:</span>
<strong id="totalTipos">0</strong>
</div>
<hr class="my-2">
<div class="d-flex justify-content-between mb-2">
<span class="text-info">Monto sugerido:</span>
<strong class="text-info">$<span id="montoSugerido">0.00</span></strong>
</div>
<div id="alertaDiferencia" class="alert alert-warning py-1 px-2 mb-0 mt-2" style="display: none; font-size: 0.85rem;">
<i class="bi bi-exclamation-triangle me-1"></i>
<span id="mensajeDiferencia"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Observaciones -->
<div class="mb-4">
<label asp-for="Observaciones" class="form-label"></label>
<textarea asp-for="Observaciones" class="form-control" rows="3" placeholder="Notas opcionales sobre esta colaboración"></textarea>
<span asp-validation-for="Observaciones" class="text-danger"></span>
</div>
<div class="d-flex justify-content-end gap-2">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Registrar Colaboración
</button>
</div>
</form>
</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>
}
</script>
}

View File

@@ -0,0 +1,130 @@
@model Rs_system.Models.Colaboracion
@{
ViewData["Title"] = "Detalle de Colaboración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Detalle de Colaboración #@Model.Id</h4>
<p class="text-muted mb-0">Información completa de la colaboración</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">
<!-- Información Principal -->
<div class="col-md-6 mb-4">
<div class="card-custom">
<h6 class="text-primary border-bottom pb-2 mb-3">Información de Registro</h6>
<div class="mb-3">
<label class="text-muted small">Miembro</label>
<div><strong>@Model.Miembro.Persona.Nombres @Model.Miembro.Persona.Apellidos</strong></div>
</div>
<div class="mb-3">
<label class="text-muted small">Fecha de Registro</label>
<div>@Model.FechaRegistro.ToString("dd/MM/yyyy HH:mm")</div>
</div>
<div class="mb-3">
<label class="text-muted small">Registrado por</label>
<div>@(Model.RegistradoPor ?? "Sistema")</div>
</div>
<div class="mb-3">
<label class="text-muted small">Monto Total</label>
<div><h4 class="text-success mb-0">$@Model.MontoTotal.ToString("N2")</h4></div>
</div>
@if (!string.IsNullOrEmpty(Model.Observaciones))
{
<div class="mb-3">
<label class="text-muted small">Observaciones</label>
<div class="alert alert-info mb-0">@Model.Observaciones</div>
</div>
}
</div>
</div>
<!-- Desglose Detallado -->
<div class="col-md-6 mb-4">
<div class="card-custom">
<h6 class="text-primary border-bottom pb-2 mb-3">Desglose por Tipo</h6>
@{
var porTipo = Model.Detalles.GroupBy(d => d.TipoColaboracion.Nombre);
}
@foreach (var grupo in porTipo)
{
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">@grupo.Key</h6>
<span class="badge bg-primary">@grupo.Count() meses</span>
</div>
<div class="text-muted small">
Total: <strong class="text-success">$@grupo.Sum(d => d.Monto).ToString("N2")</strong>
</div>
</div>
}
</div>
</div>
</div>
<!-- Tabla de Detalles Mensuales -->
<div class="card-custom">
<h6 class="text-primary border-bottom pb-2 mb-3">Meses Cubiertos</h6>
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Mes</th>
<th>Año</th>
<th>Tipo</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@foreach (var detalle in Model.Detalles.OrderBy(d => d.Anio).ThenBy(d => d.Mes))
{
<tr>
<td>
@{
var fecha = new DateTime(detalle.Anio, detalle.Mes, 1);
}
@fecha.ToString("MMMM", new System.Globalization.CultureInfo("es-ES"))
</td>
<td>@detalle.Anio</td>
<td>
<span class="badge bg-primary">@detalle.TipoColaboracion.Nombre</span>
</td>
<td class="text-end">
<strong>$@detalle.Monto.ToString("N2")</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="table-active">
<td colspan="3" class="text-end"><strong>TOTAL:</strong></td>
<td class="text-end">
<h5 class="mb-0 text-success">$@Model.Detalles.Sum(d => d.Monto).ToString("N2")</h5>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver al Listado
</a>
<a asp-action="EstadoCuenta" asp-route-id="@Model.MiembroId" class="btn btn-primary-custom">
<i class="bi bi-file-text me-1"></i> Ver Estado de Cuenta del Miembro
</a>
</div>

View File

@@ -0,0 +1,133 @@
@model Rs_system.Models.ViewModels.EstadoCuentaViewModel
@{
ViewData["Title"] = "Estado de Cuenta";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Estado de Cuenta</h4>
<p class="text-muted mb-0">@Model.NombreMiembro</p>
</div>
<div>
<button onclick="window.print()" class="btn btn-outline-secondary me-2">
<i class="bi bi-printer me-1"></i> Imprimir
</button>
<a asp-action="Index" class="btn btn-primary-custom">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
</div>
<!-- Encabezado del Estado de Cuenta -->
<div class="card-custom mb-4" style="border: 2px solid #198754;">
<div class="text-center py-4">
<h3 class="mb-3">ESTADO DE CUENTA DE COLABORACIONES</h3>
<h5 class="text-primary mb-2">@Model.NombreMiembro</h5>
<p class="text-muted mb-0">Fecha de consulta: @Model.FechaConsulta.ToString("dd/MM/yyyy HH:mm")</p>
</div>
<hr>
<div class="text-center py-3">
<h6 class="text-muted mb-2">Total Aportado</h6>
<h2 class="text-success mb-0">$@Model.TotalAportado.ToString("N2")</h2>
</div>
</div>
<!-- Historial por Tipo de Colaboración -->
@if (Model.HistorialPorTipos.Any())
{
@foreach (var historial in Model.HistorialPorTipos)
{
<div class="card-custom mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">
<span class="badge bg-primary">@historial.TipoNombre</span>
</h5>
<h6 class="mb-0 text-success">
Total: $@historial.TotalTipo.ToString("N2")
</h6>
</div>
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Mes / Año</th>
<th>Fecha de Pago</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@foreach (var registro in historial.Registros.OrderByDescending(r => r.Anio).ThenByDescending(r => r.Mes))
{
<tr>
<td><strong>@registro.MesTexto</strong></td>
<td>@registro.FechaRegistro.ToString("dd/MM/yyyy")</td>
<td class="text-end">
<strong>$@registro.Monto.ToString("N2")</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="table-active">
<td colspan="2" class="text-end"><strong>Subtotal @historial.TipoNombre:</strong></td>
<td class="text-end">
<strong class="text-success">$@historial.TotalTipo.ToString("N2")</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- Total General -->
<div class="card-custom" style="border: 2px solid #198754;">
<div class="d-flex justify-content-between align-items-center py-3">
<h4 class="mb-0">TOTAL GENERAL</h4>
<h3 class="mb-0 text-success">$@Model.TotalAportado.ToString("N2")</h3>
</div>
</div>
}
else
{
<div class="card-custom">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Este miembro aún no tiene colaboraciones registradas.
</div>
</div>
}
<!-- Pie de página para impresión -->
<div class="mt-5 text-center text-muted" style="page-break-before: auto;">
<hr>
<p class="mb-1"><small>Este documento es un comprobante de colaboraciones realizadas</small></p>
<p class="mb-0"><small>Generado el @DateTime.Now.ToString("dd/MM/yyyy HH:mm")</small></p>
</div>
<style>
@@media print {
.btn, .no-print {
display: none !important;
}
.card-custom {
border: 1px solid #dee2e6;
page-break-inside: avoid;
}
body {
background: white;
}
}
</style>
@section Scripts {
<script>
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,147 @@
@model IEnumerable<Rs_system.Models.Colaboracion>
@{
ViewData["Title"] = "Colaboraciones";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Colaboraciones Económicas</h4>
<p class="text-muted mb-0">Registro de colaboraciones mensuales de los miembros</p>
</div>
<div>
<a asp-action="Reportes" class="btn btn-outline-primary me-2">
<i class="bi bi-file-earmark-bar-graph me-1"></i> Reportes
</a>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nueva Colaboración
</a>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Recaudado Hoy</h6>
<h3 class="text-primary mb-0">
$@Model.Where(c => c.FechaRegistro.Date == DateTime.Today).Sum(c => c.MontoTotal).ToString("N2")
</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Colaboraciones Hoy</h6>
<h3 class="text-success mb-0">
@Model.Count(c => c.FechaRegistro.Date == DateTime.Today)
</h3>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Registros</h6>
<h3 class="text-info mb-0">@Model.Count()</h3>
</div>
</div>
</div>
<!-- Collaborations Table -->
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Fecha</th>
<th>Miembro</th>
<th>Tipos</th>
<th>Período</th>
<th>Monto</th>
<th>Registrado por</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-cash-coin fs-1 d-block mb-2"></i>
No hay colaboraciones registradas
</td>
</tr>
}
@foreach (var colaboracion in Model)
{
<tr>
<td>
<strong>@colaboracion.FechaRegistro.ToString("dd/MM/yyyy")</strong><br>
<small class="text-muted">@colaboracion.FechaRegistro.ToString("HH:mm")</small>
</td>
<td>
<strong>@colaboracion.Miembro.Persona.Nombres @colaboracion.Miembro.Persona.Apellidos</strong>
</td>
<td>
@{
var tipos = colaboracion.Detalles.Select(d => d.TipoColaboracion.Nombre).Distinct();
}
@foreach (var tipo in tipos)
{
<span class="badge bg-primary me-1">@tipo</span>
}
</td>
<td>
@{
var ordenados = colaboracion.Detalles.OrderBy(d => d.Anio).ThenBy(d => d.Mes).ToList();
var primero = ordenados.First();
var ultimo = ordenados.Last();
if (primero.Anio == ultimo.Anio && primero.Mes == ultimo.Mes)
{
var fecha = new DateTime(primero.Anio, primero.Mes, 1);
<text>@fecha.ToString("MMMM yyyy", new System.Globalization.CultureInfo("es-ES"))</text>
}
else
{
var fechaInicio = new DateTime(primero.Anio, primero.Mes, 1);
var fechaFin = new DateTime(ultimo.Anio, ultimo.Mes, 1);
<text>@fechaInicio.ToString("MMM yyyy", new System.Globalization.CultureInfo("es-ES")) - @fechaFin.ToString("MMM yyyy", new System.Globalization.CultureInfo("es-ES"))</text>
}
}
</td>
<td>
<strong class="text-success">$@colaboracion.MontoTotal.ToString("N2")</strong>
</td>
<td>
<small>@(colaboracion.RegistradoPor ?? "Sistema")</small>
</td>
<td class="text-center">
<a asp-action="Details" asp-route-id="@colaboracion.Id" class="btn btn-sm btn-outline-primary" title="Ver detalles">
<i class="bi bi-eye"></i>
</a>
<a asp-action="EstadoCuenta" asp-route-id="@colaboracion.MiembroId" class="btn btn-sm btn-outline-info" title="Estado de cuenta">
<i class="bi bi-file-text"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@section Scripts {
<script>
// Show success/error messages
@if (TempData["Success"] != null)
{
<text>
toastr.success('@TempData["Success"]');
</text>
}
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,150 @@
@model Rs_system.Models.ViewModels.ReporteColaboracionesViewModel
@{
ViewData["Title"] = "Reporte de Colaboraciones";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Reporte de Colaboraciones</h4>
<p class="text-muted mb-0">
Del @Model.FechaInicio.ToString("dd/MM/yyyy") al @Model.FechaFin.ToString("dd/MM/yyyy")
</p>
</div>
<div>
<button onclick="window.print()" class="btn btn-outline-secondary me-2">
<i class="bi bi-printer me-1"></i> Imprimir
</button>
<a asp-action="Reportes" class="btn btn-primary-custom">
<i class="bi bi-arrow-left me-1"></i> Nuevo Reporte
</a>
</div>
</div>
<!-- Resumen General -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Recaudado</h6>
<h2 class="text-success mb-0">$@Model.TotalRecaudado.ToString("N2")</h2>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Total Movimientos</h6>
<h2 class="text-primary mb-0">@Model.Movimientos.Count</h2>
</div>
</div>
<div class="col-md-4">
<div class="card-custom text-center">
<h6 class="text-muted mb-2">Tipos de Colaboración</h6>
<h2 class="text-info mb-0">@Model.DesglosePorTipos.Count</h2>
</div>
</div>
</div>
<!-- Desglose por Tipo -->
<div class="card-custom mb-4">
<h6 class="text-primary border-bottom pb-2 mb-3">Desglose por Tipo de Colaboración</h6>
@if (Model.DesglosePorTipos.Any())
{
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Tipo de Colaboración</th>
<th class="text-center">Cantidad de Meses</th>
<th class="text-end">Total Recaudado</th>
</tr>
</thead>
<tbody>
@foreach (var tipo in Model.DesglosePorTipos.OrderByDescending(t => t.TotalRecaudado))
{
<tr>
<td>
<span class="badge bg-primary">@tipo.TipoNombre</span>
</td>
<td class="text-center">@tipo.CantidadMeses</td>
<td class="text-end">
<strong class="text-success">$@tipo.TotalRecaudado.ToString("N2")</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="table-active">
<td colspan="2" class="text-end"><strong>TOTAL:</strong></td>
<td class="text-end">
<h5 class="mb-0 text-success">$@Model.TotalRecaudado.ToString("N2")</h5>
</td>
</tr>
</tfoot>
</table>
</div>
}
else
{
<div class="alert alert-info">
No hay datos para el período seleccionado.
</div>
}
</div>
<!-- Detalle de Movimientos -->
<div class="card-custom">
<h6 class="text-primary border-bottom pb-2 mb-3">Detalle de Movimientos</h6>
@if (Model.Movimientos.Any())
{
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Fecha</th>
<th>Miembro</th>
<th>Tipos</th>
<th>Período Cubierto</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@foreach (var movimiento in Model.Movimientos.OrderByDescending(m => m.Fecha))
{
<tr>
<td>@movimiento.Fecha.ToString("dd/MM/yyyy HH:mm")</td>
<td><strong>@movimiento.NombreMiembro</strong></td>
<td>
@foreach (var tipo in movimiento.TiposColaboracion.Split(", "))
{
<span class="badge bg-primary me-1">@tipo</span>
}
</td>
<td>@movimiento.PeriodoCubierto</td>
<td class="text-end">
<strong>$@movimiento.Monto.ToString("N2")</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="alert alert-info">
No hay movimientos registrados en este período.
</div>
}
</div>
<style>
@@media print {
.btn, .no-print {
display: none !important;
}
.card-custom {
border: 1px solid #dee2e6;
page-break-inside: avoid;
}
}
</style>

View File

@@ -0,0 +1,118 @@
@{
ViewData["Title"] = "Reportes de Colaboraciones";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Reportes de Colaboraciones</h4>
<p class="text-muted mb-0">Generar reportes por rango de fechas</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="card-custom">
<form asp-action="GenerarReporte" method="post">
<div class="row mb-4">
<div class="col-md-4">
<label for="fechaInicio" class="form-label">Fecha Inicio</label>
<input type="date"
class="form-control"
id="fechaInicio"
name="fechaInicio"
value="@ViewBag.FechaInicio?.ToString("yyyy-MM-dd")"
required />
</div>
<div class="col-md-4">
<label for="fechaFin" class="form-label">Fecha Fin</label>
<input type="date"
class="form-control"
id="fechaFin"
name="fechaFin"
value="@ViewBag.FechaFin?.ToString("yyyy-MM-dd")"
required />
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary-custom w-100">
<i class="bi bi-search me-1"></i> Generar Reporte
</button>
</div>
</div>
</form>
<!-- Acceso rápido -->
<div class="row">
<div class="col-12">
<h6 class="text-muted mb-3">Accesos Rápidos</h6>
</div>
<div class="col-md-3 mb-2">
<button class="btn btn-outline-primary w-100" onclick="reporteHoy()">
<i class="bi bi-calendar-day me-1"></i> Hoy
</button>
</div>
<div class="col-md-3 mb-2">
<button class="btn btn-outline-primary w-100" onclick="reporteSemana()">
<i class="bi bi-calendar-week me-1"></i> Esta Semana
</button>
</div>
<div class="col-md-3 mb-2">
<button class="btn btn-outline-primary w-100" onclick="reporteMes()">
<i class="bi bi-calendar-month me-1"></i> Este Mes
</button>
</div>
<div class="col-md-3 mb-2">
<button class="btn btn-outline-primary w-100" onclick="reporteAnio()">
<i class="bi bi-calendar3 me-1"></i> Este Año
</button>
</div>
</div>
</div>
@section Scripts {
<script>
function setFechas(inicio, fin) {
document.getElementById('fechaInicio').value = inicio;
document.getElementById('fechaFin').value = fin;
}
function reporteHoy() {
const hoy = new Date().toISOString().split('T')[0];
setFechas(hoy, hoy);
}
function reporteSemana() {
const hoy = new Date();
const inicioSemana = new Date(hoy);
inicioSemana.setDate(hoy.getDate() - hoy.getDay());
const finSemana = new Date(inicioSemana);
finSemana.setDate(inicioSemana.getDate() + 6);
setFechas(inicioSemana.toISOString().split('T')[0], finSemana.toISOString().split('T')[0]);
}
function reporteMes() {
const hoy = new Date();
const inicio = new Date(hoy.getFullYear(), hoy.getMonth(), 1);
const fin = new Date(hoy.getFullYear(), hoy.getMonth() + 1, 0);
setFechas(inicio.toISOString().split('T')[0], fin.toISOString().split('T')[0]);
}
function reporteAnio() {
const hoy = new Date();
const inicio = new Date(hoy.getFullYear(), 0, 1);
const fin = new Date(hoy.getFullYear(), 11, 31);
setFechas(inicio.toISOString().split('T')[0], fin.toISOString().split('T')[0]);
}
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,64 @@
@model Rs_system.Models.ContabilidadRegistro
@{
ViewData["Title"] = "Nuevo Registro Contable";
}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card card-custom">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="card-title mb-0">Nuevo Movimiento</h5>
</div>
<div class="card-body">
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="GrupoTrabajoId" class="form-label">Grupo de Trabajo</label>
<select asp-for="GrupoTrabajoId" class="form-select" asp-items="ViewBag.Grupos">
<option value="">Seleccione...</option>
</select>
<span asp-validation-for="GrupoTrabajoId" class="text-danger"></span>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="Tipo" class="form-label">Tipo de Movimiento</label>
<select asp-for="Tipo" class="form-select" asp-items="Html.GetEnumSelectList<Rs_system.Models.TipoMovimientoContable>()"></select>
<span asp-validation-for="Tipo" class="text-danger"></span>
</div>
<div class="col-md-6 mb-3">
<label asp-for="Fecha" class="form-label">Fecha</label>
<input asp-for="Fecha" type="date" class="form-control" value="@DateTime.Now.ToString("yyyy-MM-dd")" />
<span asp-validation-for="Fecha" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="Monto" class="form-label">Monto</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="Monto" class="form-control" type="number" step="0.01" />
</div>
<span asp-validation-for="Monto" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="3"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,140 @@
@using Rs_system.Models
@model List<ReporteMensualContable>
@{
ViewData["Title"] = "Contabilidad Mensual";
var grupoId = ViewBag.GrupoId as long?;
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Contabilidad Mensual</h4>
<p class="text-muted mb-0">Gestión de reportes financieros por grupo</p>
</div>
</div>
<div class="card card-custom mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-8">
<label class="form-label">Grupo de Trabajo</label>
<select name="grupoId" class="form-select" asp-items="ViewBag.Grupos" onchange="this.form.submit()">
<option value="">Seleccione un grupo...</option>
</select>
</div>
<div class="col-md-4">
<button type="button" class="btn btn-primary-custom w-100" data-bs-toggle="modal" data-bs-target="#abrirMesModal" @(grupoId.HasValue ? "" : "disabled")>
<i class="bi bi-plus-lg me-1"></i> Abrir Nuevo Mes
</button>
</div>
</form>
</div>
</div>
@if (grupoId.HasValue)
{
@if (Model != null && Model.Any())
{
<div class="card card-custom">
<div class="table-responsive">
<table class="table table-custom">
<thead>
<tr>
<th>Mes / Año</th>
<th>Saldo Inicial</th>
<th>Estado</th>
<th>Fecha Creación</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.NombreMes @item.Anio</td>
<td>@item.SaldoInicial.ToString("C")</td>
<td>
@if (item.Cerrado)
{
<span class="badge bg-secondary">Cerrado</span>
}
else
{
<span class="badge bg-success">Abierto</span>
}
</td>
<td>@item.FechaCreacion.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</td>
<td class="text-end">
<a asp-action="RegistroMensual" asp-route-id="@item.Id" class="btn btn-sm btn-outline-success">
<i class="bi bi-table me-1"></i> @(item.Cerrado ? "Ver Detalles" : "Gestionar Registros")
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="alert alert-info py-4 text-center">
<i class="bi bi-info-circle fs-3 d-block mb-3"></i>
<h5>No hay reportes mensuales para este grupo</h5>
<p class="mb-0">Haga clic en "Abrir Nuevo Mes" para comenzar el registro contable de este mes.</p>
</div>
}
}
else
{
<div class="alert alert-light border text-center py-5">
<i class="bi bi-people fs-2 d-block mb-3 text-muted"></i>
<h5 class="text-muted">Seleccione un grupo para ver sus reportes mensuales</h5>
</div>
}
<!-- Modal Abrir Mes -->
<div class="modal fade" id="abrirMesModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="AbrirMes" method="post">
<input type="hidden" name="grupoId" value="@grupoId" />
<div class="modal-header">
<h5 class="modal-title">Abrir Nuevo Mes Contable</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Mes</label>
<select name="mes" class="form-select">
@for (int i = 1; i <= 12; i++)
{
bool isCurrent = i == DateTime.Now.Month;
<!option value="@i" @(isCurrent ? "selected" : "")>@(new DateTime(2000, i, 1).ToString("MMMM", new System.Globalization.CultureInfo("es-ES")))</!option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Año</label>
<select name="anio" class="form-select">
@for (int i = DateTime.Now.Year - 1; i <= DateTime.Now.Year + 1; i++)
{
bool isCurrent = i == DateTime.Now.Year;
<!option value="@i" @(isCurrent ? "selected" : "")>@i</!option>
}
</select>
</div>
</div>
<div class="alert alert-info mt-3 mb-0 py-2 small">
<i class="bi bi-lightbulb me-2"></i>El saldo inicial se calculará automáticamente a partir del cierre del mes anterior si existe.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary-custom">Abrir Mes</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,261 @@
@model Rs_system.Models.ReporteMensualContable
@{
ViewData["Title"] = $"Reporte {Model.NombreMes} {Model.Anio}";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">@Model.GrupoTrabajo.Nombre - @Model.NombreMes @Model.Anio</h4>
<div class="d-flex align-items-center gap-2">
@if (Model.Cerrado)
{
<span class="badge bg-secondary"><i class="bi bi-lock-fill me-1"></i> REPORTE CERRADO</span>
}
else
{
<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i> REPORTE ABIERTO</span>
}
<span class="text-muted small">Creado el @Model.FechaCreacion.ToLocalTime().ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="d-flex gap-2">
<a asp-action="Index" asp-route-grupoId="@Model.GrupoTrabajoId" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver a Planilla
</a>
@if (!Model.Cerrado)
{
<form asp-action="CerrarMes" asp-route-id="@Model.Id" method="post" onsubmit="return confirm('¿Está seguro de cerrar este mes? Ya no se podrán realizar más cambios.')">
<button type="submit" class="btn btn-danger">
<i class="bi bi-lock me-1"></i> Cerrar Mes
</button>
</form>
}
</div>
</div>
<div class="row mb-4">
<div class="col-md-3">
<div class="card card-custom p-3 text-center bg-light">
<span class="text-muted small">Saldo Inicial</span>
<h4 class="mb-0">@Model.SaldoInicial.ToString("N2")</h4>
</div>
</div>
<div class="col-md-3">
<div class="card card-custom p-3 text-center bg-light">
<span class="text-muted small">Ingresos (+)</span>
<h4 class="mb-0 text-success" id="totalIngresos">0.00</h4>
</div>
</div>
<div class="col-md-3">
<div class="card card-custom p-3 text-center bg-light">
<span class="text-muted small">Egresos (-)</span>
<h4 class="mb-0 text-danger" id="totalEgresos">0.00</h4>
</div>
</div>
<div class="col-md-3">
<div class="card card-custom p-3 text-center border-primary shadow-sm">
<span class="text-muted small">Saldo Final (=)</span>
<h4 class="mb-0 text-primary" id="saldoFinal">0.00</h4>
</div>
</div>
</div>
<div class="card card-custom">
<div class="card-header d-flex justify-content-between align-items-center bg-white border-bottom py-3">
<h6 class="mb-0"><i class="bi bi-grid-3x3 me-2"></i>Registros de Movimientos</h6>
@if (!Model.Cerrado)
{
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addRow()">
<i class="bi bi-plus-lg me-1"></i> Agregar Fila
</button>
<button type="button" class="btn btn-sm btn-primary-custom" onclick="saveAll()">
<i class="bi bi-cloud-arrow-up me-1"></i> Guardar Cambios
</button>
</div>
}
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="excelTable">
<thead class="bg-light">
<tr>
<th style="width: 50px;" class="text-center">#</th>
<th style="width: 180px;">Fecha</th>
<th style="width: 200px;">Tipo</th>
<th>Descripción</th>
<th style="width: 200px;" class="text-end">Monto</th>
@if(!Model.Cerrado) { <th style="width: 60px;"></th> }
</tr>
</thead>
<tbody id="tableBody">
@if (Model.Registros != null && Model.Registros.Any())
{
int count = 1;
foreach (var item in Model.Registros.OrderBy(r => r.Fecha))
{
<tr data-id="@item.Id">
<td class="text-center text-muted">@count</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>
<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="0" @(item.Tipo == TipoMovimientoContable.Egreso ? "selected" : "")>Egreso (-)</!option>
</select>
</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="number" step="0.01" class="form-control form-control-sm text-end row-monto" value="@item.Monto.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)" @(Model.Cerrado ? "disabled" : "") onchange="updateTotals()" /></td>
@if(!Model.Cerrado) {
<td class="text-center">
<button class="btn btn-sm text-danger" onclick="deleteRow(this)"><i class="bi bi-trash"></i></button>
</td>
}
</tr>
count++;
}
}
</tbody>
@if(!Model.Cerrado) {
<tfoot>
<tr>
<td colspan="6" class="p-0">
<button type="button" class="btn btn-link w-100 text-decoration-none py-3 text-muted" onclick="addRow()">
<i class="bi bi-plus-circle me-1"></i> Haga clic aquí para agregar una nueva fila
</button>
</td>
</tr>
</tfoot>
}
</table>
</div>
</div>
</div>
@section Scripts {
<script>
const reporteId = @Model.Id;
const saldoInicial = @Model.SaldoInicial.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
const isCerrado = @Model.Cerrado.ToString().ToLower();
function addRow() {
if (isCerrado) return;
const tbody = document.getElementById('tableBody');
const rowCount = tbody.children.length + 1;
const today = new Date().toISOString().split('T')[0];
const tr = document.createElement('tr');
tr.setAttribute('data-id', '0');
tr.innerHTML = `
<td class="text-center text-muted">${rowCount}</td>
<td><input type="date" class="form-control form-control-sm row-fecha" value="${today}" /></td>
<td>
<select class="form-select form-select-sm row-tipo" onchange="updateTotals()">
<option value="1">Ingreso (+)</option>
<option value="0">Egreso (-)</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm row-descripcion" value="" placeholder="Motivo del movimiento..." /></td>
<td><input type="number" step="0.01" class="form-control form-control-sm text-end row-monto" value="0.00" onchange="updateTotals()" /></td>
<td class="text-center">
<button class="btn btn-sm text-danger" onclick="deleteRow(this)"><i class="bi bi-trash"></i></button>
</td>
`;
tbody.appendChild(tr);
updateTotals();
}
function deleteRow(btn) {
if (isCerrado) return;
const row = btn.closest('tr');
row.remove();
// Renumber rows
const rows = document.querySelectorAll('#tableBody tr');
rows.forEach((r, idx) => {
r.cells[0].innerText = idx + 1;
});
updateTotals();
}
function updateTotals() {
let ingresos = 0;
let egresos = 0;
const rows = document.querySelectorAll('#tableBody tr');
rows.forEach(row => {
const tipo = parseInt(row.querySelector('.row-tipo').value);
const monto = parseFloat(row.querySelector('.row-monto').value) || 0;
if (tipo === 1) ingresos += monto;
else egresos += monto;
});
document.getElementById('totalIngresos').innerText = ingresos.toFixed(2);
document.getElementById('totalEgresos').innerText = egresos.toFixed(2);
document.getElementById('saldoFinal').innerText = (saldoInicial + ingresos - egresos).toFixed(2);
}
async function saveAll() {
if (isCerrado) return;
const registros = [];
const rows = document.querySelectorAll('#tableBody tr');
rows.forEach(row => {
registros.push({
id: parseInt(row.getAttribute('data-id')),
fecha: row.querySelector('.row-fecha').value,
tipo: parseInt(row.querySelector('.row-tipo').value),
monto: parseFloat(row.querySelector('.row-monto').value) || 0,
descripcion: row.querySelector('.row-descripcion').value
});
});
const btn = event.target.closest('button');
const originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Guardando...';
btn.disabled = true;
try {
const response = await fetch('/Contabilidad/GuardarBulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reporteId: reporteId, registros: registros })
});
const result = await response.json();
if (result.success) {
Swal.fire({
icon: 'success',
title: '¡Guardado!',
text: 'Los registros se han guardado exitosamente.',
timer: 1500,
showConfirmButton: false
});
// Refresh view results would be better, but for now we trust the saldo returned
document.getElementById('saldoFinal').innerText = result.saldo.toFixed(2);
window.location.reload(); // Reload to refresh IDs and state
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: result.message || 'Error al guardar los registros.'
});
}
} catch (error) {
console.error(error);
Swal.fire({
icon: 'error',
title: 'Error de Red',
text: 'No se pudo conectar con el servidor.'
});
} finally {
btn.innerHTML = originalContent;
btn.disabled = false;
}
}
// Initialize totals on load
document.addEventListener('DOMContentLoaded', updateTotals);
</script>
}

View File

@@ -0,0 +1,164 @@
@model Rs_system.Models.ReporteMensualGeneral
@{
ViewData["Title"] = $"Consolidado - {Model.NombreMes} {Model.Anio}";
var ingresosPorCat = ViewBag.ConsolidadoIngresos as Dictionary<string, decimal> ?? new Dictionary<string, decimal>();
var egresosPorCat = ViewBag.ConsolidadoEgresos as Dictionary<string, decimal> ?? new Dictionary<string, decimal>();
var totalIngresosMes = ingresosPorCat.Values.Sum();
var totalEgresosMes = egresosPorCat.Values.Sum();
var saldoFinal = Model.SaldoInicial + totalIngresosMes - totalEgresosMes;
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">Consolidado: @Model.NombreMes @Model.Anio</h1>
<div>
<a asp-action="RegistroMensual" asp-route-id="@Model.Id" class="btn btn-primary btn-sm">
<i class="fas fa-edit"></i> Ver Detalle Registros
</a>
<a asp-action="Index" class="btn btn-secondary btn-sm ml-2">
<i class="fas fa-arrow-left"></i> Volver
</a>
<button onclick="window.print()" class="btn btn-info btn-sm ml-2">
<i class="fas fa-print"></i> Imprimir
</button>
</div>
</div>
<!-- Resumen Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Saldo Inicial</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@Model.SaldoInicial.ToString("C")</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Total Ingresos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@totalIngresosMes.ToString("C")</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-danger shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Total Egresos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@totalEgresosMes.ToString("C")</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Saldo Final</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@saldoFinal.ToString("C")</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Ingresos Chart/Table -->
<div class="col-lg-6 mb-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-success">Desglose de Ingresos</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="thead-light">
<tr>
<th>Categoría</th>
<th class="text-right">Monto</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
@foreach (var item in ingresosPorCat.OrderByDescending(x => x.Value))
{
var porcentaje = totalIngresosMes > 0 ? (item.Value / totalIngresosMes) * 100 : 0;
<tr>
<td>@item.Key</td>
<td class="text-right">@item.Value.ToString("C")</td>
<td class="text-right">@porcentaje.ToString("F1")%</td>
</tr>
}
</tbody>
<tfoot>
<tr class="font-weight-bold">
<td>Total</td>
<td class="text-right">@totalIngresosMes.ToString("C")</td>
<td class="text-right">100%</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- Egresos Chart/Table -->
<div class="col-lg-6 mb-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-danger">Desglose de Egresos</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="thead-light">
<tr>
<th>Categoría</th>
<th class="text-right">Monto</th>
<th class="text-right">%</th>
</tr>
</thead>
<tbody>
@foreach (var item in egresosPorCat.OrderByDescending(x => x.Value))
{
var porcentaje = totalEgresosMes > 0 ? (item.Value / totalEgresosMes) * 100 : 0;
<tr>
<td>@item.Key</td>
<td class="text-right">@item.Value.ToString("C")</td>
<td class="text-right">@porcentaje.ToString("F1")%</td>
</tr>
}
</tbody>
<tfoot>
<tr class="font-weight-bold">
<td>Total</td>
<td class="text-right">@totalEgresosMes.ToString("C")</td>
<td class="text-right">100%</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,222 @@
@{
ViewData["Title"] = "Gestión de Categorías";
var categoriasIngreso = ViewBag.CategoriasIngreso as List<Rs_system.Models.CategoriaIngreso>;
var categoriasEgreso = ViewBag.CategoriasEgreso as List<Rs_system.Models.CategoriaEgreso>;
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">Gestión de Categorías</h1>
<a asp-action="Index" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left"></i> Volver
</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success">@TempData["Success"]</div>
}
<div class="row">
<!-- Categorías de Ingreso -->
<div class="col-lg-6 mb-4">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-success">Categorías de Ingreso</h6>
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#modalCrearIngreso">
<i class="fas fa-plus"></i> Nueva
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th style="width: 100px;">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in categoriasIngreso)
{
<tr>
<td>@item.Nombre</td>
<td>@item.Descripcion</td>
<td class="text-center">
<button class="btn btn-primary btn-sm"
onclick="editarIngreso(@item.Id, '@item.Nombre', '@item.Descripcion')">
<i class="fas fa-edit"></i>
</button>
<form asp-action="EliminarCategoriaIngreso" asp-route-id="@item.Id" method="post" class="d-inline" onsubmit="return confirm('¿Eliminar esta categoría?');">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Categorías de Egreso -->
<div class="col-lg-6 mb-4">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-danger">Categorías de Egreso</h6>
<button class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#modalCrearEgreso">
<i class="fas fa-plus"></i> Nueva
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th style="width: 100px;">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in categoriasEgreso)
{
<tr>
<td>@item.Nombre</td>
<td>@item.Descripcion</td>
<td class="text-center">
<button class="btn btn-primary btn-sm"
onclick="editarEgreso(@item.Id, '@item.Nombre', '@item.Descripcion')">
<i class="fas fa-edit"></i>
</button>
<form asp-action="EliminarCategoriaEgreso" asp-route-id="@item.Id" method="post" class="d-inline" onsubmit="return confirm('¿Eliminar esta categoría?');">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Crear Ingreso -->
<div class="modal fade" id="modalCrearIngreso" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Nueva Categoría de Ingreso</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form asp-action="CrearCategoriaIngreso" method="post">
<div class="modal-body">
<div class="form-group mb-3">
<label>Nombre</label>
<input name="Nombre" class="form-control" required />
</div>
<div class="form-group mb-3">
<label>Descripción</label>
<input name="Descripcion" class="form-control" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal Crear Egreso -->
<div class="modal fade" id="modalCrearEgreso" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Nueva Categoría de Egreso</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form asp-action="CrearCategoriaEgreso" method="post">
<div class="modal-body">
<div class="form-group mb-3">
<label>Nombre</label>
<input name="Nombre" class="form-control" required />
</div>
<div class="form-group mb-3">
<label>Descripción</label>
<input name="Descripcion" class="form-control" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal Editar (Compartido y poblado por JS) -->
<div class="modal fade" id="modalEditar" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tituloEditar">Editar Categoría</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="formEditar" method="post">
<div class="modal-body">
<input type="hidden" name="Id" id="editId" />
<div class="form-group mb-3">
<label>Nombre</label>
<input name="Nombre" id="editNombre" class="form-control" required />
</div>
<div class="form-group mb-3">
<label>Descripción</label>
<input name="Descripcion" id="editDescripcion" class="form-control" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Actualizar</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
function editarIngreso(id, nombre, descripcion) {
$('#tituloEditar').text('Editar Categoría de Ingreso');
$('#formEditar').attr('action', '@Url.Action("EditarCategoriaIngreso")');
llenarModal(id, nombre, descripcion);
}
function editarEgreso(id, nombre, descripcion) {
$('#tituloEditar').text('Editar Categoría de Egreso');
$('#formEditar').attr('action', '@Url.Action("EditarCategoriaEgreso")');
llenarModal(id, nombre, descripcion);
}
function llenarModal(id, nombre, descripcion) {
$('#editId').val(id);
$('#editNombre').val(nombre);
$('#editDescripcion').val(descripcion);
var modalEl = document.getElementById('modalEditar');
var modal = new bootstrap.Modal(modalEl);
modal.show();
}
</script>
}

View File

@@ -0,0 +1,124 @@
@model List<Rs_system.Models.ReporteMensualGeneral>
@{
ViewData["Title"] = "Contabilidad General";
var anioActual = ViewBag.Anio;
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">Contabilidad General</h1>
<form asp-action="Index" method="get" class="form-inline">
<label class="me-2">Año:</label>
<select name="anio" class="form-control me-2" asp-items="ViewBag.Anios" onchange="this.form.submit()">
<option value="@anioActual" selected>@anioActual</option>
</select>
</form>
</div>
@if (TempData["Error"] != null)
{
<div class="alert alert-danger">@TempData["Error"]</div>
}
<div class="row mb-4">
<div class="col-md-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">Reportes Mensuales @anioActual</h6>
<div>
<a asp-action="GestionCategorias" class="btn btn-info btn-sm">
<i class="fas fa-tags"></i> Gestionar Categorías
</a>
<!-- Button trigger modal -->
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#abrirMesModal">
<i class="fas fa-plus"></i> Abrir Nuevo Mes
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>Mes</th>
<th>Saldo Inicial</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.NombreMes</td>
<td>@item.SaldoInicial.ToString("C")</td>
<td>
@if (item.Cerrado)
{
<span class="badge bg-secondary">Cerrado</span>
}
else
{
<span class="badge bg-success">Abierto</span>
}
</td>
<td>
<a asp-action="RegistroMensual" asp-route-id="@item.Id" class="btn btn-primary btn-sm">
<i class="fas fa-edit"></i> Gestionar
</a>
<a asp-action="Consolidado" asp-route-id="@item.Id" class="btn btn-info btn-sm">
<i class="fas fa-chart-pie"></i> Ver Consolidado
</a>
</td>
</tr>
}
@if (!Model.Any())
{
<tr>
<td colspan="4" class="text-center">No hay reportes para este año.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Abrir Mes -->
<div class="modal fade" id="abrirMesModal" tabindex="-1" role="dialog" aria-labelledby="abrirMesModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="abrirMesModalLabel">Abrir Nuevo Mes Contable</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form asp-action="AbrirMes" method="post">
<div class="modal-body">
<input type="hidden" name="anio" value="@anioActual" />
<div class="form-group mb-3">
<label>Mes</label>
<select name="mes" class="form-select" required>
@for (int i = 1; i <= 12; i++)
{
var nMes = new DateTime(2000, i, 1).ToString("MMMM", new System.Globalization.CultureInfo("es-ES"));
<option value="@i">@nMes</option>
}
</select>
</div>
<p class="text-muted small">
Nota: Al abrir el mes, se calculará automáticamente el saldo inicial basado en el cierre del mes anterior.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Crear Reporte</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,479 @@
@model Rs_system.Models.ReporteMensualGeneral
@{
ViewData["Title"] = $"Registro - {Model.NombreMes} {Model.Anio}";
var categoriasIngreso = ViewBag.CategoriasIngreso as List<Rs_system.Models.CategoriaIngreso>;
var categoriasEgreso = ViewBag.CategoriasEgreso as List<Rs_system.Models.CategoriaEgreso>;
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h3 text-gray-800">Registro Mensual: @Model.NombreMes @Model.Anio</h1>
<h5 class="text-secondary">
Saldo Actual: <span class="font-weight-bold @(ViewBag.SaldoActual >= 0 ? "text-success" : "text-danger")">@ViewBag.SaldoActual?.ToString("C")</span>
</h5>
</div>
<div>
<a asp-action="Index" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left"></i> Volver
</a>
@if (!Model.Cerrado)
{
<button id="btnGuardar" class="btn btn-primary btn-sm ml-2">
<i class="fas fa-save"></i> Guardar Cambios
</button>
<form asp-action="CerrarMes" asp-route-id="@Model.Id" method="post" class="d-inline ml-2" onsubmit="return confirm('¿Está seguro de cerrar este mes? No podrá realizar más cambios.');">
<button type="submit" class="btn btn-warning btn-sm">
<i class="fas fa-lock"></i> Cerrar Mes
</button>
</form>
}
else
{
<span class="badge badge-secondary ml-2 p-2">Mes Cerrado</span>
}
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="card shadow mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-sm mb-0" id="tablaRegistros">
<thead class="thead-light">
<tr>
<th style="width: 50px;">#</th>
<th style="width: 120px;">Fecha</th>
<th style="width: 120px;">Tipo</th>
<th style="width: 200px;">Categoría</th>
<th>Descripción</th>
<th style="width: 120px;">Comprobante</th>
<th style="width: 50px;"></th>
<th style="width: 150px;">Monto</th>
@if (!Model.Cerrado)
{
<th style="width: 50px;"></th>
}
</tr>
</thead>
<tbody id="tbodyRegistros">
<!-- Rows rendered by JS -->
</tbody>
@if (!Model.Cerrado)
{
<tfoot>
<tr>
<td colspan="9" class="text-center p-2">
<button class="btn btn-outline-primary btn-sm" onclick="agregarFila()">
<i class="fas fa-plus"></i> Agregar Fila
</button>
</td>
</tr>
</tfoot>
}
</table>
</div>
</div>
</div>
</div>
<!-- Modal Adjuntos -->
<div class="modal fade" id="modalAdjuntos" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Adjuntos del Movimiento</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="adjuntoMovimientoId" />
@if (!Model.Cerrado)
{
<div class="mb-3">
<label class="form-label">Subir Archivos (Imágenes o PDF)</label>
<!-- Usar 'form' envolvente para resetear fácil -->
<form id="formSubirAdjuntos">
<div class="input-group">
<input type="file" class="form-control" id="inputArchivos" multiple accept="image/*,.pdf">
<button class="btn btn-primary" type="button" onclick="subirArchivos()">
<i class="fas fa-upload"></i> Subir
</button>
</div>
</form>
</div>
}
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead>
<tr>
<th>Nombre</th>
<th>Fecha</th>
<th style="width: 150px;">Acciones</th>
</tr>
</thead>
<tbody id="tbodyAdjuntos">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
const esCerrado = @Model.Cerrado.ToString().ToLower();
const reporteId = @Model.Id;
// Data from server
let rawRegistros = @Html.Raw(Json.Serialize(Model.Movimientos.Select(m => new {
m.Id,
m.Tipo, // 1 = Ingreso, 2 = Egreso
m.CategoriaIngresoId,
m.CategoriaEgresoId,
m.Monto,
Fecha = m.Fecha.ToString("yyyy-MM-dd"),
m.Descripcion,
m.NumeroComprobante
})));
// Normalizar datos a mi estructura interna para evitar problemas de mayúsculas/minúsculas
let registros = rawRegistros.map(r => ({
id: r.Id || r.id || 0,
tipo: (r.Tipo !== undefined ? r.Tipo : r.tipo),
categoriaIngresoId: r.CategoriaIngresoId || r.categoriaIngresoId,
categoriaEgresoId: r.CategoriaEgresoId || r.categoriaEgresoId,
monto: r.Monto !== undefined ? r.Monto : r.monto,
fecha: r.Fecha || r.fecha,
descripcion: r.Descripcion || r.descripcion,
numeroComprobante: r.NumeroComprobante || r.numeroComprobante
}));
// Categories helper
const rawCatsIngreso = @Html.Raw(Json.Serialize(categoriasIngreso.Select(c => new { c.Id, c.Nombre })));
const rawCatsEgreso = @Html.Raw(Json.Serialize(categoriasEgreso.Select(c => new { c.Id, c.Nombre })));
const catsIngreso = rawCatsIngreso.map(c => ({ id: c.Id || c.id, nombre: c.Nombre || c.nombre }));
const catsEgreso = rawCatsEgreso.map(c => ({ id: c.Id || c.id, nombre: c.Nombre || c.nombre }));
function renderTable() {
const tbody = document.getElementById('tbodyRegistros');
tbody.innerHTML = '';
registros.forEach((reg, index) => {
const tr = document.createElement('tr');
tr.dataset.index = index;
// Logic for Attachment Button
const hasId = reg.id && reg.id > 0;
const btnAdjuntoClass = hasId ? "btn-info" : "btn-secondary";
const btnAdjuntoTitle = hasId ? "Gestionar Adjuntos" : "Guarde primero para adjuntar";
// IMPORTANTE: onclick debe ser cadena vacía si no hay ID, pero el disabled lo controla.
const btnAdjuntoAction = hasId ? `abrirAdjuntos(${reg.id})` : "";
const btnAdjuntoIcon = '<i class="fas fa-paperclip"></i>';
const disabledAttr = hasId ? "" : "disabled";
if (esCerrado) {
// Read-only view
const tipoTexto = reg.tipo === 1 ? '<span class="text-success">Ingreso</span>' : '<span class="text-danger">Egreso</span>';
const catNombre = reg.tipo === 0
? (catsIngreso.find(c => c.id === reg.categoriaIngresoId)?.nombre || '-')
: (catsEgreso.find(c => c.id === reg.categoriaEgresoId)?.nombre || '-');
tr.innerHTML = `
<td class="text-center">${index + 1}</td>
<td>${reg.fecha}</td>
<td>${tipoTexto}</td>
<td>${catNombre}</td>
<td>${reg.descripcion || ''}</td>
<td>${reg.numeroComprobante || ''}</td>
<td class="text-center">
<button type="button" class="btn btn-sm ${btnAdjuntoClass}" onclick="${btnAdjuntoAction}" title="${btnAdjuntoTitle}" ${disabledAttr}>
${btnAdjuntoIcon}
</button>
</td>
<td class="text-end">${parseFloat(reg.monto).toFixed(2)}</td>
<td></td>
`;
} else {
// Editable view
tr.innerHTML = `
<td class="text-center align-middle">${index + 1}</td>
<td><input type="date" class="form-control form-control-sm" value="${reg.fecha}" onchange="updateReg(${index}, 'fecha', this.value)"></td>
<td>
<select class="form-control form-control-sm" onchange="cambiarTipo(${index}, this.value)">
<option value="1" ${reg.tipo === 1 ? 'selected' : ''}>Ingreso</option>
<option value="2" ${reg.tipo === 2 ? 'selected' : ''}>Egreso</option>
</select>
</td>
<td>
<select class="form-control form-control-sm" onchange="updateReg(${index}, 'categoria', this.value)">
<option value="">Seleccione...</option>
${renderCatsOptions(reg.tipo, reg.tipo === 1 ? reg.categoriaIngresoId : reg.categoriaEgresoId)}
</select>
</td>
<td><input type="text" class="form-control form-control-sm" value="${reg.descripcion || ''}" onchange="updateReg(${index}, 'descripcion', this.value)"></td>
<td><input type="text" class="form-control form-control-sm" value="${reg.numeroComprobante || ''}" onchange="updateReg(${index}, 'numeroComprobante', this.value)"></td>
<td class="text-center">
<button type="button" class="btn btn-sm ${btnAdjuntoClass}" onclick="${btnAdjuntoAction}" title="${btnAdjuntoTitle}" ${disabledAttr}>
${btnAdjuntoIcon}
</button>
</td>
<td><input type="number" step="0.01" class="form-control form-control-sm text-end" value="${reg.monto}" onchange="updateReg(${index}, 'monto', this.value)"></td>
<td class="text-center align-middle">
<button class="btn btn-danger btn-sm py-0" onclick="eliminarFila(${index})" title="Eliminar"><i class="fas fa-times"></i></button>
</td>
`;
}
tbody.appendChild(tr);
});
}
function renderCatsOptions(tipo, selectedId) {
const list = tipo == 1 ? catsIngreso : catsEgreso; // loose equality for string/number match
return list.map(c => `<option value="${c.id}" ${c.id == selectedId ? 'selected' : ''}>${c.nombre}</option>`).join('');
}
function agregarFila() {
const today = new Date().toISOString().split('T')[0];
registros.push({
id: 0,
tipo: 1,
categoriaIngresoId: null,
categoriaEgresoId: null,
monto: 0,
fecha: today,
descripcion: '',
numeroComprobante: ''
});
renderTable();
}
function eliminarFila(index) {
registros.splice(index, 1);
renderTable();
}
function cambiarTipo(index, nuevoTipo) {
registros[index].tipo = parseInt(nuevoTipo);
registros[index].categoriaIngresoId = null;
registros[index].categoriaEgresoId = null;
renderTable();
}
function updateReg(index, field, value) {
const reg = registros[index];
if (field === 'categoria') {
const val = value ? parseInt(value) : null;
if (reg.tipo === 1) reg.categoriaIngresoId = val;
else reg.categoriaEgresoId = val;
} else if (field === 'monto') {
reg.monto = parseFloat(value) || 0;
} else {
reg[field] = value;
}
}
// IMPORTANT: Payload mapping for Server (Server expects PascalCase usually, but standard API accepts camelCase too)
document.getElementById('btnGuardar')?.addEventListener('click', async () => {
// Validations
for (let i = 0; i < registros.length; i++) {
const r = registros[i];
if (r.monto <= 0) {
alert(`Fila ${i+1}: El monto debe ser mayor a 0.`);
return;
}
if (r.tipo === 1 && !r.categoriaIngresoId) {
alert(`Fila ${i+1}: Debe seleccionar una categoría de ingreso.`);
return;
}
if (r.tipo === 2 && !r.categoriaEgresoId) {
alert(`Fila ${i+1}: Debe seleccionar una categoría de egreso.`);
return;
}
}
const btn = document.getElementById('btnGuardar');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Guardando...';
btn.disabled = true;
// Map back to PascalCase structure just in case server strictly needs it matches DTO
const payloadMovimientos = registros.map(r => ({
Id: r.id,
Tipo: r.tipo,
CategoriaIngresoId: r.categoriaIngresoId,
CategoriaEgresoId: r.categoriaEgresoId,
Monto: r.monto,
Fecha: r.fecha,
Descripcion: r.descripcion,
NumeroComprobante: r.numeroComprobante
}));
try {
const response = await fetch('@Url.Action("GuardarBulk")', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
ReporteId: reporteId,
Movimientos: payloadMovimientos
})
});
// ... rest of logic
const result = await response.json();
if (result.success) {
// Show toast or alert
alert('Guardado exitosamente');
location.reload();
} else {
alert('Error: ' + result.message);
}
} catch (error) {
console.error(error);
alert('Ocurrió un error al guardar.');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
});
// ================= ADJUNTOS LOGIC =================
let currentMovId = 0;
var myModalAdjuntos;
function abrirAdjuntos(id) {
currentMovId = id;
document.getElementById('adjuntoMovimientoId').value = id;
// Clear previous entries
document.getElementById('tbodyAdjuntos').innerHTML = '<tr><td colspan="3" class="text-center">Cargando...</td></tr>';
if (!myModalAdjuntos) {
var el = document.getElementById('modalAdjuntos');
myModalAdjuntos = new bootstrap.Modal(el);
}
myModalAdjuntos.show();
cargarAdjuntos(id);
}
async function cargarAdjuntos(id) {
try {
const url = '@Url.Action("ObtenerAdjuntos")?movimientoId=' + id;
const res = await fetch(url);
const data = await res.json();
const tbody = document.getElementById('tbodyAdjuntos');
tbody.innerHTML = '';
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center">Sin adjuntos</td></tr>';
return;
}
data.forEach(adj => {
const tr = document.createElement('tr');
// Si es imagen, mostrar preview pequeño en popover o algo, pero por ahora link simple
let link = `<a href="${adj.url}" target="_blank" class="text-decoration-none"><i class="fas fa-external-link-alt"></i> ${adj.nombre}</a>`;
let deleteBtn = '';
if (!esCerrado) {
deleteBtn = `<button class="btn btn-danger btn-sm" onclick="eliminarAdjunto(${adj.id})"><i class="fas fa-trash"></i></button>`;
}
tr.innerHTML = `
<td>${link}</td>
<td>${adj.fecha}</td>
<td class="text-center">
<a href="${adj.url}" download class="btn btn-secondary btn-sm"><i class="fas fa-download"></i></a>
${deleteBtn}
</td>
`;
tbody.appendChild(tr);
});
} catch (err) {
console.error(err);
document.getElementById('tbodyAdjuntos').innerHTML = '<tr><td colspan="3" class="text-danger">Error al cargar adjuntos</td></tr>';
}
}
async function subirArchivos() {
const input = document.getElementById('inputArchivos');
if (input.files.length === 0) {
alert("Seleccione al menos un archivo.");
return;
}
const formData = new FormData();
formData.append('movimientoId', currentMovId);
for (let i = 0; i < input.files.length; i++) {
formData.append('archivos', input.files[i]);
}
try {
const res = await fetch('@Url.Action("SubirAdjunto")', {
method: 'POST',
body: formData
});
const result = await res.json();
if (result.success) {
// Limpiar input y recargar
input.value = '';
cargarAdjuntos(currentMovId);
} else {
alert("Error: " + result.message);
}
} catch (err) {
console.error(err);
alert("Error al subir archivos.");
}
}
async function eliminarAdjunto(id) {
if (!confirm('¿Eliminar este adjunto?')) return;
try {
const res = await fetch('@Url.Action("EliminarAdjunto")?id=' + id, { method: 'POST' });
const result = await res.json();
if (result.success) {
cargarAdjuntos(currentMovId);
} else {
alert("No se pudo eliminar.");
}
} catch (err) {
console.error(err);
alert("Error al eliminar.");
}
}
// Initialize
if (registros.length === 0 && !esCerrado) {
agregarFila();
} else {
renderTable();
}
</script>
}

View File

@@ -0,0 +1,67 @@
@model Rs_system.Models.EstadoArticulo
@{
ViewData["Title"] = "Nuevo Estado";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nuevo Estado</h4>
<p class="text-muted mb-0">Registrar un nuevo estado para clasificación</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Bueno, Malo, Regular" autofocus />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="2" placeholder="Breve descripción del estado (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Color" class="form-label">Color de Etiqueta</label>
<select asp-for="Color" class="form-select">
<option value="secondary">Gris (Por defecto)</option>
<option value="success">Verde (Bueno/Completo)</option>
<option value="warning">Amarillo (Advertencia/Reparación)</option>
<option value="danger">Rojo (Malo/Crítico)</option>
<option value="info">Azul (Informativo)</option>
<option value="primary">Azul Oscuro (Primario)</option>
</select>
<div class="form-text">Color que se usará para mostrar el estado en las listas.</div>
<span asp-validation-for="Color" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" checked />
<label asp-for="Activo" class="form-check-label">Estado Activo</label>
</div>
<div class="form-text">Si está inactivo, no aparecerá en las selecciones.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,68 @@
@model Rs_system.Models.EstadoArticulo
@{
ViewData["Title"] = "Editar Estado";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Editar Estado</h4>
<p class="text-muted mb-0">Modificar información del estado</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Bueno, Malo, Regular" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="2" placeholder="Breve descripción del estado (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Color" class="form-label">Color de Etiqueta</label>
<select asp-for="Color" class="form-select">
<option value="secondary">Gris (Por defecto)</option>
<option value="success">Verde (Bueno/Completo)</option>
<option value="warning">Amarillo (Advertencia/Reparación)</option>
<option value="danger">Rojo (Malo/Crítico)</option>
<option value="info">Azul (Informativo)</option>
<option value="primary">Azul Oscuro (Primario)</option>
</select>
<div class="form-text">Color que se usará para mostrar el estado en las listas.</div>
<span asp-validation-for="Color" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="Activo" class="form-check-label">Estado Activo</label>
</div>
<div class="form-text">Si está inactivo, no aparecerá en las selecciones.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar Cambios
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,121 @@
@model IEnumerable<Rs_system.Models.EstadoArticulo>
@{
ViewData["Title"] = "Estados de Artículos";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Estados de Artículos</h4>
<p class="text-muted mb-0">Gestión de estados para bienes e inventario</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Estado
</a>
</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">Etiqueta Visual</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-palette fs-1 d-block mb-2"></i>
No hay estados registrados
</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td>
<strong>@item.Nombre</strong>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Descripcion))
{
<span>@item.Descripcion</span>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-center">
<span class="badge bg-@item.Color">@item.Nombre</span>
</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">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="confirmDelete(@item.Id)" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" asp-action="Delete" method="post" style="display: none;">
<input type="hidden" name="id" id="deleteId" />
</form>
@section Scripts {
<script>
function confirmDelete(id) {
Swal.fire({
title: '¿Eliminar estado?',
text: 'Esta acción moverá el estado a la papelera.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
});
}
// Show success/error messages
@if (TempData["SuccessMessage"] != null)
{
<text>
toastr.success('@TempData["SuccessMessage"]');
</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>
toastr.error('@TempData["ErrorMessage"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,298 @@
@model dynamic
@{
ViewData["Title"] = "Nuevo Movimiento";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Registrar Movimiento</h4>
<p class="text-muted mb-0">Traslados, bajas o cambios de estado</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Cancelar
</a>
</div>
<div class="row">
<!-- Step 1: Select Article -->
<div class="col-md-4 mb-4">
<div class="card-custom h-100">
<div class="card-header bg-transparent py-3">
<h5 class="card-title mb-0">1. Seleccionar Artículo</h5>
</div>
<div class="card-body">
<form method="get" asp-action="Create">
<div class="mb-3">
<label class="form-label">Buscar Artículo</label>
<select name="articuloId" class="form-select" asp-items="ViewBag.Articulos" onchange="this.form.submit()">
<option value="">-- Seleccione un artículo --</option>
</select>
<div class="form-text">Seleccione para cargar datos actuales.</div>
</div>
</form>
@if (ViewBag.ArticuloId != null)
{
<div class="alert alert-light border mt-3">
<h6 class="fw-bold mb-2">Estado Actual</h6>
<div class="mb-1"><span class="text-muted">Ubicación:</span> <strong>@ViewBag.UbicacionActual</strong></div>
<div class="mb-1"><span class="text-muted">Estado:</span> <strong>@ViewBag.EstadoActual</strong></div>
</div>
}
</div>
</div>
</div>
<!-- Step 2: Select Action -->
<div class="col-md-8">
@if (ViewBag.ArticuloId != null)
{
<div class="card-custom">
<div class="card-header bg-transparent py-3">
<ul class="nav nav-pills card-header-pills" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="pills-entrada-tab" data-bs-toggle="pill" data-bs-target="#pills-entrada" type="button" role="tab">
<i class="bi bi-plus-circle me-1"></i> Entrada
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pills-traslado-tab" data-bs-toggle="pill" data-bs-target="#pills-traslado" type="button" role="tab">
<i class="bi bi-arrow-left-right me-1"></i> Traslado
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pills-estado-tab" data-bs-toggle="pill" data-bs-target="#pills-estado" type="button" role="tab">
<i class="bi bi-cone-striped me-1"></i> Cambio de Estado
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pills-prestamo-tab" data-bs-toggle="pill" data-bs-target="#pills-prestamo" type="button" role="tab">
<i class="bi bi-box-arrow-right me-1"></i> Préstamo
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-danger" id="pills-baja-tab" data-bs-toggle="pill" data-bs-target="#pills-baja" type="button" role="tab">
<i class="bi bi-trash me-1"></i> Dar de Baja
</button>
</li>
</ul>
</div>
<div class="card-body pt-4">
<div class="tab-content" id="pills-tabContent">
<!-- ENTRADA FORM -->
<div class="tab-pane fade show active" id="pills-entrada" role="tabpanel">
<form asp-action="RegistrarEntrada" method="post">
<input type="hidden" name="articuloId" value="@ViewBag.ArticuloId" />
<h5 class="mb-3">Registrar Entrada de Inventario (Compra/Reingreso)</h5>
<div class="alert alert-success py-2">
<i class="bi bi-info-circle me-2"></i>
Esta acción aumentará el stock actual del artículo.
</div>
<div class="mb-3">
<label class="form-label fw-bold">Cantidad a Ingresar</label>
@if (ViewBag.TipoControl == "LOTE")
{
<input type="number" name="cantidad" class="form-control" min="1" value="1" required />
}
else
{
<input type="number" name="cantidad" class="form-control" value="1" readonly />
<div class="form-text">Para artículos unitarios, la cantidad es siempre 1.</div>
}
</div>
<div class="mb-3">
<label class="form-label">Observación / Referencia</label>
<textarea name="observacion" class="form-control" rows="2" placeholder="Ej: Compra según factura #123, Donación recibida..."></textarea>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-plus-lg me-1"></i> Confirmar Entrada
</button>
</form>
</div>
<!-- TRASLADO FORM -->
<div class="tab-pane fade" id="pills-traslado" role="tabpanel">
<form asp-action="RegistrarTraslado" method="post">
<input type="hidden" name="articuloId" value="@ViewBag.ArticuloId" />
<h5 class="mb-3">Registrar Traslado de Ubicación</h5>
@if (ViewBag.TipoControl == "LOTE")
{
<div class="alert alert-info py-2">
<div class="d-flex justify-content-between">
<span><strong>Control por Lote:</strong> Especifique la cantidad a mover.</span>
<span class="badge bg-light text-dark border">Global: @ViewBag.CantidadGlobal</span>
</div>
</div>
<div class="mb-3">
<label class="form-label text-primary fw-bold">Cantidad a Mover</label>
<input type="number" name="cantidad" class="form-control" min="1" max="@ViewBag.CantidadGlobal" value="1" required />
</div>
}
<div class="mb-3">
<label class="form-label">Nueva Ubicación</label>
<select name="nuevaUbicacionId" class="form-select" asp-items="ViewBag.Ubicaciones" required>
<option value="">-- Seleccionar Destino --</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Observación</label>
<textarea name="observacion" class="form-control" rows="2" placeholder="Motivo del traslado..."></textarea>
</div>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Confirmar Traslado
</button>
</form>
</div>
<!-- CAMBIO ESTADO FORM -->
<div class="tab-pane fade" id="pills-estado" role="tabpanel">
<form asp-action="RegistrarCambioEstado" method="post">
<input type="hidden" name="articuloId" value="@ViewBag.ArticuloId" />
<h5 class="mb-3">Registrar Cambio de Condición</h5>
<div class="alert alert-light border">
<i class="bi bi-info-circle me-1"></i>
@if(ViewBag.TipoControl == "LOTE") {
<span>El cambio de estado aplicará a <strong>todo el lote</strong> (@ViewBag.CantidadGlobal unidades).</span>
} else {
<span>El cambio de estado aplica a la unidad única.</span>
}
</div>
<div class="mb-3">
<label class="form-label">Nuevo Estado</label>
<select name="nuevoEstadoId" class="form-select" asp-items="ViewBag.Estados" required>
<option value="">-- Seleccionar Nuevo Estado --</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Observación</label>
<textarea name="observacion" class="form-control" rows="2" placeholder="Detalles del daño o reparación..."></textarea>
</div>
<button type="submit" class="btn btn-warning text-white">
<i class="bi bi-save me-1"></i> Confirmar Cambio
</button>
</form>
</div>
<!-- PRESTAMO FORM -->
<div class="tab-pane fade" id="pills-prestamo" role="tabpanel">
<form asp-action="RegistrarPrestamo" method="post">
<input type="hidden" name="articuloId" value="@ViewBag.ArticuloId" />
<h5 class="mb-3">Registrar Préstamo a Persona</h5>
@if (ViewBag.TipoControl == "LOTE")
{
<div class="alert alert-info py-2">
<div class="d-flex justify-content-between">
<span><strong>Control por Lote:</strong> Especifique la cantidad a prestar.</span>
<span class="badge bg-light text-dark border">Disponible: @ViewBag.CantidadGlobal</span>
</div>
</div>
<div class="mb-3">
<label class="form-label text-primary fw-bold">Cantidad a Prestar</label>
<input type="number" name="cantidad" class="form-control" min="1" max="@ViewBag.CantidadGlobal" value="1" required />
</div>
}
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Nombre de la Persona <span class="text-danger">*</span></label>
<input type="text" name="personaNombre" class="form-control" placeholder="Nombre completo" required />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Identificación</label>
<input type="text" name="personaIdentificacion" class="form-control" placeholder="Cédula, DNI, etc." />
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Fecha Devolución Estimada</label>
<input type="date" name="fechaDevolucionEstimada" class="form-control" />
<div class="form-text">Fecha aproximada de devolución del artículo.</div>
</div>
<div class="mb-3">
<label class="form-label">Observación</label>
<textarea name="observacion" class="form-control" rows="2" placeholder="Detalles del préstamo..."></textarea>
</div>
<div class="alert alert-light border">
<i class="bi bi-info-circle me-1"></i>
<span>Se generarán códigos individuales para cada artículo prestado (ej: sp-b20-001, sp-b20-002, ...)</span>
</div>
<button type="submit" class="btn btn-info text-white">
<i class="bi bi-box-arrow-right me-1"></i> Confirmar Préstamo
</button>
</form>
</div>
<!-- BAJA FORM -->
<div class="tab-pane fade" id="pills-baja" role="tabpanel">
<form asp-action="RegistrarBaja" method="post" onsubmit="return confirm('¿Está seguro de realizar esta baja?');">
<input type="hidden" name="articuloId" value="@ViewBag.ArticuloId" />
<h5 class="mb-3 text-danger">Registrar Baja de Activo</h5>
@if (ViewBag.TipoControl == "LOTE")
{
<div class="mb-3">
<label class="form-label fw-bold">Cantidad a dar de Baja</label>
<input type="number" name="cantidad" class="form-control" min="1" max="@ViewBag.CantidadGlobal" value="1" required />
<div class="form-text">Esto restará del stock global.</div>
</div>
}
else
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Atención:</strong> Esta acción marcará el artículo como inactivo.
</div>
}
<div class="mb-3">
<label class="form-label">Motivo de Baja <span class="text-danger">*</span></label>
<textarea name="motivo" class="form-control" rows="3" placeholder="Ej: Robo, Pérdida total, Venta, Donación..." required></textarea>
</div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i> Confirmar Baja
</button>
</form>
</div>
</div>
</div>
</div>
}
else
{
<div class="card-custom h-100 d-flex align-items-center justify-content-center bg-light border-0">
<div class="text-center text-muted py-5">
<i class="bi bi-arrow-left-circle fs-1 mb-3 d-block"></i>
<h5>Seleccione un artículo para comenzar</h5>
<p>Use el panel de la izquierda para buscar el activo.</p>
</div>
</div>
}
</div>
</div>
@section Scripts {
<script>
@if (TempData["ErrorMessage"] != null)
{
<text>toastr.error('@TempData["ErrorMessage"]');</text>
}
</script>
}

View File

@@ -0,0 +1,128 @@
@model IEnumerable<Rs_system.Models.MovimientoInventario>
@{
ViewData["Title"] = "Historial de Movimientos";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Historial de Movimientos</h4>
<p class="text-muted mb-0">Registro de traslados, bajas y cambios de estado</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-arrow-left-right me-1"></i> Nuevo Movimiento
</a>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom align-middle">
<thead>
<tr>
<th>Fecha</th>
<th>Artículo</th>
<th>Tipo</th>
<th>Detalles</th>
<th>Usuario</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-clock-history fs-1 d-block mb-2"></i>
No hay movimientos registrados
</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td style="width: 150px;">
<div class="fw-bold">@item.Fecha.ToLocalTime().ToString("dd/MM/yyyy")</div>
<small class="text-muted">@item.Fecha.ToLocalTime().ToString("HH:mm")</small>
</td>
<td>
<div class="fw-bold">@item.Articulo?.Codigo</div>
<small class="text-muted">@item.Articulo?.Nombre</small>
</td>
<td>
@switch (item.TipoMovimiento)
{
case "ENTRADA":
<span class="badge bg-success">ENTRADA</span>
break;
case "TRASLADO":
<span class="badge bg-info text-dark">TRASLADO</span>
break;
case "BAJA":
<span class="badge bg-danger">BAJA</span>
break;
case "PRESTAMO":
<span class="badge bg-primary">PRESTAMO</span>
break;
case "DEVOLUCION":
<span class="badge bg-teal" style="background-color: #20c997; color: white;">DEVOLUCIÓN</span>
break;
case "CAMBIO_ESTADO":
<span class="badge bg-warning text-dark">ESTADO</span>
break;
default:
<span class="badge bg-secondary">@item.TipoMovimiento</span>
break;
}
</td>
<td>
@if (item.TipoMovimiento == "TRASLADO")
{
<div class="d-flex align-items-center">
<span class="text-muted me-2">@item.UbicacionOrigen?.Nombre</span>
<i class="bi bi-arrow-right text-primary me-2"></i>
<span class="fw-bold">@item.UbicacionDestino?.Nombre</span>
</div>
}
else if (item.TipoMovimiento == "CAMBIO_ESTADO")
{
<div class="d-flex align-items-center">
<span class="text-muted me-2">@item.EstadoAnterior?.Nombre</span>
<i class="bi bi-arrow-right text-primary me-2"></i>
<span class="fw-bold">@item.EstadoNuevo?.Nombre</span>
</div>
}
@if (item.Cantidad > 1 || item.Articulo?.TipoControl == "LOTE")
{
<div class="mt-1">
<span class="badge bg-light text-dark border">Cantidad: @item.Cantidad</span>
</div>
}
@if (!string.IsNullOrEmpty(item.Observacion))
{
<div class="text-muted fst-italic mt-1 small">
<i class="bi bi-card-text me-1"></i> @item.Observacion
</div>
}
</td>
<td>
<small class="text-muted">@item.UsuarioId</small>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@section Scripts {
<script>
@if (TempData["SuccessMessage"] != null)
{
<text>toastr.success('@TempData["SuccessMessage"]');</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>toastr.error('@TempData["ErrorMessage"]');</text>
}
</script>
}

View File

@@ -0,0 +1,156 @@
@model IEnumerable<Rs_system.Models.Prestamo>
@{
ViewData["Title"] = "Préstamos Activos";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Préstamos Activos</h4>
<p class="text-muted mb-0">Artículos actualmente prestados</p>
</div>
<div>
<a asp-action="Create" class="btn btn-outline-primary me-2">
<i class="bi bi-plus-circle me-1"></i> Nuevo Préstamo
</a>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Historial
</a>
</div>
</div>
<div class="card-custom">
<div class="card-body">
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-box-arrow-in-right fs-1 text-muted mb-3 d-block"></i>
<h5 class="text-muted">No hay préstamos activos</h5>
<p class="text-muted">Todos los artículos han sido devueltos.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Artículo</th>
<th>Persona</th>
<th>Cantidad</th>
<th>Fecha Préstamo</th>
<th>Devolución Estimada</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var prestamo in Model)
{
<tr>
<td>
<div class="fw-bold">@prestamo.Articulo?.Codigo</div>
<div class="text-muted small">@prestamo.Articulo?.Nombre</div>
</td>
<td>
<div class="fw-bold">@prestamo.PersonaNombre</div>
@if (!string.IsNullOrEmpty(prestamo.PersonaIdentificacion))
{
<div class="text-muted small">@prestamo.PersonaIdentificacion</div>
}
</td>
<td>
<span class="badge bg-light text-dark">@prestamo.Cantidad</span>
</td>
<td>@prestamo.FechaPrestamo.ToString("dd/MM/yyyy")</td>
<td>
@if (prestamo.FechaDevolucionEstimada.HasValue)
{
var diasRestantes = (prestamo.FechaDevolucionEstimada.Value - DateTime.Today).Days;
var claseCss = diasRestantes < 0 ? "text-danger" : diasRestantes <= 3 ? "text-warning" : "text-success";
<span class="@claseCss">@prestamo.FechaDevolucionEstimada.Value.ToString("dd/MM/yyyy")</span>
}
else
{
<span class="text-muted">No definida</span>
}
</td>
<td>
@switch (prestamo.Estado)
{
case "ACTIVO":
<span class="badge bg-info">Activo</span>
break;
case "ATRASADO":
<span class="badge bg-danger">Atrasado</span>
break;
default:
<span class="badge bg-secondary">@prestamo.Estado</span>
break;
}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-info" onclick="verDetalles(@prestamo.Id)">
<i class="bi bi-eye"></i>
</button>
<form asp-action="RegistrarDevolucion" method="post" style="display: inline;">
<input type="hidden" name="prestamoId" value="@prestamo.Id" />
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('¿Confirmar devolución de este préstamo?');">
<i class="bi bi-check-circle"></i> Devolver
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
<!-- Modal Detalles -->
<div class="modal fade" id="detallesModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Detalles del Préstamo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="detallesContent">
<!-- Content loaded via AJAX -->
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function verDetalles(prestamoId) {
// Aquí podrías implementar una llamada AJAX para cargar los detalles
// Por ahora mostramos un mensaje simple
$('#detallesContent').html('<div class="text-center py-3"><i class="bi bi-hourglass-split"></i> Cargando detalles...</div>');
$('#detallesModal').modal('show');
// Simulación de carga
setTimeout(() => {
$('#detallesContent').html(`
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Los códigos individuales de los artículos prestados se mostrarán aquí.
</div>
`);
}, 500);
}
@if (TempData["SuccessMessage"] != null)
{
<text>toastr.success('@TempData["SuccessMessage"]');</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>toastr.error('@TempData["ErrorMessage"]');</text>
}
</script>
}

View File

@@ -0,0 +1,63 @@
@model Rs_system.Models.TipoColaboracion
@{
ViewData["Title"] = "Nuevo Tipo de Colaboración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nuevo Tipo de Colaboración</h4>
<p class="text-muted mb-0">Crear un nuevo tipo de colaboración</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="card-custom">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label"></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Mantenimiento" required />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label"></label>
<textarea asp-for="Descripcion" class="form-control" rows="2" placeholder="Descripción del tipo de colaboración"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="MontoSugerido" class="form-label"></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="MontoSugerido" type="number" step="0.01" class="form-control" placeholder="1.00" required />
</div>
<span asp-validation-for="MontoSugerido" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Orden" class="form-label"></label>
<input asp-for="Orden" type="number" class="form-control" placeholder="1" required />
<span asp-validation-for="Orden" class="text-danger"></span>
<small class="form-text text-muted">Orden de aparición en las listas</small>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input asp-for="Activo" class="form-check-input" type="checkbox" checked />
<label asp-for="Activo" class="form-check-label"></label>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,65 @@
@model Rs_system.Models.TipoColaboracion
@{
ViewData["Title"] = "Editar Tipo de Colaboración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Editar Tipo de Colaboración</h4>
<p class="text-muted mb-0">Modificar tipo de colaboración existente</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="card-custom">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="CreadoEn" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label"></label>
<input asp-for="Nombre" class="form-control" required />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label"></label>
<textarea asp-for="Descripcion" class="form-control" rows="2"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label asp-for="MontoSugerido" class="form-label"></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="MontoSugerido" type="number" step="0.01" class="form-control" required />
</div>
<span asp-validation-for="MontoSugerido" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Orden" class="form-label"></label>
<input asp-for="Orden" type="number" class="form-control" required />
<span asp-validation-for="Orden" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input asp-for="Activo" class="form-check-input" type="checkbox" />
<label asp-for="Activo" class="form-check-label"></label>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Actualizar
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,112 @@
@model IEnumerable<Rs_system.Models.TipoColaboracion>
@{
ViewData["Title"] = "Tipos de Colaboración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Tipos de Colaboración</h4>
<p class="text-muted mb-0">Gestión de tipos de colaboración económica</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nuevo Tipo
</a>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Orden</th>
<th>Nombre</th>
<th>Descripción</th>
<th>Monto Sugerido</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-list-ul fs-1 d-block mb-2"></i>
No hay tipos de colaboración registrados
</td>
</tr>
}
@foreach (var tipo in Model)
{
<tr>
<td><strong>@tipo.Orden</strong></td>
<td><strong>@tipo.Nombre</strong></td>
<td>@(tipo.Descripcion ?? "-")</td>
<td><span class="text-success">$@tipo.MontoSugerido.ToString("N2")</span></td>
<td class="text-center">
@if (tipo.Activo)
{
<span class="badge bg-success">Activo</span>
}
else
{
<span class="badge bg-secondary">Inactivo</span>
}
</td>
<td class="text-center">
<a asp-action="Edit" asp-route-id="@tipo.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
@if (tipo.Activo)
{
<button type="button" class="btn btn-sm btn-outline-danger" onclick="confirmDelete(@tipo.Id)" title="Desactivar">
<i class="bi bi-x-circle"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" asp-action="Delete" method="post" style="display: none;">
<input type="hidden" name="id" id="deleteId" />
</form>
@section Scripts {
<script>
function confirmDelete(id) {
Swal.fire({
title: '¿Desactivar tipo?',
text: 'Este tipo de colaboración será desactivado.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, desactivar',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
});
}
@if (TempData["Success"] != null)
{
<text>
toastr.success('@TempData["Success"]');
</text>
}
@if (TempData["Error"] != null)
{
<text>
toastr.error('@TempData["Error"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,63 @@
@model Rs_system.Models.Ubicacion
@{
ViewData["Title"] = "Nueva Ubicación";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Nueva Ubicación</h4>
<p class="text-muted mb-0">Registrar un nuevo lugar de almacenamiento</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Bodega Central, Auditorio, Oficina Pastoral" autofocus />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="2" placeholder="Detalles sobre la ubicación (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Responsable" class="form-label">Responsable</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input asp-for="Responsable" class="form-control" placeholder="Nombre de la persona encargada" />
</div>
<div class="form-text">Persona a cargo de esta ubicación (opcional).</div>
<span asp-validation-for="Responsable" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" checked />
<label asp-for="Activo" class="form-check-label">Ubicación Activa</label>
</div>
<div class="form-text">Si está inactiva, no se podrá asignar a nuevos artículos.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,64 @@
@model Rs_system.Models.Ubicacion
@{
ViewData["Title"] = "Editar Ubicación";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Editar Ubicación</h4>
<p class="text-muted mb-0">Modificar información del lugar</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="card-custom" style="max-width: 800px; margin: 0 auto;">
<div class="card-body">
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input asp-for="Nombre" class="form-control" placeholder="Ej: Bodega Central, Auditorio" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción</label>
<textarea asp-for="Descripcion" class="form-control" rows="2" placeholder="Detalles sobre la ubicación (opcional)"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Responsable" class="form-label">Responsable</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input asp-for="Responsable" class="form-control" placeholder="Nombre de la persona encargada" />
</div>
<div class="form-text">Persona a cargo de esta ubicación (opcional).</div>
<span asp-validation-for="Responsable" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="Activo" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="Activo" class="form-check-label">Ubicación Activa</label>
</div>
<div class="form-text">Si está inactiva, no se podrá asignar a nuevos artículos.</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a asp-action="Index" class="btn btn-secondary">Cancelar</a>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-1"></i> Guardar Cambios
</button>
</div>
</form>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,131 @@
@model IEnumerable<Rs_system.Models.Ubicacion>
@{
ViewData["Title"] = "Ubicaciones de Inventario";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Ubicaciones de Inventario</h4>
<p class="text-muted mb-0">Gestión de lugares físicos de almacenamiento</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-plus-lg me-1"></i> Nueva Ubicación
</a>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Responsable</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-geo-alt fs-1 d-block mb-2"></i>
No hay ubicaciones registradas
</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td>
<strong>@item.Nombre</strong>
</td>
<td>
@if (!string.IsNullOrEmpty(item.Descripcion))
{
<span>@item.Descripcion</span>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(item.Responsable))
{
<div class="d-flex align-items-center">
<i class="bi bi-person-circle me-2 text-muted"></i>
<span>@item.Responsable</span>
</div>
}
else
{
<span class="text-muted">-</span>
}
</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">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="confirmDelete(@item.Id)" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Delete Form -->
<form id="deleteForm" asp-action="Delete" method="post" style="display: none;">
<input type="hidden" name="id" id="deleteId" />
</form>
@section Scripts {
<script>
function confirmDelete(id) {
Swal.fire({
title: '¿Eliminar ubicación?',
text: 'Esta acción archivará la ubicación.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
});
}
// Show success/error messages
@if (TempData["SuccessMessage"] != null)
{
<text>
toastr.success('@TempData["SuccessMessage"]');
</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>
toastr.error('@TempData["ErrorMessage"]');
</text>
}
</script>
}

View File

@@ -0,0 +1,103 @@
-- =============================================
-- Script SQL: Módulo de Colaboraciones Económicas
-- Descripción: Crea las tablas para gestionar colaboraciones mensuales
-- Autor: Sistema
-- Fecha: 2026-02-01
-- =============================================
-- Tabla 1: Tipos de Colaboración (configurable)
CREATE TABLE IF NOT EXISTS public.tipos_colaboracion
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion text COLLATE pg_catalog."default",
monto_sugerido decimal(10, 2) NOT NULL DEFAULT 0,
activo boolean NOT NULL DEFAULT true,
orden integer NOT NULL DEFAULT 0,
creado_en timestamp with time zone NOT NULL DEFAULT now(),
actualizado_en timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT pk_tipos_colaboracion PRIMARY KEY (id)
);
COMMENT ON TABLE public.tipos_colaboracion IS 'Catálogo de tipos de colaboración (Transporte, Limpieza, etc.)';
COMMENT ON COLUMN public.tipos_colaboracion.nombre IS 'Nombre del tipo de colaboración';
COMMENT ON COLUMN public.tipos_colaboracion.monto_sugerido IS 'Cuota sugerida mensual';
COMMENT ON COLUMN public.tipos_colaboracion.orden IS 'Orden de presentación en UI';
-- Datos iniciales
INSERT INTO public.tipos_colaboracion (nombre, descripcion, monto_sugerido, orden)
VALUES
('Transporte', 'Colaboración mensual para transporte', 1.00, 1),
('Limpieza', 'Colaboración mensual para limpieza', 1.00, 2)
ON CONFLICT DO NOTHING;
-- =============================================
-- Tabla 2: Colaboraciones (cabecera de transacción)
CREATE TABLE IF NOT EXISTS public.colaboraciones
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
miembro_id bigint NOT NULL,
fecha_registro timestamp with time zone NOT NULL DEFAULT now(),
monto_total decimal(10, 2) NOT NULL,
observaciones text COLLATE pg_catalog."default",
registrado_por character varying(100) COLLATE pg_catalog."default",
creado_en timestamp with time zone NOT NULL DEFAULT now(),
actualizado_en timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT pk_colaboraciones PRIMARY KEY (id),
CONSTRAINT fk_colaboraciones_miembro FOREIGN KEY (miembro_id)
REFERENCES public.miembros (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT
);
COMMENT ON TABLE public.colaboraciones IS 'Registro de transacciones de colaboración';
COMMENT ON COLUMN public.colaboraciones.miembro_id IS 'Miembro que realizó el pago';
COMMENT ON COLUMN public.colaboraciones.monto_total IS 'Monto total de la transacción';
COMMENT ON COLUMN public.colaboraciones.registrado_por IS 'Usuario que registró el pago';
-- Índices para optimizar búsquedas
CREATE INDEX IF NOT EXISTS idx_colaboraciones_miembro ON public.colaboraciones(miembro_id);
CREATE INDEX IF NOT EXISTS idx_colaboraciones_fecha ON public.colaboraciones(fecha_registro);
-- =============================================
-- Tabla 3: Detalle de Colaboraciones (desglose mensual)
CREATE TABLE IF NOT EXISTS public.detalle_colaboraciones
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
colaboracion_id bigint NOT NULL,
tipo_colaboracion_id bigint NOT NULL,
mes integer NOT NULL,
anio integer NOT NULL,
monto decimal(10, 2) NOT NULL,
creado_en timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT pk_detalle_colaboraciones PRIMARY KEY (id),
CONSTRAINT fk_detalle_colaboracion FOREIGN KEY (colaboracion_id)
REFERENCES public.colaboraciones (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT fk_detalle_tipo FOREIGN KEY (tipo_colaboracion_id)
REFERENCES public.tipos_colaboracion (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT,
CONSTRAINT chk_mes_valido CHECK (mes >= 1 AND mes <= 12),
CONSTRAINT chk_anio_valido CHECK (anio >= 2000 AND anio <= 2100)
);
COMMENT ON TABLE public.detalle_colaboraciones IS 'Desglose mensual de cada colaboración';
COMMENT ON COLUMN public.detalle_colaboraciones.mes IS 'Mes cubierto (1-12)';
COMMENT ON COLUMN public.detalle_colaboraciones.anio IS 'Año cubierto';
COMMENT ON COLUMN public.detalle_colaboraciones.monto IS 'Monto para este mes/tipo específico';
-- Índices para reportes y búsquedas
CREATE INDEX IF NOT EXISTS idx_detalle_tipo ON public.detalle_colaboraciones(tipo_colaboracion_id);
CREATE INDEX IF NOT EXISTS idx_detalle_periodo ON public.detalle_colaboraciones(anio, mes);
-- Índice único para evitar duplicados (mismo mes/año/tipo en misma colaboración)
CREATE UNIQUE INDEX IF NOT EXISTS idx_detalle_unico
ON public.detalle_colaboraciones(colaboracion_id, tipo_colaboracion_id, mes, anio);
-- =============================================
-- FIN DEL SCRIPT
-- =============================================

View File

@@ -0,0 +1,17 @@
-- Tabla: movimientos_generales_adjuntos
CREATE TABLE IF NOT EXISTS public.movimientos_generales_adjuntos
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
movimiento_general_id bigint NOT NULL,
nombre_archivo character varying(255) COLLATE pg_catalog."default" NOT NULL,
ruta_archivo character varying(500) COLLATE pg_catalog."default" NOT NULL,
tipo_contenido character varying(100) COLLATE pg_catalog."default",
fecha_subida timestamp without time zone NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT pk_movimientos_generales_adjuntos PRIMARY KEY (id),
CONSTRAINT fk_adjuntos_movimiento FOREIGN KEY (movimiento_general_id)
REFERENCES public.movimientos_generales (id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_adjuntos_movimiento
ON public.movimientos_generales_adjuntos (movimiento_general_id);

View File

@@ -0,0 +1,171 @@
-- =====================================================
-- Script SQL para Sistema de Contabilidad General
-- Base de datos: PostgreSQL
-- Fecha: 2026-01-28
-- =====================================================
-- =====================================================
-- 1. CREAR TABLAS
-- =====================================================
-- Tabla: categorias_ingreso
CREATE TABLE IF NOT EXISTS public.categorias_ingreso
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(255) COLLATE pg_catalog."default",
activa boolean NOT NULL DEFAULT true,
fecha_creacion timestamp without time zone NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT pk_categorias_ingreso PRIMARY KEY (id)
);
-- Tabla: categorias_egreso
CREATE TABLE IF NOT EXISTS public.categorias_egreso
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion character varying(255) COLLATE pg_catalog."default",
activa boolean NOT NULL DEFAULT true,
fecha_creacion timestamp without time zone NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT pk_categorias_egreso PRIMARY KEY (id)
);
-- Tabla: reportes_mensuales_generales
CREATE TABLE IF NOT EXISTS public.reportes_mensuales_generales
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
mes integer NOT NULL,
anio integer NOT NULL,
saldo_inicial numeric(18,2) NOT NULL DEFAULT 0,
fecha_creacion timestamp without time zone NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
cerrado boolean NOT NULL DEFAULT false,
CONSTRAINT pk_reportes_mensuales_generales PRIMARY KEY (id),
CONSTRAINT uk_mes_anio_general UNIQUE (mes, anio),
CONSTRAINT ck_mes_rango CHECK (mes >= 1 AND mes <= 12),
CONSTRAINT ck_anio_valido CHECK (anio >= 2000 AND anio <= 2100)
);
-- Crear tipo enum para tipo_movimiento_general si no existe
DO $$ BEGIN
CREATE TYPE tipo_movimiento_general AS ENUM ('Ingreso', 'Egreso');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla: movimientos_generales
CREATE TABLE IF NOT EXISTS public.movimientos_generales
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
reporte_mensual_general_id bigint,
tipo tipo_movimiento_general NOT NULL,
categoria_ingreso_id bigint,
categoria_egreso_id bigint,
monto numeric(18,2) NOT NULL,
fecha timestamp without time zone NOT NULL,
descripcion character varying(200) COLLATE pg_catalog."default" NOT NULL DEFAULT '',
numero_comprobante character varying(50) COLLATE pg_catalog."default",
CONSTRAINT pk_movimientos_generales PRIMARY KEY (id),
CONSTRAINT fk_movimientos_reporte FOREIGN KEY (reporte_mensual_general_id)
REFERENCES public.reportes_mensuales_generales (id)
ON DELETE CASCADE,
CONSTRAINT fk_movimientos_categoria_ingreso FOREIGN KEY (categoria_ingreso_id)
REFERENCES public.categorias_ingreso (id)
ON DELETE RESTRICT,
CONSTRAINT fk_movimientos_categoria_egreso FOREIGN KEY (categoria_egreso_id)
REFERENCES public.categorias_egreso (id)
ON DELETE RESTRICT,
CONSTRAINT ck_monto_positivo CHECK (monto > 0),
CONSTRAINT ck_categoria_tipo CHECK (
(tipo = 'Ingreso' AND categoria_ingreso_id IS NOT NULL AND categoria_egreso_id IS NULL) OR
(tipo = 'Egreso' AND categoria_egreso_id IS NOT NULL AND categoria_ingreso_id IS NULL)
)
);
-- =====================================================
-- 2. CREAR ÍNDICES
-- =====================================================
CREATE INDEX IF NOT EXISTS idx_movimientos_reporte
ON public.movimientos_generales (reporte_mensual_general_id);
CREATE INDEX IF NOT EXISTS idx_movimientos_fecha
ON public.movimientos_generales (fecha);
CREATE INDEX IF NOT EXISTS idx_movimientos_categoria_ingreso
ON public.movimientos_generales (categoria_ingreso_id);
CREATE INDEX IF NOT EXISTS idx_movimientos_categoria_egreso
ON public.movimientos_generales (categoria_egreso_id);
CREATE INDEX IF NOT EXISTS idx_reportes_anio
ON public.reportes_mensuales_generales (anio);
-- =====================================================
-- 3. INSERTAR DATOS INICIALES - CATEGORÍAS DE INGRESO
-- =====================================================
INSERT INTO public.categorias_ingreso (nombre, descripcion, activa) VALUES
('Ofrendas', 'Ofrendas regulares de los cultos', true),
('Donaciones', 'Donaciones especiales de hermanos y visitantes', true),
('Diezmos', 'Diezmos de los miembros', true),
('Eventos Especiales', 'Ingresos de eventos, conferencias, retiros', true),
('Alquileres', 'Ingresos por alquiler de instalaciones', true),
('Ventas', 'Ventas de materiales, libros u otros productos', true),
('Otros Ingresos', 'Ingresos diversos no categorizados', true)
ON CONFLICT DO NOTHING;
-- =====================================================
-- 4. INSERTAR DATOS INICIALES - CATEGORÍAS DE EGRESO
-- =====================================================
INSERT INTO public.categorias_egreso (nombre, descripcion, activa) VALUES
('Agua', 'Pago del servicio de agua', true),
('Luz', 'Pago del servicio de electricidad', true),
('Teléfono/Internet', 'Servicios de telefonía e internet', true),
('Impuestos', 'Pago de impuestos municipales, prediales, etc.', true),
('Funeraria', 'Gastos relacionados con servicios funerarios', true),
('Mantenimiento Edificio', 'Reparaciones y mantenimiento de las instalaciones', true),
('Suministros Ministerio', 'Materiales para ministerios (niños, jóvenes, etc.)', true),
('Salarios Personal', 'Salarios de pastores y personal administrativo', true),
('Eventos', 'Gastos de organización de eventos', true),
('Transporte', 'Gastos de transporte y combustible', true),
('Limpieza', 'Servicios de limpieza y productos', true),
('Seguridad', 'Servicio de seguridad o vigilancia', true),
('Otros Gastos', 'Gastos diversos no categorizados', true)
ON CONFLICT DO NOTHING;
-- =====================================================
-- 5. COMENTARIOS SOBRE LAS TABLAS
-- =====================================================
COMMENT ON TABLE public.categorias_ingreso IS 'Categorías para clasificar los ingresos de la iglesia';
COMMENT ON TABLE public.categorias_egreso IS 'Categorías para clasificar los egresos de la iglesia';
COMMENT ON TABLE public.reportes_mensuales_generales IS 'Reportes mensuales de contabilidad general de la iglesia';
COMMENT ON TABLE public.movimientos_generales IS 'Movimientos individuales de ingresos y egresos';
-- =====================================================
-- 6. VERIFICACIÓN
-- =====================================================
-- Verificar que se crearon las tablas
SELECT
table_name,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) as columnas
FROM information_schema.tables t
WHERE table_schema = 'public'
AND table_name IN (
'categorias_ingreso',
'categorias_egreso',
'reportes_mensuales_generales',
'movimientos_generales'
)
ORDER BY table_name;
-- Verificar datos iniciales de categorías
SELECT 'Categorías de Ingreso' as tipo, COUNT(*) as total FROM public.categorias_ingreso
UNION ALL
SELECT 'Categorías de Egreso' as tipo, COUNT(*) as total FROM public.categorias_egreso;
-- =====================================================
-- FIN DEL SCRIPT
-- =====================================================

View File

@@ -0,0 +1,97 @@
-- ============================================
-- Script: Agregar Gestión de Tipos de Colaboración
-- Descripción: Agrega el módulo y permiso para gestionar tipos de colaboración
-- Fecha: 2026-02-01
-- NOTA: Este script NO debe ejecutarse automáticamente, el usuario lo ejecutará manualmente
-- ============================================
-- 1. Insertar permiso para Tipo Colaboración (si no existe)
DO $$
BEGIN
-- Verificar si ya existe el módulo de Finanzas
IF NOT EXISTS (SELECT 1 FROM public.modulos WHERE codigo = 'FINANZAS') THEN
INSERT INTO public.modulos (nombre, descripcion, codigo, icono, activo, orden, creado_en, actualizado_en)
VALUES ('Finanzas', 'Módulo de gestión financiera', 'FINANZAS', 'bi-cash-stack', true, 5, NOW(), NOW());
END IF;
-- Insertar permiso para Colaboraciones (si no existe)
IF NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'Colaboracion') THEN
INSERT INTO public.permisos (nombre, descripcion, codigo, modulo_id, activo, orden, creado_en, actualizado_en)
VALUES (
'Colaboraciones',
'Gestión de colaboraciones económicas mensuales',
'Colaboracion',
(SELECT id FROM public.modulos WHERE codigo = 'FINANZAS' LIMIT 1),
true,
1,
NOW(),
NOW()
);
END IF;
-- Insertar permiso para Tipos de Colaboración (si no existe)
IF NOT EXISTS (SELECT 1 FROM public.permisos WHERE codigo = 'TipoColaboracion') THEN
INSERT INTO public.permisos (nombre, descripcion, codigo, modulo_id, activo, orden, creado_en, actualizado_en)
VALUES (
'Tipos de Colaboración',
'Gestión de tipos de colaboración (Transporte, Limpieza, etc.)',
'TipoColaboracion',
(SELECT id FROM public.modulos WHERE codigo = 'FINANZAS' LIMIT 1),
true,
2,
NOW(),
NOW()
);
END IF;
RAISE NOTICE 'Permisos para Colaboraciones creados exitosamente';
END $$;
-- 2. (Opcional) Asignar permisos al rol de Administrador
-- Descomentar si se desea asignar automáticamente
/*
DO $$
DECLARE
v_rol_admin_id BIGINT;
v_permiso_colaboracion_id BIGINT;
v_permiso_tipo_id BIGINT;
BEGIN
-- Obtener el ID del rol de administrador (ajustar el nombre según tu sistema)
SELECT id INTO v_rol_admin_id FROM public.roles_sistema WHERE nombre = 'Administrador' LIMIT 1;
-- Obtener IDs de los permisos
SELECT id INTO v_permiso_colaboracion_id FROM public.permisos WHERE codigo = 'Colaboracion' LIMIT 1;
SELECT id INTO v_permiso_tipo_id FROM public.permisos WHERE codigo = 'TipoColaboracion' LIMIT 1;
IF v_rol_admin_id IS NOT NULL THEN
-- Asignar permiso de Colaboraciones
IF NOT EXISTS (SELECT 1 FROM public.roles_permisos WHERE rol_id = v_rol_admin_id AND permiso_id = v_permiso_colaboracion_id) THEN
INSERT INTO public.roles_permisos (rol_id, permiso_id, creado_en)
VALUES (v_rol_admin_id, v_permiso_colaboracion_id, NOW());
END IF;
-- Asignar permiso de Tipos de Colaboración
IF NOT EXISTS (SELECT 1 FROM public.roles_permisos WHERE rol_id = v_rol_admin_id AND permiso_id = v_permiso_tipo_id) THEN
INSERT INTO public.roles_permisos (rol_id, permiso_id, creado_en)
VALUES (v_rol_admin_id, v_permiso_tipo_id, NOW());
END IF;
RAISE NOTICE 'Permisos asignados al rol Administrador';
END IF;
END $$;
*/
-- 3. Verificación: Listar permisos creados
SELECT
m.nombre AS modulo,
p.nombre AS permiso,
p.codigo,
p.activo
FROM public.permisos p
INNER JOIN public.modulos m ON p.modulo_id = m.id
WHERE p.codigo IN ('Colaboracion', 'TipoColaboracion')
ORDER BY m.nombre, p.orden;
-- ============================================
-- FIN DEL SCRIPT
-- ============================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Some files were not shown because too many files have changed in this diff Show More