diff --git a/foundation_system/.dockerignore b/foundation_system/.dockerignore new file mode 100644 index 0000000..b4b896f --- /dev/null +++ b/foundation_system/.dockerignore @@ -0,0 +1,8 @@ +**/.dockerignore +**/.git +**/.gitignore +**/.vs +**/.vscode +**/bin +**/obj +**/logs diff --git a/foundation_system/Controllers/AccountController.cs b/foundation_system/Controllers/AccountController.cs new file mode 100644 index 0000000..ad692e0 --- /dev/null +++ b/foundation_system/Controllers/AccountController.cs @@ -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 _logger; + + public AccountController(IAuthService authService, ILogger 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 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 + { + 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 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 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(); + } +} diff --git a/foundation_system/Controllers/AntecedentesController.cs b/foundation_system/Controllers/AntecedentesController.cs new file mode 100644 index 0000000..c77ecac --- /dev/null +++ b/foundation_system/Controllers/AntecedentesController.cs @@ -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 Index(string search = "", int page = 1, int pageSize = 10) + { + var query = _context.Ninos + .Include(n => n.Persona) + .Where(n => n.Activo); + + List 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 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 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); + } +} diff --git a/foundation_system/Controllers/AsistenciaController.cs b/foundation_system/Controllers/AsistenciaController.cs new file mode 100644 index 0000000..e148227 --- /dev/null +++ b/foundation_system/Controllers/AsistenciaController.cs @@ -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 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 { "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 + { + 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 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 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 GuardarAsistenciasMasivas([FromBody] List 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; +} + diff --git a/foundation_system/Controllers/ConfiguracionController.cs b/foundation_system/Controllers/ConfiguracionController.cs new file mode 100644 index 0000000..db217f1 --- /dev/null +++ b/foundation_system/Controllers/ConfiguracionController.cs @@ -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 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 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 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); + } +} diff --git a/foundation_system/Controllers/ExpedienteController.cs b/foundation_system/Controllers/ExpedienteController.cs new file mode 100644 index 0000000..e38924e --- /dev/null +++ b/foundation_system/Controllers/ExpedienteController.cs @@ -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 Index(string search = "", int page = 1, int pageSize = 10) + { + var query = _context.Ninos + .Include(n => n.Persona) + .Where(n => n.Activo); + + List 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 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 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 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 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( + _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 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 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 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 SearchPersonas(string term, string type = "ENCARGADO") + { + if (string.IsNullOrWhiteSpace(term)) return Json(new List()); + + 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 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 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"; + } +} diff --git a/foundation_system/Controllers/UsuarioController.cs b/foundation_system/Controllers/UsuarioController.cs new file mode 100644 index 0000000..1a451d2 --- /dev/null +++ b/foundation_system/Controllers/UsuarioController.cs @@ -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 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 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 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 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 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); + } +} diff --git a/foundation_system/Data/ApplicationDbContext.cs b/foundation_system/Data/ApplicationDbContext.cs index ef510a0..2e9bbbe 100644 --- a/foundation_system/Data/ApplicationDbContext.cs +++ b/foundation_system/Data/ApplicationDbContext.cs @@ -18,6 +18,7 @@ public class ApplicationDbContext : DbContext public DbSet Asistencias { get; set; } public DbSet Configuraciones { get; set; } public DbSet EncargadosNino { get; set; } + public DbSet AntecedentesNino { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -42,5 +43,19 @@ public class ApplicationDbContext : DbContext .HasOne(u => u.Persona) .WithMany() .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( + v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc), + v => v)); + } + } } } \ No newline at end of file diff --git a/foundation_system/Data/PostgresQueryExecutor.cs b/foundation_system/Data/PostgresQueryExecutor.cs new file mode 100644 index 0000000..c4b1183 --- /dev/null +++ b/foundation_system/Data/PostgresQueryExecutor.cs @@ -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> ExecuteQueryAsync( + DbContext context, + string sql, + Func map, + Action? 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(); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(map(reader)); + } + + return results; + } +} diff --git a/foundation_system/Dockerfile b/foundation_system/Dockerfile new file mode 100644 index 0000000..71ecf49 --- /dev/null +++ b/foundation_system/Dockerfile @@ -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"] diff --git a/foundation_system/Models/AntecedenteNino.cs b/foundation_system/Models/AntecedenteNino.cs new file mode 100644 index 0000000..73518d4 --- /dev/null +++ b/foundation_system/Models/AntecedenteNino.cs @@ -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; +} diff --git a/foundation_system/Models/Asistencia.cs b/foundation_system/Models/Asistencia.cs new file mode 100644 index 0000000..a1a8f67 --- /dev/null +++ b/foundation_system/Models/Asistencia.cs @@ -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!; +} diff --git a/foundation_system/Models/ConfiguracionSistema.cs b/foundation_system/Models/ConfiguracionSistema.cs new file mode 100644 index 0000000..55d1348 --- /dev/null +++ b/foundation_system/Models/ConfiguracionSistema.cs @@ -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; +} diff --git a/foundation_system/Models/EncargadoNino.cs b/foundation_system/Models/EncargadoNino.cs new file mode 100644 index 0000000..ea1332c --- /dev/null +++ b/foundation_system/Models/EncargadoNino.cs @@ -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!; +} diff --git a/foundation_system/Models/Nino.cs b/foundation_system/Models/Nino.cs new file mode 100644 index 0000000..7f0cccf --- /dev/null +++ b/foundation_system/Models/Nino.cs @@ -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!; +} diff --git a/foundation_system/Models/Persona.cs b/foundation_system/Models/Persona.cs new file mode 100644 index 0000000..3b0e2c5 --- /dev/null +++ b/foundation_system/Models/Persona.cs @@ -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}"; +} diff --git a/foundation_system/Models/RolSistema.cs b/foundation_system/Models/RolSistema.cs new file mode 100644 index 0000000..f8ba9c8 --- /dev/null +++ b/foundation_system/Models/RolSistema.cs @@ -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 RolesUsuario { get; set; } = new List(); +} diff --git a/foundation_system/Models/RolUsuario.cs b/foundation_system/Models/RolUsuario.cs new file mode 100644 index 0000000..9735a3b --- /dev/null +++ b/foundation_system/Models/RolUsuario.cs @@ -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!; +} diff --git a/foundation_system/Models/Usuario.cs b/foundation_system/Models/Usuario.cs new file mode 100644 index 0000000..0953b69 --- /dev/null +++ b/foundation_system/Models/Usuario.cs @@ -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 RolesUsuario { get; set; } = new List(); +} diff --git a/foundation_system/Models/ViewModels/AntecedentesViewModel.cs b/foundation_system/Models/ViewModels/AntecedentesViewModel.cs new file mode 100644 index 0000000..c06b122 --- /dev/null +++ b/foundation_system/Models/ViewModels/AntecedentesViewModel.cs @@ -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 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; +} diff --git a/foundation_system/Models/ViewModels/AsistenciaGridViewModel.cs b/foundation_system/Models/ViewModels/AsistenciaGridViewModel.cs new file mode 100644 index 0000000..200b610 --- /dev/null +++ b/foundation_system/Models/ViewModels/AsistenciaGridViewModel.cs @@ -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 DiasDelMes { get; set; } = new(); + public List Expedientes { get; set; } = new(); + public Dictionary Asistencias { get; set; } = new(); // Key: "ninoId_yyyy-MM-dd", Value: "P", "T", "F" +} diff --git a/foundation_system/Models/ViewModels/ExpedienteViewModel.cs b/foundation_system/Models/ViewModels/ExpedienteViewModel.cs new file mode 100644 index 0000000..a674a39 --- /dev/null +++ b/foundation_system/Models/ViewModels/ExpedienteViewModel.cs @@ -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; } +} diff --git a/foundation_system/Models/ViewModels/LoginViewModel.cs b/foundation_system/Models/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..97182ee --- /dev/null +++ b/foundation_system/Models/ViewModels/LoginViewModel.cs @@ -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; } +} diff --git a/foundation_system/Models/ViewModels/RegisterViewModel.cs b/foundation_system/Models/ViewModels/RegisterViewModel.cs new file mode 100644 index 0000000..fc4e447 --- /dev/null +++ b/foundation_system/Models/ViewModels/RegisterViewModel.cs @@ -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; +} diff --git a/foundation_system/Models/ViewModels/UsuarioViewModel.cs b/foundation_system/Models/ViewModels/UsuarioViewModel.cs new file mode 100644 index 0000000..8e402a9 --- /dev/null +++ b/foundation_system/Models/ViewModels/UsuarioViewModel.cs @@ -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; } +} diff --git a/foundation_system/Program.cs b/foundation_system/Program.cs index 0a22a80..ddf9e8b 100644 --- a/foundation_system/Program.cs +++ b/foundation_system/Program.cs @@ -14,8 +14,9 @@ builder.Services.AddDbContext(options => builder.Services.AddDatabaseDeveloperPageExceptionFilter(); -// Register authentication service +// Register services builder.Services.AddScoped(); +builder.Services.AddScoped(); // Configure cookie authentication builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) diff --git a/foundation_system/Services/AntecedentesService.cs b/foundation_system/Services/AntecedentesService.cs new file mode 100644 index 0000000..4bb2969 --- /dev/null +++ b/foundation_system/Services/AntecedentesService.cs @@ -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> GetHistorialByNinoIdAsync(long ninoId) + { + return await _context.AntecedentesNino + .Where(a => a.NinoId == ninoId && a.Activo) + .OrderByDescending(a => a.FechaIncidente) + .ToListAsync(); + } + + public async Task AddAntecedenteAsync(AntecedenteNino antecedente) + { + try + { + _context.AntecedentesNino.Add(antecedente); + await _context.SaveChangesAsync(); + return true; + } + catch + { + return false; + } + } +} diff --git a/foundation_system/Services/AuthService.cs b/foundation_system/Services/AuthService.cs new file mode 100644 index 0000000..678fee9 --- /dev/null +++ b/foundation_system/Services/AuthService.cs @@ -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 _logger; + + public AuthService(ApplicationDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task 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> 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(); + } + } +} diff --git a/foundation_system/Services/IAntecedentesService.cs b/foundation_system/Services/IAntecedentesService.cs new file mode 100644 index 0000000..36f2f1e --- /dev/null +++ b/foundation_system/Services/IAntecedentesService.cs @@ -0,0 +1,9 @@ +using foundation_system.Models; + +namespace foundation_system.Services; + +public interface IAntecedentesService +{ + Task> GetHistorialByNinoIdAsync(long ninoId); + Task AddAntecedenteAsync(AntecedenteNino antecedente); +} diff --git a/foundation_system/Services/IAuthService.cs b/foundation_system/Services/IAuthService.cs new file mode 100644 index 0000000..153af78 --- /dev/null +++ b/foundation_system/Services/IAuthService.cs @@ -0,0 +1,27 @@ +using foundation_system.Models; +using foundation_system.Models.ViewModels; + +namespace foundation_system.Services; + +public interface IAuthService +{ + /// + /// Validates user credentials and returns the user if valid + /// + Task ValidateUserAsync(string username, string password); + + /// + /// Registers a new user + /// + Task<(bool Success, string Message, Usuario? User)> RegisterUserAsync(RegisterViewModel model); + + /// + /// Gets the roles for a user + /// + Task> GetUserRolesAsync(long userId); + + /// + /// Updates the last login timestamp + /// + Task UpdateLastLoginAsync(long userId); +} diff --git a/foundation_system/Views/Account/AccessDenied.cshtml b/foundation_system/Views/Account/AccessDenied.cshtml new file mode 100644 index 0000000..316da46 --- /dev/null +++ b/foundation_system/Views/Account/AccessDenied.cshtml @@ -0,0 +1,18 @@ +@{ + ViewData["Title"] = "Acceso Denegado"; +} + +
+
+
+

