Version Stable 001

This commit is contained in:
2025-12-31 13:36:41 -06:00
parent 4ab3e9e756
commit 09a1523e9d
41 changed files with 1929 additions and 181 deletions

View File

@@ -29,8 +29,9 @@ public class MenuViewComponent : ViewComponent
if (isRoot)
{
menuItems = await _context.Permisos
.Include(p => p.Modulo)
.Where(p => p.EsMenu)
.OrderBy(p => p.Modulo)
.OrderBy(p => p.Modulo!.Orden)
.ThenBy(p => p.Orden)
.ToListAsync();
}
@@ -40,8 +41,9 @@ public class MenuViewComponent : ViewComponent
.Where(ru => ru.UsuarioId == userId)
.Join(_context.RolesPermisos, ru => ru.RolId, rp => rp.RolId, (ru, rp) => rp)
.Join(_context.Permisos, rp => rp.PermisoId, p => p.Id, (rp, p) => p)
.Include(p => p.Modulo)
.Where(p => p.EsMenu)
.OrderBy(p => p.Modulo)
.OrderBy(p => p.Modulo!.Orden)
.ThenBy(p => p.Orden)
.Distinct()
.ToListAsync();

View File

@@ -2,11 +2,13 @@ using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using foundation_system.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers;
[Authorize]
public class AntecedentesController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using Microsoft.AspNetCore.Authorization;
namespace foundation_system.Controllers;
[Authorize]
public class AsistenciaController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
namespace foundation_system.Controllers;
[Authorize]
public class CargoColaboradorController : Controller
{
private readonly ApplicationDbContext _context;
public CargoColaboradorController(ApplicationDbContext context)
{
_context = context;
}
// GET: CargoColaborador
public async Task<IActionResult> Index()
{
return View(await _context.CargosColaboradores.OrderBy(c => c.Nombre).ToListAsync());
}
// GET: CargoColaborador/Create
public IActionResult Create()
{
return View();
}
// POST: CargoColaborador/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Nombre,Descripcion,Activo")] CargoColaborador cargo)
{
if (ModelState.IsValid)
{
cargo.CreadoEn = DateTime.UtcNow;
_context.Add(cargo);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(cargo);
}
// GET: CargoColaborador/Edit/5
public async Task<IActionResult> Edit(long? id)
{
if (id == null) return NotFound();
var cargo = await _context.CargosColaboradores.FindAsync(id);
if (cargo == null) return NotFound();
return View(cargo);
}
// POST: CargoColaborador/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(long id, [Bind("Id,Nombre,Descripcion,Activo,CreadoEn")] CargoColaborador cargo)
{
if (id != cargo.Id) return NotFound();
if (ModelState.IsValid)
{
try
{
_context.Update(cargo);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!CargoExists(cargo.Id)) return NotFound();
else throw;
}
return RedirectToAction(nameof(Index));
}
return View(cargo);
}
// POST: CargoColaborador/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(long id)
{
var cargo = await _context.CargosColaboradores.FindAsync(id);
if (cargo != null)
{
var isUsed = await _context.Colaboradores.AnyAsync(c => c.CargoId == id);
if (isUsed)
{
TempData["ErrorMessage"] = "No se puede eliminar porque hay colaboradores asignados a este cargo.";
return RedirectToAction(nameof(Index));
}
_context.CargosColaboradores.Remove(cargo);
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
private bool CargoExists(long id)
{
return _context.CargosColaboradores.Any(e => e.Id == id);
}
}

View File

@@ -20,10 +20,15 @@ public class ColaboradorAsistenciaController : Controller
public async Task<IActionResult> Index(DateOnly? fecha)
{
var selectedDate = fecha ?? DateOnly.FromDateTime(DateTime.Today);
if (fecha.HasValue)
{
TempData["InfoMessage"] = $"Mostrando asistencia del {selectedDate:dd/MM/yyyy}";
}
ViewBag.SelectedDate = selectedDate;
var colaboradores = await _context.Colaboradores
.Include(c => c.Persona)
.Include(c => c.Cargo)
.Where(c => c.Activo)
.OrderBy(c => c.Persona.Apellidos)
.ToListAsync();
@@ -63,7 +68,86 @@ public class ColaboradorAsistenciaController : Controller
asistencia.Observaciones = observaciones;
}
await _context.SaveChangesAsync();
await _context.SaveChangesAsync();
return Json(new { success = true });
}
// GET: ColaboradorAsistencia/ImprimirAsistencia
public async Task<IActionResult> ImprimirAsistencia(DateOnly? fecha)
{
var selectedDate = fecha ?? DateOnly.FromDateTime(DateTime.Today);
ViewBag.SelectedDate = selectedDate;
var colaboradores = await _context.Colaboradores
.Include(c => c.Persona)
.Include(c => c.Cargo)
.Where(c => c.Activo)
.OrderBy(c => c.Persona.Apellidos)
.ToListAsync();
var asistencias = await _context.AsistenciasColaboradores
.Where(a => a.Fecha == selectedDate)
.ToDictionaryAsync(a => a.ColaboradorId);
ViewBag.Asistencias = asistencias;
return View(colaboradores);
}
// GET: ColaboradorAsistencia/ReporteAsistencia
public async Task<IActionResult> ReporteAsistencia(long? colaboradorId, DateOnly? inicio, DateOnly? fin)
{
var startDate = inicio ?? DateOnly.FromDateTime(DateTime.Today.AddDays(-30));
var endDate = fin ?? DateOnly.FromDateTime(DateTime.Today);
ViewBag.Inicio = startDate;
ViewBag.Fin = endDate;
ViewBag.ColaboradorId = colaboradorId;
ViewBag.Colaboradores = await _context.Colaboradores
.Include(c => c.Persona)
.Include(c => c.Cargo)
.Where(c => c.Activo)
.OrderBy(c => c.Persona.Apellidos)
.ToListAsync();
if (colaboradorId.HasValue)
{
var asistencias = await _context.AsistenciasColaboradores
.Include(a => a.Colaborador)
.ThenInclude(c => c.Persona)
.Where(a => a.ColaboradorId == colaboradorId && a.Fecha >= startDate && a.Fecha <= endDate)
.OrderByDescending(a => a.Fecha)
.ToListAsync();
return View(asistencias);
}
return View(new List<AsistenciaColaborador>());
}
// GET: ColaboradorAsistencia/SearchColaboradores
[HttpGet]
public async Task<IActionResult> SearchColaboradores(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, 'COLABORADOR')",
term)
.ToListAsync();
return Json(results);
}
}
public class SearchResult
{
public long Id { get; set; }
public string Text { get; set; } = string.Empty;
public double Score { get; set; }
}

