Compare commits

...

4 Commits

Author SHA1 Message Date
ac96cb1f23 Nueva mejoras Y estabilidad 2025-12-26 22:27:20 -06:00
203859b22a nuevas correciones 2025-12-25 19:33:56 -06:00
3457721238 Todo 2025-12-25 13:54:49 -06:00
d405b61ddd Modulo de Asistencia 2025-12-25 13:54:14 -06:00
41 changed files with 5413 additions and 304 deletions

1
.gitignore vendored
View File

@@ -361,3 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/MieSystem/wwwroot/uploads

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using Dapper;
namespace MicroORM;
public static class DapperTypeMapRegistrar
{
public static void RegisterFor(Type type)
{
SqlMapper.SetTypeMap(
type,
new CustomPropertyTypeMap(
type,
(t, columnName) =>
{
// [Column]
var prop = t.GetProperties()
.FirstOrDefault(p =>
p.GetCustomAttribute<ColumnAttribute>()?.Name
.Equals(columnName, StringComparison.OrdinalIgnoreCase) == true
);
if (prop != null)
return prop;
// Directo
prop = t.GetProperty(columnName,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (prop != null)
return prop;
// snake_case
var pascal = string.Concat(
columnName.Split('_')
.Select(s => char.ToUpperInvariant(s[0]) + s[1..])
);
return t.GetProperty(pascal,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
)
);
}
}

View File

@@ -0,0 +1,18 @@
using System.Data;
using System.Data.Common;
namespace MicroORM;
public static class DbConnectionExtensions
{
public static async Task EnsureOpenAsync(this IDbConnection connection)
{
if (connection.State != ConnectionState.Open)
{
if (connection is DbConnection db)
await db.OpenAsync();
else
connection.Open();
}
}
}

View File

@@ -0,0 +1,7 @@
namespace MicroORM.Exceptions;
public class OrmException : Exception
{
public OrmException(string message, Exception? inner = null)
: base(message, inner) { }
}

View File

@@ -0,0 +1,68 @@
using System.Linq.Expressions;
using System.Reflection;
using Dapper;
namespace MicroORM;
public static class ExpressionToSqlTranslator
{
public static string Translate<T>(
Expression<Func<T, bool>> expression,
DynamicParameters parameters)
{
return Visit(expression.Body, parameters);
}
private static string Visit(Expression exp, DynamicParameters parameters)
{
return exp switch
{
BinaryExpression b => VisitBinary(b, parameters),
MemberExpression m => GetColumnName(m),
ConstantExpression c => AddParameter(c, parameters),
UnaryExpression u => Visit(u.Operand, parameters),
_ => throw new NotSupportedException($"Expression no soportada: {exp.NodeType}")
};
}
private static string VisitBinary(BinaryExpression b, DynamicParameters parameters)
{
var left = Visit(b.Left, parameters);
var right = Visit(b.Right, parameters);
var op = b.NodeType switch
{
ExpressionType.Equal => "=",
ExpressionType.NotEqual => "<>",
ExpressionType.GreaterThan => ">",
ExpressionType.GreaterThanOrEqual => ">=",
ExpressionType.LessThan => "<",
ExpressionType.LessThanOrEqual => "<=",
ExpressionType.AndAlso => "AND",
ExpressionType.OrElse => "OR",
_ => throw new NotSupportedException($"Operador no soportado: {b.NodeType}")
};
return $"({left} {op} {right})";
}
private static string GetColumnName(MemberExpression m)
{
var prop = (PropertyInfo)m.Member;
return OrmMetadata.GetColumnName(prop);
}
private static string AddParameter(ConstantExpression c, DynamicParameters parameters)
{
var name = $"@p{parameters.ParameterNames.Count()}";
parameters.Add(name, c.Value);
return name;
}
public static string GetOrderByColumn<T>(Expression<Func<T, object>> exp)
{
var body = exp.Body is UnaryExpression u ? u.Operand : exp.Body;
var member = (MemberExpression)body;
return OrmMetadata.GetColumnName((PropertyInfo)member.Member);
}
}

View File

@@ -0,0 +1,20 @@
using System.Data;
using System.Linq.Expressions;
namespace MicroORM.Interfaces;
public interface IRepository<T>
{
Task<int> InsertAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null);
Task<bool> UpdateAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null);
Task<T?> GetByIdAsync(object id);
Task<IEnumerable<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null,
Expression<Func<T, object>>? orderBy = null, bool descending = false);
Task<bool> DeleteAsync(object id);
Task<int> SaveAsync<T>(T entity);
Task<IReadOnlyList<int>> SaveAsyncList<T>(IEnumerable<T> entities);
Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null);
Task<IEnumerable<T>> QueryAsync<T>(string sql, object? parameters = null);
Task<T?> ExecuteScalarAsync<T>(string sql, object? parameters = null);
}

7
MicroORM/MicroORM.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace MicroORM;
public class MicroORM
{
}

15
MicroORM/MicroORM.csproj Normal file
View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dapper.Contrib" Version="2.0.78" />
<PackageReference Include="Dapper.SqlBuilder" Version="2.1.66" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Npgsql.Json.NET" Version="10.0.1" />
</ItemGroup>
</Project>

63
MicroORM/OrmMetadata.cs Normal file
View File

@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
namespace MicroORM;
public class OrmMetadata
{
public static string GetTableName<T>()
{
var table = typeof(T).GetCustomAttribute<TableAttribute>();
if (table == null)
throw new InvalidOperationException("La entidad no tiene [Table]");
return string.IsNullOrEmpty(table.Schema)
? table.Name
: $"{table.Schema}.{table.Name}";
}
public static PropertyInfo GetKeyProperty<T>()
{
var key = typeof(T)
.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (key == null)
throw new InvalidOperationException("La entidad no tiene [Key]");
return key;
}
public static string GetColumnName(PropertyInfo prop)
{
var columnAttr = prop.GetCustomAttribute<ColumnAttribute>();
return columnAttr?.Name ?? prop.Name;
}
public static bool IsIdentity(PropertyInfo prop)
{
var key = prop.GetCustomAttribute<KeyAttribute>() != null;
var dbGenerated = prop.GetCustomAttribute<DatabaseGeneratedAttribute>();
return key &&
dbGenerated?.DatabaseGeneratedOption == DatabaseGeneratedOption.Identity;
}
public static IEnumerable<PropertyInfo> GetScaffoldProperties<T>()
{
var type = typeof(T);
var key = GetKeyProperty<T>();
return type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p =>
p.CanWrite && // tiene setter
p.GetCustomAttribute<NotMappedAttribute>() == null &&
(
p.GetCustomAttribute<ColumnAttribute>() != null ||
p == key
)
);
}
}

610
MicroORM/Repository.cs Normal file
View File