403

+

Acceso Denegado

+

+ No tienes permisos para acceder a esta página. +

+ + Volver al Inicio + +
+
+
diff --git a/foundation_system/Views/Account/Login.cshtml b/foundation_system/Views/Account/Login.cshtml new file mode 100644 index 0000000..c0a5915 --- /dev/null +++ b/foundation_system/Views/Account/Login.cshtml @@ -0,0 +1,108 @@ +@model foundation_system.Models.ViewModels.LoginViewModel +@{ + ViewData["Title"] = "Iniciar Sesión"; + Layout = null; +} + + + + + + + @ViewData["Title"] - MIES + + + + + +
+
+
+
+
+ +
+
+ +

MIES

+

Misión Esperanza

+
+ + @if (TempData["SuccessMessage"] != null) + { + + } + +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ + +
+ + + +
+ Sistema de Gestión + + Fundación MIES +
+
+
+ + + + + + + diff --git a/foundation_system/Views/Account/Register.cshtml b/foundation_system/Views/Account/Register.cshtml new file mode 100644 index 0000000..4ee0ff5 --- /dev/null +++ b/foundation_system/Views/Account/Register.cshtml @@ -0,0 +1,140 @@ +@model foundation_system.Models.ViewModels.RegisterViewModel +@{ + ViewData["Title"] = "Crear Cuenta"; + Layout = null; +} + + + + + + + @ViewData["Title"] - MIES + + + + + +
+
+
+
+
+ +
+
+ +