View File

@@ -22,14 +22,16 @@ public class ColaboradorController : Controller
{
var colaboradores = await _context.Colaboradores
.Include(c => c.Persona)
.Include(c => c.Cargo)
.OrderBy(c => c.Persona.Apellidos)
.ToListAsync();
return View(colaboradores);
}
// GET: Colaborador/Create
public IActionResult Create()
public async Task<IActionResult> Create()
{
ViewBag.Cargos = await _context.CargosColaboradores.Where(c => c.Activo).OrderBy(c => c.Nombre).ToListAsync();
return View(new ColaboradorViewModel());
}
@@ -63,7 +65,7 @@ public class ColaboradorController : Controller
var colaborador = new Colaborador
{
PersonaId = persona.Id,
Cargo = model.Cargo,
CargoId = model.CargoId,
TipoColaborador = model.TipoColaborador,
FechaIngreso = model.FechaIngreso,
HorarioEntrada = model.HorarioEntrada,
@@ -84,6 +86,7 @@ public class ColaboradorController : Controller
ModelState.AddModelError("", "Ocurrió un error al guardar el colaborador.");
}
}
ViewBag.Cargos = await _context.CargosColaboradores.Where(c => c.Activo).OrderBy(c => c.Nombre).ToListAsync();
return View(model);
}
@@ -94,6 +97,7 @@ public class ColaboradorController : Controller
var colaborador = await _context.Colaboradores
.Include(c => c.Persona)
.Include(c => c.Cargo)
.FirstOrDefaultAsync(c => c.Id == id);
if (colaborador == null) return NotFound();
@@ -111,7 +115,7 @@ public class ColaboradorController : Controller
Email = colaborador.Persona.Email,
Telefono = colaborador.Persona.Telefono,
Direccion = colaborador.Persona.Direccion,
Cargo = colaborador.Cargo,
CargoId = colaborador.CargoId,
TipoColaborador = colaborador.TipoColaborador,
FechaIngreso = colaborador.FechaIngreso,
HorarioEntrada = colaborador.HorarioEntrada,
@@ -119,6 +123,7 @@ public class ColaboradorController : Controller
Activo = colaborador.Activo
};
ViewBag.Cargos = await _context.CargosColaboradores.Where(c => c.Activo).OrderBy(c => c.Nombre).ToListAsync();
return View(model);
}
@@ -152,7 +157,7 @@ public class ColaboradorController : Controller
colaborador.Persona.Direccion = model.Direccion;
// Update Colaborador
colaborador.Cargo = model.Cargo;
colaborador.CargoId = model.CargoId;
colaborador.TipoColaborador = model.TipoColaborador;
colaborador.FechaIngreso = model.FechaIngreso;
colaborador.HorarioEntrada = model.HorarioEntrada;
@@ -172,6 +177,7 @@ public class ColaboradorController : Controller
ModelState.AddModelError("", "Ocurrió un error al actualizar el colaborador.");
}
}
ViewBag.Cargos = await _context.CargosColaboradores.Where(c => c.Activo).OrderBy(c => c.Nombre).ToListAsync();
return View(model);
}

View File

@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Authorization;
namespace foundation_system.Controllers;
[Authorize(Roles = "ROOT,SUPERADMIN")]
[Authorize]
public class ConfiguracionController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -4,9 +4,11 @@ using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using foundation_system.Services;
using Microsoft.AspNetCore.Authorization;
namespace foundation_system.Controllers;
[Authorize]
public class ExpedienteController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using Microsoft.AspNetCore.Authorization;
namespace foundation_system.Controllers;
[Authorize]
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
namespace foundation_system.Controllers;
[Authorize]
public class ModuloController : Controller
{
private readonly ApplicationDbContext _context;
public ModuloController(ApplicationDbContext context)
{
_context = context;
}
// GET: Modulo
public async Task<IActionResult> Index()
{
return View(await _context.Modulos.OrderBy(m => m.Orden).ToListAsync());
}
// GET: Modulo/Create
public IActionResult Create()
{
return View();
}
// POST: Modulo/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Nombre,Icono,Orden,Activo")] Modulo modulo)
{
if (ModelState.IsValid)
{
modulo.CreadoEn = DateTime.UtcNow;
_context.Add(modulo);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(modulo);
}
// GET: Modulo/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var modulo = await _context.Modulos.FindAsync(id);
if (modulo == null) return NotFound();
return View(modulo);
}
// POST: Modulo/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Nombre,Icono,Orden,Activo")] Modulo modulo)
{
if (id != modulo.Id) return NotFound();
if (ModelState.IsValid)
{
try
{
_context.Update(modulo);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ModuloExists(modulo.Id)) return NotFound();
else throw;
}
return RedirectToAction(nameof(Index));
}
return View(modulo);
}
// POST: Modulo/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var modulo = await _context.Modulos.FindAsync(id);
if (modulo != null)
{
var isUsed = await _context.Permisos.AnyAsync(p => p.ModuloId == id);
if (isUsed)
{
TempData["ErrorMessage"] = "No se puede eliminar porque tiene permisos asociados.";
return RedirectToAction(nameof(Index));
}
_context.Modulos.Remove(modulo);
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
private bool ModuloExists(int id)
{
return _context.Modulos.Any(e => e.Id == id);
}
}

View File

