Se agrego contabilidad

This commit is contained in:
2025-12-31 15:24:23 -06:00
parent 09a1523e9d
commit 4e6a5448ed
27 changed files with 2138 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers
{
[Authorize]
public class CajaChicaController : Controller
{
private readonly ApplicationDbContext _context;
public CajaChicaController(ApplicationDbContext context)
{
_context = context;
}
// GET: CajaChica
public async Task<IActionResult> Index()
{
var cajas = await _context.CajasChicas
.Include(c => c.Responsable)
.ThenInclude(u => u.Persona)
.ToListAsync();
return View(cajas);
}
// GET: CajaChica/Details/5
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
var cajaChica = await _context.CajasChicas
.Include(c => c.Responsable)
.ThenInclude(u => u.Persona)
.Include(c => c.Movimientos.OrderByDescending(m => m.FechaMovimiento).ThenByDescending(m => m.Id))
.ThenInclude(m => m.CategoriaGasto)
.Include(c => c.Movimientos)
.ThenInclude(m => m.UsuarioRegistro)
.ThenInclude(u => u.Persona)
.FirstOrDefaultAsync(m => m.Id == id);
if (cajaChica == null)
{
return NotFound();
}
return View(cajaChica);
}
// GET: CajaChica/Create
public IActionResult Create()
{
ViewData["ResponsableUsuarioId"] = new SelectList(_context.Usuarios.Include(u => u.Persona).Select(u => new {
Id = u.Id,
NombreCompleto = u.Persona.Nombres + " " + u.Persona.Apellidos
}), "Id", "NombreCompleto");
return View(new CajaChicaViewModel());
}
// POST: CajaChica/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CajaChicaViewModel model)
{
if (ModelState.IsValid)
{
// Verify user doesn't already have an open box
var existingBox = await _context.CajasChicas
.AnyAsync(c => c.ResponsableUsuarioId == model.ResponsableUsuarioId && c.Estado == "ABIERTA");
if (existingBox)
{
ModelState.AddModelError("ResponsableUsuarioId", "El usuario ya tiene una caja chica abierta.");
}
else
{
var cajaChica = new CajaChica
{
Nombre = model.Nombre,
ResponsableUsuarioId = model.ResponsableUsuarioId,
MontoAsignado = model.MontoAsignado,
SaldoActual = model.MontoAsignado, // Starts full
FechaApertura = model.FechaApertura,
Estado = "ABIERTA",
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Add(cajaChica);
await _context.SaveChangesAsync();
// Create initial movement
var movimiento = new CajaChicaMovimiento
{
CajaChicaId = cajaChica.Id,
TipoMovimiento = "APERTURA",
FechaMovimiento = model.FechaApertura,
Monto = model.MontoAsignado,
Descripcion = "Apertura de Fondo Fijo",
UsuarioRegistroId = long.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "1"), // Fallback to 1 if not found
CreadoEn = DateTime.UtcNow
};
_context.Add(movimiento);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
ViewData["ResponsableUsuarioId"] = new SelectList(_context.Usuarios.Include(u => u.Persona).Select(u => new {
Id = u.Id,
NombreCompleto = u.Persona.Nombres + " " + u.Persona.Apellidos
}), "Id", "NombreCompleto", model.ResponsableUsuarioId);
return View(model);
}
// POST: CajaChica/Close/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Close(int id)
{
var caja = await _context.CajasChicas.FindAsync(id);
if (caja == null) return NotFound();
if (caja.SaldoActual != caja.MontoAsignado)
{
// In a real scenario, you'd require reimbursement first or an adjustment
// For now, we'll just allow closing but maybe warn (or simple logic)
}
caja.Estado = "CERRADA";
caja.FechaCierre = DateOnly.FromDateTime(DateTime.Today);
caja.ActualizadoEn = DateTime.UtcNow;
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
}

View File

@@ -0,0 +1,99 @@
using foundation_system.Data;
using foundation_system.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers
{
[Authorize]
public class CategoriaGastoController : Controller
{
private readonly ApplicationDbContext _context;
public CategoriaGastoController(ApplicationDbContext context)
{
_context = context;
}
// GET: CategoriaGasto
public async Task<IActionResult> Index()
{
return View(await _context.CategoriasGastos.ToListAsync());
}
// GET: CategoriaGasto/Create
public IActionResult Create()
{
return View();
}
// POST: CategoriaGasto/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Id,Nombre,CodigoCuentaContable,Activo")] CategoriaGasto categoriaGasto)
{
if (ModelState.IsValid)
{
_context.Add(categoriaGasto);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(categoriaGasto);
}
// GET: CategoriaGasto/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var categoriaGasto = await _context.CategoriasGastos.FindAsync(id);
if (categoriaGasto == null)
{
return NotFound();
}
return View(categoriaGasto);
}
// POST: CategoriaGasto/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,CodigoCuentaContable,Activo,CreadoEn")] CategoriaGasto categoriaGasto)
{
if (id != categoriaGasto.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(categoriaGasto);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!CategoriaGastoExists(categoriaGasto.Id))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToAction(nameof(Index));
}
return View(categoriaGasto);
}
private bool CategoriaGastoExists(int id)
{
return _context.CategoriasGastos.Any(e => e.Id == id);
}
}
}

View File

@@ -0,0 +1,193 @@
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers
{
[Authorize]
public class MovimientoCajaController : Controller
{
private readonly ApplicationDbContext _context;
public MovimientoCajaController(ApplicationDbContext context)
{
_context = context;
}
// GET: MovimientoCaja/CreateGasto?cajaId=5
public async Task<IActionResult> CreateGasto(int cajaId)
{
var caja = await _context.CajasChicas.FindAsync(cajaId);
if (caja == null || caja.Estado != "ABIERTA")
{
return NotFound("La caja no existe o está cerrada.");
}
var model = new RegistroGastoViewModel
{
CajaChicaId = caja.Id,
CajaChicaNombre = caja.Nombre,
SaldoDisponible = caja.SaldoActual,
Fecha = DateOnly.FromDateTime(DateTime.Today)
};
ViewData["CategoriaId"] = new SelectList(_context.CategoriasGastos.Where(c => c.Activo), "Id", "Nombre");
ViewData["ProveedorId"] = new SelectList(_context.Proveedores.Where(p => p.Activo), "Id", "Nombre");
return View(model);
}
// POST: MovimientoCaja/CreateGasto
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateGasto(RegistroGastoViewModel model)
{
var caja = await _context.CajasChicas.FindAsync(model.CajaChicaId);
if (caja == null || caja.Estado != "ABIERTA")
{
return NotFound();
}
if (model.Monto > caja.SaldoActual)
{
ModelState.AddModelError("Monto", "El monto del gasto excede el saldo disponible.");
}
if (ModelState.IsValid)
{
using var transaction = _context.Database.BeginTransaction();
try
{
// 1. Create Movement
var movimiento = new CajaChicaMovimiento
{
CajaChicaId = model.CajaChicaId,
TipoMovimiento = "GASTO",
FechaMovimiento = model.Fecha,
Monto = model.Monto,
Descripcion = model.Descripcion,
CategoriaGastoId = model.CategoriaId,
ProveedorId = model.ProveedorId,
UsuarioRegistroId = long.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "1"),
EstadoReembolso = "PENDIENTE",
CreadoEn = DateTime.UtcNow
};
_context.Add(movimiento);
await _context.SaveChangesAsync();
// 2. Update Balance
caja.SaldoActual -= model.Monto;
caja.ActualizadoEn = DateTime.UtcNow;
_context.Update(caja);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return RedirectToAction("Details", "CajaChica", new { id = model.CajaChicaId });
}
catch (Exception)
{
await transaction.RollbackAsync();
ModelState.AddModelError("", "Ocurrió un error al registrar el gasto.");
}
}
model.CajaChicaNombre = caja.Nombre;
model.SaldoDisponible = caja.SaldoActual;
ViewData["CategoriaId"] = new SelectList(_context.CategoriasGastos.Where(c => c.Activo), "Id", "Nombre", model.CategoriaId);
ViewData["ProveedorId"] = new SelectList(_context.Proveedores.Where(p => p.Activo), "Id", "Nombre", model.ProveedorId);
return View(model);
}
// GET: MovimientoCaja/Reposicion?cajaId=5
public async Task<IActionResult> Reposicion(int cajaId)
{
var caja = await _context.CajasChicas
.Include(c => c.Movimientos.Where(m => m.TipoMovimiento == "GASTO" && m.EstadoReembolso == "PENDIENTE"))
.FirstOrDefaultAsync(c => c.Id == cajaId);
if (caja == null) return NotFound();
return View(caja);
}
// POST: MovimientoCaja/ConfirmarReposicion
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ConfirmarReposicion(int cajaId, List<long> movimientoIds)
{
var caja = await _context.CajasChicas.FindAsync(cajaId);
if (caja == null) return NotFound();
if (movimientoIds == null || !movimientoIds.Any())
{
return RedirectToAction("Reposicion", new { cajaId });
}
using var transaction = _context.Database.BeginTransaction();
try
{
var movimientos = await _context.CajaChicaMovimientos
.Where(m => movimientoIds.Contains(m.Id) && m.CajaChicaId == cajaId && m.EstadoReembolso == "PENDIENTE")
.ToListAsync();
decimal totalReembolso = movimientos.Sum(m => m.Monto);
// 1. Mark expenses as reimbursed
foreach (var mov in movimientos)
{
mov.EstadoReembolso = "REEMBOLSADO";
}
_context.UpdateRange(movimientos);
// 2. Create Replenishment Movement
var reposicion = new CajaChicaMovimiento
{
CajaChicaId = cajaId,
TipoMovimiento = "REPOSICION",
FechaMovimiento = DateOnly.FromDateTime(DateTime.Today),
Monto = totalReembolso,
Descripcion = $"Reposición de {movimientos.Count} gastos.",
UsuarioRegistroId = long.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "1"),
CreadoEn = DateTime.UtcNow
};
_context.Add(reposicion);
// 3. Restore Balance
caja.SaldoActual += totalReembolso;
caja.ActualizadoEn = DateTime.UtcNow;
_context.Update(caja);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return RedirectToAction("Details", "CajaChica", new { id = cajaId });
}
catch
{
await transaction.RollbackAsync();
return RedirectToAction("Reposicion", new { cajaId });
}
}
// GET: MovimientoCaja/SearchProveedores
[HttpGet]
public async Task<IActionResult> SearchProveedores(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
{
return Json(new List<object>());
}
var results = await _context.Database
.SqlQueryRaw<SearchResult>(
"SELECT id, text, score FROM buscar_personas_v2(@p0, 'PROVEEDOR')",
term)
.ToListAsync();
return Json(results);
}
}
}