Crear Cuenta

+

Únete a MIES - Misión Esperanza

+
+ +
+
+ +
+
+
+ + + +
+
+
+
+ + + +
+
+
+ +
+ + + +
+ +
+ + + +
+ +
+
+
+ + + +
+
+
+
+ + + +
+
+
+ + +
+ + + +
+ Sistema de Gestión + + Fundación MIES +
+
+
+ + + + + + + diff --git a/foundation_system/Views/Antecedentes/Index.cshtml b/foundation_system/Views/Antecedentes/Index.cshtml new file mode 100644 index 0000000..91685f3 --- /dev/null +++ b/foundation_system/Views/Antecedentes/Index.cshtml @@ -0,0 +1,89 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Gestión de Antecedentes"; +} + +
+
+

Antecedentes

+

Seleccione un niño para gestionar su historial de antecedentes y conducta.

+
+
+ +
+
+
+
+ + + + +
+
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + @foreach (var item in Model) + { + + + + + + + + } + +
CódigoNombre CompletoGrado/NivelEstadoAcciones
No se encontraron niños.
@item.CodigoInscripcion@item.Persona.NombreCompleto@item.NivelGrado + + @item.Estado + + + + Gestionar + +
+
+ + @if (ViewBag.TotalPages > 1) + { +
+
+ Mostrando página @ViewBag.CurrentPage de @ViewBag.TotalPages +
+ +
+ } +
diff --git a/foundation_system/Views/Antecedentes/Manage.cshtml b/foundation_system/Views/Antecedentes/Manage.cshtml new file mode 100644 index 0000000..9acb77d --- /dev/null +++ b/foundation_system/Views/Antecedentes/Manage.cshtml @@ -0,0 +1,138 @@ +@model foundation_system.Models.ViewModels.AntecedentesViewModel +@{ + ViewData["Title"] = "Historial de Antecedentes - " + Model.Nino.Persona.NombreCompleto; +} + +
+ + Volver al listado + +
+
+