@@ -6,7 +6,7 @@ using foundation_system.Models;
namespace foundation_system.Controllers;
[Authorize(Roles = "ROOT")]
[Authorize]
public class PermisoController : Controller
{
private readonly ApplicationDbContext _context;
@@ -19,25 +19,32 @@ public class PermisoController : Controller
// GET: Permiso
public async Task<IActionResult> Index()
{
return View(await _context.Permisos.OrderBy(p => p.Modulo).ThenBy(p => p.Orden).ToListAsync());
var permisos = await _context.Permisos
.Include(p => p.Modulo)
.OrderBy(p => p.Modulo!.Orden)
.ThenBy(p => p.Orden)
.ToListAsync();
return View(permisos);
}
// GET: Permiso/Create
public IActionResult Create()
public async Task<IActionResult> Create()
{
ViewBag.Modulos = await _context.Modulos.OrderBy(m => m.Orden).ToListAsync();
return View();
}
// POST: Permiso/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Modulo,Codigo,Nombre,Descripcion,Url,Icono,Orden,EsMenu")] Permiso permiso)
public async Task<IActionResult> Create([Bind("ModuloId,Codigo,Nombre,Descripcion,Url,Icono,Orden,EsMenu")] Permiso permiso)
{
if (ModelState.IsValid)
{
if (await _context.Permisos.AnyAsync(p => p.Codigo == permiso.Codigo))
{
ModelState.AddModelError("Codigo", "El código ya existe.");
ViewBag.Modulos = await _context.Modulos.OrderBy(m => m.Orden).ToListAsync();
return View(permiso);
}
@@ -46,6 +53,7 @@ public class PermisoController : Controller
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
ViewBag.Modulos = await _context.Modulos.OrderBy(m => m.Orden).ToListAsync();
return View(permiso);
}
@@ -56,13 +64,15 @@ public class PermisoController : Controller
var permiso = await _context.Permisos.FindAsync(id);
if (permiso == null) return NotFound();
ViewBag.Modulos = await _context.Modulos.OrderBy(m => m.Orden).ToListAsync();
return View(permiso);
}
// POST: Permiso/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Modulo,Codigo,Nombre,Descripcion,Url,Icono,Orden,EsMenu")] Permiso permiso)
public async Task<IActionResult> Edit(int id, [Bind("Id,ModuloId,Codigo,Nombre,Descripcion,Url,Icono,Orden,EsMenu")] Permiso permiso)
{
if (id != permiso.Id) return NotFound();
@@ -80,6 +90,7 @@ public class PermisoController : Controller
}
return RedirectToAction(nameof(Index));
}
ViewBag.Modulos = await _context.Modulos.OrderBy(m => m.Orden).ToListAsync();
return View(permiso);
}

View File

@@ -6,7 +6,7 @@ using foundation_system.Models;
namespace foundation_system.Controllers;
[Authorize(Roles = "ROOT")]
[Authorize]
public class RolController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Authorization;
namespace foundation_system.Controllers;
[Authorize(Roles = "ROOT,SUPERADMIN")]
[Authorize]
public class UsuarioController : Controller
{
private readonly ApplicationDbContext _context;

View File

@@ -15,8 +15,10 @@ public class ApplicationDbContext : DbContext
public DbSet<RolSistema> RolesSistema { get; set; }
public DbSet<RolUsuario> RolesUsuario { get; set; }
public DbSet<Permiso> Permisos { get; set; }
public DbSet<Modulo> Modulos { get; set; }
public DbSet<RolPermiso> RolesPermisos { get; set; }
public DbSet<Colaborador> Colaboradores { get; set; }
public DbSet<CargoColaborador> CargosColaboradores { get; set; }
public DbSet<AsistenciaColaborador> AsistenciasColaboradores { get; set; }
public DbSet<Nino> Ninos { get; set; }
public DbSet<Asistencia> Asistencias { get; set; }
@@ -57,11 +59,21 @@ public class ApplicationDbContext : DbContext
.WithMany()
.HasForeignKey(rp => rp.PermisoId);
modelBuilder.Entity<Permiso>()
.HasOne(p => p.Modulo)
.WithMany(m => m.Permisos)
.HasForeignKey(p => p.ModuloId);
modelBuilder.Entity<Usuario>()
.HasOne(u => u.Persona)
.WithMany()
.HasForeignKey(u => u.PersonaId);
modelBuilder.Entity<Colaborador>()
.HasOne(c => c.Cargo)
.WithMany(cc => cc.Colaboradores)
.HasForeignKey(c => c.CargoId);
// Global configuration: Convert all dates to UTC when saving
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
namespace foundation_system.Filters;
public class DynamicAuthorizationFilter : IAsyncAuthorizationFilter
{
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// Skip if user is not authenticated
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
{
return;
}
// Get the controller action descriptor
if (context.ActionDescriptor is not ControllerActionDescriptor descriptor)
{
return;
}
// Allow access to Account and Home controllers by default for authenticated users
var controllerName = descriptor.ControllerName;
if (controllerName.Equals("Account", StringComparison.OrdinalIgnoreCase) ||
controllerName.Equals("Home", StringComparison.OrdinalIgnoreCase))
{
return;
}
// Check for AllowAnonymous attribute
if (descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true).Any() ||
descriptor.ControllerTypeInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true).Any())
{
return;
}
var user = context.HttpContext.User;
// ROOT role always has access
if (user.IsInRole("ROOT"))
{
return;
}
// Check if user has permission for this controller
// The permission code is expected to match the Controller Name (e.g., "Usuario", "Rol", "Colaborador")
// In AccountController, we added claims of type "Permission" with the permission code
var hasPermission = user.HasClaim(c => c.Type == "Permission" &&
c.Value.Equals(controllerName, StringComparison.OrdinalIgnoreCase));
if (!hasPermission)
{
context.Result = new ForbidResult();
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
-- 1. Create cargos_colaboradores table
CREATE TABLE IF NOT EXISTS public.cargos_colaboradores
(
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
nombre character varying(100) COLLATE pg_catalog."default" NOT NULL,
descripcion text COLLATE pg_catalog."default",
activo boolean NOT NULL DEFAULT true,
creado_en timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT cargos_colaboradores_pkey PRIMARY KEY (id)
);
-- 2. Insert unique cargos from colaboradores into cargos_colaboradores
INSERT INTO public.cargos_colaboradores (nombre)
SELECT DISTINCT cargo FROM public.colaboradores
WHERE cargo IS NOT NULL AND cargo <> '';
-- 3. Add cargo_id column to colaboradores
ALTER TABLE public.colaboradores ADD COLUMN IF NOT EXISTS cargo_id bigint;
-- 4. Update colaboradores.cargo_id based on matching names
UPDATE public.colaboradores c
SET cargo_id = cc.id
FROM public.cargos_colaboradores cc
WHERE c.cargo = cc.nombre;
-- 5. Add foreign key constraint
ALTER TABLE public.colaboradores
ADD CONSTRAINT fk_colaboradores_cargos FOREIGN KEY (cargo_id)
REFERENCES public.cargos_colaboradores (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE;
-- 6. Drop old cargo column
ALTER TABLE public.colaboradores DROP COLUMN IF EXISTS cargo;

View File

@@ -0,0 +1,38 @@
-- 1. Create modulos table
CREATE TABLE IF NOT EXISTS public.modulos
(
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,
icono character varying(50) COLLATE pg_catalog."default",
orden integer NOT NULL DEFAULT 0,
activo boolean NOT NULL DEFAULT true,
creado_en timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT modulos_pkey PRIMARY KEY (id)
);
-- 2. Insert unique module names from permisos into modulos
INSERT INTO public.modulos (nombre)
SELECT DISTINCT modulo FROM public.permisos
WHERE modulo IS NOT NULL AND modulo <> '';
-- 3. Add modulo_id column to permisos
ALTER TABLE public.permisos ADD COLUMN IF NOT EXISTS modulo_id integer;
-- 4. Update permisos.modulo_id based on matching names
UPDATE public.permisos p
SET modulo_id = m.id
FROM public.modulos m
WHERE p.modulo = m.nombre;
-- 5. Make modulo_id NOT NULL (after ensuring all are updated)
-- ALTER TABLE public.permisos ALTER COLUMN modulo_id SET NOT NULL;
-- 6. Add foreign key constraint
ALTER TABLE public.permisos
ADD CONSTRAINT fk_permisos_modulos FOREIGN KEY (modulo_id)
REFERENCES public.modulos (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE;
-- 7. Drop old modulo column
ALTER TABLE public.permisos DROP COLUMN IF EXISTS modulo;

View File

@@ -0,0 +1,298 @@
-- FUNCTION: public.buscar_personas_v2(text, text, integer)
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;
ELSE
RAISE EXCEPTION 'Tipo no soportado: %. Use NINO, ENCARGADO o COLABORADOR', p_tipo;
END IF;
END;
$BODY$;
ALTER FUNCTION public.buscar_personas_v2(text, text, integer)
OWNER TO ecclesia;

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("cargos_colaboradores", Schema = "public")]
public class CargoColaborador
{
[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("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
public virtual ICollection<Colaborador> Colaboradores { get; set; } = new List<Colaborador>();
}

View File

@@ -16,10 +16,12 @@ public class Colaborador
[ForeignKey("PersonaId")]
public virtual Persona Persona { get; set; } = null!;
[Column("cargo_id")]
[Required]
[MaxLength(100)]
[Column("cargo")]
public string Cargo { get; set; } = string.Empty;
public long CargoId { get; set; }
[ForeignKey("CargoId")]
public virtual CargoColaborador? Cargo { get; set; }
[Required]
[MaxLength(50)]

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("modulos")]
public class Modulo
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("nombre")]
[Required]
[StringLength(100)]
public string Nombre { get; set; } = string.Empty;
[Column("icono")]
[StringLength(50)]
public string? Icono { get; set; }
[Column("orden")]
public int Orden { get; set; } = 0;
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
// Navigation property
public virtual ICollection<Permiso> Permisos { get; set; } = new List<Permiso>();
}

View File

@@ -10,10 +10,12 @@ public class Permiso
[Column("id")]
public int Id { get; set; }
[Column("modulo")]
[Column("modulo_id")]
[Required]
[StringLength(50)]
public string Modulo { get; set; } = string.Empty;
public int ModuloId { get; set; }
[ForeignKey("ModuloId")]
public virtual Modulo? Modulo { get; set; }
[Column("codigo")]
[Required]

View File

@@ -39,7 +39,7 @@ public class ColaboradorViewModel
[Required(ErrorMessage = "El cargo es requerido")]
[Display(Name = "Cargo")]
public string Cargo { get; set; } = string.Empty;
public long CargoId { get; set; }
[Required(ErrorMessage = "El tipo de colaborador es requerido")]
[Display(Name = "Tipo de Colaborador")]

View File

@@ -38,6 +38,7 @@ builder.Services.AddControllersWithViews(options =>
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter(policy));
options.Filters.Add(new foundation_system.Filters.DynamicAuthorizationFilter());
});
var app = builder.Build();