@@ -0,0 +1,610 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data;
using System.Linq.Expressions;
using System.Reflection;
using Dapper;
using MicroORM.Exceptions;
using MicroORM.Interfaces;
namespace MicroORM;
public class Repository<T>: IRepository<T>
{
private readonly Func<Task<IDbConnection>> _connectionFactory;
private IRepository<T> _repositoryImplementation;
public Repository(Func<Task<IDbConnection>> connectionFactory)
{
_connectionFactory = connectionFactory;
DapperTypeMapRegistrar.RegisterFor(typeof(T));
}
static Repository()
{
DapperTypeMapRegistrar.RegisterFor(typeof(T));
}
public async Task<int> SaveAsync<T>(T entity)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
using var tx = conn.BeginTransaction();
try
{
await ResolveRelationsAsync(entity, conn, tx);
int id;
if (GetId(entity) == 0)
id = await InsertAsync(entity, conn, tx);
else
{
await UpdateAsync(entity, conn, tx);
id = GetId(entity);
}
tx.Commit();
return id;
}
catch
{
tx.Rollback();
throw;
}
}
public async Task<IReadOnlyList<int>> SaveAsyncList<T>(IEnumerable<T> entities)
{
if (entities == null)
throw new ArgumentNullException(nameof(entities));
var list = entities as IList<T> ?? entities.ToList();
if (list.Count == 0)
return Array.Empty<int>();
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
using var tx = conn.BeginTransaction();
try
{
var ids = new List<int>(list.Count);
foreach (var entity in list)
{
if (entity == null)
throw new OrmException("La lista contiene una entidad nula");
await ResolveRelationsAsync(entity, conn, tx);
int id;
if (GetId(entity) == 0)
{
id = await InsertAsync(entity, conn, tx);
SetId(entity, id); // MUY IMPORTANTE
}
else
{
await UpdateAsync(entity, conn, tx);
id = GetId(entity);
}
ids.Add(id);
}
tx.Commit();
return ids;
}
catch
{
tx.Rollback();
throw;
}
}
public async Task<int> InsertAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
bool ownsConnection = connection == null;
bool ownsTransaction = transaction == null;
IDbConnection? conn = connection;
IDbTransaction? tx = transaction;
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var props = OrmMetadata.GetScaffoldProperties<T>().Where(p => !OrmMetadata.IsIdentity(p)).ToArray();
if (!props.Any())
throw new OrmException($"No hay columnas insertables para {typeof(T).Name}");
var columns = props.Select(p => OrmMetadata.GetColumnName(p));
var parameters = props.Select(p => "@" + p.Name);
var sql = $@"
INSERT INTO {table}
({string.Join(", ", columns)})
VALUES ({string.Join(", ", parameters)})
RETURNING {OrmMetadata.GetColumnName(key)};
";
if (conn == null)
{
conn = await _connectionFactory();
await conn.EnsureOpenAsync();
}
if (tx == null)
{
tx = conn.BeginTransaction();
}
var id = await conn.ExecuteScalarAsync<int>(
sql,
entity,
tx
);
if (ownsTransaction)
tx.Commit();
return id;
}
catch (Exception ex)
{
if (ownsTransaction)
try { tx?.Rollback(); } catch { /* swallow */ }
throw new OrmException(
$"Error INSERT en {typeof(T).Name}",
ex
);
}
finally
{
if (ownsTransaction)
tx?.Dispose();
if (ownsConnection)
conn?.Dispose();
}
}
public async Task<bool> UpdateAsync<T>(T entity, IDbConnection? connection = null, IDbTransaction? transaction = null)
{
if (entity == null)
throw new ArgumentNullException(nameof(entity));
bool ownsConnection = connection == null;
bool ownsTransaction = transaction == null;
IDbConnection? conn = connection;
IDbTransaction? tx = transaction;
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var keyValue = key.GetValue(entity);
if (keyValue == null)
throw new OrmException($"La entidad {typeof(T).Name} no tiene valor en la clave primaria");
var props = OrmMetadata.GetScaffoldProperties<T>().Where(p => !OrmMetadata.IsIdentity(p)).ToArray();
if (!props.Any())
return false;
var sets = props.Select(p =>
$"{OrmMetadata.GetColumnName(p)} = @{p.Name}");
var sql = $@"
UPDATE {table}
SET {string.Join(", ", sets)}
WHERE {OrmMetadata.GetColumnName(key)} = @{key.Name};
";
if (conn == null)
{
conn = await _connectionFactory();
await conn.EnsureOpenAsync();
}
if (tx == null)
{
tx = conn.BeginTransaction();
}
var affected = await conn.ExecuteAsync(
sql,
entity,
tx
);
if (ownsTransaction)
tx.Commit();
return affected > 0;
}
catch (Exception ex)
{
if (ownsTransaction)
try { tx?.Rollback(); } catch { /* ignore */ }
throw new OrmException(
$"Error UPDATE en {typeof(T).Name}",
ex
);
}
finally
{
if (ownsTransaction)
tx?.Dispose();
if (ownsConnection)
conn?.Dispose();
}
}
public async Task<T?> GetByIdAsync(object id)
{
if (id == null)
throw new ArgumentNullException(nameof(id));
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var sql = $@"
SELECT *
FROM {table}
WHERE {OrmMetadata.GetColumnName(key)} = @Id;
";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryFirstOrDefaultAsync<T>(sql, new { Id = id });
}
catch (Exception ex)
{
throw new OrmException(
$"Error SELECT por ID en {typeof(T).Name}",
ex
);
}
}
public async Task<IEnumerable<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null, Expression<Func<T, object>>? orderBy = null, bool descending = false)
{
try
{
var table = OrmMetadata.GetTableName<T>();
var parameters = new DynamicParameters();
var sql = $"SELECT * FROM {table}";
// WHERE dinámico
if (filter != null)
{
var where = ExpressionToSqlTranslator.Translate(filter, parameters);
sql += $" WHERE {where}";
}
// ORDER BY dinámico
if (orderBy != null)
{
var column = ExpressionToSqlTranslator.GetOrderByColumn(orderBy);
sql += $" ORDER BY {column} {(descending ? "DESC" : "ASC")}";
}
sql += ";";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryAsync<T>(sql, parameters);
}
catch (Exception ex)
{
throw new OrmException(
$"Error SELECT dinámico en {typeof(T).Name}",
ex
);
}
}
public async Task<bool> DeleteAsync(object id)
{
if (id == null)
throw new ArgumentNullException(nameof(id));
try
{
var table = OrmMetadata.GetTableName<T>();
var key = OrmMetadata.GetKeyProperty<T>();
var sql = $@"
DELETE FROM {table}
WHERE {OrmMetadata.GetColumnName(key)} = @Id;
";
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.ExecuteAsync(sql, new { Id = id }) > 0;
}
catch (Exception ex)
{
throw new OrmException(
$"Error DELETE en {typeof(T).Name}",
ex
);
}
}
public async Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryFirstOrDefaultAsync<T>(
sql,
parameters
);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en QuerySingle<{typeof(T).Name}>",
ex
);
}
}
public async Task<IEnumerable<T>> QueryAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.QueryAsync<T>(sql, parameters);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en Query<{typeof(T).Name}>",
ex
);
}
}
public async Task<T?> ExecuteScalarAsync<T>(string sql, object? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
throw new ArgumentException("SQL inválido", nameof(sql));
try
{
using var conn = await _connectionFactory();
await conn.EnsureOpenAsync();
return await conn.ExecuteScalarAsync<T>(
sql,
parameters
);
}
catch (Exception ex)
{
throw new OrmException(
$"Error en ExecuteScalar<{typeof(T).Name}>",
ex
);
}
}
private async Task ResolveRelationsAsync<T>(
T entity,
IDbConnection conn,
IDbTransaction tx)
{
var navProps = typeof(T)
.GetProperties()
.Where(p =>
p.PropertyType.IsClass &&
p.PropertyType != typeof(string) &&
p.GetCustomAttribute<NotMappedAttribute>() == null);
foreach (var nav in navProps)
{
var related = nav.GetValue(entity);
if (related == null)
continue;
var relatedType = nav.PropertyType;
var relatedId = GetId(related);
if (relatedId == 0)
relatedId = await InsertDynamicAsync(related, conn, tx);
else
await UpdateDynamicAsync(related, conn, tx);
SetForeignKey(entity, nav, relatedId);
}
}
private void SetForeignKey<T>(
T entity,
PropertyInfo navProp,
int fkValue)
{
var fkAttr = navProp.GetCustomAttribute<ForeignKeyAttribute>();
if (fkAttr != null)
{
var fkProp = typeof(T).GetProperty(fkAttr.Name);
fkProp?.SetValue(entity, fkValue);
return;
}
var fkName = navProp.Name + "Id";
var prop = typeof(T).GetProperty(fkName);
if (prop == null)
throw new OrmException($"No se pudo resolver FK para {navProp.Name}");
prop.SetValue(entity, fkValue);
}
private async Task<int> InsertDynamicAsync(
object entity,
IDbConnection conn,
IDbTransaction tx)
{
var method = typeof(Repository<>)
.GetMethod(nameof(InsertAsync))!
.MakeGenericMethod(entity.GetType());
return await (Task<int>)method.Invoke(this, new[] { entity, conn, tx })!;
}
private async Task<int> UpdateDynamicAsync(
object entity,
IDbConnection conn,
IDbTransaction tx)
{
var method = typeof(Repository<>)
.GetMethod(nameof(UpdateAsync))!
.MakeGenericMethod(entity.GetType());
return await (Task<int>)method.Invoke(this, new[] { entity, conn, tx })!;
}
private static int GetId(object entity)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
var type = entity.GetType();
var keyProp = type.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (keyProp == null)
{
keyProp = type.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? type.GetProperty(type.Name + "Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
if (keyProp == null)
throw new OrmException($"No se encontró la propiedad clave (Key/Id/{type.Name}Id) en el tipo {type.FullName}");
var value = keyProp.GetValue(entity);
if (value == null) return 0;
if (value is int i) return i;
if (value is long l)
{
if (l > int.MaxValue || l < int.MinValue)
throw new OrmException($"El valor de la clave primaria ({l}) excede el rango de int para {type.FullName}");
return (int)l;
}
try
{
var converted = Convert.ToInt32(value);
return converted;
}
catch (Exception)
{
if (value is Guid)
throw new OrmException($"La clave primaria de {type.FullName} es un GUID; el flujo actual espera claves numéricas. Ajusta el método si deseas soportar GUIDs.");
throw new OrmException($"No fue posible convertir la clave primaria de {type.FullName} (tipo {value.GetType().Name}) a int.");
}
}
private static void SetId(object entity, int id)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
var type = entity.GetType();
// Buscar la propiedad clave (igual que en GetId)
var keyProp = type.GetProperties()
.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>() != null);
if (keyProp == null)
{
keyProp = type.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? type.GetProperty(type.Name + "Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
}
if (keyProp == null)
throw new OrmException($"No se encontró la propiedad clave (Key/Id/{type.Name}Id) en el tipo {type.FullName}");
if (!keyProp.CanWrite)
throw new OrmException($"La propiedad clave {keyProp.Name} en {type.FullName} no tiene setter público.");
var targetType = Nullable.GetUnderlyingType(keyProp.PropertyType) ?? keyProp.PropertyType;
object valueToSet;
if (targetType == typeof(int))
{
valueToSet = id;
}
else if (targetType == typeof(long))
{
valueToSet = (long)id;
}
else if (targetType == typeof(short))
{
valueToSet = (short)id;
}
else if (targetType == typeof(byte))
{
valueToSet = (byte)id;
}
else if (targetType == typeof(Guid))
{
throw new OrmException($"La clave primaria de {type.FullName} es GUID; SetId con int no es aplicable.");
}
else
{
try
{
valueToSet = Convert.ChangeType(id, targetType);
}
catch (Exception ex)
{
throw new OrmException($"No fue posible convertir int a {targetType.Name} para la clave de {type.FullName}.", ex);
}
}
keyProp.SetValue(entity, valueToSet);
}
}

View File