@Model.Nino.Persona.NombreCompleto

+

Código: @Model.Nino.CodigoInscripcion | Grado: @Model.Nino.NivelGrado

+
+ +
+
+ +
+
+
+
Historial de Antecedentes
+
+ + + + + + + + + + + + @if (!Model.Historial.Any()) + { + + + + } + @foreach (var item in Model.Historial) + { + + + + + + + + } + +
FechaTipoGravedadDescripciónRegistrado por
No hay antecedentes registrados para este niño.
@item.FechaIncidente.ToString("dd/MM/yyyy HH:mm") + "bg-info", + "Suspension" => "bg-warning", + "Castigo" => "bg-danger", + _ => "bg-secondary" + })"> + @item.TipoAntecedente + + + "danger", + "Media" => "warning", + "Baja" => "success", + _ => "muted" + }) fw-bold"> + @item.Gravedad + + @item.Descripcion@item.UsuarioRegistra
+
+
+
+
+ + + + +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/foundation_system/Views/Asistencia/Index.cshtml b/foundation_system/Views/Asistencia/Index.cshtml new file mode 100644 index 0000000..fbf7aad --- /dev/null +++ b/foundation_system/Views/Asistencia/Index.cshtml @@ -0,0 +1,803 @@ +@model foundation_system.Models.ViewModels.AsistenciaGridViewModel +@{ + ViewData["Title"] = "Control de Asistencia"; + + var diasSeleccionadosList = new List(); + 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(); +} + +
+
+
+
Filtros
+
+
+
+
+ + +
+ +
+ + +
+ +
+ +
+ @foreach (var dia in (List)ViewBag.DiasSemana) + { + var isChecked = diasSeleccionadosList.Contains(dia.Value); +
+ + +
+ } + +
+
+ +
+ +
+
+
+
+ + + +
+
+
+ + Asistencia - @Model.NombreMes @Model.Año + @Model.Expedientes.Count niños +
+
+ +
+
+ +
+
+ + + + + @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; + + + } + + + + @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--; + } + } + + + + + @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; + + + } + + } + + + + + + @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; + + + } + + +
+
Nombre
+
Edad
+
+
@dia.Day
+
@nombreDia
+
+
@nombreCompleto
+
Edad: @edad años
+
+ + +
+
TOTALES POR DÍA
+
P/T/F
+
+
@totalDia
+
+ @totalPresente + @totalTarde + @totalFalta + @totalJustificado + @totalEnfermo +
+
+
+
+ + +
+
+ + + +@section Styles { + +} + +@section Scripts { + +} diff --git a/foundation_system/Views/Configuracion/Edit.cshtml b/foundation_system/Views/Configuracion/Edit.cshtml new file mode 100644 index 0000000..f5f68db --- /dev/null +++ b/foundation_system/Views/Configuracion/Edit.cshtml @@ -0,0 +1,73 @@ +@model foundation_system.Models.ConfiguracionSistema +@{ + ViewData["Title"] = "Editar Configuración"; +} + +
+
+