View File

@@ -0,0 +1,54 @@
@model foundation_system.Models.CargoColaborador
@{
ViewData["Title"] = "Nuevo Cargo";
}
<div class="container-fluid">
<div class="row justify-content-center">
<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">@ViewData["Title"]</h6>
<a asp-action="Index" class="btn btn-sm btn-secondary">
<i class="bi bi-arrow-left"></i> Volver
</a>
</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 del Cargo</label>
<input asp-for="Nombre" class="form-control" placeholder="Ej. Encargado, Cocinera/o, Maestra/o..." />
<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"></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" asp-for="Activo">
<label class="form-check-label" asp-for="Activo">Activo</label>
</div>
</div>
<div class="mt-4 text-end">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Guardar Cargo
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,56 @@
@model foundation_system.Models.CargoColaborador
@{
ViewData["Title"] = "Editar Cargo";
}
<div class="container-fluid">
<div class="row justify-content-center">
<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">@ViewData["Title"]: @Model.Nombre</h6>
<a asp-action="Index" class="btn btn-sm btn-secondary">
<i class="bi bi-arrow-left"></i> Volver
</a>
</div>
<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 del Cargo</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="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="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" asp-for="Activo">
<label class="form-check-label" asp-for="Activo">Activo</label>
</div>
</div>
<div class="mt-4 text-end">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,84 @@
@model IEnumerable<foundation_system.Models.CargoColaborador>
@{
ViewData["Title"] = "Gestión de Cargos de Colaboradores";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h3 mb-0 text-gray-800"><i class="bi bi-briefcase me-2"></i>@ViewData["Title"]</h2>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Nuevo Cargo
</a>
</div>
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i> @TempData["ErrorMessage"]
<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">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th class="text-center">Estado</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.Nombre</td>
<td>@item.Descripcion</td>
<td class="text-center">
@if (item.Activo)
{
<span class="text-success"><i class="bi bi-check-circle-fill"></i></span>
}
else
{
<span class="text-muted"><i class="bi bi-x-circle"></i></span>
}
</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-outline-danger" title="Eliminar"
onclick="confirmDelete(@item.Id, '@item.Nombre')">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<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, name) {
if (confirm(`¿Está seguro de que desea eliminar el cargo "${name}"? Solo se eliminará si no tiene colaboradores asociados.`)) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
}

View File

@@ -72,9 +72,11 @@
<h6 class="text-primary border-bottom pb-2 mb-3">Información Laboral</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="Cargo" class="form-label"></label>
<input asp-for="Cargo" class="form-control" placeholder="Ej: Maestro, Cocinero, Administrador" />
<span asp-validation-for="Cargo" class="text-danger small"></span>
<label asp-for="CargoId" class="form-label"></label>
<select asp-for="CargoId" class="form-select" asp-items="@(new SelectList(ViewBag.Cargos, "Id", "Nombre"))">
<option value="">Seleccione un cargo...</option>
</select>
<span asp-validation-for="CargoId" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="TipoColaborador" class="form-label"></label>

View File