@@ -5,6 +5,8 @@ VisualStudioVersion = 17.14.36327.8 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MieSystem", "MieSystem\MieSystem.csproj", "{5D8C00CA-A04E-4414-AC79-195A25059557}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroORM", "MicroORM\MicroORM.csproj", "{43F35A16-C2FC-48D5-B2E9-5432CEDFD451}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +17,10 @@ Global
{5D8C00CA-A04E-4414-AC79-195A25059557}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5D8C00CA-A04E-4414-AC79-195A25059557}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5D8C00CA-A04E-4414-AC79-195A25059557}.Release|Any CPU.Build.0 = Release|Any CPU
{43F35A16-C2FC-48D5-B2E9-5432CEDFD451}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{43F35A16-C2FC-48D5-B2E9-5432CEDFD451}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43F35A16-C2FC-48D5-B2E9-5432CEDFD451}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43F35A16-C2FC-48D5-B2E9-5432CEDFD451}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -1,12 +1,250 @@
using Microsoft.AspNetCore.Mvc;
using MieSystem.Models;
using MieSystem.Models.ViewModels;
using MieSystem.Services;
namespace MieSystem.Controllers
{
public class AsistenciaController : Controller
{
public IActionResult Index()
private readonly ExpedienteService expedienteService;
private readonly AsistenciaService asistenciaService;
public AsistenciaController(ExpedienteService expedienteService, AsistenciaService asistenciaService)
{
return View();
this.expedienteService = expedienteService;
this.asistenciaService = asistenciaService;
}
public async Task<IActionResult> Index(int? año, int? mes, string diasSemana = null)
{
var fechaActual = DateTime.Now;
var añoSeleccionado = año ?? fechaActual.Year;
var mesSeleccionado = mes ?? fechaActual.Month;
var expedientes = await expedienteService.GetActivosAsync();
var diasDelMes = GetDiasDelMes(añoSeleccionado, mesSeleccionado);
if (!string.IsNullOrEmpty(diasSemana))
{
var diasFiltro = diasSemana.Split(',')
.Select(d => int.Parse(d))
.ToList();
diasDelMes = diasDelMes.Where(d => diasFiltro.Contains((int)d.DayOfWeek)).ToList();
}
// Obtener asistencias para el mes
var asistencias = await asistenciaService.GetAsistenciasPorMesAsync(
añoSeleccionado, mesSeleccionado);
// Crear diccionario para acceso rápido
var dictAsistencias = new Dictionary<string, string>();
foreach (var asistencia in asistencias)
{
var key = $"{asistencia.ExpedienteId}_{asistencia.Fecha:yyyy-MM-dd}";
dictAsistencias[key] = asistencia.Estado.ToString();
}
// Crear modelo de vista
var model = new AsistenciaViewModel
{
Año = añoSeleccionado,
Mes = mesSeleccionado,
NombreMes = GetNombreMes(mesSeleccionado),
DiasSemanaSeleccionados = diasSemana,
Expedientes = expedientes.ToList(),
DiasDelMes = diasDelMes,
Asistencias = dictAsistencias
};
ViewBag.Meses = GetListaMeses();
ViewBag.Años = GetListaAños();
ViewBag.DiasSemana = GetDiasSemana();
return View(model);
}
[HttpPost]
public async Task<IActionResult> GuardarAsistencia(int expedienteId, string fecha, string estado)
{
try
{
if (!DateTime.TryParse(fecha, out DateTime fechaDate))
{
return Json(new { success = false, message = "Fecha inválida" });
}
var asistencia = new Asistencia
{
ExpedienteId = expedienteId,
Fecha = fechaDate,
Estado = estado[0],
UsuarioRegistro = User.Identity?.Name ?? "Sistema"
};
var resultado = await asistenciaService.SaveAsync(asistencia);
return Json(new { success = resultado, message = "Asistencia guardada" });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
[HttpPost]
public async Task<IActionResult> GuardarAsistenciasMasivas([FromBody] List<AsistenciaMasivaDto> asistencias)
{
try
{
var asistenciasModel = new List<Asistencia>();
foreach (var asistenciaDto in asistencias)
{
var asistencia = new Asistencia
{
ExpedienteId = asistenciaDto.ExpedienteId,
Fecha = asistenciaDto.Fecha,
Estado = asistenciaDto.Estado[0],
UsuarioRegistro = User.Identity?.Name ?? "Sistema"
};
asistenciasModel.Add(asistencia);
}
var resultado = asistenciaService.GuardarAsistenciasMasivasAsync(asistenciasModel);
return Json(new
{
success = resultado,
message = resultado ? "Asistencias guardadas correctamente" : "Error al guardar asistencias"
});
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> ObtenerEstadisticas(int año, int mes)
{
try
{
var estadisticas = await asistenciaService.GetEstadisticasMesAsync(año, mes);
return Json(estadisticas);
}
catch (Exception ex)
{
return Json(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> ExportarExcel(int año, int mes, string diasSemana = null)
{
// Implementar exportación a Excel
// Por ahora solo redirige
return Content($"Exportar Excel: Año={año}, Mes={mes}, Días={diasSemana}");
}
#region Métodos auxiliares privados
private List<DateTime> GetDiasDelMes(int año, int mes)
{
var dias = new List<DateTime>();
var fecha = new DateTime(año, mes, 1);
var ultimoDia = fecha.AddMonths(1).AddDays(-1);
for (var dia = fecha; dia <= ultimoDia; dia = dia.AddDays(1))
{
dias.Add(dia);
}
return dias;
}
private string GetNombreMes(int mes)
{
return mes switch
{
1 => "Enero",
2 => "Febrero",
3 => "Marzo",
4 => "Abril",
5 => "Mayo",
6 => "Junio",
7 => "Julio",
8 => "Agosto",
9 => "Septiembre",
10 => "Octubre",
11 => "Noviembre",
12 => "Diciembre",
_ => "Mes inválido"
};
}
private List<SelectListItem> GetListaMeses()
{
return new List<SelectListItem>
{
new SelectListItem { Value = "1", Text = "Enero" },
new SelectListItem { Value = "2", Text = "Febrero" },
new SelectListItem { Value = "3", Text = "Marzo" },
new SelectListItem { Value = "4", Text = "Abril" },
new SelectListItem { Value = "5", Text = "Mayo" },
new SelectListItem { Value = "6", Text = "Junio" },
new SelectListItem { Value = "7", Text = "Julio" },
new SelectListItem { Value = "8", Text = "Agosto" },
new SelectListItem { Value = "9", Text = "Septiembre" },
new SelectListItem { Value = "10", Text = "Octubre" },
new SelectListItem { Value = "11", Text = "Noviembre" },
new SelectListItem { Value = "12", Text = "Diciembre" }
};
}
private List<SelectListItem> GetListaAños()
{
var años = new List<SelectListItem>();
var añoActual = DateTime.Now.Year;
for (int i = añoActual - 5; i <= añoActual + 1; i++)
{
años.Add(new SelectListItem
{
Value = i.ToString(),
Text = i.ToString(),
Selected = i == añoActual
});
}
return años;
}
private List<SelectListItem> GetDiasSemana()
{
return new List<SelectListItem>
{
new SelectListItem { Value = "1", Text = "Lunes" },
new SelectListItem { Value = "2", Text = "Martes" },
new SelectListItem { Value = "3", Text = "Miércoles" },
new SelectListItem { Value = "4", Text = "Jueves" },
new SelectListItem { Value = "5", Text = "Viernes" },
new SelectListItem { Value = "6", Text = "Sábado" },
new SelectListItem { Value = "0", Text = "Domingo" }
};
}
#endregion
}
#region Modelos de vista
public class SelectListItem
{
public string Value { get; set; }
public string Text { get; set; }
public bool Selected { get; set; }
}
#endregion
}

View File

@@ -1,128 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using System.Linq;
using System.Threading.Tasks;
using MieSystem.Models;
using System.Collections.Generic;
using System;
using System.IO;
using Microsoft.AspNetCore.Hosting;
using MieSystem.Models;
using MieSystem.Data.Interfaces;
using MieSystem.Models.ViewModels;
using MieSystem.Services;
namespace MieSystem.Controllers
{
public class ExpedientesController : Controller
{
private static List<Expediente> _expedientes = new List<Expediente>();
private static int _idCounter = 1;
//private static int _idCounter = 1;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly ExpedienteService expedienteService;
private readonly IExpedienteRepository _expedienteRepository;
public ExpedientesController(IExpedienteRepository expedienteRepository, IWebHostEnvironment hostingEnvironment)
public ExpedientesController(ExpedienteService expedienteService, IWebHostEnvironment hostingEnvironment)
{
_hostingEnvironment = hostingEnvironment;
_expedienteRepository = expedienteRepository;
// Datos de prueba iniciales
if (_expedientes.Count == 0)
{
_ = InitializeTestData();
}
}
private async Task InitializeTestData()
{
var today = DateTime.Today;
var lst = await _expedienteRepository.GetAllAsync();
_expedientes =new List<Expediente>(lst);
/*_expedientes.Add(new Expediente
{
Id = _idCounter++,
Nombre = "Juan",
Apellidos = "Pérez García",
FechaNacimiento = new DateTime(2015, 5, 10),
NombrePadre = "Carlos Pérez",
NombreMadre = "María García",
NombreResponsable = "Carlos Pérez",
ParentescoResponsable = "Padre",
Sexo = "M",
Direccion = "Calle Principal 123, Ciudad",
Telefono = "555-1234",
Observaciones = "Niño tranquilo",
FotoUrl = "/images/default-avatar.png",
FechaCreacion = DateTime.Now.AddDays(-30),
FechaActualizacion = DateTime.Now.AddDays(-30),
Activo = true
});
_expedientes.Add(new Expediente
{
Id = _idCounter++,
Nombre = "Ana",
Apellidos = "López Martínez",
FechaNacimiento = new DateTime(2016, today.Month, 15), // Cumple este mes
NombrePadre = "Pedro López",
NombreMadre = "Laura Martínez",
NombreResponsable = "Laura Martínez",
ParentescoResponsable = "Madre",
Sexo = "F",
Direccion = "Avenida Central 456, Ciudad",
Telefono = "555-5678",
Observaciones = "Alergia a los frutos secos",
FotoUrl = "/images/default-avatar.png",
FechaCreacion = DateTime.Now.AddDays(-15),
FechaActualizacion = DateTime.Now.AddDays(-15),
Activo = true
});
_expedientes.Add(new Expediente
{
Id = _idCounter++,
Nombre = "Carlos",
Apellidos = "Rodríguez Sánchez",
FechaNacimiento = new DateTime(2008, 8, 20), // Mayor de 14 años
NombrePadre = "Javier Rodríguez",
NombreMadre = "Carmen Sánchez",
NombreResponsable = "Javier Rodríguez",
ParentescoResponsable = "Padre",
Sexo = "M",
Direccion = "Plaza Mayor 789, Ciudad",
Telefono = "555-9012",
Observaciones = "Excelente estudiante",
FotoUrl = "/images/default-avatar.png",
FechaCreacion = DateTime.Now.AddDays(-60),
FechaActualizacion = DateTime.Now.AddDays(-60),
Activo = true
});
_expedientes.Add(new Expediente
{
Id = _idCounter++,
Nombre = "María",
Apellidos = "Gómez Fernández",
FechaNacimiento = new DateTime(2017, 11, 5),
NombrePadre = "Luis Gómez",
NombreMadre = "Sofía Fernández",
NombreResponsable = "Sofía Fernández",
ParentescoResponsable = "Madre",
Sexo = "F",
Direccion = "Calle Secundaria 101, Ciudad",
Telefono = "555-3456",
Observaciones = "Necesita atención especial en matemáticas",
FotoUrl = "/images/default-avatar.png",
FechaCreacion = DateTime.Now.AddDays(-45),
FechaActualizacion = DateTime.Now.AddDays(-45),
Activo = true
});*/
this.expedienteService = expedienteService;
}
// GET: Expedientes
public IActionResult Index()
public async Task<IActionResult> Index()
{
_ = InitializeTestData();
var lst = await expedienteService.GetAllAsync();
_expedientes = [.. lst];
return View();
}
@@ -181,7 +81,7 @@ namespace MieSystem.Controllers
FechaNacimiento = e.FechaNacimiento,
Sexo = e.Sexo,
NombreResponsable = e.NombreResponsable,
FotoUrl = e.FotoUrl
FotoUrl = ExisteFoto(e.FotoUrl)
})
.ToList();
@@ -203,7 +103,6 @@ namespace MieSystem.Controllers
// POST: Expedientes/Create
[HttpPost]
// [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ExpedienteViewModel model)
{
@@ -239,7 +138,6 @@ namespace MieSystem.Controllers
// Crear nuevo expediente
var expediente = new Expediente
{
Id = _idCounter++,
Nombre = model.Nombre,
Apellidos = model.Apellidos,
FechaNacimiento = model.FechaNacimiento,
@@ -259,7 +157,8 @@ namespace MieSystem.Controllers
try
{
await _expedienteRepository.CreateAsync(expediente);
//await _expedienteRepository.CreateAsync(expediente);
await expedienteService.SaveAsync(expediente);
return Json(new { success = true, message = "Expediente creado exitosamente" });
}
catch (Exception ex)
@@ -316,7 +215,9 @@ namespace MieSystem.Controllers
ModelState.Remove("Observaciones");
if (ModelState.IsValid)
{
var expediente = _expedientes.FirstOrDefault(e => e.Id == id);
//var expediente = await _expedienteRepository.GetByIdAsync(id);
var expediente = await expedienteService.GetByIdAsync(id);
//var expediente = _expedientes.FirstOrDefault(e => e.Id == id);
if (expediente == null)
{
return Json(new { success = false, message = "Expediente no encontrado" });
@@ -359,6 +260,7 @@ namespace MieSystem.Controllers
}
// Actualizar datos
expediente.Id = id;
expediente.Nombre = model.Nombre;
expediente.Apellidos = model.Apellidos;
expediente.FechaNacimiento = model.FechaNacimiento;
@@ -373,14 +275,24 @@ namespace MieSystem.Controllers
expediente.FotoUrl = fotoUrlActual;
expediente.FechaActualizacion = DateTime.Now;
return Json(new { success = true, message = "Expediente actualizado exitosamente" });
try
{
//await _expedienteRepository.UpdateAsync(expediente);
await expedienteService.SaveAsync(expediente);
return Json(new { success = true, message = "Expediente actualizado exitosamente" });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
return Json(new { success = false, message = "Error en los datos del formulario" });
}
// DELETE: Expedientes/Delete/5
[HttpDelete]
/*[HttpDelete]
public async Task<IActionResult> Delete(int id)
{
var expediente = _expedientes.FirstOrDefault(e => e.Id == id);
@@ -406,7 +318,7 @@ namespace MieSystem.Controllers
return Json(new { success = true, message = "Expediente eliminado exitosamente" });
}
*/
// GET: Expedientes/Details/5 (Opcional)
public IActionResult Details(int id)
{
@@ -498,6 +410,23 @@ namespace MieSystem.Controllers
}
}
public string ExisteFoto(string url)
{
if(string.IsNullOrEmpty(url))
return Path.Combine("images", "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 Path.Combine("images", "default-avatar.png");
}
// GET: Expedientes/DeleteImage
[HttpDelete]
public IActionResult DeleteImage(string imageUrl)

View File

@@ -0,0 +1,22 @@
using MieSystem.Models;
namespace MieSystem.Data.Interfaces
{
public interface IAsistenciaRepository
{
// CRUD básico
Task<Asistencia> GetByIdAsync(int id);
Task<IEnumerable<Asistencia>> GetByExpedienteAsync(int expedienteId, DateTime? fechaDesde = null, DateTime? fechaHasta = null);
Task<IEnumerable<Asistencia>> GetAsistenciasPorMesAsync(int año, int mes);
Task<bool> GuardarAsistenciaAsync(Asistencia asistencia);
Task<bool> EliminarAsistenciaAsync(int id);
// Estadísticas
Task<EstadisticasMes> GetEstadisticasMesAsync(int año, int mes);
Task<Dictionary<int, decimal>> GetPorcentajesAsistenciaAsync(int año, int mes);
// Operaciones masivas
Task<bool> GuardarAsistenciasMasivasAsync(IEnumerable<Asistencia> asistencias);
Task<bool> EliminarAsistenciasPorFechaAsync(DateTime fecha);
}
}

View File

@@ -9,5 +9,6 @@ namespace MieSystem.Data.Interfaces
Task<int> CreateAsync(Expediente expediente);
Task<bool> UpdateAsync(Expediente expediente);
Task<bool> DeleteAsync(int id);
Task<IEnumerable<Expediente?>> GetActivosAsync();
}
}

View File

@@ -0,0 +1,21 @@
using System.Data;
using Npgsql;
namespace MieSystem.Data;
public class NpgsqlConnectionFactory
{
private readonly string _connectionString;
public NpgsqlConnectionFactory(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("PostgreSQL")
?? throw new InvalidOperationException("ConnectionString PostgreSQL no configurada");
}
public Task<IDbConnection> CreateAsync()
{
IDbConnection conn = new NpgsqlConnection(_connectionString);
return Task.FromResult(conn);
}
}

View File

@@ -0,0 +1,442 @@
using Dapper;
using Microsoft.AspNetCore.Connections;
using MieSystem.Data.Interfaces;
using MieSystem.Models;
namespace MieSystem.Data.Repositories
{
public class AsistenciaRepository : IAsistenciaRepository
{
private readonly IDatabaseConnectionFactory _connectionFactory;
public AsistenciaRepository(IDatabaseConnectionFactory databaseConnectionFactory)
{
_connectionFactory = databaseConnectionFactory;
}
#region CRUD Básico
public async Task<Asistencia?> GetByIdAsync(int id)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
WHERE id = @Id";
return await connection.QueryFirstOrDefaultAsync<Asistencia>(sql, new { Id = id });
}
public async Task<IEnumerable<Asistencia?>> GetAllAsync()
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
ORDER BY fecha DESC, expediente_id";
return await connection.QueryAsync<Asistencia>(sql);
}
public async Task<int> CreateAsync(Asistencia asistencia)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
INSERT INTO asistencia (
expediente_id,
fecha,
estado,
hora_entrada,
hora_salida,
observaciones,
usuario_registro
) VALUES (
@ExpedienteId,
@Fecha,
@Estado,
@HoraEntrada,
@HoraSalida,
@Observaciones,
@UsuarioRegistro
)
RETURNING id";
var parameters = new
{
asistencia.ExpedienteId,
Fecha = asistencia.Fecha.Date,
asistencia.Estado,
asistencia.HoraEntrada,
asistencia.HoraSalida,
asistencia.Observaciones,
asistencia.UsuarioRegistro
};
return await connection.ExecuteScalarAsync<int>(sql, parameters);
}
public async Task<bool> UpdateAsync(Asistencia asistencia)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
UPDATE asistencia SET
estado = @Estado,
hora_entrada = @HoraEntrada,
hora_salida = @HoraSalida,
observaciones = @Observaciones,
usuario_registro = @UsuarioRegistro
WHERE id = @Id";
var parameters = new
{
asistencia.Id,
asistencia.Estado,
asistencia.HoraEntrada,
asistencia.HoraSalida,
asistencia.Observaciones,
asistencia.UsuarioRegistro
};
var affectedRows = await connection.ExecuteAsync(sql, parameters);
return affectedRows > 0;
}
public async Task<bool> DeleteAsync(int id)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = "DELETE FROM asistencia WHERE id = @Id";
var affected = await connection.ExecuteAsync(sql, new { Id = id });
return affected > 0;
}
#endregion
#region Métodos específicos de la interfaz
public async Task<bool> GuardarAsistenciaAsync(Asistencia asistencia)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
// Usar UPSERT (INSERT ... ON CONFLICT ... UPDATE)
var sql = @"
INSERT INTO asistencia (
expediente_id,
fecha,
estado,
hora_entrada,
hora_salida,
observaciones,
usuario_registro
) VALUES (
@ExpedienteId,
@Fecha,
@Estado,
@HoraEntrada,
@HoraSalida,
@Observaciones,
@UsuarioRegistro
)
ON CONFLICT (expediente_id, fecha)
DO UPDATE SET
estado = EXCLUDED.estado,
hora_entrada = EXCLUDED.hora_entrada,
hora_salida = EXCLUDED.hora_salida,
observaciones = EXCLUDED.observaciones,
usuario_registro = EXCLUDED.usuario_registro,
fecha_registro = CURRENT_TIMESTAMP
RETURNING id";
var parameters = new
{
asistencia.ExpedienteId,
Fecha = asistencia.Fecha.Date,
asistencia.Estado,
asistencia.HoraEntrada,
asistencia.HoraSalida,
asistencia.Observaciones,
asistencia.UsuarioRegistro
};
var result = await connection.ExecuteScalarAsync<int?>(sql, parameters);
return result.HasValue;
}
public async Task<IEnumerable<Asistencia>> GetByExpedienteAsync(int expedienteId, DateTime? fechaDesde = null, DateTime? fechaHasta = null)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
WHERE expediente_id = @ExpedienteId";
var parameters = new DynamicParameters();
parameters.Add("ExpedienteId", expedienteId);
if (fechaDesde.HasValue)
{
sql += " AND fecha >= @FechaDesde";
parameters.Add("FechaDesde", fechaDesde.Value.Date);
}
if (fechaHasta.HasValue)
{
sql += " AND fecha <= @FechaHasta";
parameters.Add("FechaHasta", fechaHasta.Value.Date);
}
sql += " ORDER BY fecha DESC";
return await connection.QueryAsync<Asistencia>(sql, parameters);
}
public async Task<IEnumerable<Asistencia>> GetAsistenciasPorMesAsync(int año, int mes)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
WHERE EXTRACT(YEAR FROM fecha) = @Año
AND EXTRACT(MONTH FROM fecha) = @Mes
ORDER BY fecha, expediente_id";
return await connection.QueryAsync<Asistencia>(sql, new { Año = año, Mes = mes });
}
public async Task<bool> GuardarAsistenciasMasivasAsync(IEnumerable<Asistencia> asistencias)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
INSERT INTO asistencia (
expediente_id,
fecha,
estado,
hora_entrada,
hora_salida,
observaciones,
usuario_registro
) VALUES (
@ExpedienteId,
@Fecha,
@Estado,
@HoraEntrada,
@HoraSalida,
@Observaciones,
@UsuarioRegistro
)
ON CONFLICT (expediente_id, fecha)
DO UPDATE SET
estado = EXCLUDED.estado,
hora_entrada = EXCLUDED.hora_entrada,
hora_salida = EXCLUDED.hora_salida,
observaciones = EXCLUDED.observaciones,
usuario_registro = EXCLUDED.usuario_registro,
fecha_registro = CURRENT_TIMESTAMP";
var exitosas = 0;
foreach (var asistencia in asistencias)
{
var parameters = new
{
asistencia.ExpedienteId,
Fecha = asistencia.Fecha.Date,
asistencia.Estado,
asistencia.HoraEntrada,
asistencia.HoraSalida,
asistencia.Observaciones,
asistencia.UsuarioRegistro
};
var affected = await connection.ExecuteAsync(sql, parameters);
if (affected > 0) exitosas++;
}
return exitosas > 0;
}
public async Task<bool> EliminarAsistenciaAsync(int id)
{
return await DeleteAsync(id);
}
public async Task<bool> EliminarAsistenciasPorFechaAsync(DateTime fecha)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = "DELETE FROM asistencia WHERE fecha = @Fecha";
var affected = await connection.ExecuteAsync(sql, new { Fecha = fecha.Date });
return affected > 0;
}
public async Task<EstadisticasMes> GetEstadisticasMesAsync(int año, int mes)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
COALESCE(COUNT(*), 0) as Total,
COALESCE(COUNT(CASE WHEN estado = 'P' THEN 1 END), 0) as Presentes,
COALESCE(COUNT(CASE WHEN estado = 'T' THEN 1 END), 0) as Tardes,
COALESCE(COUNT(CASE WHEN estado = 'F' THEN 1 END), 0) as Faltas,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'P' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajePresentes,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'T' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajeTardes,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'F' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajeFaltas
FROM asistencia
WHERE EXTRACT(YEAR FROM fecha) = @Año
AND EXTRACT(MONTH FROM fecha) = @Mes";
var result = await connection.QueryFirstOrDefaultAsync<EstadisticasMes>(sql, new { Año = año, Mes = mes });
// Siempre devolver un objeto, incluso si es nulo
return result ?? new EstadisticasMes();
}
public async Task<Dictionary<int, decimal>> GetPorcentajesAsistenciaAsync(int año, int mes)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
expediente_id,
ROUND(
COUNT(CASE WHEN estado = 'P' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
) as porcentaje_asistencia
FROM asistencia
WHERE EXTRACT(YEAR FROM fecha) = @Año
AND EXTRACT(MONTH FROM fecha) = @Mes
GROUP BY expediente_id";
var resultados = await connection.QueryAsync<(int, decimal)>(sql, new { Año = año, Mes = mes });
return resultados.ToDictionary(
r => r.Item1,
r => r.Item2
);
}
#endregion
#region Métodos adicionales útiles
public async Task<bool> ExisteAsistenciaAsync(int expedienteId, DateTime fecha)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = "SELECT COUNT(1) FROM asistencia WHERE expediente_id = @ExpedienteId AND fecha = @Fecha";
var count = await connection.ExecuteScalarAsync<int>(sql, new
{
ExpedienteId = expedienteId,
Fecha = fecha.Date
});
return count > 0;
}
public async Task<IEnumerable<Asistencia>> GetAsistenciasPorRangoAsync(DateTime fechaDesde, DateTime fechaHasta)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
WHERE fecha BETWEEN @FechaDesde AND @FechaHasta
ORDER BY fecha, expediente_id";
return await connection.QueryAsync<Asistencia>(sql, new
{
FechaDesde = fechaDesde.Date,
FechaHasta = fechaHasta.Date
});
}
public async Task<int> GetTotalAsistenciasPorExpedienteAsync(int expedienteId)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = "SELECT COUNT(*) FROM asistencia WHERE expediente_id = @ExpedienteId";
return await connection.ExecuteScalarAsync<int>(sql, new { ExpedienteId = expedienteId });
}
public async Task<int> GetAsistenciasPresentesPorExpedienteAsync(int expedienteId)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
var sql = "SELECT COUNT(*) FROM asistencia WHERE expediente_id = @ExpedienteId AND estado = 'P'";
return await connection.ExecuteScalarAsync<int>(sql, new { ExpedienteId = expedienteId });
}
#endregion
}
}