View File

@@ -0,0 +1,99 @@
using foundation_system.Data;
using foundation_system.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers
{
[Authorize]
public class ProveedorController : Controller
{
private readonly ApplicationDbContext _context;
public ProveedorController(ApplicationDbContext context)
{
_context = context;
}
// GET: Proveedor
public async Task<IActionResult> Index()
{
return View(await _context.Proveedores.ToListAsync());
}
// GET: Proveedor/Create
public IActionResult Create()
{
return View();
}
// POST: Proveedor/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Id,Nombre,NitDui,Activo")] Proveedor proveedor)
{
if (ModelState.IsValid)
{
_context.Add(proveedor);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(proveedor);
}
// GET: Proveedor/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var proveedor = await _context.Proveedores.FindAsync(id);
if (proveedor == null)
{
return NotFound();
}
return View(proveedor);
}
// POST: Proveedor/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,NitDui,Activo,CreadoEn")] Proveedor proveedor)
{
if (id != proveedor.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(proveedor);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ProveedorExists(proveedor.Id))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToAction(nameof(Index));
}
return View(proveedor);
}
private bool ProveedorExists(int id)
{
return _context.Proveedores.Any(e => e.Id == id);
}
}
}

View File

@@ -19,6 +19,13 @@ public class ApplicationDbContext : DbContext
public DbSet<RolPermiso> RolesPermisos { get; set; }
public DbSet<Colaborador> Colaboradores { get; set; }
public DbSet<CargoColaborador> CargosColaboradores { get; set; }
// Caja Chica
public DbSet<CajaChica> CajasChicas { get; set; }
public DbSet<CajaChicaMovimiento> CajaChicaMovimientos { get; set; }
public DbSet<CategoriaGasto> CategoriasGastos { get; set; }
public DbSet<Proveedor> Proveedores { get; set; }
public DbSet<DocumentoSoporte> DocumentosSoporte { get; set; }
public DbSet<AsistenciaColaborador> AsistenciasColaboradores { get; set; }
public DbSet<Nino> Ninos { get; set; }
public DbSet<Asistencia> Asistencias { get; set; }