@@ -73,9 +73,11 @@
<h6 class="text-primary border-bottom pb-2 mb-3">Información Laboral</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="Cargo" class="form-label"></label>
<input asp-for="Cargo" class="form-control" />
<span asp-validation-for="Cargo" class="text-danger small"></span>
<label asp-for="CargoId" class="form-label"></label>
<select asp-for="CargoId" class="form-select" asp-items="@(new SelectList(ViewBag.Cargos, "Id", "Nombre"))">
<option value="">Seleccione un cargo...</option>
</select>
<span asp-validation-for="CargoId" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="TipoColaborador" class="form-label"></label>

View File

@@ -42,7 +42,7 @@
</div>
</div>
</td>
<td><span class="badge bg-info text-dark">@item.Cargo</span></td>
<td><span class="badge bg-info text-dark">@(item.Cargo?.Nombre ?? "Sin Cargo")</span></td>
<td>@item.TipoColaborador</td>
<td>@item.Persona.Dui</td>
<td>@item.Persona.Telefono</td>

View File

@@ -0,0 +1,98 @@
@model IEnumerable<foundation_system.Models.Colaborador>
@{
Layout = null;
var selectedDate = (DateOnly)ViewBag.SelectedDate;
var asistencias = (Dictionary<long, foundation_system.Models.AsistenciaColaborador>)ViewBag.Asistencias;
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Asistencia - @selectedDate.ToString("dd/MM/yyyy")</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { font-family: 'Inter', sans-serif; padding: 20px; background: white; }
.report-header { border-bottom: 2px solid #333; margin-bottom: 20px; padding-bottom: 10px; }
.table th { background-color: #f8f9fa !important; }
@@media print {
.no-print { display: none !important; }
body { padding: 0; }
.table th { background-color: #f8f9fa !important; -webkit-print-color-adjust: exact; }
}
.status-PRESENTE { color: #198754; font-weight: bold; }
.status-AUSENTE { color: #dc3545; font-weight: bold; }
.status-TARDANZA { color: #fd7e14; font-weight: bold; }
.status-JUSTIFICADO { color: #0dcaf0; font-weight: bold; }
</style>
</head>
<body>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center no-print mb-4">
<a href="javascript:window.history.back()" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Volver</a>
<button onclick="window.print()" class="btn btn-primary"><i class="bi bi-printer"></i> Imprimir Reporte</button>
</div>
<div class="report-header text-center">
<h2>Misión Esperanza (MIES)</h2>
<h4>Control de Asistencia de Colaboradores</h4>
<p class="mb-0"><strong>Fecha:</strong> @selectedDate.ToString("dddd, dd de MMMM de yyyy")</p>
</div>
<table class="table table-bordered table-striped align-middle">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Colaborador</th>
<th>Cargo</th>
<th class="text-center">Estado</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody>
@{ int i = 1; }
@foreach (var item in Model)
{
var asistencia = asistencias.ContainsKey(item.Id) ? asistencias[item.Id] : null;
<tr>
<td>@i++</td>
<td>
<strong>@item.Persona.Apellidos, @item.Persona.Nombres</strong>
</td>
<td>@(item.Cargo?.Nombre ?? "Sin Cargo")</td>
<td class="text-center">
@if (asistencia != null)
{
<span class="status-@asistencia.Estado">@asistencia.Estado</span>
}
else
{
<span class="text-muted">SIN REGISTRO</span>
}
</td>
<td>@(asistencia?.Observaciones ?? "-")</td>
</tr>
}
</tbody>
</table>
<div class="mt-5 row">
<div class="col-6 text-center">
<div style="border-top: 1px solid #000; width: 200px; margin: 50px auto 0;"></div>
<p>Firma Responsable</p>
</div>
<div class="col-6 text-center">
<div style="border-top: 1px solid #000; width: 200px; margin: 50px auto 0;"></div>
<p>Sello Institucional</p>
</div>
</div>
<div class="mt-4 text-end small text-muted">
Generado el @DateTime.Now.ToString("dd/MM/yyyy HH:mm")
</div>
</div>
</body>
</html>

View File

@@ -10,10 +10,18 @@
<div class="card shadow mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0"><i class="bi bi-calendar-check me-2"></i>@ViewData["Title"]</h5>
<div class="d-flex align-items-center">
<label class="me-2 mb-0">Fecha:</label>
<input type="date" id="fechaAsistencia" class="form-control form-control-sm"
value="@selectedDate.ToString("yyyy-MM-dd")" onchange="changeDate(this.value)" />
<div class="d-flex align-items-center gap-2">
<a asp-action="ReporteAsistencia" class="btn btn-light btn-sm">
<i class="bi bi-file-earmark-bar-graph me-1"></i> Reporte por Colaborador
</a>
<a asp-action="ImprimirAsistencia" asp-route-fecha="@selectedDate.ToString("yyyy-MM-dd")" class="btn btn-light btn-sm">
<i class="bi bi-printer me-1"></i> Imprimir Hoy
</a>
<div class="d-flex align-items-center ms-2">
<label class="me-2 mb-0">Fecha:</label>
<input type="date" id="fechaAsistencia" class="form-control form-control-sm"
value="@selectedDate.ToString("yyyy-MM-dd")" onchange="changeDate(this.value)" />
</div>
</div>
</div>
<div class="card-body">
@@ -38,7 +46,7 @@
<div class="fw-bold">@item.Persona.Nombres @item.Persona.Apellidos</div>
<small class="text-muted">@item.HorarioEntrada?.ToString(@"hh\:mm") - @item.HorarioSalida?.ToString(@"hh\:mm")</small>
</td>
<td><span class="badge bg-info text-dark">@item.Cargo</span></td>
<td><span class="badge bg-info text-dark">@(item.Cargo?.Nombre ?? "Sin Cargo")</span></td>
<td>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="estado_@item.Id" id="pres_@item.Id" value="PRESENTE"
@@ -74,6 +82,29 @@
@section Scripts {
<script>
$(document).ready(function() {
// Configuración global de toastr
toastr.options = {
"closeButton": true,
"progressBar": true,
"positionClass": "toast-top-right",
"timeOut": "3000"
};
@if (TempData["InfoMessage"] != null)
{
<text>toastr.info('@TempData["InfoMessage"]');</text>
}
@if (TempData["SuccessMessage"] != null)
{
<text>toastr.success('@TempData["SuccessMessage"]');</text>
}
@if (TempData["ErrorMessage"] != null)
{
<text>toastr.error('@TempData["ErrorMessage"]');</text>
}
});
function changeDate(fecha) {
window.location.href = '@Url.Action("Index")?fecha=' + fecha;
}
@@ -105,9 +136,14 @@
.then(response => response.json())
.then(data => {
if (data.success) {
// Opcional: Mostrar feedback visual sutil
console.log('Guardado');
toastr.success('Asistencia guardada correctamente');
} else {
toastr.error('Error al guardar la asistencia');
}
})
.catch(error => {
console.error('Error:', error);
toastr.error('Ocurrió un error al procesar la solicitud');
});
}
</script>

View File

@@ -0,0 +1,327 @@
@model IEnumerable<foundation_system.Models.AsistenciaColaborador>
@{
ViewData["Title"] = "Reporte de Asistencia por Colaborador";
var colaboradores = (IEnumerable<foundation_system.Models.Colaborador>)ViewBag.Colaboradores;
var inicio = (DateOnly)ViewBag.Inicio;
var fin = (DateOnly)ViewBag.Fin;
var selectedColaboradorId = (long?)ViewBag.ColaboradorId;
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h3 mb-0 text-gray-800"><i class="bi bi-file-earmark-bar-graph me-2"></i>@ViewData["Title"]</h2>
<div class="no-print">
<button onclick="window.print()" class="btn btn-primary me-2">
<i class="bi bi-printer me-1"></i> Imprimir
</button>
<a asp-action="Index" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Volver
</a>
</div>
</div>
<div class="card shadow mb-4 no-print">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Filtros de Reporte</h6>
</div>
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label">Colaborador</label>
<div class="input-group">
<input type="hidden" id="colaboradorId" name="colaboradorId" value="@selectedColaboradorId" />
<input type="text" id="colaboradorNombre" class="form-control"
value="@(selectedColaboradorId.HasValue && colaboradores.Any(c => c.Id == selectedColaboradorId) ?
$"{colaboradores.First(c => c.Id == selectedColaboradorId).Persona.Apellidos}, {colaboradores.First(c => c.Id == selectedColaboradorId).Persona.Nombres}" : "")"
readonly placeholder="Seleccione un colaborador..." required />
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#searchModal">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Desde</label>
<input type="date" name="inicio" class="form-control" value="@inicio.ToString("yyyy-MM-dd")" />
</div>
<div class="col-md-3">
<label class="form-label">Hasta</label>
<input type="date" name="fin" class="form-control" value="@fin.ToString("yyyy-MM-dd")" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-file-earmark-text me-2"></i>Generar
</button>
</div>
</form>
</div>
</div>
@if (selectedColaboradorId.HasValue)
{
var selectedColaborador = colaboradores.FirstOrDefault(c => c.Id == selectedColaboradorId);
var stats = Model.GroupBy(a => a.Estado).ToDictionary(g => g.Key, g => g.Count());
@if (selectedColaborador != null)
{
<div class="mb-4 p-3 border rounded bg-light print-header">
<h4 class="mb-2 text-primary">@selectedColaborador.Persona.Apellidos, @selectedColaborador.Persona.Nombres</h4>
<div class="row">
<div class="col-md-4">
<strong><i class="bi bi-briefcase me-1"></i>Cargo:</strong> @(selectedColaborador.Cargo?.Nombre ?? "Sin Cargo")
</div>
<div class="col-md-4">
<strong><i class="bi bi-card-heading me-1"></i>DUI:</strong> @selectedColaborador.Persona.Dui
</div>
<div class="col-md-4">
<strong><i class="bi bi-telephone me-1"></i>Tel:</strong> @selectedColaborador.Persona.Telefono
</div>
</div>
</div>
}
<div class="row mb-4 print-row">
<div class="col-xl-3 col-md-6 mb-4 print-col">
<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">Total Registros</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@Model.Count()</div>
</div>
<div class="col-auto">
<i class="bi bi-calendar-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4 print-col">
<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">Presentes</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@(stats.ContainsKey("PRESENTE") ? stats["PRESENTE"] : 0)</div>
</div>
<div class="col-auto">
<i class="bi bi-person-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4 print-col">
<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">Ausentes</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@(stats.ContainsKey("AUSENTE") ? stats["AUSENTE"] : 0)</div>
</div>
<div class="col-auto">
<i class="bi bi-person-x fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4 print-col">
<div class="card border-left-warning 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-warning text-uppercase mb-1">Tardanzas</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">@(stats.ContainsKey("TARDANZA") ? stats["TARDANZA"] : 0)</div>
</div>
<div class="col-auto">
<i class="bi bi-clock-history fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</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>Fecha</th>
<th>Día</th>
<th>Estado</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Fecha.ToString("dd/MM/yyyy")</td>
<td class="text-capitalize">@item.Fecha.ToString("dddd")</td>
<td>
<span class="badge @(item.Estado == "PRESENTE" ? "bg-success" : item.Estado == "AUSENTE" ? "bg-danger" : "bg-warning")">
@item.Estado
</span>
</td>
<td>@item.Observaciones</td>
</tr>
}
@if (!Model.Any())
{
<tr>
<td colspan="4" class="text-center text-muted py-4">No se encontraron registros en este rango de fechas.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="text-center py-5">
<i class="bi bi-search fa-4x text-gray-300 mb-3"></i>
<p class="text-muted">Seleccione un colaborador y un rango de fechas para generar el reporte.</p>
</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 Colaborador</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 del colaborador..." 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 {
<script>
$(document).ready(function() {
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
const searchModal = new bootstrap.Modal(document.getElementById('searchModal'));
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => performSearch(this.value), 300);
});
document.getElementById('btnSearch').addEventListener('click', function() {
performSearch(searchInput.value);
});
function performSearch(term) {
if (term.length < 2) {
searchResults.innerHTML = '<div class="text-center text-muted p-3">Ingrese al menos 2 caracteres</div>';
return;
}
searchResults.innerHTML = '<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>';
fetch(`@Url.Action("SearchColaboradores")?term=${encodeURIComponent(term)}`)
.then(response => response.json())
.then(data => {
searchResults.innerHTML = '';
if (data.length === 0) {
searchResults.innerHTML = '<div class="text-center text-muted p-3">No se encontraron resultados</div>';
return;
}
data.forEach(item => {
const button = document.createElement('button');
button.className = 'list-group-item list-group-item-action';
button.innerHTML = item.text; // The text already contains Name (Cargo)
button.onclick = function() {
selectColaborador(item.id, item.text);
};
searchResults.appendChild(button);
});
})
.catch(error => {
console.error('Error:', error);
searchResults.innerHTML = '<div class="text-center text-danger p-3">Error al buscar</div>';
});
}
function selectColaborador(id, name) {
// Extract just the name part if needed, but the full text is fine for display
// Format from DB is: Name (Cargo)
document.getElementById('colaboradorId').value = id;
document.getElementById('colaboradorNombre').value = name;
// Close modal
const modalEl = document.getElementById('searchModal');
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
}
// Focus input when modal opens
document.getElementById('searchModal').addEventListener('shown.bs.modal', function () {
searchInput.focus();
});
});
</script>
}
@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; }
.text-xs { font-size: .7rem; }
@@media print {
.no-print { display: none !important; }
.sidebar, .top-header, .footer { display: none !important; }
.main-content { margin-left: 0 !important; padding: 0 !important; }
.page-container { padding: 0 !important; }
.card { border: 1px solid #ddd !important; box-shadow: none !important; }
.card-body { padding: 1rem !important; }
body { background: white !important; }
.container-fluid { width: 100% !important; padding: 0 !important; }
/* Force row layout for cards */
.print-row {
display: flex !important;
flex-wrap: nowrap !important;
gap: 10px;
}
.print-col {
flex: 0 0 25% !important;
max-width: 25% !important;
margin-bottom: 0 !important;
}
/* Header styling */
.print-header {
background-color: #f8f9fa !important;
border: 1px solid #dee2e6 !important;
padding: 15px !important;
margin-bottom: 20px !important;
}
.print-header h4 {
color: #000 !important;
margin-bottom: 10px !important;
}
}
</style>
}

View File

@@ -0,0 +1,73 @@
@model foundation_system.Models.Modulo
@{
ViewData["Title"] = "Nuevo Módulo";
}
<div class="container-fluid">
<div class="row justify-content-center">
<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">@ViewData["Title"]</h6>
<a asp-action="Index" class="btn btn-sm btn-secondary">
<i class="bi bi-arrow-left"></i> Volver
</a>
</div>
<div class="card-body">
<form asp-action="Create">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row">
<div class="col-md-8 mb-3">
<label asp-for="Nombre" class="form-label">Nombre del Módulo</label>
<input asp-for="Nombre" class="form-control" placeholder="Ej. Administración, Reportes..." />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="col-md-4 mb-3">
<label asp-for="Orden" class="form-label">Orden de Visualización</label>
<input asp-for="Orden" class="form-control" type="number" />
<span asp-validation-for="Orden" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label asp-for="Icono" class="form-label">Icono (Bootstrap Icons)</label>
<div class="input-group">
<span class="input-group-text"><i id="iconPreview" class="bi bi-question-circle"></i></span>
<input asp-for="Icono" class="form-control" placeholder="bi-gear, bi-person..." oninput="updateIconPreview(this.value)" />
</div>
<small class="text-muted">Use nombres de <a href="https://icons.getbootstrap.com/" target="_blank">Bootstrap Icons</a></small>
<span asp-validation-for="Icono" class="text-danger"></span>
</div>
<div class="col-md-4 mb-3">
<label class="form-label d-block">Estado</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" asp-for="Activo">
<label class="form-check-label" asp-for="Activo">Activo</label>
</div>
</div>
</div>
<div class="mt-4 text-end">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Guardar Módulo
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
function updateIconPreview(iconName) {
const preview = document.getElementById('iconPreview');
preview.className = 'bi ' + (iconName || 'bi-question-circle');
}
</script>
}

View File

@@ -0,0 +1,75 @@
@model foundation_system.Models.Modulo
@{
ViewData["Title"] = "Editar Módulo";
}
<div class="container-fluid">
<div class="row justify-content-center">
<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">@ViewData["Title"]: @Model.Nombre</h6>
<a asp-action="Index" class="btn btn-sm btn-secondary">
<i class="bi bi-arrow-left"></i> Volver
</a>
</div>
<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="row">
<div class="col-md-8 mb-3">
<label asp-for="Nombre" class="form-label">Nombre del Módulo</label>
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<div class="col-md-4 mb-3">
<label asp-for="Orden" class="form-label">Orden de Visualización</label>
<input asp-for="Orden" class="form-control" type="number" />
<span asp-validation-for="Orden" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label asp-for="Icono" class="form-label">Icono (Bootstrap Icons)</label>
<div class="input-group">
<span class="input-group-text"><i id="iconPreview" class="bi @(Model.Icono ?? "bi-question-circle")"></i></span>
<input asp-for="Icono" class="form-control" oninput="updateIconPreview(this.value)" />
</div>
<small class="text-muted">Use nombres de <a href="https://icons.getbootstrap.com/" target="_blank">Bootstrap Icons</a></small>
<span asp-validation-for="Icono" class="text-danger"></span>
</div>
<div class="col-md-4 mb-3">
<label class="form-label d-block">Estado</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" asp-for="Activo">
<label class="form-check-label" asp-for="Activo">Activo</label>
</div>
</div>
</div>
<div class="mt-4 text-end">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
function updateIconPreview(iconName) {
const preview = document.getElementById('iconPreview');
preview.className = 'bi ' + (iconName || 'bi-question-circle');
}
</script>
}

View File

@@ -0,0 +1,86 @@
@model IEnumerable<foundation_system.Models.Modulo>
@{
ViewData["Title"] = "Gestión de Módulos / Secciones";
}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h3 mb-0 text-gray-800"><i class="bi bi-folder2-open me-2"></i>@ViewData["Title"]</h2>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Nuevo Módulo
</a>
</div>
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i> @TempData["ErrorMessage"]
<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">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Orden</th>
<th>Nombre</th>
<th>Icono</th>
<th class="text-center">Activo</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td><span class="badge bg-secondary">@item.Orden</span></td>
<td class="fw-bold">@item.Nombre</td>
<td><i class="bi @item.Icono me-2"></i>@item.Icono</td>
<td class="text-center">
@if (item.Activo)
{
<span class="text-success"><i class="bi bi-check-circle-fill"></i></span>
}
else
{
<span class="text-muted"><i class="bi bi-x-circle"></i></span>
}
</td>
<td class="text-center">
<div class="btn-group btn-group-sm">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-outline-danger" title="Eliminar"
onclick="confirmDelete(@item.Id, '@item.Nombre')">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<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, name) {
if (confirm(`¿Está seguro de que desea eliminar el módulo "${name}"? Se eliminará solo si no tiene permisos asociados.`)) {
document.getElementById('deleteId').value = id;
document.getElementById('deleteForm').submit();
}
}
</script>
}

