segundo commit todos

This commit is contained in:
2025-12-30 18:12:13 -06:00
parent 84b891b647
commit cc28fa00e8
52 changed files with 6984 additions and 1 deletions

View File

@@ -0,0 +1,8 @@
**/.dockerignore
**/.git
**/.gitignore
**/.vs
**/.vscode
**/bin
**/obj
**/logs

View File

@@ -0,0 +1,156 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using foundation_system.Models.ViewModels;
using foundation_system.Services;
namespace foundation_system.Controllers;
public class AccountController : Controller
{
private readonly IAuthService _authService;
private readonly ILogger<AccountController> _logger;
public AccountController(IAuthService authService, ILogger<AccountController> logger)
{
_authService = authService;
_logger = logger;
}
// GET: /Account/Login
[HttpGet]
[AllowAnonymous]
public IActionResult Login(string? returnUrl = null)
{
if (User.Identity?.IsAuthenticated == true)
{
return RedirectToAction("Index", "Home");
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
// POST: /Account/Login
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
if (!ModelState.IsValid)
{
return View(model);
}
var usuario = await _authService.ValidateUserAsync(model.NombreUsuario, model.Contrasena);
if (usuario == null)
{
ModelState.AddModelError(string.Empty, "Usuario o contraseña incorrectos");
return View(model);
}
// Get user roles
var roles = await _authService.GetUserRolesAsync(usuario.Id);
// Create claims
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, usuario.Id.ToString()),
new(ClaimTypes.Name, usuario.NombreUsuario),
new(ClaimTypes.Email, usuario.Email),
new("FullName", usuario.Persona?.NombreCompleto ?? usuario.NombreUsuario)
};
// Add role claims
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties
{
IsPersistent = model.RecordarMe,
ExpiresUtc = model.RecordarMe
? DateTimeOffset.UtcNow.AddDays(30)
: DateTimeOffset.UtcNow.AddHours(8)
};
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
// Update last login
await _authService.UpdateLastLoginAsync(usuario.Id);
_logger.LogInformation("User {Username} logged in", usuario.NombreUsuario);
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return RedirectToAction("Index", "Home");
}
// GET: /Account/Register
[HttpGet]
[AllowAnonymous]
public IActionResult Register()
{
if (User.Identity?.IsAuthenticated == true)
{
return RedirectToAction("Index", "Home");
}
return View();
}
// POST: /Account/Register
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var (success, message, _) = await _authService.RegisterUserAsync(model);
if (!success)
{
ModelState.AddModelError(string.Empty, message);
return View(model);
}
TempData["SuccessMessage"] = "¡Registro exitoso! Ahora puedes iniciar sesión.";
return RedirectToAction(nameof(Login));
}
// POST: /Account/Logout
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_logger.LogInformation("User logged out");
return RedirectToAction("Index", "Home");
}
// GET: /Account/AccessDenied
[HttpGet]
[AllowAnonymous]
public IActionResult AccessDenied()
{
return View();
}
}

View File

@@ -0,0 +1,125 @@
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using foundation_system.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Controllers;
public class AntecedentesController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IAntecedentesService _antecedentesService;
public AntecedentesController(ApplicationDbContext context, IAntecedentesService antecedentesService)
{
_context = context;
_antecedentesService = antecedentesService;
}
// GET: Antecedentes
public async Task<IActionResult> Index(string search = "", int page = 1, int pageSize = 10)
{
var query = _context.Ninos
.Include(n => n.Persona)
.Where(n => n.Activo);
List<long> searchIds = new();
if (!string.IsNullOrWhiteSpace(search))
{
searchIds = await PostgresQueryExecutor.ExecuteQueryAsync(
_context,
"SELECT id FROM buscar_personas_v2(@term, 'NINO')",
reader => reader.GetInt64(0),
p => p.AddWithValue("term", search)
);
query = query.Where(n => searchIds.Contains(n.Id));
}
var totalItems = await query.CountAsync();
var totalPages = (int)Math.Ceiling(totalItems / (double)pageSize);
page = Math.Max(1, Math.Min(page, totalPages > 0 ? totalPages : 1));
var ninos = await query
.OrderByDescending(n => n.CreadoEn)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (!string.IsNullOrWhiteSpace(search))
{
// Preserve the order from the fuzzy search procedure
ninos = ninos.OrderBy(n => searchIds.IndexOf(n.Id)).ToList();
}
ViewBag.Search = search;
ViewBag.CurrentPage = page;
ViewBag.TotalPages = totalPages;
return View(ninos);
}
// GET: Antecedentes/Manage/5
public async Task<IActionResult> Manage(long id)
{
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(n => n.Id == id);
if (nino == null) return NotFound();
var historial = await _antecedentesService.GetHistorialByNinoIdAsync(id);
var viewModel = new AntecedentesViewModel
{
Nino = nino,
Historial = historial
};
return View(viewModel);
}
// POST: Antecedentes/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(long ninoId, AntecedentesViewModel model)
{
if (ModelState.IsValid)
{
var antecedente = new AntecedenteNino
{
NinoId = ninoId,
FechaIncidente = model.FechaIncidente,
TipoAntecedente = model.TipoAntecedente,
Descripcion = model.Descripcion,
Gravedad = model.Gravedad,
UsuarioRegistra = User.Identity?.Name ?? "Sistema",
Activo = true,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
var success = await _antecedentesService.AddAntecedenteAsync(antecedente);
if (success)
{
return RedirectToAction(nameof(Manage), new { id = ninoId });
}
ModelState.AddModelError("", "Error al guardar el antecedente.");
}
// Si falla, recargar la vista Manage con los datos actuales
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(n => n.Id == ninoId);
if (nino == null) return NotFound();
model.Nino = nino;
model.Historial = await _antecedentesService.GetHistorialByNinoIdAsync(ninoId);
return View("Manage", model);
}
}

View File

@@ -0,0 +1,226 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
namespace foundation_system.Controllers;
public class AsistenciaController : Controller
{
private readonly ApplicationDbContext _context;
public AsistenciaController(ApplicationDbContext context)
{
_context = context;
}
// GET: Asistencia
public async Task<IActionResult> Index(int? año, int? mes, string? diasSemana)
{
int targetAnio = año ?? DateTime.Now.Year;
int targetMes = mes ?? DateTime.Now.Month;
var diasSeleccionadosList = string.IsNullOrEmpty(diasSemana)
? new List<string> { "1", "2", "3", "4", "5" }
: diasSemana.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
var firstDayOfMonth = new DateTime(targetAnio, targetMes, 1);
var lastDayOfMonth = firstDayOfMonth.AddMonths(1).AddDays(-1);
var ninos = await _context.Ninos
.Include(n => n.Persona)
.Where(n => n.Activo && n.Estado == "ACTIVO")
.OrderBy(n => n.Persona.Nombres)
.ToListAsync();
var firstDateOnly = DateOnly.FromDateTime(firstDayOfMonth);
var lastDateOnly = DateOnly.FromDateTime(lastDayOfMonth);
var asistencias = await _context.Asistencias
.Where(a => a.Fecha >= firstDateOnly && a.Fecha <= lastDateOnly)
.ToListAsync();
var viewModel = new AsistenciaGridViewModel
{
Año = targetAnio,
Mes = targetMes,
NombreMes = firstDayOfMonth.ToString("MMMM", new System.Globalization.CultureInfo("es-ES")).ToUpper(),
DiasSemanaSeleccionados = string.Join(",", diasSeleccionadosList),
DiasDelMes = Enumerable.Range(0, lastDayOfMonth.Day)
.Select(day => firstDayOfMonth.AddDays(day))
.ToList(),
Expedientes = ninos,
Asistencias = asistencias.ToDictionary(
a => $"{a.NinoId}_{a.Fecha:yyyy-MM-dd}",
a => a.Estado switch {
"PRESENTE" => "P",
"TARDE" => "T",
"AUSENTE" => "F",
"JUSTIFICADO" => "J",
"ENFERMO" => "E",
_ => ""
}
)
};
ViewBag.Años = Enumerable.Range(DateTime.Now.Year - 5, 10)
.Select(a => new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem
{
Value = a.ToString(),
Text = a.ToString(),
Selected = a == targetAnio
}).ToList();
ViewBag.Meses = Enumerable.Range(1, 12)
.Select(m => new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem
{
Value = m.ToString(),
Text = new DateTime(2000, m, 1).ToString("MMMM", new System.Globalization.CultureInfo("es-ES")).ToUpper(),
Selected = m == targetMes
}).ToList();
ViewBag.DiasSemana = new List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>
{
new() { Value = "1", Text = "Lunes" },
new() { Value = "2", Text = "Martes" },
new() { Value = "3", Text = "Miércoles" },
new() { Value = "4", Text = "Jueves" },
new() { Value = "5", Text = "Viernes" },
new() { Value = "6", Text = "Sábado" },
new() { Value = "0", Text = "Domingo" }
};
return View(viewModel);
}
[HttpGet]
public async Task<JsonResult> ObtenerEstadisticas(int año, int mes)
{
var firstDay = new DateOnly(año, mes, 1);
var lastDay = firstDay.AddMonths(1).AddDays(-1);
var asistencias = await _context.Asistencias
.Where(a => a.Fecha >= firstDay && a.Fecha <= lastDay)
.ToListAsync();
return Json(new
{
total = asistencias.Count,
presentes = asistencias.Count(a => a.Estado == "PRESENTE"),
tardes = asistencias.Count(a => a.Estado == "TARDE"),
faltas = asistencias.Count(a => a.Estado == "AUSENTE"),
justificados = asistencias.Count(a => a.Estado == "JUSTIFICADO"),
enfermos = asistencias.Count(a => a.Estado == "ENFERMO")
});
}
[HttpPost]
public async Task<IActionResult> GuardarAsistencia(long expedienteId, DateOnly fecha, string estado)
{
string dbEstado = estado switch
{
"P" => "PRESENTE",
"T" => "TARDE",
"F" => "AUSENTE",
"J" => "JUSTIFICADO",
"E" => "ENFERMO",
_ => ""
};
if (string.IsNullOrEmpty(dbEstado))
{
// Si el estado es vacío, podríamos borrar el registro o dejarlo como pendiente
var existing = await _context.Asistencias
.FirstOrDefaultAsync(a => a.NinoId == expedienteId && a.Fecha == fecha);
if (existing != null)
{
_context.Asistencias.Remove(existing);
await _context.SaveChangesAsync();
}
return Json(new { success = true });
}
var asistencia = await _context.Asistencias
.FirstOrDefaultAsync(a => a.NinoId == expedienteId && a.Fecha == fecha);
if (asistencia == null)
{
asistencia = new Asistencia
{
NinoId = expedienteId,
Fecha = fecha,
Estado = dbEstado,
CreadoEn = DateTime.UtcNow
};
_context.Asistencias.Add(asistencia);
}
else
{
asistencia.Estado = dbEstado;
}
await _context.SaveChangesAsync();
return Json(new { success = true });
}
[HttpPost]
public async Task<IActionResult> GuardarAsistenciasMasivas([FromBody] List<AsistenciaInputModel> cambios)
{
foreach (var cambio in cambios)
{
string dbEstado = cambio.Estado switch
{
"P" => "PRESENTE",
"T" => "TARDE",
"F" => "AUSENTE",
"J" => "JUSTIFICADO",
"E" => "ENFERMO",
_ => ""
};
var asistencia = await _context.Asistencias
.FirstOrDefaultAsync(a => a.NinoId == cambio.ExpedienteId && a.Fecha == cambio.Fecha);
if (string.IsNullOrEmpty(dbEstado))
{
if (asistencia != null) _context.Asistencias.Remove(asistencia);
}
else
{
if (asistencia == null)
{
_context.Asistencias.Add(new Asistencia
{
NinoId = cambio.ExpedienteId,
Fecha = cambio.Fecha,
Estado = dbEstado,
CreadoEn = DateTime.UtcNow
});
}
else
{
asistencia.Estado = dbEstado;
}
}
}
await _context.SaveChangesAsync();
return Json(new { success = true, message = "Cambios guardados correctamente" });
}
[HttpGet]
public IActionResult ExportarExcel(int año, int mes, string diasSemana)
{
// Placeholder for Excel export
return File(new byte[0], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"Asistencia_{año}_{mes}.xlsx");
}
}
public class AsistenciaInputModel
{
public long ExpedienteId { get; set; }
public DateOnly Fecha { get; set; }
public string Estado { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,85 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
namespace foundation_system.Controllers;
public class ConfiguracionController : Controller
{
private readonly ApplicationDbContext _context;
public ConfiguracionController(ApplicationDbContext context)
{
_context = context;
}
// GET: Configuracion
public async Task<IActionResult> Index(string? categoria)
{
var query = _context.Configuraciones.AsQueryable();
if (!string.IsNullOrEmpty(categoria))
{
query = query.Where(c => c.Categoria == categoria);
}
var configuraciones = await query
.OrderBy(c => c.Categoria)
.ThenBy(c => c.Grupo)
.ThenBy(c => c.Orden)
.ToListAsync();
ViewBag.Categorias = await _context.Configuraciones
.Select(c => c.Categoria)
.Distinct()
.ToListAsync();
ViewBag.SelectedCategoria = categoria;
return View(configuraciones);
}
// GET: Configuracion/Edit/5
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
var config = await _context.Configuraciones.FindAsync(id);
if (config == null || !config.EsEditable) return NotFound();
return View(config);
}
// POST: Configuracion/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Valor")] ConfiguracionSistema model)
{
if (id != model.Id) return NotFound();
var config = await _context.Configuraciones.FindAsync(id);
if (config == null || !config.EsEditable) return NotFound();
try
{
config.Valor = model.Valor;
config.ActualizadoEn = DateTime.UtcNow;
_context.Update(config);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index), new { categoria = config.Categoria });
}
catch (DbUpdateConcurrencyException)
{
if (!ConfiguracionExists(model.Id)) return NotFound();
else throw;
}
}
private bool ConfiguracionExists(int id)
{
return _context.Configuraciones.Any(e => e.Id == id);
}
}

View File