View File

@@ -0,0 +1,20 @@
-- Script para corregir la falta de columnas en la tabla proveedores
-- Esto puede ocurrir si la tabla ya existía antes de la migración de Caja Chica
DO $$
BEGIN
-- Verificar si existe la columna creado_en en proveedores
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='proveedores' AND column_name='creado_en') THEN
ALTER TABLE proveedores ADD COLUMN creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
END IF;
-- Verificar si existe la columna activo en proveedores
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='proveedores' AND column_name='activo') THEN
ALTER TABLE proveedores ADD COLUMN activo BOOLEAN DEFAULT TRUE;
END IF;
-- Verificar si existe la columna nit_dui en proveedores
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='proveedores' AND column_name='nit_dui') THEN
ALTER TABLE proveedores ADD COLUMN nit_dui VARCHAR(50);
END IF;
END $$;

View File

@@ -0,0 +1,65 @@
-- Catálogo de Categorías de Gasto
CREATE TABLE IF NOT EXISTS categorias_gastos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
codigo_cuenta_contable VARCHAR(50), -- Para integración contable
activo BOOLEAN DEFAULT TRUE,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Catálogo de Proveedores
CREATE TABLE IF NOT EXISTS proveedores (
id SERIAL PRIMARY KEY,
nombre VARCHAR(200) NOT NULL,
nit_dui VARCHAR(50), -- Identificación fiscal
activo BOOLEAN DEFAULT TRUE,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Cabecera de Caja Chica
CREATE TABLE IF NOT EXISTS cajas_chicas (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL, -- Ej. "Caja Chica Administración"
responsable_usuario_id BIGINT NOT NULL, -- FK a Usuarios
monto_asignado DECIMAL(10,2) NOT NULL, -- El fondo fijo (ej. $500)
saldo_actual DECIMAL(10,2) NOT NULL, -- Efectivo disponible
estado VARCHAR(20) NOT NULL DEFAULT 'ABIERTA', -- ABIERTA, CERRADA, ARQUEO
fecha_apertura DATE NOT NULL,
fecha_cierre DATE,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_saldo_positivo CHECK (saldo_actual >= 0),
CONSTRAINT fk_caja_responsable FOREIGN KEY (responsable_usuario_id) REFERENCES usuarios(id)
);
-- Movimientos (Ingresos, Egresos, Reembolsos)
CREATE TABLE IF NOT EXISTS caja_chica_movimientos (
id BIGSERIAL PRIMARY KEY,
caja_chica_id INT NOT NULL REFERENCES cajas_chicas(id),
tipo_movimiento VARCHAR(20) NOT NULL, -- GASTO, APERTURA, REPOSICION, AUMENTO_FONDO, DISMINUCION_FONDO, AJUSTE
fecha_movimiento DATE NOT NULL,
monto DECIMAL(10,2) NOT NULL,
descripcion TEXT NOT NULL,
categoria_gasto_id INT REFERENCES categorias_gastos(id), -- Null si no es gasto
proveedor_id INT REFERENCES proveedores(id), -- Null si no aplica
usuario_registro_id BIGINT NOT NULL, -- Auditoría: Quién registró
estado_reembolso VARCHAR(20) DEFAULT 'PENDIENTE', -- PENDIENTE, REEMBOLSADO, ANULADO
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_monto_positivo CHECK (monto > 0),
CONSTRAINT fk_movimiento_usuario FOREIGN KEY (usuario_registro_id) REFERENCES usuarios(id)
);
-- Documentos de Soporte (Adjuntos)
CREATE TABLE IF NOT EXISTS documentos_soporte (
id BIGSERIAL PRIMARY KEY,
movimiento_id BIGINT NOT NULL REFERENCES caja_chica_movimientos(id) ON DELETE CASCADE,
tipo_documento VARCHAR(50) NOT NULL, -- FACTURA, RECIBO, VALE
numero_documento VARCHAR(50),
ruta_archivo TEXT, -- Path al archivo físico o URL
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Índices Recomendados
CREATE INDEX IF NOT EXISTS idx_movimientos_caja ON caja_chica_movimientos(caja_chica_id);
CREATE INDEX IF NOT EXISTS idx_movimientos_fecha ON caja_chica_movimientos(fecha_movimiento);
CREATE INDEX IF NOT EXISTS idx_movimientos_estado ON caja_chica_movimientos(estado_reembolso);

View File

@@ -0,0 +1,360 @@
CREATE OR REPLACE FUNCTION public.buscar_personas_v2(
p_termino text,
p_tipo text,
p_limite integer DEFAULT 10)
RETURNS TABLE(id bigint, text text, score double precision, match_type text)
LANGUAGE 'plpgsql'
COST 100
VOLATILE PARALLEL UNSAFE
ROWS 1000
AS $BODY$
DECLARE
v_termino_norm TEXT;
v_tokens TEXT[];
v_sim_threshold DOUBLE PRECISION := 0.15; -- Más permisivo
v_lev_threshold INTEGER := 3; -- Permite más errores
-- Pesos para ranking (ajusta según importancia)
v_peso_nombre DOUBLE PRECISION := 1.0;
v_peso_apellido DOUBLE PRECISION := 0.8;
v_peso_codigo DOUBLE PRECISION := 0.6;
v_peso_dui DOUBLE PRECISION := 0.5;
BEGIN
-- Normalizar el término de búsqueda
v_termino_norm := normalizar_texto(p_termino);
-- Tokenizar (separar por palabras)
v_tokens := string_to_array(v_termino_norm, ' ');
-- ========================================================================
-- BÚSQUEDA PARA NIÑOS
-- ========================================================================
IF upper(p_tipo) = 'NINO' THEN
RETURN QUERY
WITH candidatos AS (
-- FASE 1: Filtro rápido por índice (operador %)
SELECT
n.id,
p.nombres,
p.apellidos,
n.codigo_inscripcion,
normalizar_texto(p.nombres) AS nombres_norm,
normalizar_texto(p.apellidos) AS apellidos_norm,
normalizar_texto(n.codigo_inscripcion) AS codigo_norm
FROM ninos n
JOIN personas p ON p.id = n.persona_id
WHERE n.activo = true
AND p.activo = true
AND (
-- Búsqueda rápida con operador % (usa índice GIN)
normalizar_texto(p.nombres || ' ' || p.apellidos) % v_termino_norm
OR normalizar_texto(n.codigo_inscripcion) % v_termino_norm
OR normalizar_texto(p.nombres) % ANY(v_tokens)
OR normalizar_texto(p.apellidos) % ANY(v_tokens)
)
),
scoring AS (
-- FASE 2: Cálculo preciso de scores
SELECT
c.id,
(c.nombres || ' ' || c.apellidos || ' | Código: ' || c.codigo_inscripcion)::TEXT AS display_text,
-- Score por similitud de nombre completo
(similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) * v_peso_nombre) AS score_nombre_completo,
-- Score por similitud de nombres individuales
(similarity(c.nombres_norm, v_termino_norm) * v_peso_nombre) AS score_nombres,
-- Score por similitud de apellidos
(similarity(c.apellidos_norm, v_termino_norm) * v_peso_apellido) AS score_apellidos,
-- Score por código
(similarity(c.codigo_norm, v_termino_norm) * v_peso_codigo) AS score_codigo,
-- Score por Levenshtein (nombres)
(1.0 - (levenshtein(c.nombres_norm, v_termino_norm)::DOUBLE PRECISION
/ GREATEST(length(c.nombres_norm), length(v_termino_norm), 1))) * v_peso_nombre AS score_lev_nombres,
-- Score por tokens (cada palabra individual)
(
SELECT COALESCE(MAX(
GREATEST(
similarity(c.nombres_norm, tok),
similarity(c.apellidos_norm, tok)
)
), 0)
FROM unnest(v_tokens) tok
) * 0.9 AS score_tokens,
-- Determinar tipo de match
CASE
WHEN similarity(c.codigo_norm, v_termino_norm) >= v_sim_threshold THEN 'codigo'
WHEN similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) >= v_sim_threshold THEN 'nombre_completo'
WHEN levenshtein(c.nombres_norm, v_termino_norm) <= v_lev_threshold THEN 'fuzzy'
ELSE 'token'
END AS match_type
FROM candidatos c
)
SELECT
s.id,
s.display_text,
-- Score final: tomar el máximo de todos los métodos
GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_codigo,
s.score_lev_nombres,
s.score_tokens
) AS score,
s.match_type
FROM scoring s
WHERE GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_codigo,
s.score_lev_nombres,
s.score_tokens
) > 0.1 -- Umbral mínimo para aparecer en resultados
ORDER BY score DESC
LIMIT p_limite;
-- ========================================================================
-- BÚSQUEDA PARA ENCARGADOS
-- ========================================================================
ELSIF upper(p_tipo) = 'ENCARGADO' THEN
RETURN QUERY
WITH candidatos AS (
SELECT
p.id,
p.nombres,
p.apellidos,
COALESCE(p.dui, '') AS dui,
en.parentesco,
normalizar_texto(p.nombres) AS nombres_norm,
normalizar_texto(p.apellidos) AS apellidos_norm,
normalizar_texto(COALESCE(p.dui, '')) AS dui_norm
FROM encargados_nino en
JOIN personas p ON p.id = en.persona_id
WHERE p.activo = true
AND (
normalizar_texto(p.nombres || ' ' || p.apellidos) % v_termino_norm
OR normalizar_texto(COALESCE(p.dui, '')) % v_termino_norm
OR normalizar_texto(p.nombres) % ANY(v_tokens)
OR normalizar_texto(p.apellidos) % ANY(v_tokens)
)
),
scoring AS (
SELECT
c.id,
(c.nombres || ' ' || c.apellidos || ' (' || c.parentesco || ')')::TEXT AS display_text,
(similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) * v_peso_nombre) AS score_nombre_completo,
(similarity(c.nombres_norm, v_termino_norm) * v_peso_nombre) AS score_nombres,
(similarity(c.apellidos_norm, v_termino_norm) * v_peso_apellido) AS score_apellidos,
(similarity(c.dui_norm, v_termino_norm) * v_peso_dui) AS score_dui,
(1.0 - (levenshtein(c.nombres_norm, v_termino_norm)::DOUBLE PRECISION
/ GREATEST(length(c.nombres_norm), length(v_termino_norm), 1))) * v_peso_nombre AS score_lev_nombres,
(
SELECT COALESCE(MAX(
GREATEST(
similarity(c.nombres_norm, tok),
similarity(c.apellidos_norm, tok)
)
), 0)
FROM unnest(v_tokens) tok
) * 0.9 AS score_tokens,
CASE
WHEN similarity(c.dui_norm, v_termino_norm) >= v_sim_threshold THEN 'dui'
WHEN similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) >= v_sim_threshold THEN 'nombre_completo'
WHEN levenshtein(c.nombres_norm, v_termino_norm) <= v_lev_threshold THEN 'fuzzy'
ELSE 'token'
END AS match_type
FROM candidatos c
)
SELECT
s.id,
s.display_text,
GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_dui,
s.score_lev_nombres,
s.score_tokens
) AS score,
s.match_type
FROM scoring s
WHERE GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_dui,
s.score_lev_nombres,
s.score_tokens
) > 0.1
ORDER BY score DESC
LIMIT p_limite;
-- ========================================================================
-- BÚSQUEDA PARA COLABORADORES
-- ========================================================================
ELSIF upper(p_tipo) = 'COLABORADOR' THEN
RETURN QUERY
WITH candidatos AS (
SELECT
c.id,
p.nombres,
p.apellidos,
COALESCE(p.dui, '') AS dui,
cc.nombre as cargo,
normalizar_texto(p.nombres) AS nombres_norm,
normalizar_texto(p.apellidos) AS apellidos_norm,
normalizar_texto(COALESCE(p.dui, '')) AS dui_norm
FROM colaboradores c
JOIN personas p ON p.id = c.persona_id
LEFT JOIN cargos_colaboradores cc ON cc.id = c.cargo_id
WHERE c.activo = true
AND p.activo = true
AND (
normalizar_texto(p.nombres || ' ' || p.apellidos) % v_termino_norm
OR normalizar_texto(COALESCE(p.dui, '')) % v_termino_norm
OR normalizar_texto(p.nombres) % ANY(v_tokens)
OR normalizar_texto(p.apellidos) % ANY(v_tokens)
)
),
scoring AS (
SELECT
c.id,
(c.nombres || ' ' || c.apellidos || ' (' || COALESCE(c.cargo, 'Sin Cargo') || ')')::TEXT AS display_text,
(similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) * v_peso_nombre) AS score_nombre_completo,
(similarity(c.nombres_norm, v_termino_norm) * v_peso_nombre) AS score_nombres,
(similarity(c.apellidos_norm, v_termino_norm) * v_peso_apellido) AS score_apellidos,
(similarity(c.dui_norm, v_termino_norm) * v_peso_dui) AS score_dui,
(1.0 - (levenshtein(c.nombres_norm, v_termino_norm)::DOUBLE PRECISION
/ GREATEST(length(c.nombres_norm), length(v_termino_norm), 1))) * v_peso_nombre AS score_lev_nombres,
(
SELECT COALESCE(MAX(
GREATEST(
similarity(c.nombres_norm, tok),
similarity(c.apellidos_norm, tok)
)
), 0)
FROM unnest(v_tokens) tok
) * 0.9 AS score_tokens,
CASE
WHEN similarity(c.dui_norm, v_termino_norm) >= v_sim_threshold THEN 'dui'
WHEN similarity(c.nombres_norm || ' ' || c.apellidos_norm, v_termino_norm) >= v_sim_threshold THEN 'nombre_completo'
WHEN levenshtein(c.nombres_norm, v_termino_norm) <= v_lev_threshold THEN 'fuzzy'
ELSE 'token'
END AS match_type
FROM candidatos c
)
SELECT
s.id,
s.display_text,
GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_dui,
s.score_lev_nombres,
s.score_tokens
) AS score,
s.match_type
FROM scoring s
WHERE GREATEST(
s.score_nombre_completo,
s.score_nombres,
s.score_apellidos,
s.score_dui,
s.score_lev_nombres,
s.score_tokens
) > 0.1
ORDER BY score DESC
LIMIT p_limite;
-- ========================================================================
-- BÚSQUEDA PARA PROVEEDORES
-- ========================================================================
ELSIF upper(p_tipo) = 'PROVEEDOR' THEN
RETURN QUERY
WITH candidatos AS (
SELECT
pr.id,
pr.nombre,
COALESCE(pr.nit_dui, '') AS nit_dui,
normalizar_texto(pr.nombre) AS nombre_norm,
normalizar_texto(COALESCE(pr.nit_dui, '')) AS nit_norm
FROM proveedores pr
WHERE pr.activo = true
AND (
normalizar_texto(pr.nombre) % v_termino_norm
OR normalizar_texto(COALESCE(pr.nit_dui, '')) % v_termino_norm
OR normalizar_texto(pr.nombre) % ANY(v_tokens)
)
),
scoring AS (
SELECT
c.id,
(c.nombre || ' (NIT/DUI: ' || COALESCE(c.nit_dui, 'N/A') || ')')::TEXT AS display_text,
(similarity(c.nombre_norm, v_termino_norm) * v_peso_nombre) AS score_nombre,
(similarity(c.nit_norm, v_termino_norm) * v_peso_dui) AS score_nit,
(1.0 - (levenshtein(c.nombre_norm, v_termino_norm)::DOUBLE PRECISION
/ GREATEST(length(c.nombre_norm), length(v_termino_norm), 1))) * v_peso_nombre AS score_lev_nombre,
(
SELECT COALESCE(MAX(
similarity(c.nombre_norm, tok)
), 0)
FROM unnest(v_tokens) tok
) * 0.9 AS score_tokens,
CASE
WHEN similarity(c.nit_norm, v_termino_norm) >= v_sim_threshold THEN 'nit'
WHEN similarity(c.nombre_norm, v_termino_norm) >= v_sim_threshold THEN 'nombre'
WHEN levenshtein(c.nombre_norm, v_termino_norm) <= v_lev_threshold THEN 'fuzzy'
ELSE 'token'
END AS match_type
FROM candidatos c
)
SELECT
s.id::bigint,
s.display_text,
GREATEST(
s.score_nombre,
s.score_nit,
s.score_lev_nombre,
s.score_tokens
) AS score,
s.match_type
FROM scoring s
WHERE GREATEST(
s.score_nombre,
s.score_nit,
s.score_lev_nombre,
s.score_tokens
) > 0.1
ORDER BY score DESC
LIMIT p_limite;
ELSE
RAISE EXCEPTION 'Tipo no soportado: %. Use NINO, ENCARGADO, COLABORADOR o PROVEEDOR', p_tipo;
END IF;
END;
$BODY$;

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models
{
[Table("cajas_chicas")]
public class CajaChica
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[StringLength(100)]
[Column("nombre")]
public string Nombre { get; set; } = string.Empty;
[Column("responsable_usuario_id")]
public long ResponsableUsuarioId { get; set; }
[ForeignKey("ResponsableUsuarioId")]
public virtual Usuario? Responsable { get; set; }
[Column("monto_asignado", TypeName = "decimal(10,2)")]
public decimal MontoAsignado { get; set; }
[Column("saldo_actual", TypeName = "decimal(10,2)")]
public decimal SaldoActual { get; set; }
[Required]
[StringLength(20)]
[Column("estado")]
public string Estado { get; set; } = "ABIERTA"; // ABIERTA, CERRADA, ARQUEO
[Column("fecha_apertura")]
public DateOnly FechaApertura { get; set; }
[Column("fecha_cierre")]
public DateOnly? FechaCierre { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
public virtual ICollection<CajaChicaMovimiento> Movimientos { get; set; } = new List<CajaChicaMovimiento>();
}
}