Editar Parámetro

+

Modificando la clave: @Model.Clave

+
+ + Volver + +
+ +
+
+
+
+ @Html.AntiForgeryToken() + + +
+ +

@Model.Descripcion

+
+ +
+ + + @if (Model.TipoDato == "BOOLEANO") + { + + } + else if (Model.TipoDato == "HTML" || Model.TipoDato == "JSON") + { + + } + else if (Model.TipoDato == "NUMERO") + { + + } + else if (Model.TipoDato == "FECHA") + { + + } + else + { + + } + +
+ +
+
+ + Tipo: @Model.TipoDato +
+ +
+
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/foundation_system/Views/Configuracion/Index.cshtml b/foundation_system/Views/Configuracion/Index.cshtml new file mode 100644 index 0000000..9f0e400 --- /dev/null +++ b/foundation_system/Views/Configuracion/Index.cshtml @@ -0,0 +1,93 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Configuración del Sistema"; + var categorias = (List)ViewBag.Categorias; + var selectedCategoria = (string)ViewBag.SelectedCategoria; +} + +
+
+

Configuración

+

Gestione los parámetros dinámicos y ajustes globales del sistema.

+
+
+ +
+
+
+
+ + Todas + + @foreach (var cat in categorias) + { + + @cat + + } +
+
+
+
+ +
+
+ + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + @foreach (var item in Model) + { + + + + + + + + } + +
ClaveValorCategoría / GrupoDescripciónAcciones
No hay configuraciones registradas.
+ @item.Clave + + @if (item.TipoDato == "BOOLEANO") + { + + @(item.Valor?.ToLower() == "true" ? "Activado" : "Desactivado") + + } + else + { + @item.Valor + } + + @item.Categoria + @item.Grupo + @item.Descripcion + @if (item.EsEditable) + { + + + + } + else + { + + } +
+
+
diff --git a/foundation_system/Views/Expediente/Create.cshtml b/foundation_system/Views/Expediente/Create.cshtml new file mode 100644 index 0000000..4205181 --- /dev/null +++ b/foundation_system/Views/Expediente/Create.cshtml @@ -0,0 +1,465 @@ +@model foundation_system.Models.ViewModels.ExpedienteViewModel +@{ + ViewData["Title"] = "Nuevo Expediente"; +} + +@section Styles { + + +} + +
+
+

Nuevo Expediente

+

Complete la información para registrar un nuevo niño en la fundación.

+
+ + Volver al Listado + +
+ +
+ @Html.AntiForgeryToken() +
+ +
+ +
+
+
Datos Personales
+
+
+
+ + +
+
+ Foto de perfil +
+
+ Seleccionar Foto +
+
+