@@ -0,0 +1,556 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using foundation_system.Services;
namespace foundation_system.Controllers;
public class ExpedienteController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly IAntecedentesService _antecedentesService;
public ExpedienteController(ApplicationDbContext context, IWebHostEnvironment hostingEnvironment, IAntecedentesService antecedentesService)
{
_context = context;
_hostingEnvironment = hostingEnvironment;
_antecedentesService = antecedentesService;
}
// GET: Expediente
public async Task<IActionResult> Index(string search = "", int page = 1, int pageSize = 10)
{
var query = _context.Ninos
.Include(n => n.Persona)
.Where(n => n.Activo);
List<long> searchIds = new();
if (!string.IsNullOrWhiteSpace(search))
{
searchIds = await PostgresQueryExecutor.ExecuteQueryAsync(
_context,
"SELECT id FROM buscar_personas_v2(@term, 'NINO')",
reader => reader.GetInt64(0),
p => p.AddWithValue("term", search)
);
query = query.Where(n => searchIds.Contains(n.Id));
}
var totalItems = await query.CountAsync();
var totalPages = (int)Math.Ceiling(totalItems / (double)pageSize);
// Asegurar que la página esté en rango
page = Math.Max(1, Math.Min(page, totalPages > 0 ? totalPages : 1));
var ninos = await query
.OrderByDescending(n => n.CreadoEn)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (!string.IsNullOrWhiteSpace(search))
{
// Preserve the order from the fuzzy search procedure
ninos = ninos.OrderBy(n => searchIds.IndexOf(n.Id)).ToList();
}
ViewBag.Search = search;
ViewBag.CurrentPage = page;
ViewBag.TotalPages = totalPages;
ViewBag.HasPreviousPage = page > 1;
ViewBag.HasNextPage = page < totalPages;
ViewBag.PageSize = pageSize;
return View(ninos);
}
// GET: Expediente/Details/5
public async Task<IActionResult> Details(long? id)
{
if (id == null) return NotFound();
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(m => m.Id == id);
if (nino == null) return NotFound();
var model = await MapToExpedienteViewModel(nino);
return View(model);
}
// GET: Expediente/Print/5
public async Task<IActionResult> Print(long? id)
{
if (id == null) return NotFound();
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(m => m.Id == id);
if (nino == null) return NotFound();
var model = await MapToExpedienteViewModel(nino);
var antecedentes = await _antecedentesService.GetHistorialByNinoIdAsync(id.Value);
ViewBag.Antecedentes = antecedentes;
return View(model);
}
private async Task<ExpedienteViewModel> MapToExpedienteViewModel(Nino nino)
{
var model = new ExpedienteViewModel
{
Id = nino.Id,
Nombres = nino.Persona.Nombres,
Apellidos = nino.Persona.Apellidos,
Dui = nino.Persona.Dui,
Nit = nino.Persona.Nit,
FechaNacimiento = nino.Persona.FechaNacimiento,
Genero = nino.Persona.Genero,
Email = nino.Persona.Email,
Telefono = nino.Persona.Telefono,
Direccion = nino.Persona.Direccion,
FotoUrl = nino.Persona.FotoUrl,
CodigoInscripcion = nino.CodigoInscripcion,
FechaInscripcion = nino.FechaInscripcion,
Estado = nino.Estado,
NivelGrado = nino.NivelGrado,
Alergias = nino.Alergias,
ContactoEmergenciaNombre = nino.ContactoEmergenciaNombre,
ContactoEmergenciaTelefono = nino.ContactoEmergenciaTelefono
};
var encargados = await _context.EncargadosNino
.Include(e => e.Persona)
.Where(e => e.NinoId == nino.Id)
.ToListAsync();
var padre = encargados.FirstOrDefault(e => e.Parentesco == "PADRE");
if (padre != null)
{
model.PadreId = padre.PersonaId;
model.PadreNombre = padre.Persona.NombreCompleto;
}
var madre = encargados.FirstOrDefault(e => e.Parentesco == "MADRE");
if (madre != null)
{
model.MadreId = madre.PersonaId;
model.MadreNombre = madre.Persona.NombreCompleto;
}
var encargado = encargados.FirstOrDefault(e => e.Parentesco == "ENCARGADO");
if (encargado != null)
{
model.EncargadoId = encargado.PersonaId;
model.EncargadoNombre = encargado.Persona.NombreCompleto;
}
return model;
}
// GET: Expediente/Create
public IActionResult Create()
{
return View(new ExpedienteViewModel());
}
// POST: Expediente/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ExpedienteViewModel model)
{
if (ModelState.IsValid)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var persona = new Persona
{
Nombres = model.Nombres,
Apellidos = model.Apellidos,
Dui = model.Dui,
Nit = model.Nit,
FechaNacimiento = model.FechaNacimiento,
Genero = model.Genero,
Email = model.Email,
Telefono = null, // Los niños no tienen teléfono
Direccion = model.Direccion,
FotoUrl = string.IsNullOrEmpty(model.FotoUrl) ? "/Assets/default_avatar.png" : model.FotoUrl,
Activo = true
};
_context.Personas.Add(persona);
await _context.SaveChangesAsync();
// Generar Código de Inscripción automáticamente usando procedimiento almacenado
var nuevoCodigo = await PostgresScalarExecutor.ExecuteAsync<string>(
_context,
"SELECT generar_codigo_inscripcion(@apellidos, @anio)",
p =>
{
p.AddWithValue("apellidos", persona.Apellidos);
p.AddWithValue("anio", model.FechaInscripcion.Year);
}
);
var nino = new Nino
{
PersonaId = persona.Id,
CodigoInscripcion = nuevoCodigo,
FechaInscripcion = model.FechaInscripcion,
Estado = model.Estado,
NivelGrado = model.NivelGrado,
Alergias = model.Alergias,
ContactoEmergenciaNombre = model.ContactoEmergenciaNombre,
ContactoEmergenciaTelefono = model.ContactoEmergenciaTelefono,
Activo = true
};
_context.Ninos.Add(nino);
await _context.SaveChangesAsync();
// Procesar foto si se subió una
if (model.FotoFile != null)
{
var fotoUrl = await ProcessPhotoUpload(model.FotoFile, nino.CodigoInscripcion);
persona.FotoUrl = fotoUrl;
await _context.SaveChangesAsync();
}
// Guardar Padres si se seleccionaron
if (model.PadreId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.PadreId.Value,
Parentesco = "PADRE",
EsPrincipal = false
});
}
if (model.MadreId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.MadreId.Value,
Parentesco = "MADRE",
EsPrincipal = false
});
}
if (model.EncargadoId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.EncargadoId.Value,
Parentesco = "ENCARGADO",
EsPrincipal = true
});
}
if (model.PadreId.HasValue || model.MadreId.HasValue || model.EncargadoId.HasValue)
{
await _context.SaveChangesAsync();
}
await transaction.CommitAsync();
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
await transaction.RollbackAsync();
ModelState.AddModelError("", "Ocurrió un error al guardar el expediente.\n" + ex.Message);
}
}
return View(model);
}
// GET: Expediente/Edit/5
public async Task<IActionResult> Edit(long? id)
{
if (id == null) return NotFound();
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(n => n.Id == id);
if (nino == null) return NotFound();
var model = new ExpedienteViewModel
{
Id = nino.Id,
Nombres = nino.Persona.Nombres,
Apellidos = nino.Persona.Apellidos,
Dui = nino.Persona.Dui,
Nit = nino.Persona.Nit,
FechaNacimiento = nino.Persona.FechaNacimiento,
Genero = nino.Persona.Genero,
Email = nino.Persona.Email,
Telefono = nino.Persona.Telefono,
Direccion = nino.Persona.Direccion,
FotoUrl = nino.Persona.FotoUrl,
CodigoInscripcion = nino.CodigoInscripcion,
FechaInscripcion = nino.FechaInscripcion,
Estado = nino.Estado,
NivelGrado = nino.NivelGrado,
Alergias = nino.Alergias,
ContactoEmergenciaNombre = nino.ContactoEmergenciaNombre,
ContactoEmergenciaTelefono = nino.ContactoEmergenciaTelefono
};
// Cargar Padres/Encargados
var encargados = await _context.EncargadosNino
.Include(e => e.Persona)
.Where(e => e.NinoId == id)
.ToListAsync();
var padre = encargados.FirstOrDefault(e => e.Parentesco == "PADRE");
if (padre != null)
{
model.PadreId = padre.PersonaId;
model.PadreNombre = $"{padre.Persona.Nombres} {padre.Persona.Apellidos}";
}
var madre = encargados.FirstOrDefault(e => e.Parentesco == "MADRE");
if (madre != null)
{
model.MadreId = madre.PersonaId;
model.MadreNombre = $"{madre.Persona.Nombres} {madre.Persona.Apellidos}";
}
var encargado = encargados.FirstOrDefault(e => e.Parentesco == "ENCARGADO");
if (encargado != null)
{
model.EncargadoId = encargado.PersonaId;
model.EncargadoNombre = $"{encargado.Persona.Nombres} {encargado.Persona.Apellidos}";
}
return View(model);
}
// POST: Expediente/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(long id, ExpedienteViewModel model)
{
if (id != model.Id) return NotFound();
if (ModelState.IsValid)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var nino = await _context.Ninos
.Include(n => n.Persona)
.FirstOrDefaultAsync(n => n.Id == id);
if (nino == null) return NotFound();
// Update Persona
nino.Persona.Nombres = model.Nombres;
nino.Persona.Apellidos = model.Apellidos;
nino.Persona.Dui = model.Dui;
nino.Persona.Nit = model.Nit;
nino.Persona.FechaNacimiento = model.FechaNacimiento;
nino.Persona.Genero = model.Genero;
nino.Persona.Email = model.Email;
nino.Persona.Telefono = null; // Los niños no tienen teléfono
nino.Persona.Direccion = model.Direccion;
// Procesar foto si se subió una nueva
if (model.FotoFile != null)
{
nino.Persona.FotoUrl = await ProcessPhotoUpload(model.FotoFile, nino.CodigoInscripcion);
}
else if (string.IsNullOrEmpty(nino.Persona.FotoUrl))
{
nino.Persona.FotoUrl = "/Assets/default_avatar.png";
}
nino.Persona.ActualizadoEn = DateTime.UtcNow;
// Update Nino
nino.CodigoInscripcion = model.CodigoInscripcion;
nino.FechaInscripcion = model.FechaInscripcion;
nino.Estado = model.Estado;
nino.NivelGrado = model.NivelGrado;
nino.Alergias = model.Alergias;
nino.ContactoEmergenciaNombre = model.ContactoEmergenciaNombre;
nino.ContactoEmergenciaTelefono = model.ContactoEmergenciaTelefono;
nino.ActualizadoEn = DateTime.UtcNow;
await _context.SaveChangesAsync();
// Actualizar Encargados (Eliminar y volver a agregar para simplicidad)
var encargadosExistentes = await _context.EncargadosNino
.Where(e => e.NinoId == id)
.ToListAsync();
_context.EncargadosNino.RemoveRange(encargadosExistentes);
await _context.SaveChangesAsync();
if (model.PadreId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.PadreId.Value,
Parentesco = "PADRE",
EsPrincipal = false
});
}
if (model.MadreId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.MadreId.Value,
Parentesco = "MADRE",
EsPrincipal = false
});
}
if (model.EncargadoId.HasValue)
{
_context.EncargadosNino.Add(new EncargadoNino
{
NinoId = nino.Id,
PersonaId = model.EncargadoId.Value,
Parentesco = "ENCARGADO",
EsPrincipal = true
});
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
await transaction.RollbackAsync();
ModelState.AddModelError("", "Ocurrió un error al actualizar el expediente.\n" + ex.Message);
}
}
return View(model);
}
// POST: Expediente/Desactivar/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Desactivar(long id)
{
var nino = await _context.Ninos.FindAsync(id);
if (nino != null)
{
nino.Activo = false;
nino.Estado = "INACTIVO";
nino.ActualizadoEn = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
private bool NinoExists(long id)
{
return _context.Ninos.Any(e => e.Id == id);
}
// AJAX Actions for Parents
[HttpGet]
public async Task<IActionResult> SearchPersonas(string term, string type = "ENCARGADO")
{
if (string.IsNullOrWhiteSpace(term)) return Json(new List<object>());
var personas = await PostgresQueryExecutor.ExecuteQueryAsync(
_context,
"SELECT id, text, score FROM buscar_personas_v2(@term, @type)",
reader => new
{
id = reader.GetInt64(0),
text = reader.GetString(1),
score = reader.GetDouble(2)
},
paramsColl => {
paramsColl.AddWithValue("term", term);
paramsColl.AddWithValue("type", type);
}
);
return Json(personas);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreatePersona([FromBody] Persona model)
{
if (ModelState.IsValid)
{
model.Activo = true;
model.CreadoEn = DateTime.UtcNow;
model.ActualizadoEn = DateTime.UtcNow;
_context.Personas.Add(model);
await _context.SaveChangesAsync();
return Json(new { success = true, id = model.Id, text = $"{model.Nombres} {model.Apellidos} ({model.Dui ?? "Sin DUI"})" });
}
return Json(new { success = false, message = "Datos inválidos" });
}
private async Task<string> ProcessPhotoUpload(IFormFile file, string codigoExpediente)
{
if (file == null || file.Length == 0) return "/Assets/default_avatar.png";
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(fileExtension)) return "/Assets/default_avatar.png";
var uploadsFolder = Path.Combine(_hostingEnvironment.WebRootPath, "uploads", "fotos");
if (!Directory.Exists(uploadsFolder))
{
Directory.CreateDirectory(uploadsFolder);
}
// Usar el código del expediente como nombre de archivo
var fileName = codigoExpediente + fileExtension;
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return $"/uploads/fotos/{fileName}";
}
public string ExisteFoto(string url)
{
if (string.IsNullOrEmpty(url))
return "/Assets/default_avatar.png";
var uploadsFolder = Path.Combine(_hostingEnvironment.WebRootPath, "uploads", "fotos");
string[] parts = url.Split('/');
string name = parts[^1];
string fullpath = Path.Combine(uploadsFolder, name);
if (System.IO.File.Exists(fullpath))
return url;
return "/Assets/default_avatar.png";
}
}

View File

@@ -0,0 +1,191 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using BCrypt.Net;
namespace foundation_system.Controllers;
public class UsuarioController : Controller
{
private readonly ApplicationDbContext _context;
public UsuarioController(ApplicationDbContext context)
{
_context = context;
}
// GET: Usuario
public async Task<IActionResult> Index()
{
var usuarios = await _context.Usuarios
.Include(u => u.Persona)
.ToListAsync();
return View(usuarios);
}
// GET: Usuario/Create
public IActionResult Create()
{
return View(new UsuarioViewModel());
}
// POST: Usuario/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(UsuarioViewModel model)
{
if (string.IsNullOrEmpty(model.Contrasena))
{
ModelState.AddModelError("Contrasena", "La contraseña es requerida para nuevos usuarios");
}
if (ModelState.IsValid)
{
// Check if username or email already exists
if (await _context.Usuarios.AnyAsync(u => u.NombreUsuario == model.NombreUsuario))
{
ModelState.AddModelError("NombreUsuario", "El nombre de usuario ya está en uso");
return View(model);
}
if (await _context.Usuarios.AnyAsync(u => u.Email == model.Email))
{
ModelState.AddModelError("Email", "El correo electrónico ya está en uso");
return View(model);
}
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var persona = new Persona
{
Nombres = model.Nombres,
Apellidos = model.Apellidos,
Email = model.Email,
Telefono = model.Telefono,
Activo = true
};
_context.Personas.Add(persona);
await _context.SaveChangesAsync();
var usuario = new Usuario
{
PersonaId = persona.Id,
NombreUsuario = model.NombreUsuario,
Email = model.Email,
HashContrasena = BCrypt.Net.BCrypt.HashPassword(model.Contrasena),
Activo = true,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Usuarios.Add(usuario);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return RedirectToAction(nameof(Index));
}
catch (Exception)
{
await transaction.RollbackAsync();
ModelState.AddModelError("", "Ocurrió un error al crear el usuario.");
}
}
return View(model);
}
// GET: Usuario/Edit/5
public async Task<IActionResult> Edit(long? id)
{
if (id == null) return NotFound();
var usuario = await _context.Usuarios
.Include(u => u.Persona)
.FirstOrDefaultAsync(u => u.Id == id);
if (usuario == null) return NotFound();
var model = new UsuarioViewModel
{
Id = usuario.Id,
Nombres = usuario.Persona.Nombres,
Apellidos = usuario.Persona.Apellidos,
NombreUsuario = usuario.NombreUsuario,
Email = usuario.Email,
Telefono = usuario.Persona.Telefono,
Activo = usuario.Activo
};
return View(model);
}
// POST: Usuario/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(long id, UsuarioViewModel model)
{
if (id != model.Id) return NotFound();
if (ModelState.IsValid)
{
var usuario = await _context.Usuarios
.Include(u => u.Persona)
.FirstOrDefaultAsync(u => u.Id == id);
if (usuario == null) return NotFound();
try
{
// Update Persona
usuario.Persona.Nombres = model.Nombres;
usuario.Persona.Apellidos = model.Apellidos;
usuario.Persona.Telefono = model.Telefono;
usuario.Persona.ActualizadoEn = DateTime.UtcNow;
// Update Usuario
usuario.NombreUsuario = model.NombreUsuario;
usuario.Email = model.Email;
usuario.Activo = model.Activo;
usuario.ActualizadoEn = DateTime.UtcNow;
// Update password if provided
if (!string.IsNullOrEmpty(model.Contrasena))
{
usuario.HashContrasena = BCrypt.Net.BCrypt.HashPassword(model.Contrasena);
}
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateConcurrencyException)
{
if (!UsuarioExists(model.Id.Value)) return NotFound();
else throw;
}
}
return View(model);
}
// POST: Usuario/Desactivar/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Desactivar(long id)
{
var usuario = await _context.Usuarios.FindAsync(id);
if (usuario != null)
{
usuario.Activo = false;
usuario.ActualizadoEn = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
private bool UsuarioExists(long id)
{
return _context.Usuarios.Any(e => e.Id == id);
}
}

View File

@@ -18,6 +18,7 @@ public class ApplicationDbContext : DbContext
public DbSet<Asistencia> Asistencias { get; set; } public DbSet<Asistencia> Asistencias { get; set; }
public DbSet<ConfiguracionSistema> Configuraciones { get; set; } public DbSet<ConfiguracionSistema> Configuraciones { get; set; }
public DbSet<EncargadoNino> EncargadosNino { get; set; } public DbSet<EncargadoNino> EncargadosNino { get; set; }
public DbSet<AntecedenteNino> AntecedentesNino { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -42,5 +43,19 @@ public class ApplicationDbContext : DbContext
.HasOne(u => u.Persona) .HasOne(u => u.Persona)
.WithMany() .WithMany()
.HasForeignKey(u => u.PersonaId); .HasForeignKey(u => u.PersonaId);
// Global configuration: Convert all dates to UTC when saving
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var properties = entityType.GetProperties()
.Where(p => p.ClrType == typeof(DateTime) || p.ClrType == typeof(DateTime?));
foreach (var property in properties)
{
property.SetValueConverter(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => v));
}
}
} }
} }

View File

@@ -0,0 +1,39 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql;
namespace foundation_system.Data;
public static class PostgresQueryExecutor
{
public static async Task<List<T>> ExecuteQueryAsync<T>(
DbContext context,
string sql,
Func<NpgsqlDataReader, T> map,
Action<NpgsqlParameterCollection>? parameters = null
)
{
var conn = (NpgsqlConnection)context.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(sql, conn);
var currentTx = context.Database.CurrentTransaction;
if (currentTx != null)
cmd.Transaction = (NpgsqlTransaction)currentTx.GetDbTransaction();
parameters?.Invoke(cmd.Parameters);
var results = new List<T>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(map(reader));
}
return results;
}
}

View File

@@ -0,0 +1,27 @@
# Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copiar TODO el proyecto
COPY . .
# Restaurar y compilar
RUN dotnet restore "foundation_system.csproj"
RUN dotnet build "foundation_system.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Publish
FROM build AS publish
RUN dotnet publish "foundation_system.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# Final
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "foundation_system.dll"]

View File

@@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("antecedentes_nino")]
public class AntecedenteNino
{
[Key]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("nino_id")]
public long NinoId { get; set; }
[ForeignKey("NinoId")]
public virtual Nino Nino { get; set; } = null!;
[Required]
[Column("fecha_incidente")]
public DateTime FechaIncidente { get; set; } = DateTime.UtcNow;
[Required]
[Column("tipo_antecedente")]
[StringLength(50)]
public string TipoAntecedente { get; set; } = string.Empty; // LlamadaAtencion, Suspension, Castigo
[Required]
[Column("descripcion")]
public string Descripcion { get; set; } = string.Empty;
[Required]
[Column("gravedad")]
[StringLength(20)]
public string Gravedad { get; set; } = string.Empty; // Alta, Media, Baja
[Required]
[Column("usuario_registra")]
[StringLength(100)]
public string UsuarioRegistra { get; set; } = string.Empty;
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("asistencia")]
public class Asistencia
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("nino_id")]
public long NinoId { get; set; }
[Column("fecha")]
public DateOnly Fecha { get; set; } = DateOnly.FromDateTime(DateTime.Now);
[Column("estado")]
[Required]
[StringLength(20)]
public string Estado { get; set; } = "PRESENTE";
[Column("hora_entrada")]
public TimeOnly? HoraEntrada { get; set; }
[Column("hora_salida")]
public TimeOnly? HoraSalida { get; set; }
[Column("notas")]
public string? Notas { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("NinoId")]
public Nino Nino { get; set; } = null!;
}

View File

@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("configuracion_sistema")]
public class ConfiguracionSistema
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("clave")]
[StringLength(100)]
public string Clave { get; set; } = string.Empty;
[Column("valor")]
public string? Valor { get; set; }
[Column("tipo_dato")]
[StringLength(20)]
public string TipoDato { get; set; } = "TEXTO";
[Column("categoria")]
[StringLength(50)]
public string Categoria { get; set; } = "GENERAL";
[Column("grupo")]
[StringLength(50)]
public string Grupo { get; set; } = "SISTEMA";
[Column("descripcion")]
public string? Descripcion { get; set; }
[Column("es_editable")]
public bool EsEditable { get; set; } = true;
[Column("es_publico")]
public bool EsPublico { get; set; } = false;
[Column("orden")]
public int Orden { get; set; } = 0;
[Column("opciones", TypeName = "jsonb")]
public string? Opciones { get; set; }
[Column("validacion_regex")]
[StringLength(200)]
public string? ValidacionRegex { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("encargados_nino")]
public class EncargadoNino
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("nino_id")]
public long NinoId { get; set; }
[Column("persona_id")]
public long PersonaId { get; set; }
[Column("parentesco")]
[Required]
[StringLength(50)]
public string Parentesco { get; set; } = "PADRE";
[Column("es_principal")]
public bool EsPrincipal { get; set; } = false;
[Column("puede_recoger")]
public bool PuedeRecoger { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("NinoId")]
public Nino Nino { get; set; } = null!;
[ForeignKey("PersonaId")]
public Persona Persona { get; set; } = null!;
}

View File

@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("ninos")]
public class Nino
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("persona_id")]
public long PersonaId { get; set; }
[Column("fecha_inscripcion")]
public DateOnly FechaInscripcion { get; set; } = DateOnly.FromDateTime(DateTime.Now);
[Column("codigo_inscripcion")]
[Required]
[StringLength(20)]
public string CodigoInscripcion { get; set; } = string.Empty;
[Column("estado")]
[StringLength(20)]
public string Estado { get; set; } = "ACTIVO";
[Column("nivel_grado")]
[StringLength(20)]
public string? NivelGrado { get; set; }
[Column("alergias")]
public string? Alergias { get; set; }
[Column("contacto_emergencia_nombre")]
[StringLength(100)]
public string? ContactoEmergenciaNombre { get; set; }
[Column("contacto_emergencia_telefono")]
[StringLength(20)]
public string? ContactoEmergenciaTelefono { get; set; }
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("PersonaId")]
public Persona Persona { get; set; } = null!;
}

View File

@@ -0,0 +1,64 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("personas")]
public class Persona
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("nombres")]
[Required]
[StringLength(100)]
public string Nombres { get; set; } = string.Empty;
[Column("apellidos")]
[Required]
[StringLength(100)]
public string Apellidos { get; set; } = string.Empty;
[Column("dui")]
[StringLength(12)]
public string? Dui { get; set; }
[Column("nit")]
[StringLength(17)]
public string? Nit { get; set; }
[Column("fecha_nacimiento")]
public DateOnly? FechaNacimiento { get; set; }
[Column("genero")]
[StringLength(1)]
public string? Genero { get; set; }
[Column("email")]
[StringLength(255)]
public string? Email { get; set; }
[Column("telefono")]
[StringLength(20)]
public string? Telefono { get; set; }
[Column("direccion")]
public string? Direccion { get; set; }
[Column("foto_url")]
public string? FotoUrl { get; set; }
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Nombre completo
[NotMapped]
public string NombreCompleto => $"{Nombres} {Apellidos}";
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("roles_sistema")]
public class RolSistema
{
[Key]
[Column("id")]
public int Id { get; set; }
[Column("codigo")]
[Required]
[StringLength(50)]
public string Codigo { get; set; } = string.Empty;
[Column("nombre")]
[Required]
[StringLength(100)]
public string Nombre { get; set; } = string.Empty;
[Column("descripcion")]
public string? Descripcion { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
public ICollection<RolUsuario> RolesUsuario { get; set; } = new List<RolUsuario>();
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("roles_usuario")]
public class RolUsuario
{
[Column("usuario_id")]
public long UsuarioId { get; set; }
[Column("rol_id")]
public int RolId { get; set; }
[Column("asignado_en")]
public DateTime AsignadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("UsuarioId")]
public Usuario Usuario { get; set; } = null!;
[ForeignKey("RolId")]
public RolSistema Rol { get; set; } = null!;
}

View File

