Nueva mejoras Y estabilidad
This commit is contained in:
46
MicroORM/DapperTypeMapRegistrar.cs
Normal file
46
MicroORM/DapperTypeMapRegistrar.cs
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
18
MicroORM/DbConnectionExtensions.cs
Normal file
18
MicroORM/DbConnectionExtensions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
MicroORM/Exceptions/OrmException.cs
Normal file
7
MicroORM/Exceptions/OrmException.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicroORM.Exceptions;
|
||||
|
||||
public class OrmException : Exception
|
||||
{
|
||||
public OrmException(string message, Exception? inner = null)
|
||||
: base(message, inner) { }
|
||||
}
|
||||
68
MicroORM/ExpressionToSqlTranslator.cs
Normal file
68
MicroORM/ExpressionToSqlTranslator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
MicroORM/Interfaces/IRepository.cs
Normal file
20
MicroORM/Interfaces/IRepository.cs
Normal 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
7
MicroORM/MicroORM.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicroORM;
|
||||
|
||||
public class MicroORM
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
15
MicroORM/MicroORM.csproj
Normal file
15
MicroORM/MicroORM.csproj
Normal 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
63
MicroORM/OrmMetadata.cs
Normal 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
610
MicroORM/Repository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MieSystem.Data.Interfaces;
|
||||
using MieSystem.Models;
|
||||
using MieSystem.Models.ViewModels;
|
||||
using MieSystem.Services;
|
||||
|
||||
namespace MieSystem.Controllers
|
||||
{
|
||||
public class AsistenciaController : Controller
|
||||
{
|
||||
private readonly IExpedienteRepository _expedienteRepository;
|
||||
private readonly IAsistenciaRepository _asistenciaRepository;
|
||||
|
||||
public AsistenciaController(
|
||||
IExpedienteRepository expedienteRepository,
|
||||
IAsistenciaRepository asistenciaRepository)
|
||||
private readonly ExpedienteService expedienteService;
|
||||
private readonly AsistenciaService asistenciaService;
|
||||
public AsistenciaController(ExpedienteService expedienteService, AsistenciaService asistenciaService)
|
||||
{
|
||||
_expedienteRepository = expedienteRepository;
|
||||
_asistenciaRepository = asistenciaRepository;
|
||||
this.expedienteService = expedienteService;
|
||||
this.asistenciaService = asistenciaService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index(int? año, int? mes, string diasSemana = null)
|
||||
{
|
||||
// Valores por defecto: mes actual
|
||||
var fechaActual = DateTime.Now;
|
||||
var añoSeleccionado = año ?? fechaActual.Year;
|
||||
var mesSeleccionado = mes ?? fechaActual.Month;
|
||||
|
||||
// Obtener todos los niños activos
|
||||
var expedientes = await _expedienteRepository.GetActivosAsync();
|
||||
var expedientes = await expedienteService.GetActivosAsync();
|
||||
|
||||
// Obtener días del mes seleccionado
|
||||
var diasDelMes = GetDiasDelMes(añoSeleccionado, mesSeleccionado);
|
||||
|
||||
// Filtrar por días de semana si se especifica
|
||||
if (!string.IsNullOrEmpty(diasSemana))
|
||||
{
|
||||
var diasFiltro = diasSemana.Split(',')
|
||||
@@ -45,7 +34,7 @@ namespace MieSystem.Controllers
|
||||
}
|
||||
|
||||
// Obtener asistencias para el mes
|
||||
var asistencias = await _asistenciaRepository.GetAsistenciasPorMesAsync(
|
||||
var asistencias = await asistenciaService.GetAsistenciasPorMesAsync(
|
||||
añoSeleccionado, mesSeleccionado);
|
||||
|
||||
// Crear diccionario para acceso rápido
|
||||
@@ -53,7 +42,7 @@ namespace MieSystem.Controllers
|
||||
foreach (var asistencia in asistencias)
|
||||
{
|
||||
var key = $"{asistencia.ExpedienteId}_{asistencia.Fecha:yyyy-MM-dd}";
|
||||
dictAsistencias[key] = asistencia.Estado;
|
||||
dictAsistencias[key] = asistencia.Estado.ToString();
|
||||
}
|
||||
|
||||
// Crear modelo de vista
|
||||
@@ -89,11 +78,11 @@ namespace MieSystem.Controllers
|
||||
{
|
||||
ExpedienteId = expedienteId,
|
||||
Fecha = fechaDate,
|
||||
Estado = estado,
|
||||
Estado = estado[0],
|
||||
UsuarioRegistro = User.Identity?.Name ?? "Sistema"
|
||||
};
|
||||
|
||||
var resultado = await _asistenciaRepository.GuardarAsistenciaAsync(asistencia);
|
||||
var resultado = await asistenciaService.SaveAsync(asistencia);
|
||||
|
||||
return Json(new { success = resultado, message = "Asistencia guardada" });
|
||||
}
|
||||
@@ -116,13 +105,13 @@ namespace MieSystem.Controllers
|
||||
{
|
||||
ExpedienteId = asistenciaDto.ExpedienteId,
|
||||
Fecha = asistenciaDto.Fecha,
|
||||
Estado = asistenciaDto.Estado,
|
||||
Estado = asistenciaDto.Estado[0],
|
||||
UsuarioRegistro = User.Identity?.Name ?? "Sistema"
|
||||
};
|
||||
asistenciasModel.Add(asistencia);
|
||||
}
|
||||
|
||||
var resultado = await _asistenciaRepository.GuardarAsistenciasMasivasAsync(asistenciasModel);
|
||||
var resultado = asistenciaService.GuardarAsistenciasMasivasAsync(asistenciasModel);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
@@ -141,7 +130,7 @@ namespace MieSystem.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var estadisticas = await _asistenciaRepository.GetEstadisticasMesAsync(año, mes);
|
||||
var estadisticas = await asistenciaService.GetEstadisticasMesAsync(año, mes);
|
||||
return Json(estadisticas);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MieSystem.Data.Interfaces;
|
||||
using MieSystem.Models;
|
||||
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;
|
||||
this.expedienteService = expedienteService;
|
||||
}
|
||||
|
||||
// GET: Expedientes
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var lst = await _expedienteRepository.GetAllAsync();
|
||||
var lst = await expedienteService.GetAllAsync();
|
||||
_expedientes = [.. lst];
|
||||
return View();
|
||||
}
|
||||
@@ -83,7 +81,7 @@ namespace MieSystem.Controllers
|
||||
FechaNacimiento = e.FechaNacimiento,
|
||||
Sexo = e.Sexo,
|
||||
NombreResponsable = e.NombreResponsable,
|
||||
FotoUrl = e.FotoUrl
|
||||
FotoUrl = ExisteFoto(e.FotoUrl)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -140,7 +138,6 @@ namespace MieSystem.Controllers
|
||||
// Crear nuevo expediente
|
||||
var expediente = new Expediente
|
||||
{
|
||||
Id = _idCounter++,
|
||||
Nombre = model.Nombre,
|
||||
Apellidos = model.Apellidos,
|
||||
FechaNacimiento = model.FechaNacimiento,
|
||||
@@ -160,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)
|
||||
@@ -217,7 +215,8 @@ namespace MieSystem.Controllers
|
||||
ModelState.Remove("Observaciones");
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
var expediente = await _expedienteRepository.GetByIdAsync(id);
|
||||
//var expediente = await _expedienteRepository.GetByIdAsync(id);
|
||||
var expediente = await expedienteService.GetByIdAsync(id);
|
||||
//var expediente = _expedientes.FirstOrDefault(e => e.Id == id);
|
||||
if (expediente == null)
|
||||
{
|
||||
@@ -278,7 +277,8 @@ namespace MieSystem.Controllers
|
||||
|
||||
try
|
||||
{
|
||||
await _expedienteRepository.UpdateAsync(expediente);
|
||||
//await _expedienteRepository.UpdateAsync(expediente);
|
||||
await expedienteService.SaveAsync(expediente);
|
||||
return Json(new { success = true, message = "Expediente actualizado exitosamente" });
|
||||
|
||||
}
|
||||
@@ -292,7 +292,7 @@ namespace MieSystem.Controllers
|
||||
}
|
||||
|
||||
// DELETE: Expedientes/Delete/5
|
||||
[HttpDelete]
|
||||
/*[HttpDelete]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var expediente = _expedientes.FirstOrDefault(e => e.Id == id);
|
||||
@@ -318,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)
|
||||
{
|
||||
@@ -410,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)
|
||||
|
||||
21
MieSystem/Data/NpgsqlConnectionFactory.cs
Normal file
21
MieSystem/Data/NpgsqlConnectionFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -25,4 +26,8 @@
|
||||
<Folder Include="wwwroot\uploads\fotos\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MicroORM\MicroORM.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,33 +1,64 @@
|
||||
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; }
|
||||
public int ExpedienteId { get; set; }
|
||||
public DateTime Fecha { get; set; }
|
||||
public string Estado { get; set; } // P, T, F
|
||||
public TimeSpan? HoraEntrada { get; set; }
|
||||
public TimeSpan? HoraSalida { get; set; }
|
||||
public string Observaciones { get; set; }
|
||||
public DateTime FechaRegistro { get; set; }
|
||||
public string UsuarioRegistro { get; set; }
|
||||
|
||||
// Propiedades calculadas
|
||||
[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" => "Falto",
|
||||
'P' => "Presente",
|
||||
'T' => "Tarde",
|
||||
'F' => "Faltó",
|
||||
_ => "Desconocido"
|
||||
};
|
||||
|
||||
[NotMapped]
|
||||
public string ColorEstado => Estado switch
|
||||
{
|
||||
"P" => "success",
|
||||
"T" => "warning",
|
||||
"F" => "danger",
|
||||
'P' => "success",
|
||||
'T' => "warning",
|
||||
'F' => "danger",
|
||||
_ => "secondary"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,85 @@
|
||||
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
|
||||
// ============================
|
||||
|
||||
|
||||
// Propiedades calculadas (solo lectura)
|
||||
[NotMapped]
|
||||
public string NombreCompleto => $"{Nombre} {Apellidos}".Trim();
|
||||
|
||||
[NotMapped]
|
||||
public int Edad
|
||||
{
|
||||
get
|
||||
@@ -64,17 +87,14 @@ namespace MieSystem.Models
|
||||
var today = DateTime.Today;
|
||||
var age = today.Year - FechaNacimiento.Year;
|
||||
|
||||
// Si aún no ha cumplido años este año, restar 1
|
||||
if (FechaNacimiento.Date > today.AddYears(-age))
|
||||
{
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
}
|
||||
}
|
||||
|
||||
// Otra propiedad útil para mostrar
|
||||
[NotMapped]
|
||||
public string EdadConMeses
|
||||
{
|
||||
get
|
||||
@@ -84,9 +104,7 @@ namespace MieSystem.Models
|
||||
var months = today.Month - FechaNacimiento.Month;
|
||||
|
||||
if (today.Day < FechaNacimiento.Day)
|
||||
{
|
||||
months--;
|
||||
}
|
||||
|
||||
if (months < 0)
|
||||
{
|
||||
@@ -98,10 +116,10 @@ namespace MieSystem.Models
|
||||
}
|
||||
}
|
||||
|
||||
// Para mostrar en selectores
|
||||
[NotMapped]
|
||||
public string NombreConEdad => $"{NombreCompleto} ({Edad} años)";
|
||||
|
||||
// Para mostrar en listas
|
||||
[NotMapped]
|
||||
public string InformacionBasica => $"{NombreCompleto} | {Edad} años | {Sexo}";
|
||||
}
|
||||
}
|
||||
|
||||
43
MieSystem/Models/Persona.cs
Normal file
43
MieSystem/Models/Persona.cs
Normal 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; }
|
||||
}
|
||||
@@ -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,27 @@ 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
93
MieSystem/Services/AsistenciaService.cs
Normal file
93
MieSystem/Services/AsistenciaService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
MieSystem/Services/ExpedienteService.cs
Normal file
35
MieSystem/Services/ExpedienteService.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -71,7 +71,8 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn w-100" style="background: linear-gradient(180deg, #2c3e50 0%, #1a2530 100%); color: white;">
|
||||
<button type="submit" class="btn w-100"
|
||||
style="background: linear-gradient(180deg, #2c3e50 0%, #1a2530 100%); color: white;">
|
||||
<i class="fas fa-search text-white"></i> Filtrar
|
||||
</button>
|
||||
</div>
|
||||
@@ -218,7 +219,62 @@
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<tfoot class="table-dark">
|
||||
<tr>
|
||||
<td class="sticky-left bg-dark text-white fw-bold">
|
||||
<div>TOTALES POR DÍA</div>
|
||||
<div class="small text-light">P/T/F</div>
|
||||
</td>
|
||||
|
||||
@foreach (var dia in Model.DiasDelMes)
|
||||
{
|
||||
var diaSemana = ((int)dia.DayOfWeek).ToString();
|
||||
var isChecked = Model.DiasSemanaSeleccionados?.Contains(diaSemana) ?? true;
|
||||
|
||||
if (!string.IsNullOrEmpty(Model.DiasSemanaSeleccionados) && !isChecked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var esFinDeSemana = dia.DayOfWeek == DayOfWeek.Saturday || dia.DayOfWeek == DayOfWeek.Sunday;
|
||||
|
||||
// Calcular totales para este día
|
||||
var totalPresente = 0;
|
||||
var totalTarde = 0;
|
||||
var totalFalta = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var totalDia = totalPresente + totalTarde + totalFalta;
|
||||
|
||||
<td class="text-center p-1 @(esFinDeSemana ? "bg-secondary" : "bg-dark") text-white"
|
||||
style="min-width: 65px; font-size: 0.85rem;">
|
||||
<div class="fw-bold mb-1">@totalDia</div>
|
||||
<div class="d-flex justify-content-center gap-1">
|
||||
<span class="badge bg-success" title="Presentes">@totalPresente</span>
|
||||
<span class="badge bg-warning text-dark" title="Tardes">@totalTarde</span>
|
||||
<span class="badge bg-danger" title="Faltas">@totalFalta</span>
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,7 +314,114 @@
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
/* Fijar altura del contenedor de estadísticas */
|
||||
#estadisticasContainer {
|
||||
min-height: 90px; /* ← mitad */
|
||||
transition: min-height 0.3s ease;
|
||||
}
|
||||
|
||||
/* Estilos para las tarjetas de estadísticas */
|
||||
#estadisticasContainer .card {
|
||||
height: 100%;
|
||||
min-height: 90px; /* ← mitad */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#estadisticasContainer .card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spinner centrado */
|
||||
#estadisticasContainer .spinner-border {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
/* Alert centrado */
|
||||
#estadisticasContainer .alert {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Reemplaza los estilos del tfoot con estos: */
|
||||
tfoot.table-dark td {
|
||||
vertical-align: middle !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
tfoot .badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25em 0.5em;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
tfoot tr:first-child td {
|
||||
border-top: 2px solid #6c757d;
|
||||
}
|
||||
|
||||
tfoot tr.bg-success td {
|
||||
border-top: 2px solid #198754;
|
||||
}
|
||||
|
||||
/* Para la celda combinada de totales generales */
|
||||
tfoot td[colspan] {
|
||||
background: linear-gradient(135deg, #198754 0%, #146c43 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
tfoot td[colspan] .badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4em 0.8em;
|
||||
}
|
||||
|
||||
tfoot td[colspan] .text-light {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Estilos específicos para el footer oscuro */
|
||||
tfoot.table-dark .sticky-left {
|
||||
background: #343a40 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
tfoot.table-dark td.bg-dark {
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
|
||||
tfoot.table-dark td.bg-secondary {
|
||||
background-color: #6c757d !important;
|
||||
}
|
||||
|
||||
/* Asegurar que el texto sea visible en fondo oscuro */
|
||||
tfoot.table-dark .text-white,
|
||||
tfoot.table-dark .text-light {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
tfoot.table-dark .text-muted {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Estilos generales de la tabla */
|
||||
.table th, .table td {
|
||||
vertical-align: middle;
|
||||
@@ -409,9 +572,7 @@
|
||||
|
||||
/* Responsive adjustments */
|
||||
@@media (max-width: 768px) {
|
||||
.table-responsive
|
||||
|
||||
{
|
||||
.table-responsive {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -469,13 +630,13 @@
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
// Actualizar estadísticas al cargar
|
||||
cargarEstadisticas();
|
||||
|
||||
// Manejar checkboxes de días de semana
|
||||
$('.dia-checkbox').change(function() {
|
||||
var diasSeleccionados = $('.dia-checkbox:checked').map(function() {
|
||||
$('.dia-checkbox').change(function () {
|
||||
var diasSeleccionados = $('.dia-checkbox:checked').map(function () {
|
||||
return $(this).val();
|
||||
}).get().join(',');
|
||||
$('#diasSemanaInput').val(diasSeleccionados);
|
||||
@@ -484,32 +645,8 @@
|
||||
// Inicializar colores al cargar
|
||||
inicializarColores();
|
||||
|
||||
// Guardar asistencia individual y actualizar colores
|
||||
$('.estado-select').change(function() {
|
||||
var estado = $(this).val();
|
||||
var celda = $(this).closest('.celda-asistencia');
|
||||
var initial = $(this).data('initial');
|
||||
|
||||
// Actualizar colores
|
||||
aplicarColorSelect($(this), estado);
|
||||
aplicarColorCelda(celda, estado);
|
||||
|
||||
// Marcar como cambiado si es diferente
|
||||
if (initial !== estado) {
|
||||
celda.addClass('changed');
|
||||
} else {
|
||||
celda.removeClass('changed');
|
||||
}
|
||||
|
||||
// Guardar automáticamente
|
||||
var expedienteId = celda.data('expediente');
|
||||
var fecha = celda.data('fecha');
|
||||
|
||||
guardarAsistencia(expedienteId, fecha, estado, celda);
|
||||
});
|
||||
|
||||
// Guardar todas las asistencias cambiadas
|
||||
$('#btnGuardarTodo').click(function() {
|
||||
$('#btnGuardarTodo').click(function () {
|
||||
var cambios = [];
|
||||
var celdasCambiadas = $('.celda-asistencia.changed');
|
||||
|
||||
@@ -524,7 +661,7 @@
|
||||
|
||||
$('#modalCarga').modal('show');
|
||||
|
||||
celdasCambiadas.each(function() {
|
||||
celdasCambiadas.each(function () {
|
||||
var celda = $(this);
|
||||
var select = celda.find('.estado-select');
|
||||
var expedienteId = celda.data('expediente');
|
||||
@@ -544,12 +681,12 @@
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(cambios),
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
$('#modalCarga').modal('hide');
|
||||
if (response.success) {
|
||||
// Quitar marca de cambiado y actualizar estado inicial
|
||||
$('.celda-asistencia').removeClass('changed');
|
||||
$('.estado-select').each(function() {
|
||||
$('.estado-select').each(function () {
|
||||
$(this).data('initial', $(this).val());
|
||||
});
|
||||
toastr.success(response.message);
|
||||
@@ -558,7 +695,7 @@
|
||||
toastr.error(response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
$('#modalCarga').modal('hide');
|
||||
toastr.error('Error al guardar los cambios');
|
||||
}
|
||||
@@ -566,7 +703,7 @@
|
||||
});
|
||||
|
||||
// Exportar a Excel
|
||||
$('#btnExportar').click(function() {
|
||||
$('#btnExportar').click(function () {
|
||||
var año = $('#selectAnio').val();
|
||||
var mes = $('#selectMes').val();
|
||||
var diasSemana = $('#diasSemanaInput').val();
|
||||
@@ -580,7 +717,7 @@
|
||||
});
|
||||
|
||||
// Cambiar mes/año con selects
|
||||
$('#selectAnio, #selectMes').change(function() {
|
||||
$('#selectAnio, #selectMes').change(function () {
|
||||
$('#filtroForm').submit();
|
||||
});
|
||||
|
||||
@@ -594,7 +731,7 @@
|
||||
celda.removeClass('celda-presente celda-tarde celda-falta');
|
||||
|
||||
// Aplicar nueva clase según estado
|
||||
switch(estado) {
|
||||
switch (estado) {
|
||||
case 'P':
|
||||
celda.addClass('celda-presente');
|
||||
break;
|
||||
@@ -616,7 +753,7 @@
|
||||
select.removeClass('bg-success bg-warning bg-danger text-white');
|
||||
|
||||
// Aplicar nueva clase según estado
|
||||
switch(estado) {
|
||||
switch (estado) {
|
||||
case 'P':
|
||||
select.addClass('bg-success text-white');
|
||||
break;
|
||||
@@ -634,7 +771,7 @@
|
||||
|
||||
// Función para inicializar colores al cargar la página
|
||||
function inicializarColores() {
|
||||
$('.estado-select').each(function() {
|
||||
$('.estado-select').each(function () {
|
||||
var estado = $(this).val();
|
||||
var celda = $(this).closest('.celda-asistencia');
|
||||
|
||||
@@ -645,8 +782,7 @@
|
||||
|
||||
// ============================================
|
||||
// FUNCIONES PARA GUARDAR ASISTENCIA
|
||||
// ============================================
|
||||
|
||||
// ===========================================
|
||||
function guardarAsistencia(expedienteId, fecha, estado, celda) {
|
||||
$.ajax({
|
||||
url: '@Url.Action("GuardarAsistencia", "Asistencia")',
|
||||
@@ -656,12 +792,15 @@
|
||||
fecha: fecha,
|
||||
estado: estado
|
||||
},
|
||||
success: function(response) {
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
celda.removeClass('changed');
|
||||
celda.find('.estado-select').data('initial', estado);
|
||||
toastr.success('Guardado correctamente');
|
||||
|
||||
// Actualizar estadísticas y footer
|
||||
cargarEstadisticas();
|
||||
actualizarFooterTabla();
|
||||
} else {
|
||||
toastr.error(response.message);
|
||||
// Revertir color si hay error
|
||||
@@ -672,7 +811,7 @@
|
||||
select.val(initial);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function () {
|
||||
toastr.error('Error de conexión');
|
||||
// Revertir color
|
||||
var select = celda.find('.estado-select');
|
||||
@@ -692,21 +831,26 @@
|
||||
var año = $('#selectAnio').val();
|
||||
var mes = $('#selectMes').val();
|
||||
|
||||
// Mostrar indicador de carga
|
||||
$('#estadisticasContainer').html(`
|
||||
<div class="col-md-12 text-center">
|
||||
// Mostrar indicador de carga SIN cambiar el HTML completo
|
||||
var container = $('#estadisticasContainer');
|
||||
var currentContent = container.html();
|
||||
|
||||
container.html(`
|
||||
<div class="col-md-12 d-flex align-items-center justify-content-center" style="min-height: 180px;">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Cargando...</span>
|
||||
</div>
|
||||
<p class="mt-2">Cargando estadísticas...</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$.ajax({
|
||||
url: '@Url.Action("ObtenerEstadisticas", "Asistencia")',
|
||||
type: 'GET',
|
||||
data: { año: año, mes: mes },
|
||||
success: function(data) {
|
||||
data: {año: año, mes: mes},
|
||||
success: function (data) {
|
||||
// Verificar si data es null o undefined
|
||||
if (!data) {
|
||||
mostrarEstadisticasVacias();
|
||||
@@ -741,14 +885,68 @@
|
||||
// Mostrar estadísticas
|
||||
mostrarEstadisticasHTML(total, presentes, tardes, faltas,
|
||||
porcentajePresentes, porcentajeTardes, porcentajeFaltas);
|
||||
|
||||
// Actualizar el footer de la tabla
|
||||
actualizarFooterTabla();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
error: function (xhr, status, error) {
|
||||
console.error('Error AJAX:', status, error);
|
||||
mostrarEstadisticasVacias('Error al cargar estadísticas');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function actualizarFooterTabla() {
|
||||
// Recalcular totales por día basados en los datos actuales
|
||||
$('tfoot tr td').each(function (index) {
|
||||
if (index === 0) return; // Saltar la primera celda (TOTALES POR DÍA)
|
||||
|
||||
var td = $(this);
|
||||
var colIndex = td.index();
|
||||
|
||||
// Solo procesar celdas que contienen totales por día
|
||||
if (td.hasClass('text-center')) {
|
||||
var totalPresente = 0;
|
||||
var totalTarde = 0;
|
||||
var totalFalta = 0;
|
||||
|
||||
// Recorrer todas las filas del tbody para esta columna
|
||||
$('tbody tr').each(function () {
|
||||
var celda = $(this).find('td').eq(colIndex);
|
||||
if (celda.length) {
|
||||
var select = celda.find('.estado-select');
|
||||
var estado = select.val();
|
||||
|
||||
switch (estado) {
|
||||
case 'P':
|
||||
totalPresente++;
|
||||
break;
|
||||
case 'T':
|
||||
totalTarde++;
|
||||
break;
|
||||
case 'F':
|
||||
totalFalta++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var totalDia = totalPresente + totalTarde + totalFalta;
|
||||
|
||||
// Actualizar la celda del footer
|
||||
td.html(`
|
||||
<div class="fw-bold mb-1">${totalDia}</div>
|
||||
<div class="d-flex justify-content-center gap-1">
|
||||
<span class="badge bg-success" title="Presentes">${totalPresente}</span>
|
||||
<span class="badge bg-warning text-dark" title="Tardes">${totalTarde}</span>
|
||||
<span class="badge bg-danger" title="Faltas">${totalFalta}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Función auxiliar para extraer números de forma segura
|
||||
function extraerNumero(obj, propiedad) {
|
||||
if (!obj || obj[propiedad] === null || obj[propiedad] === undefined) {
|
||||
@@ -788,8 +986,8 @@
|
||||
var mensajeMostrar = mensaje || 'No hay datos de asistencia para este período';
|
||||
|
||||
$('#estadisticasContainer').html(`
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-warning">
|
||||
<div class="col-md-12 d-flex align-items-center justify-content-center" style="min-height: 180px;">
|
||||
<div class="alert alert-warning m-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> ${mensajeMostrar}
|
||||
</div>
|
||||
</div>
|
||||
@@ -806,13 +1004,13 @@
|
||||
|
||||
$('#estadisticasContainer').html(`
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body text-center">
|
||||
<div class="card bg-success text-white h-100">
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-check-circle"></i> Presentes
|
||||
</h6>
|
||||
<h2 class="display-6">${presentes.toLocaleString()}</h2>
|
||||
<div class="mt-2">
|
||||
<h2 class="display-6 mb-2">${presentes.toLocaleString()}</h2>
|
||||
<div class="mt-auto">
|
||||
<small>${porcentajePresentes}%</small>
|
||||
<div class="progress mt-1" style="height: 5px;">
|
||||
<div class="progress-bar bg-light"
|
||||
@@ -823,13 +1021,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-warning">
|
||||
<div class="card-body text-center">
|
||||
<div class="card bg-warning h-100">
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-clock"></i> Tardes
|
||||
</h6>
|
||||
<h2 class="display-6">${tardes.toLocaleString()}</h2>
|
||||
<div class="mt-2">
|
||||
<h2 class="display-6 mb-2">${tardes.toLocaleString()}</h2>
|
||||
<div class="mt-auto">
|
||||
<small>${porcentajeTardes}%</small>
|
||||
<div class="progress mt-1" style="height: 5px;">
|
||||
<div class="progress-bar bg-dark"
|
||||
@@ -840,13 +1038,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-danger text-white">
|
||||
<div class="card-body text-center">
|
||||
<div class="card bg-danger text-white h-100">
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-times-circle"></i> Faltas
|
||||
</h6>
|
||||
<h2 class="display-6">${faltas.toLocaleString()}</h2>
|
||||
<div class="mt-2">
|
||||
<h2 class="display-6 mb-2">${faltas.toLocaleString()}</h2>
|
||||
<div class="mt-auto">
|
||||
<small>${porcentajeFaltas}%</small>
|
||||
<div class="progress mt-1" style="height: 5px;">
|
||||
<div class="progress-bar bg-light"
|
||||
@@ -857,13 +1055,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-info text-white">
|
||||
<div class="card-body text-center">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-chart-bar"></i> Resumen
|
||||
</h6>
|
||||
<h2 class="display-6">${total.toLocaleString()}</h2>
|
||||
<div class="mt-2">
|
||||
<h2 class="display-6 mb-2">${total.toLocaleString()}</h2>
|
||||
<div class="mt-auto">
|
||||
<small>Asistencia: ${asistenciaGeneral}%</small>
|
||||
<div class="progress mt-1" style="height: 5px;">
|
||||
<div class="progress-bar bg-light"
|
||||
@@ -886,18 +1084,32 @@
|
||||
// ============================================
|
||||
|
||||
function aplicarColoresDias() {
|
||||
$('th.text-center').each(function() {
|
||||
$('th.text-center').each(function () {
|
||||
var textoDia = $(this).find('.small').text().trim();
|
||||
var claseColor = '';
|
||||
|
||||
switch(textoDia) {
|
||||
case 'Lun': claseColor = 'bg-lunes'; break;
|
||||
case 'Mar': claseColor = 'bg-martes'; break;
|
||||
case 'Mié': claseColor = 'bg-miercoles'; break;
|
||||
case 'Jue': claseColor = 'bg-jueves'; break;
|
||||
case 'Vie': claseColor = 'bg-viernes'; break;
|
||||
case 'Sáb': claseColor = 'bg-sabado'; break;
|
||||
case 'Dom': claseColor = 'bg-domingo'; break;
|
||||
switch (textoDia) {
|
||||
case 'Lun':
|
||||
claseColor = 'bg-lunes';
|
||||
break;
|
||||
case 'Mar':
|
||||
claseColor = 'bg-martes';
|
||||
break;
|
||||
case 'Mié':
|
||||
claseColor = 'bg-miercoles';
|
||||
break;
|
||||
case 'Jue':
|
||||
claseColor = 'bg-jueves';
|
||||
break;
|
||||
case 'Vie':
|
||||
claseColor = 'bg-viernes';
|
||||
break;
|
||||
case 'Sáb':
|
||||
claseColor = 'bg-sabado';
|
||||
break;
|
||||
case 'Dom':
|
||||
claseColor = 'bg-domingo';
|
||||
break;
|
||||
}
|
||||
|
||||
if (claseColor) {
|
||||
@@ -906,5 +1118,32 @@
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// También actualizar el footer cuando se cambia un select individualmente
|
||||
$('.estado-select').change(function () {
|
||||
var estado = $(this).val();
|
||||
var celda = $(this).closest('.celda-asistencia');
|
||||
var initial = $(this).data('initial');
|
||||
|
||||
// Actualizar colores
|
||||
aplicarColorSelect($(this), estado);
|
||||
aplicarColorCelda(celda, estado);
|
||||
|
||||
// Marcar como cambiado si es diferente
|
||||
if (initial !== estado) {
|
||||
celda.addClass('changed');
|
||||
} else {
|
||||
celda.removeClass('changed');
|
||||
}
|
||||
|
||||
// Actualizar el footer inmediatamente (sin esperar guardar)
|
||||
actualizarFooterTabla();
|
||||
|
||||
// Guardar automáticamente
|
||||
var expedienteId = celda.data('expediente');
|
||||
var fecha = celda.data('fecha');
|
||||
|
||||
guardarAsistencia(expedienteId, fecha, estado, celda);
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -1,147 +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")"
|
||||
<img id="@(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">
|
||||
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>
|
||||
<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" id="sexoM">
|
||||
<label class="form-check-label" for="sexoM">Masculino</label>
|
||||
<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" id="sexoF">
|
||||
<label class="form-check-label" for="sexoF">Femenino</label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -150,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>
|
||||
|
||||
Reference in New Issue
Block a user