Haga clic para seleccionar una foto

+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + +
+
+
+ + +
+
Padres / Encargados
+
+
+ +
+ + + +
+ +
+
+ +
+ + + +
+ +
+
+ +
+ + + +
+ +
+
+
+ +
+
Contacto de Emergencia (Opcional)
+
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+
Inscripción
+
+
+ + + El código se generará al guardar. +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+
Salud (Opcional)
+
+ + + +
+
+ +
+ +
+
+
+
+ + + + +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + + + +} diff --git a/foundation_system/Views/Expediente/Details.cshtml b/foundation_system/Views/Expediente/Details.cshtml new file mode 100644 index 0000000..ddd186f --- /dev/null +++ b/foundation_system/Views/Expediente/Details.cshtml @@ -0,0 +1,188 @@ +@model foundation_system.Models.ViewModels.ExpedienteViewModel +@{ + ViewData["Title"] = "Detalles del Expediente"; +} + +@section Styles { + +} + +
+
+

Detalles del Expediente

+

Información completa de: @Model.Nombres @Model.Apellidos

+
+ +
+ +
+ +
+
+
Datos Personales
+
+
+
+
+ @if (!string.IsNullOrEmpty(Model.FotoUrl)) + { + Foto de perfil + } + else + { + Foto de perfil + } +
+
+
+
+
Nombres
+
@Model.Nombres
+
+
+
Apellidos
+
@Model.Apellidos
+
+
+
Fecha de Nacimiento
+
@Model.FechaNacimiento?.ToString("dd/MM/yyyy")
+
+
+
Género
+
@(Model.Genero == "M" ? "Masculino" : "Femenino")
+
+
+
Dirección
+
@Model.Direccion
+
+
+
+ + +
+
Padres / Encargados
+
+
+
Padre
+
@(Model.PadreNombre ?? "No registrado")
+
+
+
Madre
+
@(Model.MadreNombre ?? "No registrada")
+
+
+
Encargado Principal
+
@(Model.EncargadoNombre ?? "No registrado")
+
+
+
+ +
+
Contacto de Emergencia
+
+
+
Nombre del Responsable
+
@(Model.ContactoEmergenciaNombre ?? "No registrado")
+
+
+
Teléfono de Contacto
+
@(Model.ContactoEmergenciaTelefono ?? "No registrado")
+
+
+
+
+ + +
+
+
Inscripción
+
+
+
Código de Inscripción
+
@Model.CodigoInscripcion
+
+
+
Fecha de Inscripción
+
@Model.FechaInscripcion.ToString("dd/MM/yyyy")
+
+
+
Nivel / Grado
+
@Model.NivelGrado
+
+
+
Estado
+
+ @if (Model.Estado == "ACTIVO") + { + Activo + } + else if (Model.Estado == "GRADUADO") + { + Graduado + } + else + { + Inactivo + } +
+
+
+
+ +
+
Salud
+
+
Alergias / Condiciones
+
@(Model.Alergias ?? "Ninguna registrada")
+
+
+
+
diff --git a/foundation_system/Views/Expediente/Edit.cshtml b/foundation_system/Views/Expediente/Edit.cshtml new file mode 100644 index 0000000..fef8118 --- /dev/null +++ b/foundation_system/Views/Expediente/Edit.cshtml @@ -0,0 +1,473 @@ +@model foundation_system.Models.ViewModels.ExpedienteViewModel +@{ + ViewData["Title"] = "Editar Expediente"; +} + +@section Styles { + + +} + +
+
+

Editar Expediente

+

Actualice la información del niño(a): @Model.Nombres @Model.Apellidos

+
+ + Volver al Listado + +
+ +
+ @Html.AntiForgeryToken() + +
+ +
+ +
+
+
Datos Personales
+
+
+
+ + +
+
+ @if (!string.IsNullOrEmpty(Model.FotoUrl)) + { + Foto de perfil + } + else + { + Foto de perfil + } +
+
+ Cambiar Foto +
+
+

Haga clic para cambiar la foto