View File

@@ -17,9 +17,12 @@
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Modulo" class="form-label">Módulo (Sección)</label>
<input asp-for="Modulo" class="form-control" placeholder="Ej: Gestión, Administración" />
<span asp-validation-for="Modulo" class="text-danger small"></span>
<label asp-for="ModuloId" class="form-label">Módulo (Sección)</label>
<select asp-for="ModuloId" class="form-select"
asp-items="@(new SelectList(ViewBag.Modulos, "Id", "Nombre"))">
<option value="">-- Seleccione un Módulo --</option>
</select>
<span asp-validation-for="ModuloId" class="text-danger small"></span>
</div>
<div class="col-md-6">

View File

@@ -18,9 +18,12 @@
<div class="row g-3">
<div class="col-md-6">
<label asp-for="Modulo" class="form-label">Módulo (Sección)</label>
<input asp-for="Modulo" class="form-control" />
<span asp-validation-for="Modulo" class="text-danger small"></span>
<label asp-for="ModuloId" class="form-label">Módulo (Sección)</label>
<select asp-for="ModuloId" class="form-select"
asp-items="@(new SelectList(ViewBag.Modulos, "Id", "Nombre"))">
<option value="">-- Seleccione un Módulo --</option>
</select>
<span asp-validation-for="ModuloId" class="text-danger small"></span>
</div>
<div class="col-md-6">