View File

@@ -1,5 +1,4 @@
using Dapper;
using MieSystem.Data.Interfaces;
using MieSystem.Data.Interfaces;
using Npgsql;
using System.Data;
@@ -18,7 +17,6 @@ namespace MieSystem.Data.Repositories
// Mapear tipos de PostgreSQL a .NET
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
NpgsqlConnection.GlobalTypeMapper.UseJsonNet();
}
public IDbConnection CreateConnection()

View File

@@ -15,12 +15,6 @@ namespace MieSystem.Data.Repositories
_connectionFactory = databaseConnectionFactory;
}
/*public async Task<int> CreateAsync(Expediente expediente)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
return await connection.InsertAsync(expediente);
}*/
public async Task<int> CreateAsync(Expediente expediente)
{
using var connection = await _connectionFactory.CreateConnectionAsync();
@@ -92,34 +86,27 @@ namespace MieSystem.Data.Repositories
return affected > 0;
}
public async Task<IEnumerable<Expediente?>> GetActivosAsync()
{
using var connection = await _connectionFactory.CreateConnectionAsync();
if (connection.State == System.Data.ConnectionState.Open)
{
var result = await connection.QueryAsync<Expediente?>(@"SELECT * FROM expedientes where activo = True ORDER BY nombre");
return result;
}
else
{
System.Diagnostics.Debug.WriteLine("Estado de la conexion es:" + connection.State);
return null;
}
}
public async Task<IEnumerable<Expediente?>> GetAllAsync()
{
using var connection = await _connectionFactory.CreateConnectionAsync();
if (connection.State == System.Data.ConnectionState.Open) {
/*var result = await connection.QueryAsync<Expediente>(
@"SELECT
id,
nombre,
apellidos,
fecha_nacimiento::timestamp as fecha_nacimiento, -- Convertir a timestamp
sexo,
nombre_padre,
nombre_madre,
nombre_responsable,
parentesco_responsable,
direccion,
telefono,
observaciones,
foto_url,
fecha_creacion,
fecha_actualizacion,
activo
FROM expedientes
ORDER BY nombre"
);*/
var result = await connection.QueryAsync<Expediente>(
@"SELECT *
FROM expedientes
@@ -148,8 +135,44 @@ namespace MieSystem.Data.Repositories
{
using var connection = await _connectionFactory.CreateConnectionAsync();
// Con Dapper.Contrib
return await connection.UpdateAsync(expediente);
var sql = @"
UPDATE expedientes SET
nombre = @Nombre,
apellidos = @Apellidos,
fecha_nacimiento = @FechaNacimiento,
sexo = @Sexo,
nombre_padre = @NombrePadre,
nombre_madre = @NombreMadre,
nombre_responsable = @NombreResponsable,
parentesco_responsable = @ParentescoResponsable,
direccion = @Direccion,
telefono = @Telefono,
observaciones = @Observaciones,
foto_url = @FotoUrl,
fecha_actualizacion = CURRENT_TIMESTAMP,
activo = @Activo
WHERE id = @Id";
var parameters = new
{
expediente.Id,
expediente.Nombre,
expediente.Apellidos,
FechaNacimiento = expediente.FechaNacimiento.Date, // Solo fecha
expediente.Sexo,
expediente.NombrePadre,
expediente.NombreMadre,
expediente.NombreResponsable,
expediente.ParentescoResponsable,
expediente.Direccion,
expediente.Telefono,
expediente.Observaciones,
expediente.FotoUrl,
expediente.Activo
};
var affectedRows = await connection.ExecuteAsync(sql, parameters);
return affectedRows > 0;
}
}
}

View File

@@ -12,6 +12,7 @@
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dapper.Contrib" Version="2.0.78" />
<PackageReference Include="Dapper.SqlBuilder" Version="2.1.66" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
@@ -21,7 +22,12 @@
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\lib\bootstrap\dist\css\fonts\" />
<Folder Include="wwwroot\uploads\fotos\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MicroORM\MicroORM.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MieSystem.Models
{
[Table("asistencia", Schema = "public")]
public class Asistencia
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("expediente_id")]
public int ExpedienteId { get; set; }
[Required]
[Column("fecha")]
public DateTime Fecha { get; set; }
[Required]
[Column("estado")]
public char Estado { get; set; } // P, T, F
[Column("hora_entrada")]
public TimeSpan? HoraEntrada { get; set; }
[Column("hora_salida")]
public TimeSpan? HoraSalida { get; set; }
[Column("observaciones")]
public string? Observaciones { get; set; }
[Required]
[Column("fecha_registro")]
public DateTime FechaRegistro { get; set; }
[StringLength(100)]
[Column("usuario_registro")]
public string? UsuarioRegistro { get; set; }
// ==========================
// PROPIEDADES CALCULADAS
// ==========================
[NotMapped]
public string EstadoTexto => Estado switch
{
'P' => "Presente",
'T' => "Tarde",
'F' => "Faltó",
_ => "Desconocido"
};
[NotMapped]
public string ColorEstado => Estado switch
{
'P' => "success",
'T' => "warning",
'F' => "danger",
_ => "secondary"
};
}
}