+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + +
+
+
+ + +
+
Padres / Encargados
+
+
+ +
+ + + +
+ +
+
+ +
+ + + +
+ +
+
+ +
+ + + +
+ +
+
+
+ +
+
Contacto de Emergencia (Opcional)
+
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+
Inscripción
+
+
+ + + El código de expediente no es editable. +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+
Salud (Opcional)
+
+ + + +
+
+ +
+ +
+
+
+
+ + + + +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + + + +} diff --git a/foundation_system/Views/Expediente/Index.cshtml b/foundation_system/Views/Expediente/Index.cshtml new file mode 100644 index 0000000..9c43827 --- /dev/null +++ b/foundation_system/Views/Expediente/Index.cshtml @@ -0,0 +1,172 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Expedientes de Niños"; +} + +
+
+

Expedientes

+

Gestión de registros y expedientes de los niños de la fundación.

+
+ + Nuevo Expediente + +
+ +
+
+
+
+ + + + +
+
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + @if (!Model.Any()) + { + + + + } + @foreach (var item in Model) + { + + + + + + + + + + } + +
CódigoNombre CompletoEdadGrado/NivelFecha InscripciónEstadoAcciones
No hay expedientes registrados.
@item.CodigoInscripcion@item.Persona.NombreCompleto + @{ + 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 + { + N/A + } + } + @item.NivelGrado@item.FechaInscripcion.ToShortDateString() + + @item.Estado + + +
+ + + + + + + +
+
+
+ + @if (ViewBag.TotalPages > 1) + { +
+
+ Mostrando página @ViewBag.CurrentPage de @ViewBag.TotalPages +
+ +
+ } +
+ + + + +@section Scripts { + +} + diff --git a/foundation_system/Views/Expediente/Print.cshtml b/foundation_system/Views/Expediente/Print.cshtml new file mode 100644 index 0000000..ba200db --- /dev/null +++ b/foundation_system/Views/Expediente/Print.cshtml @@ -0,0 +1,232 @@ +@model foundation_system.Models.ViewModels.ExpedienteViewModel +@{ + Layout = null; + var antecedentes = ViewBag.Antecedentes as List; +} + + + + + + + Imprimir Expediente - @Model.CodigoInscripcion + + + + + +
+
+ + +
+ + + +
+
+
+ @if (!string.IsNullOrEmpty(Model.FotoUrl)) + { + Foto + } + else + { + Foto + } +
+
+ + @Model.Estado + +
+
+
+
Información Personal
+
+
+ Nombres + @Model.Nombres +
+
+ Apellidos + @Model.Apellidos +
+
+ Fecha de Nacimiento + @Model.FechaNacimiento?.ToString("dd/MM/yyyy") +
+
+ Género + @(Model.Genero == "M" ? "Masculino" : "Femenino") +
+
+ Dirección + @Model.Direccion +
+
+
+
+ +
+
+
Padres y Encargados
+
+ Padre + @(Model.PadreNombre ?? "N/A") +
+
+ Madre + @(Model.MadreNombre ?? "N/A") +
+
+ Encargado Principal + @(Model.EncargadoNombre ?? "N/A") +
+
+
+
Inscripción y Salud
+
+ Fecha de Inscripción + @Model.FechaInscripcion.ToString("dd/MM/yyyy") +
+
+ Nivel / Grado + @Model.NivelGrado +
+
+ Alergias / Condiciones + @(Model.Alergias ?? "Ninguna") +
+
+
+ +
Historial de Antecedentes
+ @if (antecedentes != null && antecedentes.Any()) + { + + + + + + + + + + + @foreach (var item in antecedentes) + { + + + + + + + } + +
FechaTipoDescripciónGravedad
@item.FechaIncidente.ToString("dd/MM/yyyy")@item.TipoAntecedente@item.Descripcion + + @item.Gravedad + +
+ } + else + { +

No se registran antecedentes para este niño(a).

+ } + +
+
+
+
+
Firma del Encargado
+
+
+
+
Firma Director(a)
+
+
+
+
Sello de la Fundación
+
+
+
+
+ + + + + diff --git a/foundation_system/Views/Shared/_Layout.cshtml b/foundation_system/Views/Shared/_Layout.cshtml index ed2a00a..9c6fc5a 100644 --- a/foundation_system/Views/Shared/_Layout.cshtml +++ b/foundation_system/Views/Shared/_Layout.cshtml @@ -6,6 +6,8 @@ @ViewData["Title"] - MIES + + @@ -35,6 +37,9 @@ Asistencia + + Antecedentes + @@ -79,6 +84,7 @@ + @await RenderSectionAsync("Scripts", required: false) diff --git a/foundation_system/Views/Usuario/Create.cshtml b/foundation_system/Views/Usuario/Create.cshtml new file mode 100644 index 0000000..41a695a --- /dev/null +++ b/foundation_system/Views/Usuario/Create.cshtml @@ -0,0 +1,78 @@ +@model foundation_system.Models.ViewModels.UsuarioViewModel +@{ + ViewData["Title"] = "Nuevo Usuario"; +} + + + +
+
+
+
+ @Html.AntiForgeryToken() +
+ +
Información Personal
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
Credenciales de Acceso
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +
+
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/foundation_system/Views/Usuario/Edit.cshtml b/foundation_system/Views/Usuario/Edit.cshtml new file mode 100644 index 0000000..3d95032 --- /dev/null +++ b/foundation_system/Views/Usuario/Edit.cshtml @@ -0,0 +1,95 @@ +@model foundation_system.Models.ViewModels.UsuarioViewModel +@{ + ViewData["Title"] = "Editar Usuario"; +} + +
+
+