View File

@@ -26,8 +26,8 @@
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Orden</th>
<th>Módulo</th>
<th>Orden</th>
<th>Nombre</th>
<th>Ruta (URL)</th>
<th>Icono</th>
@@ -39,8 +39,13 @@
@foreach (var item in Model)
{
<tr>
<td>
<span class="badge bg-info text-dark">
<i class="bi @item.Modulo?.Icono me-1"></i>
@(item.Modulo?.Nombre ?? "Sin Módulo")
</span>
</td>
<td><span class="badge bg-secondary">@item.Orden</span></td>
<td><span class="badge bg-info text-dark">@item.Modulo</span></td>
<td class="fw-bold">@item.Nombre</td>
<td class="font-monospace small">@item.Url</td>
<td><i class="bi @item.Icono me-2"></i>@item.Icono</td>

View File

@@ -12,7 +12,13 @@
@foreach (var group in groupedMenu)
{
<div class="nav-section-title mt-3">@group.Key</div>
<div class="nav-section-title mt-3">
@if (!string.IsNullOrEmpty(group.Key?.Icono))
{
<i class="bi @group.Key.Icono me-1"></i>
}
@(group.Key?.Nombre ?? "Sin Módulo")
</div>
@foreach (var item in group)
{
<a class="nav-link-custom @(currentController == item.Codigo ? "active" : "")" href="@item.Url">

View File

@@ -1,219 +1,227 @@
:root {
--sidebar-width: 260px;
--sidebar-bg: #1e293b;
--sidebar-hover: #334155;
--sidebar-active: #3b82f6;
--sidebar-text: #f1f5f9;
--sidebar-text-muted: #94a3b8;
--sidebar-width: 260px;
--sidebar-bg: #1e293b;
--sidebar-hover: #334155;
--sidebar-active: #3b82f6;
--sidebar-text: #f1f5f9;
--sidebar-text-muted: #94a3b8;
--header-height: 60px;
--header-bg: #ffffff;
--header-height: 60px;
--header-bg: #ffffff;
--content-bg: #f8fafc;
--content-bg: #f8fafc;
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--text-main: #0f172a;
--text-muted: #64748b;
--text-main: #0f172a;
--text-muted: #64748b;
--border-color: #e2e8f0;
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--border-color: #e2e8f0;
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--content-bg);
color: var(--text-main);
margin: 0;
padding: 0;
overflow-x: hidden;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background-color: var(--content-bg);
color: var(--text-main);
margin: 0;
padding: 0;
overflow-x: hidden;
}
/* Layout Structure */
.app-wrapper {
display: flex;
min-height: 100vh;
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 1000;
transition: all 0.3s ease;
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 1000;
transition: all 0.3s ease;
}
.sidebar-header {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-brand {
font-size: 1.25rem;
font-weight: 700;
color: white;
text-decoration: none;
letter-spacing: 1px;
font-size: 1.25rem;
font-weight: 700;
color: white;
text-decoration: none;
letter-spacing: 1px;
}
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-section-title {
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--sidebar-text-muted);
letter-spacing: 0.05em;
padding: 1.25rem 1.5rem 0.5rem;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
color: white;
letter-spacing: 0.08em;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 0.25rem;
opacity: 0.9;
}
.nav-link-custom {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--sidebar-text-muted);
text-decoration: none;
transition: all 0.2s;
gap: 0.75rem;
font-size: 0.9375rem;
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--sidebar-text-muted);
text-decoration: none;
transition: all 0.2s;
gap: 0.75rem;
font-size: 0.9375rem;
}
.nav-link-custom:hover {
background-color: var(--sidebar-hover);
color: white;
background-color: var(--sidebar-hover);
color: white;
}
.nav-link-custom.active {
background-color: var(--sidebar-active);
color: white;
background-color: var(--sidebar-active);
color: white;
}
.nav-link-custom i {
font-size: 1.1rem;
width: 20px;
text-align: center;
font-size: 1.1rem;
width: 20px;
text-align: center;
}
/* Main Content */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
min-width: 0;
}
.top-header {
height: var(--header-height);
background-color: var(--header-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 900;
height: var(--header-height);
background-color: var(--header-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 900;
}
.page-container {
padding: 2rem;
flex: 1;
padding: 2rem;
flex: 1;
}
/* Components */
.card-custom {
background: white;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
background: white;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.btn-primary-custom {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.2s;
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary-custom:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
color: var(--text-main);
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
color: var(--text-main);
}
.text-muted-custom {
color: var(--text-muted);
color: var(--text-muted);
}
/* Tables */
.table-custom {
width: 100%;
border-collapse: collapse;
width: 100%;
border-collapse: collapse;
}
.table-custom th {
background-color: #f8fafc;
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
background-color: #f8fafc;
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.table-custom td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.875rem;
}
.table-custom tr:hover {
background-color: #f1f5f9;
background-color: #f1f5f9;
}
/* Footer */
.footer {
background-color: var(--header-bg);
border-top: 1px solid var(--border-color);
padding: 1rem 2rem;
margin-top: auto;
width: 100%;
background-color: var(--header-bg);
border-top: 1px solid var(--border-color);
padding: 1rem 2rem;
margin-top: auto;
width: 100%;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.main-content {
margin-left: 0;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar {
transform: translateX(-100%);
}
.main-content {
margin-left: 0;
}
.sidebar.open {
transform: translateX(0);
}
}