View File

@@ -0,0 +1,13 @@
namespace MieSystem.Models
{
public class EstadisticasMes
{
public int Total { get; set; }
public int Presentes { get; set; }
public int Tardes { get; set; }
public int Faltas { get; set; }
public decimal PorcentajePresentes { get; set; }
public decimal PorcentajeTardes { get; set; }
public decimal PorcentajeFaltas { get; set; }
}
}

View File

@@ -1,55 +1,125 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MieSystem.Models
{
[Table("expedientes")]
public class Expediente
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("id")]
public int Id { get; set; }
[Required]
[StringLength(100)]
[Column("nombre")]
public string Nombre { get; set; }
public string Nombre { get; set; } = string.Empty;
[Required]
[StringLength(150)]
[Column("apellidos")]
public string Apellidos { get; set; }
public string Apellidos { get; set; } = string.Empty;
[Required]
[Column("fecha_nacimiento")]
public DateTime FechaNacimiento { get; set; }
[Column("nombre_padre")]
public string NombrePadre { get; set; }
[Column("nombre_madre")]
public string NombreMadre { get; set; }
[Column("nombre_responsable")]
public string NombreResponsable { get; set; }
[Column("parentesco_responsable")]
public string ParentescoResponsable { get; set; }
[Required]
[StringLength(1)]
[Column("sexo")]
public string Sexo { get; set; }
public string Sexo { get; set; } = string.Empty;
[StringLength(150)]
[Column("nombre_padre")]
public string? NombrePadre { get; set; }
[StringLength(150)]
[Column("nombre_madre")]
public string? NombreMadre { get; set; }
[StringLength(150)]
[Column("nombre_responsable")]
public string? NombreResponsable { get; set; }
[StringLength(50)]
[Column("parentesco_responsable")]
public string? ParentescoResponsable { get; set; }
[Column("direccion")]
public string Direccion { get; set; }
public string? Direccion { get; set; }
[StringLength(20)]
[Column("telefono")]
public string Telefono { get; set; }
public string? Telefono { get; set; }
[Column("observaciones")]
public string Observaciones { get; set; }
public string? Observaciones { get; set; }
[StringLength(500)]
[Column("foto_url")]
public string FotoUrl { get; set; }
public string? FotoUrl { get; set; }
[Required]
[Column("fecha_creacion")]
public DateTime FechaCreacion { get; set; }
[Required]
[Column("fecha_actualizacion")]
public DateTime FechaActualizacion { get; set; }
[Required]
[Column("activo")]
public bool Activo { get; set; }
// ============================
// 🔹 Propiedades calculadas
// ============================
[NotMapped]
public string NombreCompleto => $"{Nombre} {Apellidos}".Trim();
[NotMapped]
public int Edad
{
get
{
var today = DateTime.Today;
var age = today.Year - FechaNacimiento.Year;
if (FechaNacimiento.Date > today.AddYears(-age))
age--;
return age;
}
}
[NotMapped]
public string EdadConMeses
{
get
{
var today = DateTime.Today;
var age = today.Year - FechaNacimiento.Year;
var months = today.Month - FechaNacimiento.Month;
if (today.Day < FechaNacimiento.Day)
months--;
if (months < 0)
{
age--;
months += 12;
}
return $"{age} años, {months} meses";
}
}
[NotMapped]
public string NombreConEdad => $"{NombreCompleto} ({Edad} años)";
[NotMapped]
public string InformacionBasica => $"{NombreCompleto} | {Edad} años | {Sexo}";
}
}

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MieSystem.Models;
[Table("Personas", Schema = "public")]
public class Persona
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("Id")]
public int Id { get; set; }
[Required]
[StringLength(250)]
[Column("Nombres")]
public string Nombres { get; set; } = string.Empty;
[Required]
[StringLength(250)]
[Column("Apellidos")]
public string Apellidos { get; set; } = string.Empty;
[StringLength(50)]
[Column("DUI")]
public string? Dui { get; set; }
[StringLength(1000)]
[Column("Direccion")]
public string? Direccion { get; set; }
[Required]
[Column("sexo")]
public char Sexo { get; set; }
[Required]
[Column("FechaNacimiento")]
public DateTime FechaNacimiento { get; set; }
[StringLength(1000)]
[Column("FotoUrl")]
public string? FotoUrl { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace MieSystem.Models.ViewModels
{
public class AsistenciaViewModel
{
public int Año { get; set; }
public int Mes { get; set; }
public string NombreMes { get; set; }
public string DiasSemanaSeleccionados { get; set; }
public List<Expediente> Expedientes { get; set; }
public List<DateTime> DiasDelMes { get; set; }
public Dictionary<string, string> Asistencias { get; set; }
}
public class AsistenciaMasivaDto
{
public int ExpedienteId { get; set; }
public DateTime Fecha { get; set; }
public string Estado { get; set; }
}
}

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace MieSystem.Models
namespace MieSystem.Models.ViewModels
{
public class ExpedienteViewModel
{

View File

@@ -1,5 +1,10 @@
using System.Data;
using MicroORM;
using MicroORM.Interfaces;
using MieSystem.Data;
using MieSystem.Data.Interfaces;
using MieSystem.Data.Repositories;
using MieSystem.Services;
namespace MieSystem
{
@@ -10,11 +15,28 @@ namespace MieSystem
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
builder.Services.AddSingleton<IDatabaseConnectionFactory, DatabaseConnectionFactory>();
builder.Services.AddScoped<IExpedienteRepository, ExpedienteRepository>();
/* Esto es lo nuevo */
builder.Services.AddSingleton<NpgsqlConnectionFactory>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Delegate que consume el Repository<T>
builder.Services.AddScoped<Func<Task<IDbConnection>>>(sp =>
{
var factory = sp.GetRequiredService<NpgsqlConnectionFactory>();
return factory.CreateAsync;
});
builder.Services.AddScoped<ExpedienteService>();
builder.Services.AddScoped<AsistenciaService>();
/***********/
//builder.Services.AddScoped<IExpedienteRepository, ExpedienteRepository>();
builder.Services.AddScoped<IAsistenciaRepository, AsistenciaRepository>();
var app = builder.Build();
app.UseStaticFiles();
// Configure the HTTP request pipeline.

View File

@@ -7,7 +7,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5037"
"applicationUrl": "http://localhost:8090"
},
"https": {
"commandName": "Project",
@@ -16,7 +16,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7037;http://localhost:5037"
"applicationUrl": "https://localhost:7037;http://localhost:8090"
},
"Container (Dockerfile)": {
"commandName": "Docker",

View File

@@ -0,0 +1,93 @@
using System.Diagnostics.Tracing;
using MicroORM.Interfaces;
using MieSystem.Models;
namespace MieSystem.Services;
public class AsistenciaService
{
private readonly IRepository<Asistencia> _repo;
public AsistenciaService(IRepository<Asistencia> expe)
{
_repo = expe;
}
public Task<int> SaveAsync(Asistencia expediente)
{
return _repo.SaveAsync(expediente);
}
public Task<Asistencia?> GetByIdAsync(object id)
{
return _repo.GetByIdAsync(id);
}
public Task<IEnumerable<Asistencia>> GetAllAsync()
{
return _repo.GetAllAsync();
}
public Task<IEnumerable<Asistencia>> GetAsistenciasPorMesAsync(int añoSeleccionado, int mesSeleccionado)
{
var sql = @"
SELECT
id,
expediente_id as ExpedienteId,
fecha,
estado,
hora_entrada as HoraEntrada,
hora_salida as HoraSalida,
observaciones,
fecha_registro as FechaRegistro,
usuario_registro as UsuarioRegistro
FROM asistencia
WHERE EXTRACT(YEAR FROM fecha) = @Anio
AND EXTRACT(MONTH FROM fecha) = @Mes
ORDER BY fecha, expediente_id";
return _repo.QueryAsync<Asistencia>(sql, new {Anio = añoSeleccionado, Mes = mesSeleccionado});
}
public Task<EstadisticasMes?> GetEstadisticasMesAsync(int año, int mes)
{
var sql = @"
SELECT
COALESCE(COUNT(*), 0) as Total,
COALESCE(COUNT(CASE WHEN estado = 'P' THEN 1 END), 0) as Presentes,
COALESCE(COUNT(CASE WHEN estado = 'T' THEN 1 END), 0) as Tardes,
COALESCE(COUNT(CASE WHEN estado = 'F' THEN 1 END), 0) as Faltas,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'P' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajePresentes,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'T' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajeTardes,
COALESCE(ROUND(
COUNT(CASE WHEN estado = 'F' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
), 0) as PorcentajeFaltas
FROM asistencia
WHERE EXTRACT(YEAR FROM fecha) = @Anio
AND EXTRACT(MONTH FROM fecha) = @Mes";
return _repo.QuerySingleAsync<EstadisticasMes?>(sql, new { Anio = año, Mes = mes });
}
public bool GuardarAsistenciasMasivasAsync(List<Asistencia> lst)
{
try
{
_repo.SaveAsyncList(lst);
return true;
}
catch (Exception e)
{
Console.WriteLine(e);
return true;
}
}
}

View File

@@ -0,0 +1,35 @@
using MicroORM.Interfaces;
using MieSystem.Models;
namespace MieSystem.Services;
public class ExpedienteService
{
private readonly IRepository<Expediente> _repo;
public ExpedienteService(IRepository<Expediente> expe)
{
_repo = expe;
}
public Task<int> SaveAsync(Expediente expediente)
{
return _repo.SaveAsync(expediente);
}
public Task<Expediente?> GetByIdAsync(object id)
{
return _repo.GetByIdAsync(id);
}
public Task<IEnumerable<Expediente>> GetAllAsync()
{
return _repo.GetAllAsync();
}
public Task<IEnumerable<Expediente>> GetActivosAsync()
{
return _repo.GetAllAsync(e=> e.Activo == true);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
@model ExpedienteViewModel
@using MieSystem.Models.ViewModels
@model ExpedienteViewModel
@{
Layout = null;
ViewData["Title"] = "Expediente - " + Model.NombreCompleto;
@@ -529,7 +530,7 @@
<button onclick="window.print()" class="btn btn-primary">
<i class="bi bi-printer"></i> Imprimir Documento
</button>
<button onclick="downloadPDF()" class="btn btn-info">
<button onclick="downloadPDF()" style="visibility:hidden" class="btn btn-info">
<i class="bi bi-download"></i> Guardar como PDF
</button>
</div>

View File

@@ -1,4 +1,5 @@
@{
@using MieSystem.Models.ViewModels
@{
ViewData["Title"] = "Expedientes";
ViewData["ActionButtons"] = @"<button class='btn btn-primary' data-bs-toggle='modal' data-bs-target='#createModal'>
<i class='bi bi-plus-circle me-1'></i> Nuevo Expediente
@@ -104,7 +105,7 @@
</div>
<!-- Modal para crear nuevo expediente -->
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true">
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true" >
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
@@ -262,7 +263,7 @@
<button class="btn btn-sm btn-outline-warning" onclick="editExpediente(${expediente.id})" title="Editar">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteExpediente(${expediente.id})" title="Eliminar">
<button style="visibility:hidden" class="btn btn-sm btn-outline-danger" onclick="deleteExpediente(${expediente.id})" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</div>
@@ -322,10 +323,8 @@
);
renderExpedientesTable(filtered);
$('#pagination').empty(); // Limpiar paginación en búsqueda
$('#pagination').empty();
}
// Guardar nuevo expediente
function saveExpediente() {
const formData = new FormData($('#createForm')[0]);
@@ -336,7 +335,6 @@
processData: false,
contentType: false,
success: function(response) {
// Mostrar siempre el mensaje que viene en response.message
const message = response.message ||
(response.success ? 'Operación completada' : 'Error en la operación');
@@ -350,7 +348,6 @@
}
},
error: function(xhr) {
// Extraer el message del JSON si existe
let message = 'Error en la solicitud';
try {
const jsonResponse = JSON.parse(xhr.responseText);
@@ -358,7 +355,6 @@
message = jsonResponse.message;
}
} catch (e) {
// Si no es JSON válido, usar el texto de respuesta
if (xhr.responseText) {
message = xhr.responseText;
}
@@ -377,7 +373,6 @@
$('#editModalBody').html(data);
$('#editModal').modal('show');
// Mostrar/ocultar botón de eliminar imagen según si hay imagen personalizada
const fotoUrl = $('#editForm input[name="FotoUrl"]').val();
const deleteBtn = $('#deleteFotoEdit');
if (fotoUrl && fotoUrl !== '/images/default-avatar.png') {
@@ -440,8 +435,6 @@
}
}
// ========== FUNCIONES PARA MANEJO DE IMÁGENES ==========
// Mostrar vista previa de imagen
function previewImage(input, previewId) {
const preview = document.getElementById(previewId);
@@ -518,16 +511,13 @@
}
}
showAlert('error', message);
// Restaurar imagen predeterminada
preview.src = '/images/default-avatar.png';
// Ocultar botón de eliminar
const deleteBtnId = isEdit ? 'deleteFotoEdit' : 'deleteFoto';
$(`#${deleteBtnId}`).hide();
}
});
}
// Eliminar imagen
function deleteImage(isEdit = false) {
const formId = isEdit ? 'editForm' : 'createForm';
const hiddenInput = $(`#${formId} input[name="FotoUrl"]`);
@@ -543,7 +533,6 @@
data: { imageUrl: imageUrl },
success: function(response) {
if (response.success) {
// Restaurar imagen predeterminada
$(`#${previewId}`).attr('src', '/images/default-avatar.png');
hiddenInput.val('/images/default-avatar.png');
$(`#${deleteBtnId}`).hide();
@@ -560,9 +549,6 @@
}
}
// ========== FUNCIONES UTILITARIAS ==========
// Calcular edad
function calculateAge(birthDate) {
const today = new Date();
const birth = new Date(birthDate);
@@ -576,7 +562,6 @@
return age;
}
// Formatear fecha
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
@@ -586,9 +571,7 @@
});
}
// Mostrar alerta
function showAlert(type, message) {
// Crear elemento de alerta
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;';
@@ -597,10 +580,8 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
// Agregar al body
document.body.appendChild(alertDiv);
// Auto-eliminar después de 5 segundos
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();

View File

@@ -1,146 +1,139 @@
@using MieSystem.Models;
@using MieSystem.Models
@using MieSystem.Models.ViewModels
@model ExpedienteViewModel
<form id="@(ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"] ? "editForm" : "createForm")"
method="post"
enctype="multipart/form-data"
data-id="@(Model?.Id ?? 0)">
@{
bool isEdit = ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"];
}
@if (ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"])
<form id="@(isEdit ? "editForm" : "createForm")"
method="post"
enctype="multipart/form-data">
@* SOLO EN EDIT SE ENVÍA EL ID *@
@if (isEdit)
{
<input type="hidden" asp-for="Id" />
}
<!-- Campo oculto para almacenar la URL de la imagen subida -->
<input type="hidden" asp-for="FotoUrl" />
@* Campo oculto para la URL de la imagen *@
<input type="hidden" asp-for="FotoUrl" value="@(Model?.FotoUrl ?? "/images/default-avatar.png")" />
<div class="row">
<!-- Columna izquierda - Datos personales -->
<!-- ================= COLUMNA IZQUIERDA ================= -->
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header bg-light">
<h6 class="mb-0">Datos Personales</h6>
</div>
<div class="card-body">
<!-- Foto -->
<!-- FOTO -->
<div class="mb-3 text-center">
<div class="mb-2">
<img id="@(ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"] ? "fotoPreviewEdit" : "fotoPreview")"
src="@(Model?.FotoUrl ?? "/images/default-avatar.png")"
alt="Foto del niño"
class="rounded-circle border"
style="width: 120px; height: 120px; object-fit: cover;">
</div>
<div class="d-flex justify-content-center align-items-center gap-2">
<img id="@(isEdit ? "fotoPreviewEdit" : "fotoPreview")"
src="@(Model?.FotoUrl ?? "/images/default-avatar.png")"
class="rounded-circle border mb-2"
style="width:120px;height:120px;object-fit:cover;" />
<div class="d-flex justify-content-center gap-2">
<label class="btn btn-sm btn-outline-primary">
<i class="bi bi-camera me-1"></i> Seleccionar Foto
<input type="file"
asp-for="Foto"
id="@(ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"] ? "FotoEdit" : "Foto")"
id="@(isEdit ? "FotoEdit" : "Foto")"
class="d-none"
accept="image/*">
accept="image/*" />
</label>
<!-- Botón para eliminar imagen -->
<button type="button"
id="@(isEdit ? "deleteFotoEdit" : "deleteFoto")"
class="btn btn-sm btn-outline-danger"
id="@(ViewData["IsEdit"] != null && (bool)ViewData["IsEdit"] ? "deleteFotoEdit" : "deleteFoto")"
style="display: @((Model?.FotoUrl != null && Model.FotoUrl != "/images/default-avatar.png") ? "block" : "none");">
style="display:@(Model?.FotoUrl != null && Model.FotoUrl != "/images/default-avatar.png" ? "block" : "none")">
<i class="bi bi-trash me-1"></i> Eliminar
</button>
</div>
<div class="mt-2">
<small class="text-muted">Formatos permitidos: JPG, PNG, GIF, BMP. Tamaño máximo: 5MB</small>
</div>
<span asp-validation-for="Foto" class="text-danger"></span>
</div>
<!-- Nombre -->
<!-- NOMBRE -->
<div class="mb-3">
<label asp-for="Nombre" class="form-label">Nombre *</label>
<input asp-for="Nombre" class="form-control" placeholder="Ingrese el nombre">
<input asp-for="Nombre" class="form-control" />
<span asp-validation-for="Nombre" class="text-danger"></span>
</div>
<!-- Apellidos -->
<!-- APELLIDOS -->
<div class="mb-3">
<label asp-for="Apellidos" class="form-label">Apellidos *</label>
<input asp-for="Apellidos" class="form-control" placeholder="Ingrese los apellidos">
<input asp-for="Apellidos" class="form-control" />
<span asp-validation-for="Apellidos" class="text-danger"></span>
</div>
<!-- Fecha de Nacimiento -->
<!-- FECHA NACIMIENTO -->
<div class="mb-3">
<label asp-for="FechaNacimiento" class="form-label">Fecha de Nacimiento *</label>
<input asp-for="FechaNacimiento" type="date" class="form-control">
<input asp-for="FechaNacimiento" type="date" class="form-control" />
<span asp-validation-for="FechaNacimiento" class="text-danger"></span>
</div>
<!-- Sexo -->
<!-- SEXO -->
<div class="mb-3">
<label asp-for="Sexo" class="form-label">Sexo *</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" asp-for="Sexo" value="M" id="sexoM">
<label class="form-check-label" for="sexoM">Masculino</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" asp-for="Sexo" value="F" id="sexoF">
<label class="form-check-label" for="sexoF">Femenino</label>
</div>
<label class="form-label">Sexo *</label><br />
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" asp-for="Sexo" value="M" />
<label class="form-check-label">Masculino</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" asp-for="Sexo" value="F" />
<label class="form-check-label">Femenino</label>
</div>
<span asp-validation-for="Sexo" class="text-danger"></span>
</div>
</div>
</div>
</div>
<!-- Columna derecha - Datos familiares y dirección -->
<!-- ================= COLUMNA DERECHA ================= -->
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header bg-light">
<h6 class="mb-0">Datos Familiares</h6>
</div>
<div class="card-body">
<!-- Nombre del Padre -->
<div class="mb-3">
<label asp-for="NombrePadre" class="form-label">Nombre del Padre</label>
<input asp-for="NombrePadre" class="form-control" placeholder="Ingrese nombre del padre">
<span asp-validation-for="NombrePadre" class="text-danger"></span>
<input asp-for="NombrePadre" class="form-control" />
</div>
<!-- Nombre de la Madre -->
<div class="mb-3">
<label asp-for="NombreMadre" class="form-label">Nombre de la Madre</label>
<input asp-for="NombreMadre" class="form-control" placeholder="Ingrese nombre de la madre">
<span asp-validation-for="NombreMadre" class="text-danger"></span>
<input asp-for="NombreMadre" class="form-control" />
</div>
<!-- Nombre del Responsable -->
<div class="mb-3">
<label asp-for="NombreResponsable" class="form-label">Nombre del Responsable *</label>
<input asp-for="NombreResponsable" class="form-control" placeholder="Ingrese nombre del responsable">
<input asp-for="NombreResponsable" class="form-control" />
<span asp-validation-for="NombreResponsable" class="text-danger"></span>
</div>
<!-- Parentesco del Responsable -->
<div class="mb-3">
<label asp-for="ParentescoResponsable" class="form-label">Parentesco del Responsable</label>
<label asp-for="ParentescoResponsable" class="form-label">Parentesco</label>
<select asp-for="ParentescoResponsable" class="form-select">
<option value="">Seleccione parentesco</option>
<option value="Padre">Padre</option>
<option value="Madre">Madre</option>
<option value="Abuelo">Abuelo</option>
<option value="Abuela">Abuela</option>
<option value="Tío">Tío</option>
<option value="Tía">Tía</option>
<option value="Hermano">Hermano</option>
<option value="Hermana">Hermana</option>
<option value="Otro">Otro</option>
<option value="">Seleccione</option>
<option>Padre</option>
<option>Madre</option>
<option>Abuelo</option>
<option>Abuela</option>
<option>Tío</option>
<option>Tía</option>
<option>Otro</option>
</select>
<span asp-validation-for="ParentescoResponsable" class="text-danger"></span>
</div>
</div>
</div>
@@ -149,26 +142,23 @@
<h6 class="mb-0">Dirección y Contacto</h6>
</div>
<div class="card-body">
<!-- Dirección -->
<div class="mb-3">
<label asp-for="Direccion" class="form-label">Dirección *</label>
<textarea asp-for="Direccion" class="form-control" rows="3" placeholder="Ingrese dirección completa"></textarea>
<textarea asp-for="Direccion" class="form-control"></textarea>
<span asp-validation-for="Direccion" class="text-danger"></span>
</div>
<!-- Teléfono -->
<div class="mb-3">
<label asp-for="Telefono" class="form-label">Teléfono de Contacto</label>
<input asp-for="Telefono" class="form-control" placeholder="Ingrese número telefónico">
<span asp-validation-for="Telefono" class="text-danger"></span>
<label asp-for="Telefono" class="form-label">Teléfono</label>
<input asp-for="Telefono" class="form-control" />
</div>
<!-- Observaciones -->
<div class="mb-3">
<label asp-for="Observaciones" class="form-label">Observaciones</label>
<textarea asp-for="Observaciones" class="form-control" rows="2" placeholder="Observaciones adicionales"></textarea>
<span asp-validation-for="Observaciones" class="text-danger"></span>
<textarea asp-for="Observaciones" class="form-control"></textarea>
</div>
</div>
</div>
</div>