View File

@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models
{
[Table("caja_chica_movimientos")]
public class CajaChicaMovimiento
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("caja_chica_id")]
public int CajaChicaId { get; set; }
[ForeignKey("CajaChicaId")]
public virtual CajaChica? CajaChica { get; set; }
[Required]
[StringLength(20)]
[Column("tipo_movimiento")]
public string TipoMovimiento { get; set; } = string.Empty; // GASTO, APERTURA, REPOSICION, etc.
[Column("fecha_movimiento")]
public DateOnly FechaMovimiento { get; set; }
[Column("monto", TypeName = "decimal(10,2)")]
public decimal Monto { get; set; }
[Required]
[Column("descripcion")]
public string Descripcion { get; set; } = string.Empty;
[Column("categoria_gasto_id")]
public int? CategoriaGastoId { get; set; }
[ForeignKey("CategoriaGastoId")]
public virtual CategoriaGasto? CategoriaGasto { get; set; }
[Column("proveedor_id")]
public int? ProveedorId { get; set; }
[ForeignKey("ProveedorId")]
public virtual Proveedor? Proveedor { get; set; }
[Column("usuario_registro_id")]
public long UsuarioRegistroId { get; set; }
[ForeignKey("UsuarioRegistroId")]
public virtual Usuario? UsuarioRegistro { get; set; }
[Column("estado_reembolso")]
[StringLength(20)]
public string EstadoReembolso { get; set; } = "PENDIENTE"; // PENDIENTE, REEMBOLSADO, ANULADO
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
public virtual ICollection<DocumentoSoporte> Documentos { get; set; } = new List<DocumentoSoporte>();
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models
{
[Table("categorias_gastos")]
public class CategoriaGasto
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[StringLength(100)]
[Column("nombre")]
public string Nombre { get; set; } = string.Empty;
[StringLength(50)]
[Column("codigo_cuenta_contable")]
public string? CodigoCuentaContable { get; set; }
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models
{
[Table("documentos_soporte")]
public class DocumentoSoporte
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("movimiento_id")]
public long MovimientoId { get; set; }
[ForeignKey("MovimientoId")]
public virtual CajaChicaMovimiento? Movimiento { get; set; }
[Required]
[StringLength(50)]
[Column("tipo_documento")]
public string TipoDocumento { get; set; } = string.Empty; // FACTURA, RECIBO, VALE
[StringLength(50)]
[Column("numero_documento")]
public string? NumeroDocumento { get; set; }
[Column("ruta_archivo")]
public string? RutaArchivo { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models
{
[Table("proveedores")]
public class Proveedor
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[StringLength(200)]
[Column("nombre")]
public string Nombre { get; set; } = string.Empty;
[StringLength(50)]
[Column("nit_dui")]
public string? NitDui { get; set; }
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels
{
public class CajaChicaViewModel
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Nombre { get; set; } = string.Empty;
[Required]
[Display(Name = "Responsable")]
public long ResponsableUsuarioId { get; set; }
[Required]
[Range(0.01, 10000, ErrorMessage = "El monto asignado debe ser mayor a 0.")]
[Display(Name = "Monto Asignado")]
public decimal MontoAsignado { get; set; }
[Required]
[DataType(DataType.Date)]
[Display(Name = "Fecha de Apertura")]
public DateOnly FechaApertura { get; set; } = DateOnly.FromDateTime(DateTime.Today);
}
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels
{
public class RegistroGastoViewModel
{
[Required]
public int CajaChicaId { get; set; }
public string? CajaChicaNombre { get; set; }
public decimal SaldoDisponible { get; set; }
[Required]
[Range(0.01, 10000, ErrorMessage = "El monto debe ser mayor a 0.")]
public decimal Monto { get; set; }
[Required]
public DateOnly Fecha { get; set; } = DateOnly.FromDateTime(DateTime.Today);
[Required]
public string Descripcion { get; set; } = string.Empty;
[Required(ErrorMessage = "Seleccione una categoría")]
public int CategoriaId { get; set; }
public int? ProveedorId { get; set; }
// For file upload if needed later
// public IFormFile? Comprobante { get; set; }
}
}

View File

@@ -0,0 +1,74 @@
@model foundation_system.Models.ViewModels.CajaChicaViewModel
@{
ViewData["Title"] = "Apertura de Caja Chica";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Datos de Apertura</h6>
</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="Nombre" class="form-label">Nombre de la Caja</label>
<input asp-for="Nombre" class="form-control" placeholder="Ej. Caja Chica Administración" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="ResponsableUsuarioId" class="form-label">Responsable (Custodio)</label>
<select asp-for="ResponsableUsuarioId" class="form-select" asp-items="ViewBag.ResponsableUsuarioId">
<option value="">-- Seleccione un Responsable --</option>
</select>
<span asp-validation-for="ResponsableUsuarioId" class="text-danger"></span>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="MontoAsignado" class="form-label">Monto del Fondo ($)</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="MontoAsignado" class="form-control" type="number" step="0.01" min="0" />
</div>
<span asp-validation-for="MontoAsignado" class="text-danger"></span>
</div>
<div class="col-md-6 mb-3">
<label asp-for="FechaApertura" class="form-label">Fecha de Apertura</label>
<input asp-for="FechaApertura" class="form-control" type="date" />
<span asp-validation-for="FechaApertura" class="text-danger"></span>
</div>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Al crear la caja, se registrará automáticamente un movimiento de "Apertura" por el monto asignado.
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-2"></i> Abrir Caja
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,176 @@
@model foundation_system.Models.CajaChica
@{
ViewData["Title"] = "Detalle de Caja Chica";
var porcentajeSaldo = (Model.MontoAsignado > 0) ? (Model.SaldoActual / Model.MontoAsignado) * 100 : 0;
var colorSaldo = porcentajeSaldo < 20 ? "danger" : (porcentajeSaldo < 50 ? "warning" : "success");
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0 text-gray-800">@Model.Nombre</h1>
<p class="mb-0 text-muted">Responsable: @Model.Responsable?.Persona?.Nombres @Model.Responsable?.Persona?.Apellidos</p>
</div>
<div>
<a asp-action="Index" class="btn btn-secondary me-2">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
@if (Model.Estado == "ABIERTA")
{
<a asp-controller="MovimientoCaja" asp-action="CreateGasto" asp-route-cajaId="@Model.Id" class="btn btn-danger me-2">
<i class="bi bi-dash-circle me-1"></i> Registrar Gasto
</a>
<a asp-controller="MovimientoCaja" asp-action="Reposicion" asp-route-cajaId="@Model.Id" class="btn btn-success me-2">
<i class="bi bi-arrow-repeat me-1"></i> Reposición
</a>
<form id="formCloseBox" asp-action="Close" asp-route-id="@Model.Id" method="post" class="d-inline">
<button type="button" class="btn btn-secondary" onclick="confirmClose()">
<i class="bi bi-lock me-1"></i> Cerrar Caja
</button>
</form>
}
else
{
<span class="badge bg-secondary p-2">CAJA CERRADA</span>
}
</div>
</div>
<!-- Status Cards -->
<div class="row mb-4">
<div class="col-xl-4 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">Monto Asignado</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@Model.MontoAsignado.ToString("C")</div>
</div>
<div class="col-auto">
<i class="bi bi-wallet2 fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-@colorSaldo 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-@colorSaldo text-uppercase mb-1">Saldo Disponible</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@Model.SaldoActual.ToString("C")</div>
</div>
<div class="col-auto">
<i class="bi bi-cash-stack fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 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">Gastos Pendientes</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
@Model.Movimientos.Where(m => m.TipoMovimiento == "GASTO" && m.EstadoReembolso == "PENDIENTE").Sum(m => m.Monto).ToString("C")
</div>
</div>
<div class="col-auto">
<i class="bi bi-receipt fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Movimientos Table -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Historial de Movimientos</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="dataTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Descripción</th>
<th>Categoría</th>
<th>Monto</th>
<th>Estado</th>
<th>Registrado Por</th>
</tr>
</thead>
<tbody>
@foreach (var mov in Model.Movimientos)
{
<tr>
<td>@mov.FechaMovimiento.ToString("dd/MM/yyyy")</td>
<td>
@if(mov.TipoMovimiento == "GASTO") { <span class="badge bg-danger">GASTO</span> }
else if(mov.TipoMovimiento == "REPOSICION") { <span class="badge bg-success">REPOSICIÓN</span> }
else { <span class="badge bg-info">@mov.TipoMovimiento</span> }
</td>
<td>@mov.Descripcion</td>
<td>@(mov.CategoriaGasto?.Nombre ?? "-")</td>
<td class="text-end">@mov.Monto.ToString("C")</td>
<td>
@if(mov.TipoMovimiento == "GASTO")
{
@if(mov.EstadoReembolso == "PENDIENTE") { <span class="badge bg-warning text-dark">Pendiente</span> }
else { <span class="badge bg-success">Reembolsado</span> }
}
else
{
<span>-</span>
}
</td>
<td>@mov.UsuarioRegistro?.Persona?.Nombres</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.border-left-primary { border-left: 0.25rem solid #4e73df !important; }
.border-left-success { border-left: 0.25rem solid #1cc88a !important; }
.border-left-danger { border-left: 0.25rem solid #e74a3b !important; }
.border-left-warning { border-left: 0.25rem solid #f6c23e !important; }
.border-left-info { border-left: 0.25rem solid #36b9cc !important; }
.text-xs { font-size: .7rem; }
</style>
}
@section Scripts {
<script>
function confirmClose() {
Swal.fire({
title: '¿Cerrar Caja Chica?',
text: "Esta acción cambiará el estado de la caja a CERRADA. Asegúrese de haber realizado el arqueo final.",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#6c757d',
cancelButtonColor: '#d33',
confirmButtonText: 'Sí, cerrar caja',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
document.getElementById('formCloseBox').submit();
}
})
}
</script>
}

View File

@@ -0,0 +1,61 @@
@model IEnumerable<foundation_system.Models.CajaChica>
@{
ViewData["Title"] = "Cajas Chicas";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Apertura de Caja
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>Responsable</th>
<th>Monto Asignado</th>
<th>Saldo Actual</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Nombre</td>
<td>@item.Responsable?.Persona?.Nombres @item.Responsable?.Persona?.Apellidos</td>
<td>@item.MontoAsignado.ToString("C")</td>
<td class="@(item.SaldoActual < (item.MontoAsignado * 0.2m) ? "text-danger fw-bold" : "text-success fw-bold")">
@item.SaldoActual.ToString("C")
</td>
<td>
@if (item.Estado == "ABIERTA")
{
<span class="badge bg-success">Abierta</span>
}
else
{
<span class="badge bg-secondary">@item.Estado</span>
}
</td>
<td>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-info text-white">
<i class="bi bi-eye"></i> Detalle
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,53 @@
@model foundation_system.Models.CategoriaGasto
@{
ViewData["Title"] = "Nueva Categoría de Gasto";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card shadow mb-4">
<div class="card-body">
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre</label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="CodigoCuentaContable" class="form-label">Código Contable</label>
<input asp-for="CodigoCuentaContable" class="form-control" />
<span asp-validation-for="CodigoCuentaContable" class="text-danger"></span>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" asp-for="Activo" />
<label class="form-check-label" asp-for="Activo">
Activo
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,55 @@
@model foundation_system.Models.CategoriaGasto
@{
ViewData["Title"] = "Editar Categoría de Gasto";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card shadow mb-4">
<div class="card-body">
<form asp-action="Edit">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="CreadoEn" />
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre</label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="CodigoCuentaContable" class="form-label">Código Contable</label>
<input asp-for="CodigoCuentaContable" class="form-control" />
<span asp-validation-for="CodigoCuentaContable" class="text-danger"></span>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" asp-for="Activo" />
<label class="form-check-label" asp-for="Activo">
Activo
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Guardar Cambios</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,55 @@
@model IEnumerable<foundation_system.Models.CategoriaGasto>
@{
ViewData["Title"] = "Categorías de Gasto";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Nueva Categoría
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>Código Contable</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Nombre</td>
<td>@item.CodigoCuentaContable</td>
<td>
@if (item.Activo)
{
<span class="badge bg-success">Activo</span>
}
else
{
<span class="badge bg-secondary">Inactivo</span>
}
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,160 @@
@model foundation_system.Models.ViewModels.RegistroGastoViewModel
@{
ViewData["Title"] = "Registrar Gasto";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-controller="CajaChica" asp-action="Details" asp-route-id="@Model.CajaChicaId" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Detalles del Gasto</h6>
<span class="badge bg-success">Saldo Disponible: @Model.SaldoDisponible.ToString("C")</span>
</div>
<div class="card-body">
<form asp-action="CreateGasto">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="CajaChicaId" />
<div class="row">
<div class="col-md-6 mb-3">
<label asp-for="Fecha" class="form-label">Fecha</label>
<input asp-for="Fecha" class="form-control" type="date" />
<span asp-validation-for="Fecha" class="text-danger"></span>
</div>
<div class="col-md-6 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" min="0.01" max="@Model.SaldoDisponible" />
</div>
<span asp-validation-for="Monto" class="text-danger"></span>
</div>
</div>
<div class="mb-3">
<label asp-for="CategoriaId" class="form-label">Categoría</label>
<select asp-for="CategoriaId" class="form-select" asp-items="ViewBag.CategoriaId">
<option value="">-- Seleccione una Categoría --</option>
</select>
<span asp-validation-for="CategoriaId" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="ProveedorId" class="form-label">Proveedor (Opcional)</label>
<div class="input-group">
<input type="hidden" asp-for="ProveedorId" id="ProveedorId" />
<input type="text" id="ProveedorDisplay" class="form-control" placeholder="Buscar proveedor..." readonly />
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#searchModal">
<i class="bi bi-search"></i> Buscar
</button>
<button class="btn btn-outline-danger" type="button" id="btnClearProveedor" title="Limpiar selección">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label">Descripción / Concepto</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">
<button type="submit" class="btn btn-danger">
<i class="bi bi-dash-circle me-2"></i> Registrar Gasto
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Buscar Proveedor</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<input type="text" id="searchInput" class="form-control" placeholder="Escriba el nombre o NIT del proveedor..." autocomplete="off">
<button class="btn btn-outline-primary" type="button" id="btnSearch">Buscar</button>
</div>
<div class="list-group" id="searchResults">
<!-- Results will be populated here -->
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
$(document).ready(function() {
let searchTimeout;
// Search function
function performSearch() {
const term = $('#searchInput').val();
if (term.length < 2) return;
$('#searchResults').html('<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>');
$.get('@Url.Action("SearchProveedores", "MovimientoCaja")', { term: term })
.done(function(data) {
$('#searchResults').empty();
if (data.length === 0) {
$('#searchResults').append('<div class="list-group-item text-muted">No se encontraron resultados</div>');
} else {
data.forEach(function(item) {
const btn = $('<button type="button" class="list-group-item list-group-item-action"></button>')
.text(item.text)
.click(function() {
$('#ProveedorId').val(item.id);
$('#ProveedorDisplay').val(item.text);
$('#searchModal').modal('hide');
});
$('#searchResults').append(btn);
});
}
})
.fail(function() {
$('#searchResults').html('<div class="list-group-item text-danger">Error al buscar proveedores</div>');
});
}
// Event listeners
$('#btnSearch').click(performSearch);
$('#searchInput').on('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(performSearch, 500);
});
$('#btnClearProveedor').click(function() {
$('#ProveedorId').val('');
$('#ProveedorDisplay').val('');
});
// Focus input when modal opens
$('#searchModal').on('shown.bs.modal', function () {
$('#searchInput').focus();
});
});
</script>
}

View File

@@ -0,0 +1,91 @@
@model foundation_system.Models.CajaChica
@{
ViewData["Title"] = "Reposición de Fondo";
var gastosPendientes = Model.Movimientos.Where(m => m.TipoMovimiento == "GASTO" && m.EstadoReembolso == "PENDIENTE").ToList();
var totalReembolso = gastosPendientes.Sum(m => m.Monto);
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-controller="CajaChica" asp-action="Details" asp-route-id="@Model.Id" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Gastos Pendientes de Reembolso</h6>
<h5 class="m-0 font-weight-bold text-success">Total a Reembolsar: @totalReembolso.ToString("C")</h5>
</div>
<div class="card-body">
@if (gastosPendientes.Any())
{
<form asp-action="ConfirmarReposicion" method="post">
<input type="hidden" name="cajaId" value="@Model.Id" />
<div class="table-responsive mb-4">
<table class="table table-hover">
<thead>
<tr>
<th><input type="checkbox" id="checkAll" checked /></th>
<th>Fecha</th>
<th>Descripción</th>
<th>Categoría</th>
<th class="text-end">Monto</th>
</tr>
</thead>
<tbody>
@foreach (var item in gastosPendientes)
{
<tr>
<td>
<input type="checkbox" name="movimientoIds" value="@item.Id" checked class="mov-check" />
</td>
<td>@item.FechaMovimiento.ToString("dd/MM/yyyy")</td>
<td>@item.Descripcion</td>
<td>@(item.CategoriaGasto?.Nombre ?? "-")</td>
<td class="text-end">@item.Monto.ToString("C")</td>
</tr>
}
</tbody>
</table>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
Al confirmar, se generará un movimiento de "REPOSICIÓN" por el total seleccionado y el saldo de la caja aumentará.
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-check-lg me-2"></i> Confirmar Reposición
</button>
</div>
</form>
}
else
{
<div class="text-center py-5">
<p class="text-muted">No hay gastos pendientes de reembolso.</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.getElementById('checkAll').addEventListener('change', function() {
var checkboxes = document.getElementsByClassName('mov-check');
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = this.checked;
}
});
</script>
}

View File

@@ -0,0 +1,53 @@
@model foundation_system.Models.Proveedor
@{
ViewData["Title"] = "Nuevo Proveedor";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card shadow mb-4">
<div class="card-body">
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre / Razón Social</label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="NitDui" class="form-label">NIT / DUI</label>
<input asp-for="NitDui" class="form-control" />
<span asp-validation-for="NitDui" class="text-danger"></span>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" asp-for="Activo" />
<label class="form-check-label" asp-for="Activo">
Activo
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,55 @@
@model foundation_system.Models.Proveedor
@{
ViewData["Title"] = "Editar Proveedor";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left me-1"></i> Volver
</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card shadow mb-4">
<div class="card-body">
<form asp-action="Edit">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="CreadoEn" />
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre / Razón Social</label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="NitDui" class="form-label">NIT / DUI</label>
<input asp-for="NitDui" class="form-control" />
<span asp-validation-for="NitDui" class="text-danger"></span>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" asp-for="Activo" />
<label class="form-check-label" asp-for="Activo">
Activo
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Guardar Cambios</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,55 @@
@model IEnumerable<foundation_system.Models.Proveedor>
@{
ViewData["Title"] = "Proveedores";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0 text-gray-800">@ViewData["Title"]</h1>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Nuevo Proveedor
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>NIT / DUI</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Nombre</td>
<td>@item.NitDui</td>
<td>
@if (item.Activo)
{
<span class="badge bg-success">Activo</span>
}
else
{
<span class="badge bg-secondary">Inactivo</span>
}
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -63,6 +64,7 @@
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>