Editar Usuario

+

Actualizando perfil de: @Model.NombreUsuario

+
+ + Volver + +
+ +
+
+
+
+ @Html.AntiForgeryToken() + +
+ +
Información Personal
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
Configuración de Cuenta
+
+
+ + + +
+
+ + + +
+
+ +
+ + Deje los campos de contraseña en blanco si no desea cambiarla. +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ +
+
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/foundation_system/Views/Usuario/Index.cshtml b/foundation_system/Views/Usuario/Index.cshtml new file mode 100644 index 0000000..511070d --- /dev/null +++ b/foundation_system/Views/Usuario/Index.cshtml @@ -0,0 +1,97 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Gestión de Usuarios"; +} + +
+
+

Usuarios del Sistema

+

Administre las cuentas de acceso y perfiles del personal administrativo.

+
+ + Nuevo Usuario + +
+ +
+
+ + + + + + + + + + + + + @foreach (var item in Model) + { + + + + + + + + + } + +
UsuarioNombre CompletoEmailÚltimo AccesoEstadoAcciones
@item.NombreUsuario@item.Persona.Nombres @item.Persona.Apellidos@item.Email + @(item.UltimoLogin?.ToString("dd/MM/yyyy HH:mm") ?? "Nunca") + + + @(item.Activo ? "Activo" : "Inactivo") + + +
+ + + + @if (item.Activo && item.NombreUsuario != User.Identity?.Name) + { + + } +
+
+
+
+ + + + +@section Scripts { + +} diff --git a/foundation_system/init_sql.sql b/foundation_system/init_sql.sql new file mode 100644 index 0000000..647058b --- /dev/null +++ b/foundation_system/init_sql.sql @@ -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'); \ No newline at end of file diff --git a/foundation_system/wwwroot/Assets/default_avatar.png b/foundation_system/wwwroot/Assets/default_avatar.png new file mode 100644 index 0000000..c4db2b5 Binary files /dev/null and b/foundation_system/wwwroot/Assets/default_avatar.png differ diff --git a/foundation_system/wwwroot/css/auth.css b/foundation_system/wwwroot/css/auth.css new file mode 100644 index 0000000..db301d2 --- /dev/null +++ b/foundation_system/wwwroot/css/auth.css @@ -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%; + } +} diff --git a/foundation_system/wwwroot/uploads/fotos/9b408009-af51-47d5-b158-c749977b4b6f.jpg b/foundation_system/wwwroot/uploads/fotos/9b408009-af51-47d5-b158-c749977b4b6f.jpg new file mode 100644 index 0000000..bc057c9 Binary files /dev/null and b/foundation_system/wwwroot/uploads/fotos/9b408009-af51-47d5-b158-c749977b4b6f.jpg differ diff --git a/foundation_system/wwwroot/uploads/fotos/SA25001.jpg b/foundation_system/wwwroot/uploads/fotos/SA25001.jpg new file mode 100644 index 0000000..5dcc51b Binary files /dev/null and b/foundation_system/wwwroot/uploads/fotos/SA25001.jpg differ