View File

@@ -5,9 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - MieSystem</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap-icons.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/MieSystem.styles.css" asp-append-version="true" />
<!-- Toastr CSS -->
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/toastr.min.css" />
@await RenderSectionAsync("Styles", required: false)
<style>
/* Estilos personalizados para el menú lateral */
body {
@@ -200,6 +204,7 @@
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow">
<div class="container-fluid">
<button class="toggle-sidebar-btn" id="toggleSidebar">
@@ -252,32 +257,32 @@
</a>
</li>
<!-- Separador y sección de administración -->
<li class="mt-4">
<li class="mt-4" style="visibility:hidden">
<div class="sidebar-header">
<h6 class="text-uppercase text-muted small">Administración</h6>
<h6 class="text-uppercase text-muted small" >Administración</h6>
</div>
</li>
<li>
<li style="visibility:hidden">
<a href="#" class="nav-link">
<i class="bi bi-people"></i> Niños
</a>
</li>
<li>
<li style="visibility:hidden">
<a href="#" class="nav-link">
<i class="bi bi-person-badge"></i> Maestros
</a>
</li>
<li>
<li style ="visibility:hidden">
<a href="#" class="nav-link">
<i class="bi bi-person-workspace"></i> Personal
<i class="bi bi-person-workspace" ></i> Personal
</a>
</li>
<li>
<li style="visibility:hidden">
<a href="#" class="nav-link">
<i class="bi bi-bar-chart"></i> Reportes
</a>
</li>
<li>
<li style="visibility:hidden">
<a asp-area="" asp-controller="Home" asp-action="Privacy" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Home" && ViewContext.RouteData.Values["Action"]?.ToString() == "Privacy" ? "active" : "")">
<i class="bi bi-shield-check"></i> Privacidad
</a>
@@ -319,10 +324,10 @@
</div>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/lib/bootstrap/dist/js/toastr.min.js"></script>
<script>
// Control del menú lateral

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long