@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace foundation_system.Models;
[Table("usuarios")]
public class Usuario
{
[Key]
[Column("id")]
public long Id { get; set; }
[Column("persona_id")]
public long? PersonaId { get; set; }
[Column("nombre_usuario")]
[Required]
[StringLength(50)]
public string NombreUsuario { get; set; } = string.Empty;
[Column("email")]
[Required]
[StringLength(255)]
public string Email { get; set; } = string.Empty;
[Column("hash_contrasena")]
[Required]
public string HashContrasena { get; set; } = string.Empty;
[Column("activo")]
public bool Activo { get; set; } = true;
[Column("ultimo_login")]
public DateTime? UltimoLogin { get; set; }
[Column("creado_en")]
public DateTime CreadoEn { get; set; } = DateTime.UtcNow;
[Column("actualizado_en")]
public DateTime ActualizadoEn { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("PersonaId")]
public Persona? Persona { get; set; }
public ICollection<RolUsuario> RolesUsuario { get; set; } = new List<RolUsuario>();
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using foundation_system.Models;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace foundation_system.Models.ViewModels;
public class AntecedentesViewModel
{
[ValidateNever]
public Nino? Nino { get; set; }
[ValidateNever]
public List<AntecedenteNino> Historial { get; set; } = new();
// Form fields for new record
[Required(ErrorMessage = "La fecha es requerida")]
[Display(Name = "Fecha del Incidente")]
public DateTime FechaIncidente { get; set; } = DateTime.UtcNow;
[Required(ErrorMessage = "El tipo de antecedente es requerido")]
[Display(Name = "Tipo de Antecedente")]
public string TipoAntecedente { get; set; } = string.Empty;
[Required(ErrorMessage = "La descripción es requerida")]
[Display(Name = "Descripción")]
public string Descripcion { get; set; } = string.Empty;
[Required(ErrorMessage = "La gravedad es requerida")]
[Display(Name = "Gravedad")]
public string Gravedad { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
using foundation_system.Models;
namespace foundation_system.Models.ViewModels;
public class AsistenciaGridViewModel
{
public int Año { get; set; }
public int Mes { get; set; }
public string NombreMes { get; set; } = string.Empty;
public string DiasSemanaSeleccionados { get; set; } = string.Empty;
public List<DateTime> DiasDelMes { get; set; } = new();
public List<Nino> Expedientes { get; set; } = new();
public Dictionary<string, string> Asistencias { get; set; } = new(); // Key: "ninoId_yyyy-MM-dd", Value: "P", "T", "F"
}

View File

@@ -0,0 +1,89 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels;
public class ExpedienteViewModel
{
public long? Id { get; set; } // Nino Id
// Persona Data
[Required(ErrorMessage = "Los nombres son requeridos")]
[Display(Name = "Nombres")]
public string Nombres { get; set; } = string.Empty;
[Required(ErrorMessage = "Los apellidos son requeridos")]
[Display(Name = "Apellidos")]
public string Apellidos { get; set; } = string.Empty;
[Display(Name = "DUI")]
[StringLength(12)]
public string? Dui { get; set; }
[Display(Name = "NIT")]
[StringLength(17)]
public string? Nit { get; set; }
[Required(ErrorMessage = "La fecha de nacimiento es requerida")]
[Display(Name = "Fecha de Nacimiento")]
[DataType(DataType.Date)]
public DateOnly? FechaNacimiento { get; set; }
[Required(ErrorMessage = "El género es requerido")]
[Display(Name = "Género")]
public string? Genero { get; set; }
[EmailAddress(ErrorMessage = "Correo electrónico inválido")]
public string? Email { get; set; }
[Display(Name = "Teléfono")]
public string? Telefono { get; set; }
[Display(Name = "Foto")]
public string? FotoUrl { get; set; }
[Display(Name = "Archivo de Foto")]
public IFormFile? FotoFile { get; set; }
[Required(ErrorMessage = "La dirección es requerida")]
[Display(Name = "Dirección")]
public string? Direccion { get; set; }
// Nino Data
[Display(Name = "Código de Inscripción")]
public string? CodigoInscripcion { get; set; }
[Required(ErrorMessage = "La fecha de inscripción es requerida")]
[Display(Name = "Fecha de Inscripción")]
[DataType(DataType.Date)]
public DateOnly FechaInscripcion { get; set; } = DateOnly.FromDateTime(DateTime.Now);
[Required(ErrorMessage = "El estado es requerido")]
[Display(Name = "Estado")]
public string Estado { get; set; } = "ACTIVO";
[Required(ErrorMessage = "El nivel/grado es requerido")]
[Display(Name = "Nivel/Grado")]
public string? NivelGrado { get; set; }
[Display(Name = "Alergias")]
public string? Alergias { get; set; }
[Display(Name = "Contacto de Emergencia")]
public string? ContactoEmergenciaNombre { get; set; }
[Display(Name = "Teléfono de Emergencia")]
public string? ContactoEmergenciaTelefono { get; set; }
// Parents
[Required(ErrorMessage = "El padre es requerido")]
public long? PadreId { get; set; }
public string? PadreNombre { get; set; }
[Required(ErrorMessage = "La madre es requerida")]
public long? MadreId { get; set; }
public string? MadreNombre { get; set; }
[Required(ErrorMessage = "El encargado es requerido")]
public long? EncargadoId { get; set; }
public string? EncargadoNombre { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels;
public class LoginViewModel
{
[Required(ErrorMessage = "El nombre de usuario es requerido")]
[Display(Name = "Usuario")]
public string NombreUsuario { get; set; } = string.Empty;
[Required(ErrorMessage = "La contraseña es requerida")]
[DataType(DataType.Password)]
[Display(Name = "Contraseña")]
public string Contrasena { get; set; } = string.Empty;
[Display(Name = "Recordarme")]
public bool RecordarMe { get; set; }
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels;
public class RegisterViewModel
{
[Required(ErrorMessage = "Los nombres son requeridos")]
[StringLength(100, ErrorMessage = "Máximo 100 caracteres")]
[Display(Name = "Nombres")]
public string Nombres { get; set; } = string.Empty;
[Required(ErrorMessage = "Los apellidos son requeridos")]
[StringLength(100, ErrorMessage = "Máximo 100 caracteres")]
[Display(Name = "Apellidos")]
public string Apellidos { get; set; } = string.Empty;
[Required(ErrorMessage = "El nombre de usuario es requerido")]
[StringLength(50, MinimumLength = 3, ErrorMessage = "El usuario debe tener entre 3 y 50 caracteres")]
[RegularExpression(@"^[a-zA-Z0-9_]+$", ErrorMessage = "Solo letras, números y guiones bajos")]
[Display(Name = "Nombre de Usuario")]
public string NombreUsuario { get; set; } = string.Empty;
[Required(ErrorMessage = "El correo electrónico es requerido")]
[EmailAddress(ErrorMessage = "Correo electrónico inválido")]
[Display(Name = "Correo Electrónico")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "La contraseña es requerida")]
[StringLength(100, MinimumLength = 6, ErrorMessage = "La contraseña debe tener al menos 6 caracteres")]
[DataType(DataType.Password)]
[Display(Name = "Contraseña")]
public string Contrasena { get; set; } = string.Empty;
[Required(ErrorMessage = "Debe confirmar la contraseña")]
[Compare("Contrasena", ErrorMessage = "Las contraseñas no coinciden")]
[DataType(DataType.Password)]
[Display(Name = "Confirmar Contraseña")]
public string ConfirmarContrasena { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace foundation_system.Models.ViewModels;
public class UsuarioViewModel
{
public long? Id { get; set; }
[Required(ErrorMessage = "Los nombres son requeridos")]
[Display(Name = "Nombres")]
public string Nombres { get; set; } = string.Empty;
[Required(ErrorMessage = "Los apellidos son requeridos")]
[Display(Name = "Apellidos")]
public string Apellidos { get; set; } = string.Empty;
[Required(ErrorMessage = "El nombre de usuario es requerido")]
[Display(Name = "Nombre de Usuario")]
[StringLength(50)]
public string NombreUsuario { get; set; } = string.Empty;
[Required(ErrorMessage = "El correo electrónico es requerido")]
[EmailAddress(ErrorMessage = "Correo electrónico inválido")]
[Display(Name = "Email")]
public string Email { get; set; } = string.Empty;
[Display(Name = "Contraseña")]
[DataType(DataType.Password)]
[StringLength(100, MinimumLength = 6, ErrorMessage = "La contraseña debe tener al menos 6 caracteres")]
public string? Contrasena { get; set; }
[Display(Name = "Confirmar Contraseña")]
[DataType(DataType.Password)]
[Compare("Contrasena", ErrorMessage = "Las contraseñas no coinciden")]
public string? ConfirmarContrasena { get; set; }
[Display(Name = "Estado")]
public bool Activo { get; set; } = true;
[Display(Name = "Teléfono")]
public string? Telefono { get; set; }
}

View File

@@ -14,8 +14,9 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddDatabaseDeveloperPageExceptionFilter();
// Register authentication service // Register services
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAntecedentesService, AntecedentesService>();
// Configure cookie authentication // Configure cookie authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)

View File

@@ -0,0 +1,37 @@
using foundation_system.Data;
using foundation_system.Models;
using Microsoft.EntityFrameworkCore;
namespace foundation_system.Services;
public class AntecedentesService : IAntecedentesService
{
private readonly ApplicationDbContext _context;
public AntecedentesService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<AntecedenteNino>> GetHistorialByNinoIdAsync(long ninoId)
{
return await _context.AntecedentesNino
.Where(a => a.NinoId == ninoId && a.Activo)
.OrderByDescending(a => a.FechaIncidente)
.ToListAsync();
}
public async Task<bool> AddAntecedenteAsync(AntecedenteNino antecedente)
{
try
{
_context.AntecedentesNino.Add(antecedente);
await _context.SaveChangesAsync();
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,148 @@
using Microsoft.EntityFrameworkCore;
using foundation_system.Data;
using foundation_system.Models;
using foundation_system.Models.ViewModels;
using BC = BCrypt.Net.BCrypt;
namespace foundation_system.Services;
public class AuthService : IAuthService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<AuthService> _logger;
public AuthService(ApplicationDbContext context, ILogger<AuthService> logger)
{
_context = context;
_logger = logger;
}
public async Task<Usuario?> ValidateUserAsync(string username, string password)
{
var usuario = await _context.Usuarios
.Include(u => u.Persona)
.Include(u => u.RolesUsuario)
.ThenInclude(ru => ru.Rol)
.FirstOrDefaultAsync(u => u.NombreUsuario == username && u.Activo);
if (usuario == null)
{
_logger.LogWarning("Login attempt for non-existent user: {Username}", username);
return null;
}
// Verify password using BCrypt
try
{
if (!BC.Verify(password, usuario.HashContrasena))
{
_logger.LogWarning("Invalid password for user: {Username}", username);
return null;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying password for user: {Username}", username);
return null;
}
_logger.LogInformation("User {Username} logged in successfully", username);
return usuario;
}
public async Task<(bool Success, string Message, Usuario? User)> RegisterUserAsync(RegisterViewModel model)
{
// Check if username already exists
if (await _context.Usuarios.AnyAsync(u => u.NombreUsuario == model.NombreUsuario))
{
return (false, "El nombre de usuario ya existe", null);
}
// Check if email already exists
if (await _context.Usuarios.AnyAsync(u => u.Email == model.Email))
{
return (false, "El correo electrónico ya está registrado", null);
}
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Create persona first
var persona = new Persona
{
Nombres = model.Nombres,
Apellidos = model.Apellidos,
Email = model.Email,
Activo = true,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Personas.Add(persona);
await _context.SaveChangesAsync();
// Create user with hashed password
var usuario = new Usuario
{
PersonaId = persona.Id,
NombreUsuario = model.NombreUsuario,
Email = model.Email,
HashContrasena = BC.HashPassword(model.Contrasena),
Activo = true,
CreadoEn = DateTime.UtcNow,
ActualizadoEn = DateTime.UtcNow
};
_context.Usuarios.Add(usuario);
await _context.SaveChangesAsync();
// Assign default role (LECTOR - reader)
var defaultRole = await _context.RolesSistema
.FirstOrDefaultAsync(r => r.Codigo == "LECTOR");
if (defaultRole != null)
{
var rolUsuario = new RolUsuario
{
UsuarioId = usuario.Id,
RolId = defaultRole.Id,
AsignadoEn = DateTime.UtcNow
};
_context.RolesUsuario.Add(rolUsuario);
await _context.SaveChangesAsync();
}
await transaction.CommitAsync();
_logger.LogInformation("New user registered: {Username}", model.NombreUsuario);
return (true, "Usuario registrado exitosamente", usuario);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Error registering user: {Username}", model.NombreUsuario);
return (false, "Error al registrar el usuario", null);
}
}
public async Task<List<string>> GetUserRolesAsync(long userId)
{
return await _context.RolesUsuario
.Where(ru => ru.UsuarioId == userId)
.Include(ru => ru.Rol)
.Select(ru => ru.Rol.Codigo)
.ToListAsync();
}
public async Task UpdateLastLoginAsync(long userId)
{
var usuario = await _context.Usuarios.FindAsync(userId);
if (usuario != null)
{
usuario.UltimoLogin = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,9 @@
using foundation_system.Models;
namespace foundation_system.Services;
public interface IAntecedentesService
{
Task<List<AntecedenteNino>> GetHistorialByNinoIdAsync(long ninoId);
Task<bool> AddAntecedenteAsync(AntecedenteNino antecedente);
}

View File

@@ -0,0 +1,27 @@
using foundation_system.Models;
using foundation_system.Models.ViewModels;
namespace foundation_system.Services;
public interface IAuthService
{
/// <summary>
/// Validates user credentials and returns the user if valid
/// </summary>
Task<Usuario?> ValidateUserAsync(string username, string password);
/// <summary>
/// Registers a new user
/// </summary>
Task<(bool Success, string Message, Usuario? User)> RegisterUserAsync(RegisterViewModel model);
/// <summary>
/// Gets the roles for a user
/// </summary>
Task<List<string>> GetUserRolesAsync(long userId);
/// <summary>
/// Updates the last login timestamp
/// </summary>
Task UpdateLastLoginAsync(long userId);
}

View File

@@ -0,0 +1,18 @@
@{
ViewData["Title"] = "Acceso Denegado";
}
<div class="container text-center py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<h1 class="display-1 fw-bold text-danger">403</h1>
<h2 class="mb-4">Acceso Denegado</h2>
<p class="text-muted mb-4">
No tienes permisos para acceder a esta página.
</p>
<a asp-controller="Home" asp-action="Index" class="btn btn-primary">
Volver al Inicio
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,108 @@
@model foundation_system.Models.ViewModels.LoginViewModel
@{
ViewData["Title"] = "Iniciar Sesión";
Layout = null;
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"] - MIES</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true"/>
</head>
<body>
<div class="auth-container">
<div class="auth-background">
<div class="auth-gradient"></div>
<div class="auth-pattern"></div>
</div>
<div class="auth-card animate-fade-in">
<div class="auth-header">
<div class="auth-logo">
<div class="logo-icon">
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="4"/>
<path d="M30 55 L50 35 L70 55" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40 65 L50 55 L60 65" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<h1 class="auth-title">MIES</h1>
<p class="auth-subtitle">Misión Esperanza</p>
</div>
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>
@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<form asp-action="Login" method="post" class="auth-form">
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="form-floating mb-3">
<input asp-for="NombreUsuario" class="form-control" id="username" placeholder="Usuario" autofocus autocomplete="username"/>
<label for="username">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Usuario
</label>
<span asp-validation-for="NombreUsuario" class="text-danger small"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Contrasena" class="form-control" id="password" placeholder="Contraseña" autocomplete="current-password"/>
<label for="password">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Contraseña
</label>
<span asp-validation-for="Contrasena" class="text-danger small"></span>
</div>
<div class="form-check mb-4">
<input asp-for="RecordarMe" class="form-check-input" id="rememberMe"/>
<label class="form-check-label" for="rememberMe">
Recordarme
</label>
</div>
<button type="submit" class="btn btn-primary btn-auth">
<span>Iniciar Sesión</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</form>
<div class="auth-footer">
<p>¿No tienes una cuenta?</p>
<a asp-action="Register" class="auth-link">Crear cuenta</a>
</div>
<div class="auth-branding">
<span>Sistema de Gestión</span>
<span class="separator">•</span>
<span>Fundación MIES</span>
</div>
</div>
</div>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,140 @@
@model foundation_system.Models.ViewModels.RegisterViewModel
@{
ViewData["Title"] = "Crear Cuenta";
Layout = null;
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"] - MIES</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true"/>
</head>
<body>
<div class="auth-container">
<div class="auth-background">
<div class="auth-gradient"></div>
<div class="auth-pattern"></div>
</div>
<div class="auth-card auth-card-register animate-fade-in">
<div class="auth-header">
<div class="auth-logo">
<div class="logo-icon">
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="4"/>
<path d="M30 55 L50 35 L70 55" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40 65 L50 55 L60 65" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<h1 class="auth-title">Crear Cuenta</h1>
<p class="auth-subtitle">Únete a MIES - Misión Esperanza</p>
</div>
<form asp-action="Register" method="post" class="auth-form">
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<input asp-for="Nombres" class="form-control" id="nombres" placeholder="Nombres" autofocus/>
<label for="nombres">Nombres</label>
<span asp-validation-for="Nombres" class="text-danger small"></span>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<input asp-for="Apellidos" class="form-control" id="apellidos" placeholder="Apellidos"/>
<label for="apellidos">Apellidos</label>
<span asp-validation-for="Apellidos" class="text-danger small"></span>
</div>
</div>
</div>
<div class="form-floating mb-3">
<input asp-for="NombreUsuario" class="form-control" id="username" placeholder="Nombre de Usuario" autocomplete="username"/>
<label for="username">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Nombre de Usuario
</label>
<span asp-validation-for="NombreUsuario" class="text-danger small"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Email" class="form-control" id="email" placeholder="Correo Electrónico" type="email" autocomplete="email"/>
<label for="email">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
Correo Electrónico
</label>
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-floating mb-3">
<input asp-for="Contrasena" class="form-control" id="password" placeholder="Contraseña" autocomplete="new-password"/>
<label for="password">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Contraseña
</label>
<span asp-validation-for="Contrasena" class="text-danger small"></span>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3">
<input asp-for="ConfirmarContrasena" class="form-control" id="confirmPassword" placeholder="Confirmar Contraseña" autocomplete="new-password"/>
<label for="confirmPassword">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Confirmar
</label>
<span asp-validation-for="ConfirmarContrasena" class="text-danger small"></span>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-auth">
<span>Crear Cuenta</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</button>
</form>
<div class="auth-footer">
<p>¿Ya tienes una cuenta?</p>
<a asp-action="Login" class="auth-link">Iniciar Sesión</a>
</div>
<div class="auth-branding">
<span>Sistema de Gestión</span>
<span class="separator">•</span>
<span>Fundación MIES</span>
</div>
</div>
</div>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,89 @@
@model IEnumerable<foundation_system.Models.Nino>
@{
ViewData["Title"] = "Gestión de Antecedentes";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Antecedentes</h2>
<p class="text-muted small mb-0">Seleccione un niño para gestionar su historial de antecedentes y conducta.</p>
</div>
</div>
<div class="card-custom mb-4">
<form asp-action="Index" method="get" class="row g-3 align-items-center">
<div class="col-md-10">
<div class="input-group">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0 ps-0"
placeholder="Buscar por código o nombre..." value="@ViewBag.Search" />
</div>
</div>
<div class="col-md-2 d-grid">
<button type="submit" class="btn btn-primary">Buscar</button>
</div>
</form>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Código</th>
<th>Nombre Completo</th>
<th>Grado/Nivel</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center py-4 text-muted">No se encontraron niños.</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.CodigoInscripcion</td>
<td>@item.Persona.NombreCompleto</td>
<td>@item.NivelGrado</td>
<td>
<span class="badge @(item.Estado == "ACTIVO" ? "bg-success" : "bg-secondary")">
@item.Estado
</span>
</td>
<td>
<a asp-action="Manage" asp-route-id="@item.Id" class="btn btn-sm btn-primary" title="Gestionar Antecedentes">
<i class="bi bi-journal-text me-1"></i> Gestionar
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (ViewBag.TotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted small">
Mostrando página @ViewBag.CurrentPage de @ViewBag.TotalPages
</div>
<nav aria-label="Page navigation">
<ul class="pagination pagination-sm mb-0">
@for (int i = 1; i <= ViewBag.TotalPages; i++)
{
<li class="page-item @(i == ViewBag.CurrentPage ? "active" : "")">
<a class="page-link" asp-action="Index" asp-route-page="@i" asp-route-search="@ViewBag.Search">@i</a>
</li>
}
</ul>
</nav>
</div>
}
</div>

View File

@@ -0,0 +1,138 @@
@model foundation_system.Models.ViewModels.AntecedentesViewModel
@{
ViewData["Title"] = "Historial de Antecedentes - " + Model.Nino.Persona.NombreCompleto;
}
<div class="mb-4">
<a asp-action="Index" class="btn btn-link p-0 text-decoration-none mb-2">
<i class="bi bi-arrow-left"></i> Volver al listado
</a>
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="mb-1">@Model.Nino.Persona.NombreCompleto</h2>
<p class="text-muted small mb-0">Código: <strong>@Model.Nino.CodigoInscripcion</strong> | Grado: @Model.Nino.NivelGrado</p>
</div>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalNuevoAntecedente">
<i class="bi bi-plus-circle me-2"></i>Nuevo Registro
</button>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card-custom">
<h5 class="card-title mb-4">Historial de Antecedentes</h5>
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Gravedad</th>
<th>Descripción</th>
<th>Registrado por</th>
</tr>
</thead>
<tbody>
@if (!Model.Historial.Any())
{
<tr>
<td colspan="5" class="text-center py-4 text-muted">No hay antecedentes registrados para este niño.</td>
</tr>
}
@foreach (var item in Model.Historial)
{
<tr>
<td>@item.FechaIncidente.ToString("dd/MM/yyyy HH:mm")</td>
<td>
<span class="badge @(item.TipoAntecedente switch {
"LlamadaAtencion" => "bg-info",
"Suspension" => "bg-warning",
"Castigo" => "bg-danger",
_ => "bg-secondary"
})">
@item.TipoAntecedente
</span>
</td>
<td>
<span class="text-@(item.Gravedad switch {
"Alta" => "danger",
"Media" => "warning",
"Baja" => "success",
_ => "muted"
}) fw-bold">
@item.Gravedad
</span>
</td>
<td>@item.Descripcion</td>
<td class="small">@item.UsuarioRegistra</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Nuevo Antecedente -->
<div class="modal fade" id="modalNuevoAntecedente" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="ninoId" value="@Model.Nino.Id" />
<div class="modal-header">
<h5 class="modal-title">Registrar Nuevo Antecedente</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label asp-for="FechaIncidente" class="form-label"></label>
<input asp-for="FechaIncidente" class="form-control" type="datetime-local" />
<span asp-validation-for="FechaIncidente" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="TipoAntecedente" class="form-label"></label>
<select asp-for="TipoAntecedente" class="form-select">
<option value="">Seleccione un tipo...</option>
<option value="LlamadaAtencion">Llamada de Atención</option>
<option value="Suspension">Suspensión</option>
<option value="Castigo">Castigo</option>
</select>
<span asp-validation-for="TipoAntecedente" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Gravedad" class="form-label"></label>
<select asp-for="Gravedad" class="form-select">
<option value="">Seleccione gravedad...</option>
<option value="Baja">Baja</option>
<option value="Media">Media</option>
<option value="Alta">Alta</option>
</select>
<span asp-validation-for="Gravedad" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Descripcion" class="form-label"></label>
<textarea asp-for="Descripcion" class="form-control" rows="4" placeholder="Detalle lo sucedido..."></textarea>
<span asp-validation-for="Descripcion" class="text-danger"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar Registro</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,803 @@
@model foundation_system.Models.ViewModels.AsistenciaGridViewModel
@{
ViewData["Title"] = "Control de Asistencia";
var diasSeleccionadosList = new List<string>();
if (!string.IsNullOrEmpty(Model.DiasSemanaSeleccionados))
{
diasSeleccionadosList = Model.DiasSemanaSeleccionados
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => d.Trim())
.ToList();
}
var diasAMostrar = Model.DiasDelMes
.Where(d => diasSeleccionadosList.Count == 0 ||
diasSeleccionadosList.Contains(((int)d.DayOfWeek).ToString()))
.ToList();
}
<div class="container-fluid">
<div class="card mb-4">
<div class="card-header text-white d-flex align-items-center" style="background: linear-gradient(180deg, #2c3e50 0%, #1a2530 100%);">
<h5 class="mb-0 text-white"><i class="bi bi-funnel"></i> Filtros</h5>
</div>
<div class="card-body">
<form method="get" id="filtroForm" class="row g-3">
<div class="col-md-3">
<label class="form-label">Año</label>
<select name="año" class="form-select" id="selectAnio">
@foreach (var año in (List<SelectListItem>)ViewBag.Años)
{
<option value="@año.Value" selected="@año.Selected">
@año.Text
</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Mes</label>
<select name="mes" class="form-select" id="selectMes">
@foreach (var mes in (List<SelectListItem>)ViewBag.Meses)
{
<option value="@mes.Value" selected="@(mes.Value == Model.Mes.ToString())">
@mes.Text
</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Días de la semana</label>
<div class="dias-semana-checkboxes">
@foreach (var dia in (List<SelectListItem>)ViewBag.DiasSemana)
{
var isChecked = diasSeleccionadosList.Contains(dia.Value);
<div class="form-check form-check-inline">
<input class="form-check-input dia-checkbox"
type="checkbox"
value="@dia.Value"
id="dia@(dia.Value)"
@(isChecked ? "checked" : "")>
<label class="form-check-label" for="dia@(dia.Value)">
@dia.Text
</label>
</div>
}
<input type="hidden" name="diasSemana" id="diasSemanaInput"
value="@Model.DiasSemanaSeleccionados">
</div>
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn w-100"
style="background: linear-gradient(180deg, #2c3e50 0%, #1a2530 100%); color: white;">
<i class="bi bi-search text-white"></i> Filtrar
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-calendar-check"></i>
Asistencia - @Model.NombreMes @Model.Año
<span class="badge bg-light text-dark ms-2">@Model.Expedientes.Count niños</span>
</h5>
<div>
<button class="btn btn-light btn-sm" id="btnExportar">
<i class="bi bi-file-earmark-excel"></i> Exportar
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-bordered table-hover table-sm" id="tablaAsistencia">
<thead class="table-dark sticky-top">
<tr>
<th class="sticky-left bg-dark text-white border-secondary" style="min-width: 200px;">
<div class="text-white">Nombre</div>
<div class="small text-light">Edad</div>
</th>
@foreach (var dia in Model.DiasDelMes)
{
var diaSemana = ((int)dia.DayOfWeek).ToString();
var isChecked = Model.DiasSemanaSeleccionados?.Contains(diaSemana) ?? true;
if (!string.IsNullOrEmpty(Model.DiasSemanaSeleccionados) && !isChecked)
{
continue;
}
var nombreDia = dia.ToString("ddd", new System.Globalization.CultureInfo("es-ES"));
var esFinDeSemana = dia.DayOfWeek == DayOfWeek.Saturday || dia.DayOfWeek == DayOfWeek.Sunday;
<th class="text-center text-dark border-secondary @(esFinDeSemana ? "" : "")" style="min-width: 50px;">
<div class="text-dark">@dia.Day</div>
<div class="small text-muted">@nombreDia</div>
</th>
}
</tr>
</thead>
<tbody>
@foreach (var expediente in Model.Expedientes)
{
var nombreCompleto = $"{expediente.Persona.Nombres} {expediente.Persona.Apellidos}".Trim();
var edad = 0;
if (expediente.Persona.FechaNacimiento.HasValue)
{
var birthDate = expediente.Persona.FechaNacimiento.Value;
var today = DateOnly.FromDateTime(DateTime.Today);
edad = today.Year - birthDate.Year;
if (birthDate > today.AddYears(-edad))
{
edad--;
}
}
<tr data-expediente-id="@expediente.Id">
<td class="sticky-left bg-white" style="min-width: 200px;">
<div class="fw-bold">@nombreCompleto</div>
<div class="small text-muted">Edad: @edad años</div>
</td>
@foreach (var dia in Model.DiasDelMes)
{
var diaSemana = ((int)dia.DayOfWeek).ToString();
var isChecked = Model.DiasSemanaSeleccionados?.Contains(diaSemana) ?? true;
if (!string.IsNullOrEmpty(Model.DiasSemanaSeleccionados) && !isChecked)
{
continue;
}
var key = $"{expediente.Id}_{dia:yyyy-MM-dd}";
var estadoActual = Model.Asistencias.ContainsKey(key)
? Model.Asistencias[key]
: "";
var claseEstado = estadoActual switch
{
"P" => "celda-presente",
"T" => "celda-tarde",
"F" => "celda-falta",
_ => ""
};
var esFinDeSemana = dia.DayOfWeek == DayOfWeek.Saturday || dia.DayOfWeek == DayOfWeek.Sunday;
<td class="text-center p-0 celda-asistencia @claseEstado @(esFinDeSemana ? "bg-light" : "")"
data-expediente="@expediente.Id"
data-fecha="@dia.ToString("yyyy-MM-dd")"
style="min-width: 50px;">
<select class="form-select form-select-sm estado-select border-0 h-100 w-100"
data-initial="@estadoActual">
<option value=""></option>
<option value="P" selected="@(estadoActual == "P")" class="bg-success text-white">P</option>
<option value="T" selected="@(estadoActual == "T")" class="bg-warning">T</option>
<option value="F" selected="@(estadoActual == "F")" class="bg-danger text-white">F</option>
<option value="J" selected="@(estadoActual == "J")" class="bg-info text-white">J</option>
<option value="E" selected="@(estadoActual == "E")" class="bg-secondary text-white">E</option>
</select>
</td>
}
</tr>
}
</tbody>
<tfoot class="table-dark">
<tr>
<td class="sticky-left bg-dark text-white fw-bold">
<div>TOTALES POR DÍA</div>
<div class="small text-light">P/T/F</div>
</td>
@foreach (var dia in Model.DiasDelMes)
{
var diaSemana = ((int)dia.DayOfWeek).ToString();
var isChecked = Model.DiasSemanaSeleccionados?.Contains(diaSemana) ?? true;
if (!string.IsNullOrEmpty(Model.DiasSemanaSeleccionados) && !isChecked)
{
continue;
}
var esFinDeSemana = dia.DayOfWeek == DayOfWeek.Saturday || dia.DayOfWeek == DayOfWeek.Sunday;
var totalPresente = 0;
var totalTarde = 0;
var totalFalta = 0;
var totalJustificado = 0;
var totalEnfermo = 0;
foreach (var expediente in Model.Expedientes)
{
var key = $"{expediente.Id}_{dia:yyyy-MM-dd}";
if (Model.Asistencias.ContainsKey(key))
{
var estado = Model.Asistencias[key];
switch (estado)
{
case "P": totalPresente++; break;
case "T": totalTarde++; break;
case "F": totalFalta++; break;
case "J": totalJustificado++; break;
case "E": totalEnfermo++; break;
}
}
}
var totalDia = totalPresente + totalTarde + totalFalta + totalJustificado + totalEnfermo;
<td class="text-center p-1 @(esFinDeSemana ? "bg-secondary" : "bg-dark") text-white"
style="min-width: 65px; font-size: 0.85rem;">
<div class="fw-bold mb-1">@totalDia</div>
<div class="d-flex justify-content-center gap-1 flex-wrap">
<span class="badge bg-success" title="Presentes">@totalPresente</span>
<span class="badge bg-warning text-dark" title="Tardes">@totalTarde</span>
<span class="badge bg-danger" title="Faltas">@totalFalta</span>
<span class="badge bg-info" title="Justificados">@totalJustificado</span>
<span class="badge bg-secondary" title="Enfermos">@totalEnfermo</span>
</div>
</td>
}
</tr>
</tfoot>
</table>
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col-md-6">
<div class="leyenda">
<span class="badge bg-success me-2">P = Presente</span>
<span class="badge bg-warning me-2 text-dark">T = Tarde</span>
<span class="badge bg-danger me-2">F = Falto</span>
<span class="badge bg-info me-2">J = Justificado</span>
<span class="badge bg-secondary me-2">E = Enfermo</span>
<span class="text-muted ms-3">(Vacío = No registrado)</span>
</div>
</div>
<div class="col-md-6 text-end">
<small class="text-muted">
Total: @Model.Expedientes.Count niños × @diasAMostrar.Count días =
@(Model.Expedientes.Count * diasAMostrar.Count) registros posibles
</small>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="modalCarga" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-body text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
<p class="mt-2">Guardando cambios...</p>
</div>
</div>
</div>
</div>
@section Styles {
<style>
#estadisticasContainer {
min-height: 90px;
transition: min-height 0.3s ease;
}
#estadisticasContainer .card {
height: 100%;
min-height: 90px;
display: flex;
flex-direction: column;
}
#estadisticasContainer .card-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
#estadisticasContainer .spinner-border {
width: 1.75rem;
height: 1.75rem;
}
#estadisticasContainer .alert {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
}
tfoot.table-dark td {
vertical-align: middle !important;
border-color: #495057 !important;
}
tfoot .badge {
font-size: 0.7rem;
padding: 0.25em 0.5em;
min-width: 24px;
}
tfoot tr:first-child td {
border-top: 2px solid #6c757d;
}
tfoot tr.bg-success td {
border-top: 2px solid #198754;
}
tfoot td[colspan] {
background: linear-gradient(135deg, #198754 0%, #146c43 100%) !important;
color: white !important;
}
tfoot td[colspan] .badge {
font-size: 0.8rem;
padding: 0.4em 0.8em;
}
tfoot td[colspan] .text-light {
color: rgba(255, 255, 255, 0.9) !important;
}
tfoot.table-dark .sticky-left {
background: #343a40 !important;
color: white !important;
}
tfoot.table-dark td.bg-dark {
background-color: #343a40 !important;
}
tfoot.table-dark td.bg-secondary {
background-color: #6c757d !important;
}
tfoot.table-dark .text-white,
tfoot.table-dark .text-light {
color: white !important;
}
tfoot.table-dark .text-muted {
color: rgba(255, 255, 255, 0.7) !important;
}
.table th, .table td {
vertical-align: middle;
}
.celda-presente {
background-color: #d1e7dd !important;
}
.celda-tarde {
background-color: #fff3cd !important;
}
.celda-falta {
background-color: #f8d7da !important;
}
.celda-justificado {
background-color: #cff4fc !important;
}
.celda-enfermo {
background-color: #e2e3e5 !important;
}
.estado-select {
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent !important;
text-align: center;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.estado-select::-ms-expand {
display: none;
}
.estado-select:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
outline: none;
}
.estado-select.bg-success {
background-color: #198754 !important;
color: white !important;
border-color: #198754 !important;
}
.estado-select.bg-warning {
background-color: #ffc107 !important;
color: black !important;
border-color: #ffc107 !important;
}
.estado-select.bg-danger {
background-color: #dc3545 !important;
color: white !important;
border-color: #dc3545 !important;
}
.estado-select.bg-info {
background-color: #0dcaf0 !important;
color: white !important;
border-color: #0dcaf0 !important;
}
.estado-select.bg-secondary {
background-color: #6c757d !important;
color: white !important;
border-color: #6c757d !important;
}
.estado-select:not([value]):not([value=""]) {
background-color: white !important;
color: black !important;
}
.celda-asistencia.changed {
outline: 2px solid #0d6efd;
outline-offset: -2px;
}
.sticky-left {
position: sticky;
left: 0;
background: white;
z-index: 5;
}
.dias-semana-checkboxes {
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
border: 1px solid #dee2e6;
}
.bg-lunes { background-color: #e3f2fd !important; }
.bg-martes { background-color: #f3e5f5 !important; }
.bg-miercoles { background-color: #e8f5e8 !important; }
.bg-jueves { background-color: #fff3e0 !important; }
.bg-viernes { background-color: #fce4ec !important; }
.bg-sabado { background-color: #f1f8e9 !important; }
.bg-domingo { background-color: #fff8e1 !important; }
.table-dark.sticky-top {
z-index: 10;
}
.card .display-6 {
font-size: 2rem;
font-weight: bold;
}
.progress {
background-color: rgba(255, 255, 255, 0.3);
}
.celda-asistencia:hover {
background-color: #f8f9fa !important;
}
.celda-presente:hover {
background-color: #c3e6cb !important;
}
.celda-tarde:hover {
background-color: #ffeaa7 !important;
}
.celda-falta:hover {
background-color: #f5c6cb !important;
}
.celda-justificado:hover {
background-color: #b6effb !important;
}
.celda-enfermo:hover {
background-color: #d3d6d8 !important;
}
@@media (max-width: 768px) {
.table-responsive {
font-size: 0.85rem;
}
.estado-select {
padding: 0.25rem;
font-size: 0.8rem;
}
.dias-semana-checkboxes .form-check-inline {
display: block;
margin-right: 0;
margin-bottom: 5px;
}
}
.celda-asistencia, .estado-select {
transition: background-color 0.3s ease, color 0.3s ease;
}
.bg-light {
background-color: #f8f9fa !important;
}
.leyenda .badge {
font-size: 0.8rem;
padding: 0.4em 0.8em;
}
.celda-asistencia {
min-width: 65px !important;
}
.table th.text-center {
min-width: 65px !important;
}
.sticky-left {
min-width: 240px !important;
}
.estado-select {
padding: 10px 4px !important;
font-size: 14px !important;
}
</style>
}
@section Scripts {
<script>
$(document).ready(function () {
cargarEstadisticas();
$('.dia-checkbox').change(function () {
var diasSeleccionados = $('.dia-checkbox:checked').map(function () {
return $(this).val();
}).get().join(',');
$('#diasSemanaInput').val(diasSeleccionados);
});
inicializarColores();
$('#btnGuardarTodo').click(function () {
var cambios = [];
var celdasCambiadas = $('.celda-asistencia.changed');
if (celdasCambiadas.length === 0) {
toastr.info('No hay cambios para guardar');
return;
}
if (!confirm(`¿Guardar ${celdasCambiadas.length} cambios?`)) {
return;
}
$('#modalCarga').modal('show');
celdasCambiadas.each(function () {
var celda = $(this);
var select = celda.find('.estado-select');
var expedienteId = celda.data('expediente');
var fecha = celda.data('fecha');
var estado = select.val();
cambios.push({
expedienteId: expedienteId,
fecha: fecha,
estado: estado
});
});
$.ajax({
url: '@Url.Action("GuardarAsistenciasMasivas", "Asistencia")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(cambios),
success: function (response) {
$('#modalCarga').modal('hide');
if (response.success) {
$('.celda-asistencia').removeClass('changed');
$('.estado-select').each(function () {
$(this).data('initial', $(this).val());
});
toastr.success(response.message);
cargarEstadisticas();
} else {
toastr.error(response.message);
}
},
error: function () {
$('#modalCarga').modal('hide');
toastr.error('Error al guardar los cambios');
}
});
});
$('#btnExportar').click(function () {
var año = $('#selectAnio').val();
var mes = $('#selectMes').val();
var diasSemana = $('#diasSemanaInput').val();
var url = '@Url.Action("ExportarExcel", "Asistencia")' +
'?año=' + año +
'&mes=' + mes +
'&diasSemana=' + diasSemana;
window.open(url, '_blank');
});
$('#selectAnio, #selectMes').change(function () {
$('#filtroForm').submit();
});
aplicarColoresDias();
$('.estado-select').change(function () {
var estado = $(this).val();
var celda = $(this).closest('.celda-asistencia');
var initial = $(this).data('initial');
aplicarColorSelect($(this), estado);
aplicarColorCelda(celda, estado);
if (initial !== estado) {
celda.addClass('changed');
} else {
celda.removeClass('changed');
}
actualizarFooterTabla();
var expedienteId = celda.data('expediente');
var fecha = celda.data('fecha');
guardarAsistencia(expedienteId, fecha, estado, celda);
});
});
function aplicarColorCelda(celda, estado) {
celda.removeClass('celda-presente celda-tarde celda-falta celda-justificado celda-enfermo');
switch (estado) {
case 'P': celda.addClass('celda-presente'); break;
case 'T': celda.addClass('celda-tarde'); break;
case 'F': celda.addClass('celda-falta'); break;
case 'J': celda.addClass('celda-justificado'); break;
case 'E': celda.addClass('celda-enfermo'); break;
}
}
function aplicarColorSelect(select, estado) {
select.removeClass('bg-success bg-warning bg-danger bg-info bg-secondary text-white');
switch (estado) {
case 'P': select.addClass('bg-success text-white'); break;
case 'T': select.addClass('bg-warning'); break;
case 'F': select.addClass('bg-danger text-white'); break;
case 'J': select.addClass('bg-info text-white'); break;
case 'E': select.addClass('bg-secondary text-white'); break;
}
}
function inicializarColores() {
$('.estado-select').each(function () {
var estado = $(this).val();
var celda = $(this).closest('.celda-asistencia');
aplicarColorSelect($(this), estado);
aplicarColorCelda(celda, estado);
});
}
function guardarAsistencia(expedienteId, fecha, estado, celda) {
$.ajax({
url: '@Url.Action("GuardarAsistencia", "Asistencia")',
type: 'POST',
data: {
expedienteId: expedienteId,
fecha: fecha,
estado: estado
},
success: function (response) {
if (response.success) {
celda.removeClass('changed');
celda.find('.estado-select').data('initial', estado);
toastr.success('Guardado correctamente');
actualizarFooterTabla();
} else {
toastr.error(response.message);
var select = celda.find('.estado-select');
var initial = select.data('initial');
aplicarColorSelect(select, initial);
aplicarColorCelda(celda, initial);
select.val(initial);
}
},
error: function () {
toastr.error('Error de conexión');
var select = celda.find('.estado-select');
var initial = select.data('initial');
aplicarColorSelect(select, initial);
aplicarColorCelda(celda, initial);
select.val(initial);
}
});
}
function cargarEstadisticas() {
// Statistics cards removed from UI
actualizarFooterTabla();
}
function actualizarFooterTabla() {
$('tfoot tr td').each(function (index) {
if (index === 0) return;
var td = $(this);
var colIndex = td.index();
if (td.hasClass('text-center')) {
var totalPresente = 0, totalTarde = 0, totalFalta = 0, totalJustificado = 0, totalEnfermo = 0;
$('tbody tr').each(function () {
var celda = $(this).find('td').eq(colIndex);
if (celda.length) {
var estado = celda.find('.estado-select').val();
if (estado === 'P') totalPresente++;
else if (estado === 'T') totalTarde++;
else if (estado === 'F') totalFalta++;
else if (estado === 'J') totalJustificado++;
else if (estado === 'E') totalEnfermo++;
}
});
var totalDia = totalPresente + totalTarde + totalFalta + totalJustificado + totalEnfermo;
td.html(`
<div class="fw-bold mb-1">${totalDia}</div>
<div class="d-flex justify-content-center gap-1 flex-wrap">
<span class="badge bg-success" title="Presentes">${totalPresente}</span>
<span class="badge bg-warning text-dark" title="Tardes">${totalTarde}</span>
<span class="badge bg-danger" title="Faltas">${totalFalta}</span>
<span class="badge bg-info" title="Justificados">${totalJustificado}</span>
<span class="badge bg-secondary" title="Enfermos">${totalEnfermo}</span>
</div>
`);
}
});
}
function aplicarColoresDias() {
$('th.text-center').each(function () {
var textoDia = $(this).find('.small').text().trim();
var claseColor = '';
switch (textoDia) {
case 'lun': claseColor = 'bg-lunes'; break;
case 'mar': claseColor = 'bg-martes'; break;
case 'mié': claseColor = 'bg-miercoles'; break;
case 'jue': claseColor = 'bg-jueves'; break;
case 'vie': claseColor = 'bg-viernes'; break;
case 'sáb': claseColor = 'bg-sabado'; break;
case 'dom': claseColor = 'bg-domingo'; break;
}
if (claseColor) $(this).addClass(claseColor);
});
}
</script>
}

View File

@@ -0,0 +1,73 @@
@model foundation_system.Models.ConfiguracionSistema
@{
ViewData["Title"] = "Editar Configuración";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Editar Parámetro</h2>
<p class="text-muted small mb-0">Modificando la clave: <code>@Model.Clave</code></p>
</div>
<a asp-action="Index" asp-route-categoria="@Model.Categoria" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver
</a>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card-custom">
<form asp-action="Edit" method="post">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div class="mb-4">
<label class="form-label fw-bold">Descripción</label>
<p class="text-muted small">@Model.Descripcion</p>
</div>
<div class="mb-4">
<label asp-for="Valor" class="form-label fw-bold">Valor</label>
@if (Model.TipoDato == "BOOLEANO")
{
<select asp-for="Valor" class="form-select">
<option value="true">Activado (True)</option>
<option value="false">Desactivado (False)</option>
</select>
}
else if (Model.TipoDato == "HTML" || Model.TipoDato == "JSON")
{
<textarea asp-for="Valor" class="form-control" rows="8" style="font-family: monospace;"></textarea>
}
else if (Model.TipoDato == "NUMERO")
{
<input asp-for="Valor" class="form-control" type="number" />
}
else if (Model.TipoDato == "FECHA")
{
<input asp-for="Valor" class="form-control" type="date" />
}
else
{
<input asp-for="Valor" class="form-control" />
}
<span asp-validation-for="Valor" class="text-danger small"></span>
</div>
<div class="d-flex justify-content-between align-items-center mt-5">
<div class="small text-muted">
<i class="bi bi-info-circle me-1"></i>
Tipo: <span class="badge bg-light text-dark border">@Model.TipoDato</span>
</div>
<button type="submit" class="btn btn-primary-custom">
<i class="bi bi-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,93 @@
@model IEnumerable<foundation_system.Models.ConfiguracionSistema>
@{
ViewData["Title"] = "Configuración del Sistema";
var categorias = (List<string>)ViewBag.Categorias;
var selectedCategoria = (string)ViewBag.SelectedCategoria;
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Configuración</h2>
<p class="text-muted small mb-0">Gestione los parámetros dinámicos y ajustes globales del sistema.</p>
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<div class="card-custom py-2 px-3">
<div class="d-flex gap-2 overflow-auto">
<a asp-action="Index" class="btn btn-sm @(string.IsNullOrEmpty(selectedCategoria) ? "btn-primary-custom" : "btn-outline-secondary")">
Todas
</a>
@foreach (var cat in categorias)
{
<a asp-action="Index" asp-route-categoria="@cat"
class="btn btn-sm @(selectedCategoria == cat ? "btn-primary-custom" : "btn-outline-secondary")">
@cat
</a>
}
</div>
</div>
</div>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Clave</th>
<th>Valor</th>
<th>Categoría / Grupo</th>
<th>Descripción</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="5" class="text-center py-4 text-muted">No hay configuraciones registradas.</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">
<code>@item.Clave</code>
</td>
<td>
@if (item.TipoDato == "BOOLEANO")
{
<span class="badge @(item.Valor?.ToLower() == "true" ? "bg-success" : "bg-danger")">
@(item.Valor?.ToLower() == "true" ? "Activado" : "Desactivado")
</span>
}
else
{
<span class="text-truncate d-inline-block" style="max-width: 200px;">@item.Valor</span>
}
</td>
<td>
<span class="badge bg-light text-dark border">@item.Categoria</span>
<span class="badge bg-light text-muted border">@item.Grupo</span>
</td>
<td class="small text-muted">@item.Descripcion</td>
<td>
@if (item.EsEditable)
{
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
}
else
{
<i class="bi bi-lock-fill text-muted" title="Solo lectura"></i>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,465 @@
@model foundation_system.Models.ViewModels.ExpedienteViewModel
@{
ViewData["Title"] = "Nuevo Expediente";
}
@section Styles {
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<style>
.gender-group {
display: flex;
gap: 20px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.gender-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.gender-option input {
cursor: pointer;
}
.photo-upload-container {
position: relative;
width: 150px;
height: 150px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
border: 3px solid #dee2e6;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
}
.photo-upload-container:hover {
border-color: #0d6efd;
opacity: 0.8;
}
.photo-upload-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-upload-container .upload-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.5);
color: white;
text-align: center;
padding: 5px 0;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s ease;
}
.photo-upload-container:hover .upload-overlay {
opacity: 1;
}
.photo-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 60px;
color: #adb5bd;
}
</style>
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Nuevo Expediente</h2>
<p class="text-muted small mb-0">Complete la información para registrar un nuevo niño en la fundación.</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver al Listado
</a>
</div>
<form asp-action="Create" method="post" id="expedienteForm" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="row">
<!-- Datos Personales -->
<div class="col-md-8">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-person-vcard me-2 text-primary"></i>Datos Personales</h5>
<div class="row g-3">
<div class="col-md-12">
<div class="text-center mb-4">
<input type="hidden" asp-for="FotoUrl" id="FotoUrl" />
<input asp-for="FotoFile" type="file" id="photoInput" style="display: none;" accept="image/*" onchange="previewPhoto(this)" />
<div class="photo-upload-container" onclick="document.getElementById('photoInput').click()">
<div id="photoPreview">
<img src="/Assets/default_avatar.png" alt="Foto de perfil" id="previewImg" />
</div>
<div class="upload-overlay">
<i class="bi bi-camera"></i> Seleccionar Foto
</div>
</div>
<p class="text-muted small">Haga clic para seleccionar una foto</p>
</div>
</div>
<div class="col-md-6">
<label asp-for="Nombres" class="form-label fw-semibold"></label>
<input asp-for="Nombres" class="form-control" placeholder="Ej. Juan Alberto" required />
<span asp-validation-for="Nombres" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Apellidos" class="form-label fw-semibold"></label>
<input asp-for="Apellidos" class="form-control" placeholder="Ej. Pérez García" required />
<span asp-validation-for="Apellidos" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="FechaNacimiento" class="form-label fw-semibold"></label>
<input asp-for="FechaNacimiento" class="form-control" type="date" required />
<span asp-validation-for="FechaNacimiento" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Genero" class="form-label fw-semibold"></label>
<div class="gender-group">
<label class="gender-option">
<input type="radio" asp-for="Genero" value="M" required />
<span>Masculino</span>
</label>
<label class="gender-option">
<input type="radio" asp-for="Genero" value="F" required />
<span>Femenino</span>
</label>
</div>
<span asp-validation-for="Genero" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="Direccion" class="form-label fw-semibold"></label>
<textarea asp-for="Direccion" class="form-control" rows="2" placeholder="Dirección completa de residencia" required></textarea>
<span asp-validation-for="Direccion" class="text-danger small"></span>
</div>
</div>
</div>
<!-- Padres / Encargados -->
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-people me-2 text-info"></i>Padres / Encargados</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Padre <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="PadreId" id="PadreId" required />
<input type="text" id="PadreNombreDisplay" class="form-control" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('PADRE')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="PadreId" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Madre <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="MadreId" id="MadreId" required />
<input type="text" id="MadreNombreDisplay" class="form-control" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('MADRE')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="MadreId" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Encargado <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="EncargadoId" id="EncargadoId" required />
<input type="text" id="EncargadoNombreDisplay" class="form-control" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('ENCARGADO')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="EncargadoId" class="text-danger small"></span>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-telephone-outbound me-2 text-danger"></i>Contacto de Emergencia (Opcional)</h5>
<div class="row g-3">
<div class="col-md-7">
<label asp-for="ContactoEmergenciaNombre" class="form-label fw-semibold"></label>
<input asp-for="ContactoEmergenciaNombre" class="form-control" placeholder="Nombre del responsable" />
<span asp-validation-for="ContactoEmergenciaNombre" class="text-danger small"></span>
</div>
<div class="col-md-5">
<label asp-for="ContactoEmergenciaTelefono" class="form-label fw-semibold"></label>
<input asp-for="ContactoEmergenciaTelefono" class="form-control" placeholder="Teléfono de contacto" />
<span asp-validation-for="ContactoEmergenciaTelefono" class="text-danger small"></span>
</div>
</div>
</div>
</div>
<!-- Datos de Inscripción -->
<div class="col-md-4">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-clipboard-check me-2 text-success"></i>Inscripción</h5>
<div class="row g-3">
<div class="col-md-12">
<label asp-for="CodigoInscripcion" class="form-label fw-semibold"></label>
<input asp-for="CodigoInscripcion" class="form-control fw-bold text-primary" placeholder="Generado automáticamente" disabled />
<span class="text-muted extra-small">El código se generará al guardar.</span>
</div>
<div class="col-md-12">
<label asp-for="FechaInscripcion" class="form-label fw-semibold"></label>
<input asp-for="FechaInscripcion" class="form-control" type="date" required />
<span asp-validation-for="FechaInscripcion" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="NivelGrado" class="form-label fw-semibold"></label>
<select asp-for="NivelGrado" class="form-select" required>
<option value="">Seleccione grado...</option>
<option value="NoEstudia">No estudia</option>
<option value="Parvularia">Parvularia</option>
<option value="Primer Ciclo">Primer Ciclo</option>
<option value="Segundo Ciclo">Segundo Ciclo</option>
<option value="Tercer Ciclo">Tercer Ciclo</option>
</select>
<span asp-validation-for="NivelGrado" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="Estado" class="form-label fw-semibold"></label>
<select asp-for="Estado" class="form-select" required>
<option value="ACTIVO">Activo</option>
<option value="INACTIVO">Inactivo</option>
<option value="GRADUADO">Graduado</option>
</select>
<span asp-validation-for="Estado" class="text-danger small"></span>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-heart-pulse me-2 text-warning"></i>Salud (Opcional)</h5>
<div class="col-md-12">
<label asp-for="Alergias" class="form-label fw-semibold"></label>
<textarea asp-for="Alergias" class="form-control" rows="3" placeholder="Detalle alergias o condiciones médicas..."></textarea>
<span asp-validation-for="Alergias" class="text-danger small"></span>
</div>
</div>
<div class="mt-4 d-grid">
<button type="submit" class="btn btn-primary-custom btn-lg">
<i class="bi bi-save me-2"></i>Guardar Expediente
</button>
</div>
</div>
</div>
</form>
<!-- Modal para Seleccionar/Crear Padre/Madre -->
<div class="modal fade" id="parentModal" tabindex="-1" aria-labelledby="parentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="parentModalLabel">Seleccionar Persona</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" id="parentTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="search-tab" data-bs-toggle="tab" data-bs-target="#search-pane" type="button" role="tab">Buscar Existente</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="create-tab" data-bs-toggle="tab" data-bs-target="#create-pane" type="button" role="tab">Crear Nuevo</button>
</li>
</ul>
<div class="tab-content" id="parentTabsContent">
<!-- Tab Buscar -->
<div class="tab-pane fade show active" id="search-pane" role="tabpanel">
<div class="mb-3">
<div class="input-group">
<input type="text" id="searchPersonaInput" class="form-control" placeholder="Buscar por nombre o DUI..." />
<button class="btn btn-primary" type="button" onclick="searchPersonas()">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div id="searchResults" class="list-group">
<!-- Resultados de búsqueda aquí -->
</div>
</div>
<!-- Tab Crear -->
<div class="tab-pane fade" id="create-pane" role="tabpanel">
<form id="createPersonaForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nombres</label>
<input type="text" name="Nombres" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Apellidos</label>
<input type="text" name="Apellidos" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">DUI</label>
<input type="text" name="Dui" class="form-control" placeholder="00000000-0" />
</div>
<div class="col-md-6">
<label class="form-label">Teléfono</label>
<input type="text" name="Telefono" class="form-control" placeholder="0000-0000" />
</div>
<div class="col-md-12 text-end">
<button type="button" class="btn btn-success" onclick="createPersona()">
<i class="bi bi-plus-circle me-2"></i>Crear y Seleccionar
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script>
let currentParentType = '';
function showToast(message, type = 'success') {
Toastify({
text: message,
duration: 3000,
close: true,
gravity: "top",
position: "right",
stopOnFocus: true,
style: {
background: type === 'success' ? "#28a745" : "#dc3545",
}
}).showToast();
}
function openParentModal(type) {
currentParentType = type;
let label = 'Seleccionar ';
if (type === 'PADRE') label += 'Padre';
else if (type === 'MADRE') label += 'Madre';
else label += 'Encargado';
document.getElementById('parentModalLabel').innerText = label;
const modal = new bootstrap.Modal(document.getElementById('parentModal'));
modal.show();
}
async function searchPersonas() {
const term = document.getElementById('searchPersonaInput').value;
if (term.length < 3) {
showToast('Por favor ingrese al menos 3 caracteres', 'error');
return;
}
try {
const response = await fetch(`/Expediente/SearchPersonas?term=${encodeURIComponent(term)}&type=ENCARGADO`);
const data = await response.json();
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '';
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
return;
}
data.forEach(p => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action';
btn.innerText = p.text;
btn.onclick = () => selectPersona(p.id, p.text);
resultsDiv.appendChild(btn);
});
} catch (error) {
showToast('Error al buscar personas', 'error');
}
}
function selectPersona(id, name) {
if (currentParentType === 'PADRE') {
document.getElementById('PadreId').value = id;
document.getElementById('PadreNombreDisplay').value = name;
} else if (currentParentType === 'MADRE') {
document.getElementById('MadreId').value = id;
document.getElementById('MadreNombreDisplay').value = name;
} else {
document.getElementById('EncargadoId').value = id;
document.getElementById('EncargadoNombreDisplay').value = name;
}
const modalElement = document.getElementById('parentModal');
const modal = bootstrap.Modal.getInstance(modalElement);
modal.hide();
showToast('Persona seleccionada correctamente');
}
async function createPersona() {
const form = document.getElementById('createPersonaForm');
const formData = new FormData(form);
const persona = {};
formData.forEach((value, key) => persona[key] = value);
if (!persona.Nombres || !persona.Apellidos) {
showToast('Nombres y Apellidos son requeridos', 'error');
return;
}
try {
const response = await fetch('/Expediente/CreatePersona', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify(persona)
});
const result = await response.json();
if (result.success) {
selectPersona(result.id, result.text);
form.reset();
showToast('Persona creada y seleccionada');
} else {
showToast(result.message || 'Error al crear la persona', 'error');
}
} catch (error) {
showToast('Error de conexión al crear persona', 'error');
}
}
function previewPhoto(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('previewImg').src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
showToast('Foto seleccionada');
}
}
// Form validation with Toastify
document.getElementById('expedienteForm').addEventListener('submit', function(e) {
const form = this;
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
showToast('Por favor complete todos los campos requeridos', 'error');
}
form.classList.add('was-validated');
});
</script>
}

View File

@@ -0,0 +1,188 @@
@model foundation_system.Models.ViewModels.ExpedienteViewModel
@{
ViewData["Title"] = "Detalles del Expediente";
}
@section Styles {
<style>
.gender-group {
display: flex;
gap: 20px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.photo-container {
width: 150px;
height: 150px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
border: 3px solid #dee2e6;
background: #f8f9fa;
}
.photo-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info-label {
font-weight: 600;
color: #495057;
font-size: 0.9rem;
margin-bottom: 0.2rem;
}
.info-value {
padding: 0.5rem 0.75rem;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
min-height: 2.5rem;
display: flex;
align-items: center;
}
</style>
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Detalles del Expediente</h2>
<p class="text-muted small mb-0">Información completa de: <strong>@Model.Nombres @Model.Apellidos</strong></p>
</div>
<div class="d-flex gap-2">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
<i class="bi bi-pencil me-2"></i>Editar
</a>
<a asp-action="Print" asp-route-id="@Model.Id" target="_blank" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Imprimir
</a>
</div>
</div>
<div class="row">
<!-- Datos Personales -->
<div class="col-md-8">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-person-vcard me-2 text-primary"></i>Datos Personales</h5>
<div class="row g-3">
<div class="col-md-12">
<div class="text-center mb-4">
<div class="photo-container">
@if (!string.IsNullOrEmpty(Model.FotoUrl))
{
<img src="@Model.FotoUrl" alt="Foto de perfil" />
}
else
{
<img src="/Assets/default_avatar.png" alt="Foto de perfil" />
}
</div>
</div>
</div>
<div class="col-md-6">
<div class="info-label">Nombres</div>
<div class="info-value">@Model.Nombres</div>
</div>
<div class="col-md-6">
<div class="info-label">Apellidos</div>
<div class="info-value">@Model.Apellidos</div>
</div>
<div class="col-md-6">
<div class="info-label">Fecha de Nacimiento</div>
<div class="info-value">@Model.FechaNacimiento?.ToString("dd/MM/yyyy")</div>
</div>
<div class="col-md-6">
<div class="info-label">Género</div>
<div class="info-value">@(Model.Genero == "M" ? "Masculino" : "Femenino")</div>
</div>
<div class="col-md-12">
<div class="info-label">Dirección</div>
<div class="info-value">@Model.Direccion</div>
</div>
</div>
</div>
<!-- Padres / Encargados -->
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-people me-2 text-info"></i>Padres / Encargados</h5>
<div class="row g-3">
<div class="col-md-4">
<div class="info-label">Padre</div>
<div class="info-value">@(Model.PadreNombre ?? "No registrado")</div>
</div>
<div class="col-md-4">
<div class="info-label">Madre</div>
<div class="info-value">@(Model.MadreNombre ?? "No registrada")</div>
</div>
<div class="col-md-4">
<div class="info-label">Encargado Principal</div>
<div class="info-value">@(Model.EncargadoNombre ?? "No registrado")</div>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-telephone-outbound me-2 text-danger"></i>Contacto de Emergencia</h5>
<div class="row g-3">
<div class="col-md-7">
<div class="info-label">Nombre del Responsable</div>
<div class="info-value">@(Model.ContactoEmergenciaNombre ?? "No registrado")</div>
</div>
<div class="col-md-5">
<div class="info-label">Teléfono de Contacto</div>
<div class="info-value">@(Model.ContactoEmergenciaTelefono ?? "No registrado")</div>
</div>
</div>
</div>
</div>
<!-- Datos de Inscripción -->
<div class="col-md-4">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-clipboard-check me-2 text-success"></i>Inscripción</h5>
<div class="row g-3">
<div class="col-md-12">
<div class="info-label">Código de Inscripción</div>
<div class="info-value fw-bold text-primary">@Model.CodigoInscripcion</div>
</div>
<div class="col-md-12">
<div class="info-label">Fecha de Inscripción</div>
<div class="info-value">@Model.FechaInscripcion.ToString("dd/MM/yyyy")</div>
</div>
<div class="col-md-12">
<div class="info-label">Nivel / Grado</div>
<div class="info-value">@Model.NivelGrado</div>
</div>
<div class="col-md-12">
<div class="info-label">Estado</div>
<div class="info-value">
@if (Model.Estado == "ACTIVO")
{
<span class="badge bg-success">Activo</span>
}
else if (Model.Estado == "GRADUADO")
{
<span class="badge bg-primary">Graduado</span>
}
else
{
<span class="badge bg-danger">Inactivo</span>
}
</div>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-heart-pulse me-2 text-warning"></i>Salud</h5>
<div class="col-md-12">
<div class="info-label">Alergias / Condiciones</div>
<div class="info-value" style="min-height: 100px; align-items: flex-start;">@(Model.Alergias ?? "Ninguna registrada")</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,473 @@
@model foundation_system.Models.ViewModels.ExpedienteViewModel
@{
ViewData["Title"] = "Editar Expediente";
}
@section Styles {
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<style>
.gender-group {
display: flex;
gap: 20px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.gender-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.gender-option input {
cursor: pointer;
}
.photo-upload-container {
position: relative;
width: 150px;
height: 150px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
border: 3px solid #dee2e6;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
}
.photo-upload-container:hover {
border-color: #0d6efd;
opacity: 0.8;
}
.photo-upload-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-upload-container .upload-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.5);
color: white;
text-align: center;
padding: 5px 0;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s ease;
}
.photo-upload-container:hover .upload-overlay {
opacity: 1;
}
.photo-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 60px;
color: #adb5bd;
}
</style>
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Editar Expediente</h2>
<p class="text-muted small mb-0">Actualice la información del niño(a): <strong>@Model.Nombres @Model.Apellidos</strong></p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver al Listado
</a>
</div>
<form asp-action="Edit" method="post" id="expedienteForm" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="row">
<!-- Datos Personales -->
<div class="col-md-8">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-person-vcard me-2 text-primary"></i>Datos Personales</h5>
<div class="row g-3">
<div class="col-md-12">
<div class="text-center mb-4">
<input type="hidden" asp-for="FotoUrl" id="FotoUrl" />
<input asp-for="FotoFile" type="file" id="photoInput" style="display: none;" accept="image/*" onchange="previewPhoto(this)" />
<div class="photo-upload-container" onclick="document.getElementById('photoInput').click()">
<div id="photoPreview">
@if (!string.IsNullOrEmpty(Model.FotoUrl))
{
<img src="@Model.FotoUrl" alt="Foto de perfil" id="previewImg" />
}
else
{
<img src="/Assets/default_avatar.png" alt="Foto de perfil" id="previewImg" />
}
</div>
<div class="upload-overlay">
<i class="bi bi-camera"></i> Cambiar Foto
</div>
</div>
<p class="text-muted small">Haga clic para cambiar la foto</p>
</div>
</div>
<div class="col-md-6">
<label asp-for="Nombres" class="form-label fw-semibold"></label>
<input asp-for="Nombres" class="form-control" placeholder="Ej. Juan Alberto" required />
<span asp-validation-for="Nombres" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Apellidos" class="form-label fw-semibold"></label>
<input asp-for="Apellidos" class="form-control" placeholder="Ej. Pérez García" required />
<span asp-validation-for="Apellidos" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="FechaNacimiento" class="form-label fw-semibold"></label>
<input asp-for="FechaNacimiento" class="form-control" type="date" required />
<span asp-validation-for="FechaNacimiento" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Genero" class="form-label fw-semibold"></label>
<div class="gender-group">
<label class="gender-option">
<input type="radio" asp-for="Genero" value="M" required />
<span>Masculino</span>
</label>
<label class="gender-option">
<input type="radio" asp-for="Genero" value="F" required />
<span>Femenino</span>
</label>
</div>
<span asp-validation-for="Genero" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="Direccion" class="form-label fw-semibold"></label>
<textarea asp-for="Direccion" class="form-control" rows="2" placeholder="Dirección completa de residencia" required></textarea>
<span asp-validation-for="Direccion" class="text-danger small"></span>
</div>
</div>
</div>
<!-- Padres / Encargados -->
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-people me-2 text-info"></i>Padres / Encargados</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Padre <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="PadreId" id="PadreId" required />
<input type="text" id="PadreNombreDisplay" class="form-control" value="@Model.PadreNombre" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('PADRE')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="PadreId" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Madre <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="MadreId" id="MadreId" required />
<input type="text" id="MadreNombreDisplay" class="form-control" value="@Model.MadreNombre" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('MADRE')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="MadreId" class="text-danger small"></span>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Encargado <span class="text-danger">*</span></label>
<div class="input-group">
<input type="hidden" asp-for="EncargadoId" id="EncargadoId" required />
<input type="text" id="EncargadoNombreDisplay" class="form-control" value="@Model.EncargadoNombre" placeholder="Seleccionar..." readonly />
<button type="button" class="btn btn-outline-primary" onclick="openParentModal('ENCARGADO')">
<i class="bi bi-search"></i>
</button>
</div>
<span asp-validation-for="EncargadoId" class="text-danger small"></span>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-telephone-outbound me-2 text-danger"></i>Contacto de Emergencia (Opcional)</h5>
<div class="row g-3">
<div class="col-md-7">
<label asp-for="ContactoEmergenciaNombre" class="form-label fw-semibold"></label>
<input asp-for="ContactoEmergenciaNombre" class="form-control" placeholder="Nombre del responsable" />
<span asp-validation-for="ContactoEmergenciaNombre" class="text-danger small"></span>
</div>
<div class="col-md-5">
<label asp-for="ContactoEmergenciaTelefono" class="form-label fw-semibold"></label>
<input asp-for="ContactoEmergenciaTelefono" class="form-control" placeholder="Teléfono de contacto" />
<span asp-validation-for="ContactoEmergenciaTelefono" class="text-danger small"></span>
</div>
</div>
</div>
</div>
<!-- Datos de Inscripción -->
<div class="col-md-4">
<div class="card-custom mb-4">
<h5 class="card-title mb-4"><i class="bi bi-clipboard-check me-2 text-success"></i>Inscripción</h5>
<div class="row g-3">
<div class="col-md-12">
<label asp-for="CodigoInscripcion" class="form-label fw-semibold"></label>
<input asp-for="CodigoInscripcion" class="form-control fw-bold text-muted" readonly />
<span class="text-muted extra-small">El código de expediente no es editable.</span>
</div>
<div class="col-md-12">
<label asp-for="FechaInscripcion" class="form-label fw-semibold"></label>
<input asp-for="FechaInscripcion" class="form-control" type="date" required />
<span asp-validation-for="FechaInscripcion" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="NivelGrado" class="form-label fw-semibold"></label>
<select asp-for="NivelGrado" class="form-select" required>
<option value="">Seleccione grado...</option>
<option value="NoEstudia">No estudia</option>
<option value="Parvularia">Parvularia</option>
<option value="Primer Ciclo">Primer Ciclo</option>
<option value="Segundo Ciclo">Segundo Ciclo</option>
<option value="Tercer Ciclo">Tercer Ciclo</option>
</select>
<span asp-validation-for="NivelGrado" class="text-danger small"></span>
</div>
<div class="col-md-12">
<label asp-for="Estado" class="form-label fw-semibold"></label>
<select asp-for="Estado" class="form-select" required>
<option value="ACTIVO">Activo</option>
<option value="INACTIVO">Inactivo</option>
<option value="GRADUADO">Graduado</option>
</select>
<span asp-validation-for="Estado" class="text-danger small"></span>
</div>
</div>
</div>
<div class="card-custom">
<h5 class="card-title mb-4"><i class="bi bi-heart-pulse me-2 text-warning"></i>Salud (Opcional)</h5>
<div class="col-md-12">
<label asp-for="Alergias" class="form-label fw-semibold"></label>
<textarea asp-for="Alergias" class="form-control" rows="3" placeholder="Detalle alergias o condiciones médicas..."></textarea>
<span asp-validation-for="Alergias" class="text-danger small"></span>
</div>
</div>
<div class="mt-4 d-grid">
<button type="submit" class="btn btn-primary-custom btn-lg">
<i class="bi bi-check2-circle me-2"></i>Actualizar Expediente
</button>
</div>
</div>
</div>
</form>
<!-- Modal para Seleccionar/Crear Padre/Madre -->
<div class="modal fade" id="parentModal" tabindex="-1" aria-labelledby="parentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="parentModalLabel">Seleccionar Persona</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" id="parentTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="search-tab" data-bs-toggle="tab" data-bs-target="#search-pane" type="button" role="tab">Buscar Existente</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="create-tab" data-bs-toggle="tab" data-bs-target="#create-pane" type="button" role="tab">Crear Nuevo</button>
</li>
</ul>
<div class="tab-content" id="parentTabsContent">
<!-- Tab Buscar -->
<div class="tab-pane fade show active" id="search-pane" role="tabpanel">
<div class="mb-3">
<div class="input-group">
<input type="text" id="searchPersonaInput" class="form-control" placeholder="Buscar por nombre o DUI..." />
<button class="btn btn-primary" type="button" onclick="searchPersonas()">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div id="searchResults" class="list-group">
<!-- Resultados de búsqueda aquí -->
</div>
</div>
<!-- Tab Crear -->
<div class="tab-pane fade" id="create-pane" role="tabpanel">
<form id="createPersonaForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nombres</label>
<input type="text" name="Nombres" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">Apellidos</label>
<input type="text" name="Apellidos" class="form-control" required />
</div>
<div class="col-md-6">
<label class="form-label">DUI</label>
<input type="text" name="Dui" class="form-control" placeholder="00000000-0" />
</div>
<div class="col-md-6">
<label class="form-label">Teléfono</label>
<input type="text" name="Telefono" class="form-control" placeholder="0000-0000" />
</div>
<div class="col-md-12 text-end">
<button type="button" class="btn btn-success" onclick="createPersona()">
<i class="bi bi-plus-circle me-2"></i>Crear y Seleccionar
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script>
let currentParentType = '';
function showToast(message, type = 'success') {
Toastify({
text: message,
duration: 3000,
close: true,
gravity: "top",
position: "right",
stopOnFocus: true,
style: {
background: type === 'success' ? "#28a745" : "#dc3545",
}
}).showToast();
}
function openParentModal(type) {
currentParentType = type;
let label = 'Seleccionar ';
if (type === 'PADRE') label += 'Padre';
else if (type === 'MADRE') label += 'Madre';
else label += 'Encargado';
document.getElementById('parentModalLabel').innerText = label;
const modal = new bootstrap.Modal(document.getElementById('parentModal'));
modal.show();
}
async function searchPersonas() {
const term = document.getElementById('searchPersonaInput').value;
if (term.length < 3) {
showToast('Por favor ingrese al menos 3 caracteres', 'error');
return;
}
try {
const response = await fetch(`/Expediente/SearchPersonas?term=${encodeURIComponent(term)}&type=ENCARGADO`);
const data = await response.json();
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '';
if (data.length === 0) {
resultsDiv.innerHTML = '<div class="list-group-item text-muted">No se encontraron resultados</div>';
return;
}
data.forEach(p => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action';
btn.innerText = p.text;
btn.onclick = () => selectPersona(p.id, p.text);
resultsDiv.appendChild(btn);
});
} catch (error) {
showToast('Error al buscar personas', 'error');
}
}
function selectPersona(id, name) {
if (currentParentType === 'PADRE') {
document.getElementById('PadreId').value = id;
document.getElementById('PadreNombreDisplay').value = name;
} else if (currentParentType === 'MADRE') {
document.getElementById('MadreId').value = id;
document.getElementById('MadreNombreDisplay').value = name;
} else {
document.getElementById('EncargadoId').value = id;
document.getElementById('EncargadoNombreDisplay').value = name;
}
const modalElement = document.getElementById('parentModal');
const modal = bootstrap.Modal.getInstance(modalElement);
modal.hide();
showToast('Persona seleccionada correctamente');
}
async function createPersona() {
const form = document.getElementById('createPersonaForm');
const formData = new FormData(form);
const persona = {};
formData.forEach((value, key) => persona[key] = value);
if (!persona.Nombres || !persona.Apellidos) {
showToast('Nombres y Apellidos son requeridos', 'error');
return;
}
try {
const response = await fetch('/Expediente/CreatePersona', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify(persona)
});
const result = await response.json();
if (result.success) {
selectPersona(result.id, result.text);
form.reset();
showToast('Persona creada y seleccionada');
} else {
showToast(result.message || 'Error al crear la persona', 'error');
}
} catch (error) {
showToast('Error de conexión al crear persona', 'error');
}
}
function previewPhoto(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('previewImg').src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
showToast('Foto seleccionada');
}
}
// Form validation with Toastify
document.getElementById('expedienteForm').addEventListener('submit', function(e) {
const form = this;
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
showToast('Por favor complete todos los campos requeridos', 'error');
}
form.classList.add('was-validated');
});
</script>
}

View File

@@ -0,0 +1,172 @@
@model IEnumerable<foundation_system.Models.Nino>
@{
ViewData["Title"] = "Expedientes de Niños";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Expedientes</h2>
<p class="text-muted small mb-0">Gestión de registros y expedientes de los niños de la fundación.</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-person-plus-fill me-2"></i>Nuevo Expediente
</a>
</div>
<div class="card-custom mb-4">
<form asp-action="Index" method="get" class="row g-3 align-items-center">
<div class="col-md-10">
<div class="input-group">
<span class="input-group-text bg-white border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0 ps-0"
placeholder="Buscar por código, nombre o DUI..." value="@ViewBag.Search" />
</div>
</div>
<div class="col-md-2 d-grid">
<button type="submit" class="btn btn-primary">Buscar</button>
</div>
</form>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Código</th>
<th>Nombre Completo</th>
<th>Edad</th>
<th>Grado/Nivel</th>
<th>Fecha Inscripción</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr>
<td colspan="6" class="text-center py-4 text-muted">No hay expedientes registrados.</td>
</tr>
}
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.CodigoInscripcion</td>
<td>@item.Persona.NombreCompleto</td>
<td>
@{
if (item.Persona.FechaNacimiento.HasValue)
{
var today = DateOnly.FromDateTime(DateTime.Now);
var age = today.Year - item.Persona.FechaNacimiento.Value.Year;
if (item.Persona.FechaNacimiento.Value > today.AddYears(-age)) age--;
@age @(age == 1 ? "año" : "años")
}
else
{
<span class="text-muted">N/A</span>
}
}
</td>
<td>@item.NivelGrado</td>
<td>@item.FechaInscripcion.ToShortDateString()</td>
<td>
<span class="badge @(item.Estado == "ACTIVO" ? "bg-success" : "bg-secondary")">
@item.Estado
</span>
</td>
<td>
<div class="btn-group">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-info" title="Ver Detalle">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" title="Desactivar"
onclick="confirmarDesactivacion(@item.Id, '@item.Persona.NombreCompleto')">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (ViewBag.TotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted small">
Mostrando página @ViewBag.CurrentPage de @ViewBag.TotalPages
</div>
<nav aria-label="Page navigation">
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(!ViewBag.HasPreviousPage ? "disabled" : "")">
<a class="page-link" asp-action="Index"
asp-route-page="@(ViewBag.CurrentPage - 1)"
asp-route-search="@ViewBag.Search">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (int i = 1; i <= ViewBag.TotalPages; i++)
{
<li class="page-item @(i == ViewBag.CurrentPage ? "active" : "")">
<a class="page-link" asp-action="Index"
asp-route-page="@i"
asp-route-search="@ViewBag.Search">@i</a>
</li>
}
<li class="page-item @(!ViewBag.HasNextPage ? "disabled" : "")">
<a class="page-link" asp-action="Index"
asp-route-page="@(ViewBag.CurrentPage + 1)"
asp-route-search="@ViewBag.Search">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
}
</div>
<!-- Modal de Confirmación -->
<div class="modal fade" id="modalDesactivar" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Desactivación</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
¿Está seguro que desea desactivar el expediente de <strong id="nombreNino"></strong>?
<p class="text-muted small mt-2">Esta acción cambiará el estado a INACTIVO y no aparecerá en el listado principal.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<form id="formDesactivar" asp-action="Desactivar" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="ninoIdDesactivar" />
<button type="submit" class="btn btn-danger">Desactivar</button>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function confirmarDesactivacion(id, nombre) {
document.getElementById('nombreNino').innerText = nombre;
document.getElementById('ninoIdDesactivar').value = id;
new bootstrap.Modal(document.getElementById('modalDesactivar')).show();
}
</script>
}

View File

@@ -0,0 +1,232 @@
@model foundation_system.Models.ViewModels.ExpedienteViewModel
@{
Layout = null;
var antecedentes = ViewBag.Antecedentes as List<foundation_system.Models.AntecedenteNino>;
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Imprimir Expediente - @Model.CodigoInscripcion</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.0/font/bootstrap-icons.css">
<style>
@@media print {
.no-print { display: none !important; }
body { padding: 0; margin: 0; }
.container { width: 100% !important; max-width: none !important; }
.card { border: none !important; }
.badge { border: 1px solid #000; color: #000 !important; background: none !important; }
}
body {
background-color: white;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.print-header {
border-bottom: 2px solid #0d6efd;
margin-bottom: 2rem;
padding-bottom: 1rem;
}
.photo-box {
width: 120px;
height: 120px;
border: 2px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.photo-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.section-title {
background-color: #f8f9fa;
padding: 0.5rem 1rem;
border-left: 4px solid #0d6efd;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: bold;
text-transform: uppercase;
font-size: 0.9rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
margin-bottom: 0.5rem;
}
.info-label {
font-weight: 600;
font-size: 0.8rem;
color: #6c757d;
display: block;
}
.info-value {
font-size: 1rem;
color: #212529;
}
.table-antecedentes {
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="container my-4">
<div class="no-print text-end mb-4">
<button onclick="window.print()" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Imprimir Ahora
</button>
<button onclick="window.close()" class="btn btn-outline-secondary">
<i class="bi bi-x-lg me-2"></i>Cerrar
</button>
</div>
<div class="print-header d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-0 text-primary">EXPEDIENTE</h1>
<p class="text-muted mb-0">Fundación MIES - Sistema de Gestión</p>
</div>
<div class="text-end">
<div class="fw-bold">Código: @Model.CodigoInscripcion</div>
<div class="small text-muted">Fecha Impresión: @DateTime.Now.ToString("dd/MM/yyyy HH:mm")</div>
</div>
</div>
<div class="row">
<div class="col-3">
<div class="photo-box mx-auto">
@if (!string.IsNullOrEmpty(Model.FotoUrl))
{
<img src="@Model.FotoUrl" alt="Foto" />
}
else
{
<img src="/Assets/default_avatar.png" alt="Foto" />
}
</div>
<div class="text-center mt-2">
<span class="badge @(Model.Estado == "ACTIVO" ? "bg-success" : "bg-secondary")">
@Model.Estado
</span>
</div>
</div>
<div class="col-9">
<div class="section-title">Información Personal</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Nombres</span>
<span class="info-value">@Model.Nombres</span>
</div>
<div class="info-item">
<span class="info-label">Apellidos</span>
<span class="info-value">@Model.Apellidos</span>
</div>
<div class="info-item">
<span class="info-label">Fecha de Nacimiento</span>
<span class="info-value">@Model.FechaNacimiento?.ToString("dd/MM/yyyy")</span>
</div>
<div class="info-item">
<span class="info-label">Género</span>
<span class="info-value">@(Model.Genero == "M" ? "Masculino" : "Femenino")</span>
</div>
<div class="info-item" style="grid-column: span 2;">
<span class="info-label">Dirección</span>
<span class="info-value">@Model.Direccion</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="section-title">Padres y Encargados</div>
<div class="info-item">
<span class="info-label">Padre</span>
<span class="info-value">@(Model.PadreNombre ?? "N/A")</span>
</div>
<div class="info-item">
<span class="info-label">Madre</span>
<span class="info-value">@(Model.MadreNombre ?? "N/A")</span>
</div>
<div class="info-item">
<span class="info-label">Encargado Principal</span>
<span class="info-value">@(Model.EncargadoNombre ?? "N/A")</span>
</div>
</div>
<div class="col-6">
<div class="section-title">Inscripción y Salud</div>
<div class="info-item">
<span class="info-label">Fecha de Inscripción</span>
<span class="info-value">@Model.FechaInscripcion.ToString("dd/MM/yyyy")</span>
</div>
<div class="info-item">
<span class="info-label">Nivel / Grado</span>
<span class="info-value">@Model.NivelGrado</span>
</div>
<div class="info-item">
<span class="info-label">Alergias / Condiciones</span>
<span class="info-value">@(Model.Alergias ?? "Ninguna")</span>
</div>
</div>
</div>
<div class="section-title">Historial de Antecedentes</div>
@if (antecedentes != null && antecedentes.Any())
{
<table class="table table-bordered table-sm table-antecedentes">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Descripción</th>
<th>Gravedad</th>
</tr>
</thead>
<tbody>
@foreach (var item in antecedentes)
{
<tr>
<td>@item.FechaIncidente.ToString("dd/MM/yyyy")</td>
<td>@item.TipoAntecedente</td>
<td>@item.Descripcion</td>
<td>
<span class="badge @(item.Gravedad == "Alta" ? "bg-danger" : item.Gravedad == "Media" ? "bg-warning text-dark" : "bg-info text-dark")">
@item.Gravedad
</span>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted italic">No se registran antecedentes para este niño(a).</p>
}
<div class="mt-5 pt-5">
<div class="row text-center">
<div class="col-4">
<div style="border-top: 1px solid #000; width: 80%; margin: 0 auto;"></div>
<div class="small">Firma del Encargado</div>
</div>
<div class="col-4">
<div style="border-top: 1px solid #000; width: 80%; margin: 0 auto;"></div>
<div class="small">Firma Director(a)</div>
</div>
<div class="col-4">
<div style="border-top: 1px solid #000; width: 80%; margin: 0 auto;"></div>
<div class="small">Sello de la Fundación</div>
</div>
</div>
</div>
</div>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -6,6 +6,8 @@
<title>@ViewData["Title"] - MIES</title> <title>@ViewData["Title"] - MIES</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/> <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"> <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="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -35,6 +37,9 @@
<a class="nav-link-custom @(ViewContext.RouteData.Values["controller"]?.ToString() == "Asistencia" ? "active" : "")" asp-controller="Asistencia" asp-action="Index"> <a class="nav-link-custom @(ViewContext.RouteData.Values["controller"]?.ToString() == "Asistencia" ? "active" : "")" asp-controller="Asistencia" asp-action="Index">
<i class="bi bi-calendar-check"></i> Asistencia <i class="bi bi-calendar-check"></i> Asistencia
</a> </a>
<a class="nav-link-custom @(ViewContext.RouteData.Values["controller"]?.ToString() == "Antecedentes" ? "active" : "")" asp-controller="Antecedentes" asp-action="Index">
<i class="bi bi-journal-text"></i> Antecedentes
</a>
<div class="nav-section-title">Administración</div> <div class="nav-section-title">Administración</div>
<a class="nav-link-custom @(ViewContext.RouteData.Values["controller"]?.ToString() == "Usuario" ? "active" : "")" asp-controller="Usuario" asp-action="Index"> <a class="nav-link-custom @(ViewContext.RouteData.Values["controller"]?.ToString() == "Usuario" ? "active" : "")" asp-controller="Usuario" asp-action="Index">
@@ -79,6 +84,7 @@
<script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.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="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>

View File

@@ -0,0 +1,78 @@
@model foundation_system.Models.ViewModels.UsuarioViewModel
@{
ViewData["Title"] = "Nuevo Usuario";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Nuevo Usuario</h2>
<p class="text-muted small mb-0">Cree una nueva cuenta de acceso administrativo.</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver
</a>
</div>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card-custom">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<h5 class="mb-4 text-primary border-bottom pb-2">Información Personal</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="Nombres" class="form-label fw-semibold"></label>
<input asp-for="Nombres" class="form-control" />
<span asp-validation-for="Nombres" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Apellidos" class="form-label fw-semibold"></label>
<input asp-for="Apellidos" class="form-control" />
<span asp-validation-for="Apellidos" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label fw-semibold"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Telefono" class="form-label fw-semibold"></label>
<input asp-for="Telefono" class="form-control" />
<span asp-validation-for="Telefono" class="text-danger small"></span>
</div>
</div>
<h5 class="mb-4 text-primary border-bottom pb-2">Credenciales de Acceso</h5>
<div class="row g-3 mb-4">
<div class="col-md-12">
<label asp-for="NombreUsuario" class="form-label fw-semibold"></label>
<input asp-for="NombreUsuario" class="form-control" />
<span asp-validation-for="NombreUsuario" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Contrasena" class="form-label fw-semibold"></label>
<input asp-for="Contrasena" class="form-control" />
<span asp-validation-for="Contrasena" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="ConfirmarContrasena" class="form-label fw-semibold"></label>
<input asp-for="ConfirmarContrasena" class="form-control" />
<span asp-validation-for="ConfirmarContrasena" class="text-danger small"></span>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<button type="submit" class="btn btn-primary-custom px-5">
<i class="bi bi-save me-2"></i>Crear Usuario
</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,95 @@
@model foundation_system.Models.ViewModels.UsuarioViewModel
@{
ViewData["Title"] = "Editar Usuario";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Editar Usuario</h2>
<p class="text-muted small mb-0">Actualizando perfil de: <strong>@Model.NombreUsuario</strong></p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Volver
</a>
</div>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card-custom">
<form asp-action="Edit" method="post">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<h5 class="mb-4 text-primary border-bottom pb-2">Información Personal</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="Nombres" class="form-label fw-semibold"></label>
<input asp-for="Nombres" class="form-control" />
<span asp-validation-for="Nombres" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Apellidos" class="form-label fw-semibold"></label>
<input asp-for="Apellidos" class="form-control" />
<span asp-validation-for="Apellidos" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Email" class="form-label fw-semibold"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Telefono" class="form-label fw-semibold"></label>
<input asp-for="Telefono" class="form-control" />
<span asp-validation-for="Telefono" class="text-danger small"></span>
</div>
</div>
<h5 class="mb-4 text-primary border-bottom pb-2">Configuración de Cuenta</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="NombreUsuario" class="form-label fw-semibold"></label>
<input asp-for="NombreUsuario" class="form-control" />
<span asp-validation-for="NombreUsuario" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="Activo" class="form-label fw-semibold"></label>
<select asp-for="Activo" class="form-select">
<option value="true">Activo</option>
<option value="false">Inactivo</option>
</select>
<span asp-validation-for="Activo" class="text-danger small"></span>
</div>
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-2"></i>
Deje los campos de contraseña en blanco si no desea cambiarla.
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label asp-for="Contrasena" class="form-label fw-semibold">Nueva Contraseña</label>
<input asp-for="Contrasena" class="form-control" placeholder="Opcional" />
<span asp-validation-for="Contrasena" class="text-danger small"></span>
</div>
<div class="col-md-6">
<label asp-for="ConfirmarContrasena" class="form-label fw-semibold"></label>
<input asp-for="ConfirmarContrasena" class="form-control" placeholder="Opcional" />
<span asp-validation-for="ConfirmarContrasena" class="text-danger small"></span>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<button type="submit" class="btn btn-primary-custom px-5">
<i class="bi bi-check2-circle me-2"></i>Actualizar Usuario
</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@@ -0,0 +1,97 @@
@model IEnumerable<foundation_system.Models.Usuario>
@{
ViewData["Title"] = "Gestión de Usuarios";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Usuarios del Sistema</h2>
<p class="text-muted small mb-0">Administre las cuentas de acceso y perfiles del personal administrativo.</p>
</div>
<a asp-action="Create" class="btn btn-primary-custom">
<i class="bi bi-person-plus-fill me-2"></i>Nuevo Usuario
</a>
</div>
<div class="card-custom">
<div class="table-responsive">
<table class="table-custom">
<thead>
<tr>
<th>Usuario</th>
<th>Nombre Completo</th>
<th>Email</th>
<th>Último Acceso</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td class="fw-bold">@item.NombreUsuario</td>
<td>@item.Persona.Nombres @item.Persona.Apellidos</td>
<td>@item.Email</td>
<td class="small text-muted">
@(item.UltimoLogin?.ToString("dd/MM/yyyy HH:mm") ?? "Nunca")
</td>
<td>
<span class="badge @(item.Activo ? "bg-success" : "bg-danger")">
@(item.Activo ? "Activo" : "Inactivo")
</span>
</td>
<td>
<div class="btn-group">
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" title="Editar">
<i class="bi bi-pencil"></i>
</a>
@if (item.Activo && item.NombreUsuario != User.Identity?.Name)
{
<button type="button" class="btn btn-sm btn-outline-danger" title="Desactivar"
onclick="confirmarDesactivacion(@item.Id, '@item.NombreUsuario')">
<i class="bi bi-person-x"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Modal de Confirmación -->
<div class="modal fade" id="modalDesactivar" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Desactivación</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
¿Está seguro que desea revocar el acceso al usuario <strong id="nombreUsuario"></strong>?
<p class="text-muted small mt-2">El usuario ya no podrá iniciar sesión en el sistema.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<form id="formDesactivar" asp-action="Desactivar" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="usuarioIdDesactivar" />
<button type="submit" class="btn btn-danger">Desactivar</button>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function confirmarDesactivacion(id, nombre) {
document.getElementById('nombreUsuario').innerText = nombre;
document.getElementById('usuarioIdDesactivar').value = id;
new bootstrap.Modal(document.getElementById('modalDesactivar')).show();
}
</script>
}

View File

@@ -0,0 +1,954 @@
-- ============================================
-- 0. EXTENSIONES
-- ============================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- 2. TABLA PERSONAS (SIMPLIFICADA)
-- ============================================
CREATE TABLE personas (
id BIGSERIAL PRIMARY KEY,
nombres VARCHAR(100) NOT NULL,
apellidos VARCHAR(100) NOT NULL,
dui VARCHAR(12) UNIQUE,
nit VARCHAR(17) UNIQUE,
fecha_nacimiento DATE,
genero CHAR(1) CHECK (genero IN ('M', 'F', 'O')),
email VARCHAR(255),
telefono VARCHAR(20),
direccion TEXT,
foto_url VARCHAR(255),
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE personas IS 'Almacena información personal de todas las personas del sistema';
-- Índices básicos
CREATE INDEX idx_personas_activas ON personas(activo) WHERE activo = true;
CREATE INDEX idx_personas_nombre ON personas(nombres, apellidos);
-- ============================================
-- 3. TABLA NIÑOS (SIMPLIFICADA)
-- ============================================
CREATE TABLE ninos (
id BIGSERIAL PRIMARY KEY,
persona_id BIGINT NOT NULL UNIQUE REFERENCES personas(id),
fecha_inscripcion DATE NOT NULL DEFAULT CURRENT_DATE,
codigo_inscripcion VARCHAR(20) UNIQUE NOT NULL,
estado VARCHAR(20) DEFAULT 'ACTIVO'
CHECK (estado IN ('ACTIVO', 'INACTIVO', 'GRADUADO')),
nivel_grado VARCHAR(20),
alergias TEXT,
contacto_emergencia_nombre VARCHAR(100),
contacto_emergencia_telefono VARCHAR(20),
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE ninos IS 'Niños registrados en la fundación con información específica';
-- Índices básicos
CREATE INDEX idx_ninos_estado ON ninos(estado);
CREATE INDEX idx_ninos_activos ON ninos(activo) WHERE activo = true;
-- ============================================
-- 4. TABLA ENCARGADOS (SIMPLIFICADA)
-- ============================================
CREATE TABLE encargados_nino (
id BIGSERIAL PRIMARY KEY,
nino_id BIGINT NOT NULL REFERENCES ninos(id),
persona_id BIGINT NOT NULL REFERENCES personas(id),
parentesco VARCHAR(50) NOT NULL DEFAULT 'PADRE'
CHECK (parentesco IN ('PADRE', 'MADRE', 'ENCARGADO', 'ABUELO')),
es_principal BOOLEAN DEFAULT false,
puede_recoger BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now()
);
-- Restricción para un solo encargado principal por niño
CREATE UNIQUE INDEX uq_encargado_principal_nino
ON encargados_nino(nino_id)
WHERE es_principal = true;
-- Índices básicos
CREATE INDEX idx_encargados_nino ON encargados_nino(nino_id);
CREATE INDEX idx_encargados_persona ON encargados_nino(persona_id);
-- ============================================
-- 5. TABLA ASISTENCIA (SIMPLIFICADA)
-- ============================================
CREATE TABLE asistencia (
id BIGSERIAL PRIMARY KEY,
nino_id BIGINT NOT NULL REFERENCES ninos(id),
fecha DATE NOT NULL DEFAULT CURRENT_DATE,
estado VARCHAR(20) NOT NULL DEFAULT 'PRESENTE'
CHECK (estado IN ('PRESENTE', 'AUSENTE', 'JUSTIFICADO', 'ENFERMO', 'TARDE')),
hora_entrada TIME,
hora_salida TIME,
notas TEXT,
creado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE asistencia IS 'Registro diario de asistencia de los niños';
-- Índices básicos
CREATE INDEX idx_asistencia_fecha ON asistencia(fecha DESC);
CREATE INDEX idx_asistencia_nino_fecha ON asistencia(nino_id, fecha DESC);
CREATE UNIQUE INDEX uq_asistencia_nino_fecha ON asistencia(nino_id, fecha);
-- ============================================
-- 6. TABLA ANTECEDENTES (NUEVA)
-- ============================================
CREATE TABLE antecedentes_nino (
id BIGSERIAL PRIMARY KEY,
nino_id BIGINT NOT NULL REFERENCES ninos(id),
fecha_incidente TIMESTAMPTZ NOT NULL DEFAULT now(),
tipo_antecedente VARCHAR(50) NOT NULL
CHECK (tipo_antecedente IN ('LlamadaAtencion', 'Suspension', 'Castigo')),
descripcion TEXT NOT NULL,
gravedad VARCHAR(20) NOT NULL
CHECK (gravedad IN ('Alta', 'Media', 'Baja')),
usuario_registra VARCHAR(100) NOT NULL,
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE antecedentes_nino IS 'Historial de antecedentes, incidentes y sanciones de los niños';
CREATE INDEX idx_antecedentes_nino ON antecedentes_nino(nino_id);
CREATE INDEX idx_antecedentes_fecha ON antecedentes_nino(fecha_incidente DESC);
-- ============================================
-- 6. TABLA INVENTARIO (SIMPLIFICADA)
-- ============================================
CREATE TABLE categorias_inventario (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
descripcion TEXT,
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE items_inventario (
id BIGSERIAL PRIMARY KEY,
categoria_id INTEGER REFERENCES categorias_inventario(id),
nombre VARCHAR(200) NOT NULL,
descripcion TEXT,
unidad VARCHAR(20) DEFAULT 'UNIDAD',
stock_actual INTEGER DEFAULT 0,
stock_minimo INTEGER DEFAULT 0,
ubicacion VARCHAR(100),
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE items_inventario IS 'Items del inventario de la fundación';
-- Índices básicos
CREATE INDEX idx_items_inventario_categoria ON items_inventario(categoria_id);
CREATE INDEX idx_items_inventario_activos ON items_inventario(activo) WHERE activo = true;
-- ============================================
-- 7. TABLA CAJA CHICA (SIMPLIFICADA)
-- ============================================
CREATE TABLE categorias_gasto (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
descripcion TEXT,
activo BOOLEAN DEFAULT true,
creado_en TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE transacciones_caja (
id BIGSERIAL PRIMARY KEY,
fecha_transaccion DATE NOT NULL DEFAULT CURRENT_DATE,
tipo_transaccion VARCHAR(20) NOT NULL
CHECK (tipo_transaccion IN ('INGRESO', 'EGRESO')),
categoria_id INTEGER REFERENCES categorias_gasto(id),
monto DECIMAL(12,2) NOT NULL CHECK (monto > 0),
descripcion VARCHAR(200) NOT NULL,
referencia VARCHAR(100),
metodo_pago VARCHAR(20) DEFAULT 'EFECTIVO'
CHECK (metodo_pago IN ('EFECTIVO', 'CHEQUE', 'TRANSFERENCIA')),
creado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE transacciones_caja IS 'Movimientos financieros de la fundación';
-- Índices básicos
CREATE INDEX idx_transacciones_fecha ON transacciones_caja(fecha_transaccion DESC);
CREATE INDEX idx_transacciones_tipo ON transacciones_caja(tipo_transaccion);
-- ============================================
-- 8. TABLA USUARIOS (SIMPLIFICADA)
-- ============================================
CREATE TABLE usuarios (
id BIGSERIAL PRIMARY KEY,
persona_id BIGINT UNIQUE REFERENCES personas(id),
nombre_usuario VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
hash_contrasena TEXT NOT NULL,
activo BOOLEAN DEFAULT true,
ultimo_login TIMESTAMPTZ,
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE usuarios IS 'Usuarios del sistema con credenciales de acceso';
-- Índices básicos
CREATE INDEX idx_usuarios_activos ON usuarios(activo) WHERE activo = true;
-- ============================================
-- 9. TABLA ROLES (SIMPLIFICADA)
-- ============================================
CREATE TABLE roles_sistema (
id SERIAL PRIMARY KEY,
codigo VARCHAR(50) UNIQUE NOT NULL,
nombre VARCHAR(100) NOT NULL,
descripcion TEXT,
creado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE roles_sistema IS 'Roles del sistema para control de acceso (RBAC)';
-- Roles básicos
INSERT INTO roles_sistema (codigo, nombre, descripcion) VALUES
('ADMIN', 'Administrador', 'Acceso completo al sistema'),
('MAESTRO', 'Maestro', 'Personal docente'),
('ENCARGADO', 'Encargado', 'Encargado de niños'),
('LECTOR', 'Solo Lectura', 'Acceso de solo lectura');
-- ============================================
-- 10. TABLA PERMISOS (SIMPLIFICADA)
-- ============================================
CREATE TABLE permisos (
id SERIAL PRIMARY KEY,
modulo VARCHAR(50) NOT NULL,
codigo VARCHAR(100) UNIQUE NOT NULL,
nombre VARCHAR(100) NOT NULL,
descripcion TEXT,
creado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE permisos IS 'Permisos específicos del sistema organizados por módulos';
-- Permisos básicos
INSERT INTO permisos (modulo, codigo, nombre, descripcion) VALUES
('PERSONAS', 'personas.ver', 'Ver Personas', 'Ver lista de personas'),
('PERSONAS', 'personas.editar', 'Editar Personas', 'Editar información de personas'),
('NIÑOS', 'ninos.ver', 'Ver Niños', 'Ver lista de niños'),
('NIÑOS', 'ninos.editar', 'Editar Niños', 'Editar información de niños'),
('ASISTENCIA', 'asistencia.ver', 'Ver Asistencia', 'Ver registros de asistencia'),
('ASISTENCIA', 'asistencia.marcar', 'Marcar Asistencia', 'Registrar asistencia diaria'),
('INVENTARIO', 'inventario.ver', 'Ver Inventario', 'Ver items del inventario'),
('INVENTARIO', 'inventario.editar', 'Editar Inventario', 'Editar información de items'),
('CAJA', 'caja.ver', 'Ver Caja', 'Ver movimientos de caja'),
('CAJA', 'caja.editar', 'Editar Caja', 'Registrar movimientos de caja'),
('USUARIOS', 'usuarios.ver', 'Ver Usuarios', 'Ver lista de usuarios'),
('USUARIOS', 'usuarios.editar', 'Editar Usuarios', 'Editar información de usuarios');
-- ============================================
-- 11. TABLA ROLES-USUARIO (SIMPLIFICADA)
-- ============================================
CREATE TABLE roles_usuario (
usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
rol_id INTEGER NOT NULL REFERENCES roles_sistema(id),
asignado_en TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (usuario_id, rol_id)
);
-- ============================================
-- 12. TABLA AUDITORÍA (SIMPLIFICADA)
-- ============================================
CREATE TABLE registros_auditoria (
id BIGSERIAL PRIMARY KEY,
usuario_id BIGINT REFERENCES usuarios(id),
accion VARCHAR(50) NOT NULL,
entidad VARCHAR(50) NOT NULL,
entidad_id BIGINT,
descripcion TEXT,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE registros_auditoria IS 'Registro de auditoría de todas las acciones importantes del sistema';
-- Índices básicos
CREATE INDEX idx_auditoria_fecha ON registros_auditoria(creado_en DESC);
CREATE INDEX idx_auditoria_usuario ON registros_auditoria(usuario_id);
-- ============================================
-- 13. DATOS INICIALES
-- ============================================
-- Insertar categorías de gasto básicas
INSERT INTO categorias_gasto (nombre, descripcion) VALUES
('ALIMENTACION', 'Gastos de alimentación'),
('EDUCACION', 'Material educativo'),
('MANTENIMIENTO', 'Mantenimiento de instalaciones'),
('SERVICIOS', 'Servicios públicos'),
('SALARIOS', 'Pago de salarios'),
('OTROS', 'Otros gastos');
-- Insertar categorías de inventario básicas
INSERT INTO categorias_inventario (nombre, descripcion) VALUES
('ALIMENTOS', 'Productos alimenticios'),
('MATERIAL_ESCOLAR', 'Material escolar y educativo'),
('LIMPIEZA', 'Productos de limpieza'),
('OFICINA', 'Material de oficina'),
('OTROS', 'Otras categorías');
-- ============================================
-- 14. USUARIO ADMIN POR DEFECTO
-- ============================================
DO $$
DECLARE
persona_id BIGINT;
usuario_id BIGINT;
BEGIN
-- Crear persona admin
INSERT INTO personas (
nombres,
apellidos,
email,
telefono,
activo
) VALUES (
'Administrador',
'Sistema',
'admin@fundacion.org',
'0000-0000',
true
) RETURNING id INTO persona_id;
-- Crear usuario admin (contraseña: Admin123!)
INSERT INTO usuarios (
persona_id,
nombre_usuario,
email,
hash_contrasena,
activo
) VALUES (
persona_id,
'admin',
'admin@fundacion.org',
-- bcrypt hash para 'Admin123!'
'$2a$12$Y8vjQJfT6V5UeH5qQ8qB3uK9zLmNpQrS2T3U4V5W6X7Y8Z9A0B1C2D3E4F5G6H7I8',
true
) RETURNING id INTO usuario_id;
-- Asignar rol de admin
INSERT INTO roles_usuario (usuario_id, rol_id)
SELECT usuario_id, id
FROM roles_sistema
WHERE codigo = 'ADMIN';
END
$$;
-- ============================================
-- 15. FUNCIÓN PARA ACTUALIZAR TIMESTAMPS
-- ============================================
CREATE OR REPLACE FUNCTION actualizar_actualizado_en()
RETURNS TRIGGER AS $$
BEGIN
NEW.actualizado_en = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers para actualizar automáticamente
CREATE TRIGGER trg_personas_actualizado
BEFORE UPDATE ON personas
FOR EACH ROW
EXECUTE FUNCTION actualizar_actualizado_en();
CREATE TRIGGER trg_ninos_actualizado
BEFORE UPDATE ON ninos
FOR EACH ROW
EXECUTE FUNCTION actualizar_actualizado_en();
CREATE TRIGGER trg_items_inventario_actualizado
BEFORE UPDATE ON items_inventario
FOR EACH ROW
EXECUTE FUNCTION actualizar_actualizado_en();
CREATE TRIGGER trg_usuarios_actualizado
BEFORE UPDATE ON usuarios
FOR EACH ROW
EXECUTE FUNCTION actualizar_actualizado_en();
-- ============================================
-- 16. VISTAS ÚTILES
-- ============================================
-- Vista para información básica de niños
CREATE VIEW vista_ninos_completa AS
SELECT
n.id,
n.codigo_inscripcion,
p.nombres,
p.apellidos,
p.fecha_nacimiento,
EXTRACT(YEAR FROM AGE(p.fecha_nacimiento)) as edad,
p.genero,
n.estado,
n.nivel_grado,
n.fecha_inscripcion,
n.contacto_emergencia_nombre,
n.contacto_emergencia_telefono
FROM ninos n
JOIN personas p ON n.persona_id = p.id
WHERE n.activo = true AND p.activo = true;
-- Vista para asistencia del mes actual
CREATE VIEW vista_asistencia_mensual AS
SELECT
nino_id,
DATE_TRUNC('month', fecha) as mes,
COUNT(*) as total_dias,
COUNT(*) FILTER (WHERE estado = 'PRESENTE') as dias_presente,
COUNT(*) FILTER (WHERE estado = 'AUSENTE') as dias_ausente,
ROUND(
(COUNT(*) FILTER (WHERE estado = 'PRESENTE') * 100.0 /
NULLIF(COUNT(*), 0)
), 2) as porcentaje_asistencia
FROM asistencia
GROUP BY nino_id, DATE_TRUNC('month', fecha);
-- Vista para inventario bajo stock
CREATE VIEW vista_inventario_bajo_stock AS
SELECT
i.id,
i.nombre,
c.nombre as categoria,
i.unidad,
i.stock_actual,
i.stock_minimo,
CASE
WHEN i.stock_actual <= 0 THEN 'AGOTADO'
WHEN i.stock_actual <= i.stock_minimo THEN 'BAJO'
ELSE 'SUFICIENTE'
END as estado_stock
FROM items_inventario i
JOIN categorias_inventario c ON i.categoria_id = c.id
WHERE i.activo = true AND (i.stock_actual <= i.stock_minimo OR i.stock_actual <= 0)
ORDER BY i.stock_actual ASC;
-- ============================================
-- 17. TABLA CONFIGURACIÓN DEL SISTEMA (DINÁMICA)
-- ============================================
CREATE TABLE configuracion_sistema (
id SERIAL PRIMARY KEY,
clave VARCHAR(100) UNIQUE NOT NULL,
valor TEXT,
tipo_dato VARCHAR(20) DEFAULT 'TEXTO'
CHECK (tipo_dato IN ('TEXTO', 'NUMERO', 'BOOLEANO', 'FECHA', 'JSON', 'HTML')),
categoria VARCHAR(50) DEFAULT 'GENERAL',
grupo VARCHAR(50) DEFAULT 'SISTEMA',
descripcion TEXT,
es_editable BOOLEAN DEFAULT true,
es_publico BOOLEAN DEFAULT false,
orden INTEGER DEFAULT 0,
opciones JSONB, -- Para campos con opciones predefinidas
validacion_regex VARCHAR(200), -- Expresión regular para validación
creado_en TIMESTAMPTZ DEFAULT now(),
actualizado_en TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE configuracion_sistema IS 'Configuración dinámica del sistema - clave-valor extensible';
-- Índices para búsqueda eficiente
CREATE INDEX idx_configuracion_clave ON configuracion_sistema(clave);
CREATE INDEX idx_configuracion_categoria ON configuracion_sistema(categoria);
CREATE INDEX idx_configuracion_grupo ON configuracion_sistema(grupo);
-- ============================================
-- 18. VISTAS PARA CONFIGURACIÓN
-- ============================================
-- Vista para obtener configuración organizada
CREATE VIEW vista_configuracion_organizada AS
SELECT
id,
clave,
valor,
tipo_dato,
categoria,
grupo,
descripcion,
es_editable,
es_publico,
orden,
opciones,
validacion_regex,
creado_en,
actualizado_en
FROM configuracion_sistema
ORDER BY categoria, grupo, orden, clave;
-- Vista para configuración pública (para APIs o frontend)
CREATE VIEW vista_configuracion_publica AS
SELECT
clave,
valor,
tipo_dato,
categoria,
grupo,
descripcion
FROM configuracion_sistema
WHERE es_publico = true
ORDER BY categoria, grupo, orden, clave;
-- ============================================
-- 19. FUNCIONES ÚTILES PARA CONFIGURACIÓN
-- ============================================
-- Función para obtener un valor de configuración con tipo correcto
CREATE OR REPLACE FUNCTION obtener_configuracion(
p_clave VARCHAR(100),
p_valor_default TEXT DEFAULT NULL
)
RETURNS TEXT AS $$
DECLARE
v_valor TEXT;
BEGIN
SELECT valor INTO v_valor
FROM configuracion_sistema
WHERE clave = p_clave;
RETURN COALESCE(v_valor, p_valor_default);
END;
$$ LANGUAGE plpgsql;
-- Función para establecer configuración (inserta o actualiza)
CREATE OR REPLACE FUNCTION establecer_configuracion(
p_clave VARCHAR(100),
p_valor TEXT,
p_tipo_dato VARCHAR(20) DEFAULT 'TEXTO',
p_categoria VARCHAR(50) DEFAULT 'GENERAL',
p_grupo VARCHAR(50) DEFAULT 'SISTEMA',
p_descripcion TEXT DEFAULT NULL,
p_es_editable BOOLEAN DEFAULT true,
p_es_publico BOOLEAN DEFAULT false,
p_orden INTEGER DEFAULT 0
)
RETURNS VOID AS $$
BEGIN
INSERT INTO configuracion_sistema (
clave, valor, tipo_dato, categoria, grupo,
descripcion, es_editable, es_publico, orden
) VALUES (
p_clave, p_valor, p_tipo_dato, p_categoria, p_grupo,
p_descripcion, p_es_editable, p_es_publico, p_orden
)
ON CONFLICT (clave) DO UPDATE SET
valor = EXCLUDED.valor,
tipo_dato = EXCLUDED.tipo_dato,
categoria = EXCLUDED.categoria,
grupo = EXCLUDED.grupo,
descripcion = COALESCE(EXCLUDED.descripcion, configuracion_sistema.descripcion),
es_editable = EXCLUDED.es_editable,
es_publico = EXCLUDED.es_publico,
orden = EXCLUDED.orden,
actualizado_en = now();
END;
$$ LANGUAGE plpgsql;
-- Función para obtener configuración por categoría
CREATE OR REPLACE FUNCTION obtener_configuracion_categoria(
p_categoria VARCHAR(50)
)
RETURNS TABLE(
clave VARCHAR(100),
valor TEXT,
tipo_dato VARCHAR(20),
grupo VARCHAR(50),
descripcion TEXT
) AS $$
BEGIN
RETURN QUERY
SELECT
cs.clave,
cs.valor,
cs.tipo_dato,
cs.grupo,
cs.descripcion
FROM configuracion_sistema cs
WHERE cs.categoria = p_categoria
ORDER BY cs.grupo, cs.orden, cs.clave;
END;
$$ LANGUAGE plpgsql;
-- ============================================
-- 20. TRIGGER PARA ACTUALIZAR TIMESTAMP
-- ============================================
CREATE TRIGGER trg_configuracion_actualizado
BEFORE UPDATE ON configuracion_sistema
FOR EACH ROW
EXECUTE FUNCTION actualizar_actualizado_en();
-- ============================================
-- 21. FUNCIÓN PARA GENERAR CÓDIGO DE INSCRIPCIÓN
-- ============================================
CREATE OR REPLACE FUNCTION generar_codigo_inscripcion(
p_apellidos TEXT,
p_anio SMALLINT
)
RETURNS VARCHAR(20) AS $$
DECLARE
v_iniciales TEXT := '';
v_anio TEXT := LPAD((p_anio % 100)::TEXT, 2, '0');
v_correlativo INTEGER;
v_ultimo_codigo TEXT;
v_parte TEXT;
BEGIN
-- Validación básica
IF p_anio < 2000 OR p_anio > 2100 THEN
RAISE EXCEPTION 'Año inválido: %', p_anio;
END IF;
-- Iniciales de apellidos
FOR v_parte IN SELECT unnest(string_to_array(p_apellidos, ' ')) LOOP
IF v_parte <> '' THEN
v_iniciales := v_iniciales || UPPER(LEFT(v_parte, 1));
END IF;
END LOOP;
-- Último correlativo por inicial + año
SELECT codigo_inscripcion
INTO v_ultimo_codigo
FROM ninos
WHERE codigo_inscripcion LIKE v_iniciales || v_anio || '___'
ORDER BY codigo_inscripcion DESC
LIMIT 1;
IF v_ultimo_codigo IS NOT NULL THEN
v_correlativo := RIGHT(v_ultimo_codigo, 3)::INTEGER + 1;
ELSE
v_correlativo := 1;
END IF;
RETURN v_iniciales || v_anio || LPAD(v_correlativo::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- MOTOR DE BÚSQUEDA AVANZADO PARA PERSONAS
-- ============================================================================
-- Características:
-- ✅ Normalización (acentos, mayúsculas)
-- ✅ Tokenización (búsqueda por palabras individuales)
-- ✅ Fuzzy matching (errores ortográficos)
-- ✅ Ranking ponderado (diferentes campos, diferentes pesos)
-- ✅ Búsqueda en dos fases (índice + CPU)
-- ============================================================================
-- 1⃣ Asegurar que las extensiones estén activas
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
CREATE EXTENSION IF NOT EXISTS unaccent;
-- ============================================================================
-- 2⃣ FUNCIÓN AUXILIAR: Normalizar texto
-- ============================================================================
CREATE OR REPLACE FUNCTION normalizar_texto(texto TEXT)
RETURNS TEXT AS $$
BEGIN
RETURN unaccent(lower(trim(texto)));
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- ============================================================================
-- 3⃣ FUNCIÓN PRINCIPAL: Búsqueda avanzada
-- ============================================================================
CREATE OR REPLACE FUNCTION 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 -- Para debugging: qué tipo de match fue
) AS $$
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;
ELSE
RAISE EXCEPTION 'Tipo no soportado: %. Use NINO o ENCARGADO', p_tipo;
END IF;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- 4⃣ ÍNDICES RECOMENDADOS (CRÍTICO PARA PERFORMANCE)
-- ============================================================================
-- Para tabla personas
CREATE INDEX IF NOT EXISTS idx_personas_nombres_trgm
ON personas USING gin(normalizar_texto(nombres) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_personas_apellidos_trgm
ON personas USING gin(normalizar_texto(apellidos) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_personas_nombre_completo_trgm
ON personas USING gin(normalizar_texto(nombres || ' ' || apellidos) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_personas_dui_trgm
ON personas USING gin(normalizar_texto(dui) gin_trgm_ops)
WHERE dui IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_personas_activo
ON personas(activo) WHERE activo = true;
-- Para tabla ninos
CREATE INDEX IF NOT EXISTS idx_ninos_codigo_trgm
ON ninos USING gin(normalizar_texto(codigo_inscripcion) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_ninos_activo
ON ninos(activo) WHERE activo = true;
CREATE INDEX IF NOT EXISTS idx_ninos_persona_id
ON ninos(persona_id);
-- Para tabla encargados_nino
CREATE INDEX IF NOT EXISTS idx_encargados_persona_id
ON encargados_nino(persona_id);
-- ============================================================================
-- 5⃣ EJEMPLOS DE USO
-- ============================================================================
/*
-- Búsqueda de niños con errores ortográficos
SELECT * FROM buscar_personas_v2('juan albero', 'NINO'); -- encuentra "Juan Alberto"
SELECT * FROM buscar_personas_v2('mria', 'NINO'); -- encuentra "María"
SELECT * FROM buscar_personas_v2('jose', 'NINO'); -- encuentra "José"
-- Búsqueda por código parcial
SELECT * FROM buscar_personas_v2('2024', 'NINO');
-- Búsqueda de encargados
SELECT * FROM buscar_personas_v2('ana garcia', 'ENCARGADO');
SELECT * FROM buscar_personas_v2('12345678', 'ENCARGADO'); -- por DUI
-- Con límite personalizado
SELECT * FROM buscar_personas_v2('juan', 'NINO', 20);
*/
-- ============================================================================
-- 6⃣ ESTADÍSTICAS Y DEBUGGING
-- ============================================================================
-- Ver distribución de tipos de match
CREATE OR REPLACE FUNCTION analizar_busqueda(p_termino TEXT, p_tipo TEXT)
RETURNS TABLE(
match_type TEXT,
cantidad BIGINT,
score_promedio DOUBLE PRECISION
) AS $$
BEGIN
RETURN QUERY
SELECT
b.match_type,
COUNT(*) AS cantidad,
AVG(b.score) AS score_promedio
FROM buscar_personas_v2(p_termino, p_tipo, 100) b
GROUP BY b.match_type
ORDER BY cantidad DESC;
END;
$$ LANGUAGE plpgsql;
-- Ejemplo: SELECT * FROM analizar_busqueda('juan', 'NINO');

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,472 @@
/* ===============================================
MIES - Authentication Styles
Premium glassmorphism design with animations
=============================================== */
/* CSS Variables */
:root {
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--accent-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--primary-color: #667eea;
--primary-dark: #5a67d8;
--secondary-color: #764ba2;
--accent-color: #4facfe;
--glass-bg: rgba(255, 255, 255, 0.15);
--glass-border: rgba(255, 255, 255, 0.25);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--text-primary: #1a1a2e;
--text-secondary: #4a4a68;
--text-light: #8888a4;
--font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
--border-radius: 16px;
--border-radius-sm: 12px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
min-height: 100vh;
overflow-x: hidden;
}
/* Auth Container */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
}
/* Background Effects */
.auth-background {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
}
.auth-gradient {
position: absolute;
inset: 0;
background: var(--primary-gradient);
}
.auth-gradient::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 50%
);
animation: pulse 15s ease-in-out infinite;
}
.auth-pattern {
position: absolute;
inset: 0;
background-image: radial-gradient(
circle at 20% 80%,
rgba(255, 255, 255, 0.1) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(255, 255, 255, 0.08) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 40%,
rgba(255, 255, 255, 0.05) 0%,
transparent 50%
);
}
/* Floating orbs */
.auth-background::after {
content: "";
position: absolute;
width: 400px;
height: 400px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
top: -100px;
right: -100px;
animation: float 8s ease-in-out infinite;
}
/* Auth Card - Glassmorphism */
.auth-card {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: var(--border-radius);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.1);
padding: 40px;
position: relative;
overflow: hidden;
}
.auth-card-register {
max-width: 520px;
}
.auth-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--primary-gradient);
}
/* Auth Header */
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.auth-logo {
margin-bottom: 16px;
}
.logo-icon {
width: 72px;
height: 72px;
margin: 0 auto;
padding: 12px;
background: var(--primary-gradient);
border-radius: 20px;
color: white;
box-shadow: 0 10px 30px -5px rgba(102, 126, 234, 0.5);
transition: var(--transition);
}
.logo-icon:hover {
transform: scale(1.05) rotate(5deg);
}
.auth-title {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 3px;
margin-bottom: 4px;
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
letter-spacing: 1px;
}
/* Form Styles */
.auth-form {
margin-bottom: 24px;
}
.form-floating {
position: relative;
}
.form-floating > .form-control {
height: 56px;
padding: 16px;
padding-left: 16px;
border: 2px solid #e8e8f0;
border-radius: var(--border-radius-sm);
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
background: rgba(248, 248, 252, 0.8);
transition: var(--transition);
}
.form-floating > .form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15);
background: white;
}
.form-floating > .form-control::placeholder {
color: transparent;
}
.form-floating > label {
position: absolute;
top: 0;
left: 0;
height: 100%;
padding: 16px;
pointer-events: none;
border: 2px solid transparent;
transform-origin: 0 0;
transition: var(--transition);
color: var(--text-light);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.input-icon {
width: 18px;
height: 18px;
opacity: 0.6;
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label {
opacity: 0.8;
transform: scale(0.85) translateY(-0.75rem) translateX(0.15rem);
background: white;
padding: 0 8px;
height: auto;
}
/* Check input */
.form-check-input {
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.form-check-input:focus {
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.form-check-label {
color: var(--text-secondary);
font-size: 0.9rem;
cursor: pointer;
}
/* Auth Button */
.btn-auth {
width: 100%;
height: 52px;
background: var(--primary-gradient);
border: none;
border-radius: var(--border-radius-sm);
color: white;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
transition: var(--transition);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-auth:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5);
color: white;
}
.btn-auth:active {
transform: translateY(0);
}
.btn-auth svg {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
}
.btn-auth:hover svg {
transform: translateX(4px);
}
/* Auth Footer */
.auth-footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.auth-footer p {
font-size: 0.9rem;
color: var(--text-light);
margin-bottom: 4px;
}
.auth-link {
color: var(--primary-color);
font-weight: 600;
text-decoration: none;
transition: var(--transition);
}
.auth-link:hover {
color: var(--secondary-color);
text-decoration: underline;
}
/* Auth Branding */
.auth-branding {
text-align: center;
margin-top: 24px;
padding-top: 16px;
font-size: 0.75rem;
color: var(--text-light);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.auth-branding .separator {
color: var(--primary-color);
}
/* Alert Styles */
.alert {
border-radius: var(--border-radius-sm);
font-size: 0.875rem;
border: none;
}
.alert-success {
background: linear-gradient(
135deg,
rgba(72, 187, 120, 0.1) 0%,
rgba(56, 178, 172, 0.1) 100%
);
color: #276749;
}
.alert-danger {
background: linear-gradient(
135deg,
rgba(245, 101, 101, 0.1) 0%,
rgba(237, 100, 166, 0.1) 100%
);
color: #c53030;
}
/* Validation Styles */
.text-danger {
font-size: 0.75rem;
margin-top: 4px;
display: block;
}
.input-validation-error {
border-color: #e53e3e !important;
}
/* Animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.1) rotate(180deg);
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-30px, 30px);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
/* Responsive Styles */
@media (max-width: 576px) {
.auth-card {
padding: 28px 24px;
}
.auth-title {
font-size: 1.75rem;
}
.logo-icon {
width: 60px;
height: 60px;
}
.auth-card-register .row > div {
width: 100%;
}
}
/* Row helpers for registration */
.auth-form .row {
display: flex;
flex-wrap: wrap;
margin: 0 -8px;
}
.auth-form .row > .col-md-6 {
padding: 0 8px;
flex: 0 0 50%;
max-width: 50%;
}
@media (max-width: 576px) {
.auth-form .row > .col-md-6 {
flex: 0 0 100%;
max-width: 100%;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB