commit f476f704eb0e00a619fd44939f3779e03797169a Author: Nurfog Date: Fri Jul 31 14:39:25 2026 -0400 feat: implement core domain models, authentication service, and UI components for the attendance system diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/AUDITORIA.md b/AUDITORIA.md new file mode 100644 index 0000000..57f9404 --- /dev/null +++ b/AUDITORIA.md @@ -0,0 +1,201 @@ +# INFORME DE AUDITORÍA + +## Proyecto Desktop: Asistencia (Avalonia UI) +**Ruta:** `/home/juan/dev/asistencia-desktop/` +**Referencia (guía):** `/home/juan/dev/sam/Profesores/` (Módulo Profesores SAM) + +--- + +## 1. RESUMEN EJECUTIVO + +| Aspecto | Desktop App (Asistencia) | SAM Profesores (guía) | +|---------|------------------------|----------------------| +| **Framework** | Avalonia UI + .NET 10 | ASP.NET WebForms .NET 4.8.1 | +| **Base de datos** | SQLite local (aislada) | MariaDB remota (AWS EC2) | +| **Tipo de app** | Desktop (Windows/Linux/macOS) | Web | +| **Estado** | Prototipo inicial - solo 4 vistas | Completo - 11 páginas | +| **Conexión SAM** | No conecta | Sí, via SPs | + +--- + +## 2. ESTRUCTURA DEL PROYECTO DESKTOP + +``` +asistencia-desktop/ +├── Asistencia.slnx +├── Asistencia/ +│ ├── Asistencia.csproj (.NET 10 + Avalonia 12.1.1 + SQLite + DI) +│ ├── Program.cs (entry point clásico Avalonia) +│ ├── App.axaml / App.axaml.cs (DI: DbContext, Repos, Services, ViewModels) +│ ├── ViewLocator.cs +│ ├── app.manifest +│ ├── Models/ (7 entidades) +│ │ ├── Profesor.cs +│ │ ├── Curso.cs +│ │ ├── CursoAbierto.cs +│ │ ├── DetalleContrato.cs +│ │ ├── AsistenciaProfesor.cs +│ │ ├── AsistenciaAlumno.cs +│ │ └── TipoAsistencia.cs +│ ├── Data/ +│ │ ├── AppDbContext.cs (SQLite + SeedData) +│ │ └── Repositories/ +│ │ ├── ProfesorRepository.cs +│ │ ├── CursoRepository.cs +│ │ └── AsistenciaRepository.cs +│ ├── Services/ +│ │ ├── AuthService.cs (login, crear clave, cambiar clave, email) +│ │ └── AsistenciaService.cs (iniciar/finalizar sesión, asistencia alumnos) +│ ├── Helpers/ +│ │ └── CryptoHelper.cs (AES key hardcodeada) +│ ├── ViewModels/ +│ │ ├── MainWindowViewModel.cs (navegación: Login→Dashboard→Asistencia→Perfil) +│ │ ├── LoginViewModel.cs +│ │ ├── DashboardViewModel.cs +│ │ ├── AsistenciaViewModel.cs (incluye AlumnoAsistenciaViewModel) +│ │ └── PerfilViewModel.cs +│ └── Views/ +│ ├── MainWindow.axaml +│ ├── LoginView.axaml +│ ├── DashboardView.axaml +│ ├── AsistenciaView.axaml +│ └── PerfilView.axaml +``` + +--- + +## 3. FUNCIONALIDAD IMPLEMENTADA VS. GUÍA (SAM PROFESORES) + +| # | Funcionalidad | Desktop App | SAM Profesores | Estado | +|---|--------------|-------------|----------------|--------| +| 1 | **Login / Autenticación** | ✅ SQLite local | ✅ FormsAuth + MariaDB | **Completo** | +| 2 | **Crear clave primera vez** | ✅ Implementado | ✅ CrearClave.aspx | **Completo** | +| 3 | **Dashboard (lista cursos)** | ✅ Básico | ✅ Index.aspx | **Completo** | +| 4 | **Iniciar clase (marcación)** | ✅ SQLite local | ✅ MariaDB + IP validation | **⚠ Parcial** | +| 5 | **Tomar asistencia alumnos** | ✅ P/L/A radio buttons | ✅ P/L/A radio buttons | **Completo** | +| 6 | **Finalizar clase** | ✅ | ✅ | **Completo** | +| 7 | **Perfil (email, clave)** | ✅ | ✅ Profile.aspx | **Completo** | +| 8 | **Ver sesiones y pagos** | ❌ No implementado | ✅ Sesiones.aspx | **FALTANTE** | +| 9 | **Notas / Calificaciones** | ❌ No implementado | ✅ Notas.aspx (CA, MWT, MOT, FOT, FWT) | **FALTANTE** | +| 10 | **Reportes por curso** | ❌ No implementado | ✅ Reports.aspx | **FALTANTE** | +| 11 | **Reemplazo de profesor** | ❌ No implementado | ✅ Reemplazo.aspx | **FALTANTE** | +| 12 | **Contenido del curso (bitácora)** | ❌ No implementado | ✅ Contenido.aspx | **FALTANTE** | +| 13 | **Validación IP/sede** | ❌ No implementado | ✅ Attendance.aspx (IP whitelist) | **FALTANTE** | +| 14 | **Notificaciones email** | ❌ No implementado | ✅ Mails.cs (reemplazos, sedes) | **FALTANTE** | +| 15 | **Soporte múltiples schemas** (abierto/cerrado) | ❌ No implementado | ✅ Plan Central + Empresa | **FALTANTE** | +| 16 | **Olvido de clave** | ❌ No implementado | ✅ OlvidoClave.aspx | **FALTANTE** | + +--- + +## 4. HALLAZGOS CRÍTICOS + +### 4.1 Base de datos aislada (SQLite local) + +La desktop app usa SQLite local con datos de prueba (seed data). **No se conecta a la base de datos real SAM (MariaDB)**. Esto significa que: + +- Los profesores no pueden ver sus cursos reales +- Las marcaciones no quedan registradas en el sistema SAM +- Los datos de asistencia de alumnos no se sincronizan +- No hay integración con el módulo de pagos (RRHH) + +**Solución:** Reemplazar SQLite por conexión directa a MariaDB usando las mismas stored procedures que usa el módulo SAM Profesores (sam.BuscarCursoProfesor, sam.IngresoAsistenciaProfesor, etc.) + +### 4.2 Seguridad: Encriptación AES con clave hardcodeada + +Ambos proyectos comparten la misma vulnerabilidad: + +``` +Clave AES: "S4M_Pru3b4_2024!" +IV: new byte[16] (ceros estáticos) +Modo: CBC +``` + +La clave está hardcodeada en el código fuente (`CryptoHelper.cs` y `Seguridad.cs`). Un atacante con acceso al binario o al código puede desencriptar todas las contraseñas de los profesores. + +**Recomendación:** Usar hash con salt (bcrypt/argon2) en lugar de encriptación reversible, o al menos mover la clave a una variable de entorno/configuración segura. + +### 4.3 Sin validación de IP/Sede + +La app SAM actual valida que el profesor solo pueda iniciar clases desde las IPs de la red del instituto: +```csharp +string[] IPsPermitidas = { "200.68.55.74", "200.54.121.26", "181.212.110.122", "127.0.0.1", "::1" }; +``` + +Y para sedes ONLINE/EMPRESAS omite esta validación. La desktop app **no tiene ninguna validación**, lo que permite iniciar clases desde cualquier lugar. + +**Recomendación:** La carta que se generó previamente (sobre inicio solo desde sedes físicas) debería reflejarse aquí: validar que el equipo esté en la red de una sede física autorizada. + +### 4.4 Sin soporte para schemas múltiples + +SAM maneja dos orígenes de datos: +- **Plan Central** (schema `sige_sam_V3`) — cursos regulares +- **Empresa** (schema `sige_sam_empresa`) — cursos cerrados + +La desktop app no diferencia entre ambos. + +### 4.5 Sin ciclo de vida de sesión completo + +En SAM, cuando se finaliza una clase se registra `HoraSalida` y se cambia `Estado = 'Finalizada'`. La desktop hace lo mismo localmente, pero no hay registro del período de pago (21→20), ni se calculan horas para RRHH. + +### 4.6 Modelo AsistenciaProfesor tiene campo `Temporal` no usado + +El modelo `AsistenciaProfesor` tiene un campo `Temporal` (bool, default false) que no se utiliza en ninguna parte del código. En SAM este campo se usa para identificar reemplazos (`temporal = 1`). + +### 4.7 Sin View ni ViewModel para Sesiones, Notas, Reportes, Reemplazo, Contenido + +Faltan **5 vistas completas** para igualar la funcionalidad del módulo SAM. + +--- + +## 5. COMPARATIVA ARQUITECTÓNICA + +### SAM Profesores (WebForms) +``` +Web (aspx.cs) → Negocio (NProfesor, NCurso, Seguridad, Mails) → Datos (Conexion MariaDB, Profesor.cs, Curso.cs) +``` + +### Desktop App (Avalonia) +``` +Views (axaml) → ViewModels → Services (AuthService, AsistenciaService) → Repositories → AppDbContext (SQLite) +``` + +Ambos usan una arquitectura en capas similar, pero el desktop reemplaza la capa de datos MariaDB por SQLite local. + +--- + +## 6. PENDIENTE PARA COMPILAR MULTIPLATAFORMA + +Antes de compilar para Windows, macOS y Linux (deb + Arch), se debe: + +### 6.1 Funcional (para igualar la guía SAM) +1. Conectar a MariaDB real (replicar stored procedures) +2. Implementar validación de IP/Sede (inicio solo desde sedes físicas) +3. Agregar vistas: Sesiones (pagos), Notas, Reportes, Reemplazo, Contenido +4. Agregar notificaciones por email +5. Soportar schemas Plan Central y Empresa + +### 6.2 Técnico +1. Corregir seguridad de contraseñas (bcrypt/argon2) +2. Mover configuración sensible a variables de entorno o archivo de configuración +3. Agregar `RuntimeIdentifiers` en `.csproj` para publicación multiplataforma +4. Probar compilación con: + - `dotnet publish -c Release -r win-x64 --self-contained` + - `dotnet publish -c Release -r linux-x64 --self-contained` + - `dotnet publish -c Release -r osx-x64 --self-contained` +5. Para empaquetado: + - **Windows**: NSIS o Inno Setup (o .exe directo) + - **Linux .deb**: `dotnet publish` + `dpkg-deb` para crear .deb + - **Linux Arch**: PKGBUILD para AUR/local + - **macOS**: .app bundle o .dmg + +--- + +## 7. CONCLUSIÓN + +La desktop app es un **prototipo funcional** con las 4 vistas básicas (login, dashboard, asistencia, perfil) pero que **actualmente opera en aislamiento** con SQLite local. No reemplaza al sistema SAM real porque: + +1. No está conectada a la base de datos de producción +2. Le falta más de la mitad de la funcionalidad del módulo Profesores SAM +3. Carece de las validaciones de seguridad y sede que el sistema actual tiene + +**Para producción:** Se requiere conectar a MariaDB (usando las mismas SPs), agregar las vistas faltantes, e implementar las validaciones de IP/sede antes de distribuir ejecutables a los profesores. diff --git a/Asistencia.slnx b/Asistencia.slnx new file mode 100644 index 0000000..000d47b --- /dev/null +++ b/Asistencia.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Asistencia/App.axaml b/Asistencia/App.axaml new file mode 100644 index 0000000..5af017a --- /dev/null +++ b/Asistencia/App.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Asistencia/App.axaml.cs b/Asistencia/App.axaml.cs new file mode 100644 index 0000000..b48f28e --- /dev/null +++ b/Asistencia/App.axaml.cs @@ -0,0 +1,51 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Asistencia.ViewModels; +using Asistencia.Views; +using Asistencia.Data; +using Asistencia.Data.Repositories; +using Asistencia.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Asistencia; + +public partial class App : Application +{ + public static IServiceProvider? Services { get; private set; } + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + var services = new ServiceCollection(); + + services.AddDbContext(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + Services = services.BuildServiceProvider(); + + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var vm = Services.GetRequiredService(); + desktop.MainWindow = new MainWindow + { + DataContext = vm + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Asistencia/Asistencia.csproj b/Asistencia/Asistencia.csproj new file mode 100644 index 0000000..e31a4fa --- /dev/null +++ b/Asistencia/Asistencia.csproj @@ -0,0 +1,32 @@ + + + WinExe + net10.0 + enable + enable + app.manifest + + + + + + + + + + + + + + None + All + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/Asistencia/Assets/avalonia-logo.ico b/Asistencia/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Asistencia/Assets/avalonia-logo.ico differ diff --git a/Asistencia/Data/AppDbContext.cs b/Asistencia/Data/AppDbContext.cs new file mode 100644 index 0000000..1d5911a --- /dev/null +++ b/Asistencia/Data/AppDbContext.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using Asistencia.Models; + +namespace Asistencia.Data; + +public class AppDbContext : DbContext +{ + public DbSet Profesores => Set(); + public DbSet Cursos => Set(); + public DbSet CursosAbiertos => Set(); + public DbSet DetallesContrato => Set(); + public DbSet AsistenciasProfesor => Set(); + public DbSet AsistenciasAlumno => Set(); + + public string DbPath { get; } + + public AppDbContext() + { + var folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appFolder = Path.Combine(folder, "Asistencia"); + Directory.CreateDirectory(appFolder); + DbPath = Path.Combine(appFolder, "asistencia.db"); + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite($"Data Source={DbPath}"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(c => c.Curso) + .WithMany() + .HasForeignKey(c => c.IdCursos); + + modelBuilder.Entity() + .HasOne(d => d.CursoAbierto) + .WithMany() + .HasForeignKey(d => d.IdCursoAbierto); + + modelBuilder.Entity() + .HasOne(a => a.CursoAbierto) + .WithMany() + .HasForeignKey(a => a.IdCursoAbierto); + + modelBuilder.Entity() + .HasKey(a => new { a.IdDetalleContrato, a.FechaAsistencia }); + + modelBuilder.Entity() + .HasOne(a => a.DetalleContrato) + .WithMany() + .HasForeignKey(a => a.IdDetalleContrato); + + SeedData(modelBuilder); + } + + private static void SeedData(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Profesor + { + RutPasaporte = "12345678-9", + Nombre = "Juan", + Paterno = "Perez", + Materno = "Gonzalez", + Email = "juan.perez@norteamericano.cl", + Fono = "+56912345678", + Direccion = "Av. Providencia 1234, Santiago", + Clave = "", + CrearClave = true + } + ); + + modelBuilder.Entity().HasData( + new Curso { IdCursos = 1, NombreCurso = "English Basic A1" }, + new Curso { IdCursos = 2, NombreCurso = "English Intermediate B1" }, + new Curso { IdCursos = 3, NombreCurso = "English Advanced C1" } + ); + + modelBuilder.Entity().HasData( + new CursoAbierto + { + IdCursoAbierto = 1, + IdCursos = 1, + Sala = "Sala 101", + Sede = "Providencia", + HoraInicio = "09:00", + HoraFin = "10:30", + FechaInicio = new DateTime(2026, 3, 1), + FechaTermino = new DateTime(2026, 7, 31), + ProfesorRun = "12345678-9" + }, + new CursoAbierto + { + IdCursoAbierto = 2, + IdCursos = 2, + Sala = "Sala 202", + Sede = "Las Condes", + HoraInicio = "14:00", + HoraFin = "15:30", + FechaInicio = new DateTime(2026, 3, 1), + FechaTermino = new DateTime(2026, 7, 31), + ProfesorRun = "12345678-9" + } + ); + + modelBuilder.Entity().HasData( + new DetalleContrato + { + IdDetalleContrato = 1, + IdCursoAbierto = 1, + AP_Paterno = "Garcia", + AP_Materno = "Lopez", + Nombres = "Maria", + Email = "maria.garcia@email.com", + Telefono = "+56998765432" + }, + new DetalleContrato + { + IdDetalleContrato = 2, + IdCursoAbierto = 1, + AP_Paterno = "Rodriguez", + AP_Materno = "Soto", + Nombres = "Carlos", + Email = "carlos.rodriguez@email.com", + Telefono = "+56987654321" + }, + new DetalleContrato + { + IdDetalleContrato = 3, + IdCursoAbierto = 2, + AP_Paterno = "Martinez", + AP_Materno = "Fernandez", + Nombres = "Ana", + Email = "ana.martinez@email.com", + Telefono = "+56976543210" + } + ); + } +} diff --git a/Asistencia/Data/Repositories/AsistenciaRepository.cs b/Asistencia/Data/Repositories/AsistenciaRepository.cs new file mode 100644 index 0000000..4ba5018 --- /dev/null +++ b/Asistencia/Data/Repositories/AsistenciaRepository.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Asistencia.Models; + +namespace Asistencia.Data.Repositories; + +public class AsistenciaRepository +{ + private readonly AppDbContext _context; + + public AsistenciaRepository(AppDbContext context) + { + _context = context; + } + + public async Task BuscarSesionActivaAsync(int idCursoAbierto, DateTime fecha) + { + return await _context.AsistenciasProfesor + .FirstOrDefaultAsync(a => + a.IdCursoAbierto == idCursoAbierto && + a.FechaAsistencia.Date == fecha.Date && + a.Estado == "Activa"); + } + + public async Task IniciarSesionAsync(int idCursoAbierto, string rut, DateTime fecha, string horaInicio) + { + var sesion = new AsistenciaProfesor + { + IdCursoAbierto = idCursoAbierto, + RutPasaporte = rut, + FechaAsistencia = fecha, + HoraInicio = horaInicio, + Estado = "Activa" + }; + _context.AsistenciasProfesor.Add(sesion); + await _context.SaveChangesAsync(); + return sesion; + } + + public async Task FinalizarSesionAsync(int idSesion, string horaFin) + { + var sesion = await _context.AsistenciasProfesor.FindAsync(idSesion); + if (sesion == null) return false; + + sesion.HoraFin = horaFin; + sesion.Estado = "Finalizada"; + await _context.SaveChangesAsync(); + return true; + } + + public async Task> ObtenerAsistenciaAlumnosAsync(int idCursoAbierto, DateTime fecha) + { + var alumnos = await _context.DetallesContrato + .Where(d => d.IdCursoAbierto == idCursoAbierto) + .ToListAsync(); + + var asistencias = await _context.AsistenciasAlumno + .Where(a => alumnos.Select(al => al.IdDetalleContrato).Contains(a.IdDetalleContrato) + && a.FechaAsistencia.Date == fecha.Date) + .ToListAsync(); + + return asistencias.ToDictionary(a => a.IdDetalleContrato, a => a.IdTipoAsistencia); + } + + public async Task GuardarAsistenciaAlumnoAsync(int idDetalleContrato, DateTime fecha, string hora, int tipoAsistencia) + { + var existing = await _context.AsistenciasAlumno + .FirstOrDefaultAsync(a => a.IdDetalleContrato == idDetalleContrato + && a.FechaAsistencia.Date == fecha.Date); + + if (existing != null) + { + existing.IdTipoAsistencia = tipoAsistencia; + existing.Hora = hora; + } + else + { + _context.AsistenciasAlumno.Add(new AsistenciaAlumno + { + IdDetalleContrato = idDetalleContrato, + FechaAsistencia = fecha, + Hora = hora, + IdTipoAsistencia = tipoAsistencia + }); + } + + await _context.SaveChangesAsync(); + } +} diff --git a/Asistencia/Data/Repositories/CursoRepository.cs b/Asistencia/Data/Repositories/CursoRepository.cs new file mode 100644 index 0000000..adfd3bd --- /dev/null +++ b/Asistencia/Data/Repositories/CursoRepository.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Asistencia.Models; + +namespace Asistencia.Data.Repositories; + +public class CursoRepository +{ + private readonly AppDbContext _context; + + public CursoRepository(AppDbContext context) + { + _context = context; + } + + public async Task> BuscarCursosPorProfesorAsync(string profesorRun) + { + return await _context.CursosAbiertos + .Include(c => c.Curso) + .Where(c => c.ProfesorRun == profesorRun) + .ToListAsync(); + } + + public async Task BuscarCursoAbiertoPorIdAsync(int idCursoAbierto) + { + return await _context.CursosAbiertos + .Include(c => c.Curso) + .FirstOrDefaultAsync(c => c.IdCursoAbierto == idCursoAbierto); + } + + public async Task> BuscarAlumnosPorCursoAsync(int idCursoAbierto) + { + return await _context.DetallesContrato + .Where(d => d.IdCursoAbierto == idCursoAbierto) + .ToListAsync(); + } +} diff --git a/Asistencia/Data/Repositories/ProfesorRepository.cs b/Asistencia/Data/Repositories/ProfesorRepository.cs new file mode 100644 index 0000000..27eadb2 --- /dev/null +++ b/Asistencia/Data/Repositories/ProfesorRepository.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Asistencia.Models; + +namespace Asistencia.Data.Repositories; + +public class ProfesorRepository +{ + private readonly AppDbContext _context; + + public ProfesorRepository(AppDbContext context) + { + _context = context; + } + + public async Task BuscarPorRutAsync(string rut) + { + return await _context.Profesores.FirstOrDefaultAsync(p => p.RutPasaporte == rut); + } + + public async Task ActualizarClaveAsync(string rut, string nuevaClave) + { + var profesor = await _context.Profesores.FirstOrDefaultAsync(p => p.RutPasaporte == rut); + if (profesor == null) return false; + + profesor.Clave = nuevaClave; + profesor.CrearClave = false; + await _context.SaveChangesAsync(); + return true; + } + + public async Task ActualizarEmailAsync(string rut, string email) + { + var profesor = await _context.Profesores.FirstOrDefaultAsync(p => p.RutPasaporte == rut); + if (profesor == null) return false; + + profesor.Email = email; + await _context.SaveChangesAsync(); + return true; + } + + public async Task AutenticarAsync(string rut, string clave) + { + var profesor = await _context.Profesores.FirstOrDefaultAsync(p => p.RutPasaporte == rut); + if (profesor == null) return null; + + if (profesor.CrearClave) return null; + + var claveDescifrada = Helpers.CryptoHelper.Desencriptar(profesor.Clave); + return claveDescifrada == clave ? profesor : null; + } +} diff --git a/Asistencia/Helpers/CryptoHelper.cs b/Asistencia/Helpers/CryptoHelper.cs new file mode 100644 index 0000000..23ac618 --- /dev/null +++ b/Asistencia/Helpers/CryptoHelper.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Asistencia.Helpers; + +public static class CryptoHelper +{ + private static readonly byte[] Key = Encoding.UTF8.GetBytes("S4M_Pru3b4_2024!"); + private static readonly byte[] IV = new byte[16]; + + public static string Encriptar(string texto) + { + using var aes = Aes.Create(); + aes.Key = Key; + aes.IV = IV; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + using var ms = new MemoryStream(); + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + using (var writer = new StreamWriter(cs)) + { + writer.Write(texto); + } + return Convert.ToBase64String(ms.ToArray()); + } + + public static string Desencriptar(string textoEncriptado) + { + using var aes = Aes.Create(); + aes.Key = Key; + aes.IV = IV; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + var buffer = Convert.FromBase64String(textoEncriptado); + using var decryptor = aes.CreateDecryptor(); + using var ms = new MemoryStream(buffer); + using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read); + using var reader = new StreamReader(cs); + return reader.ReadToEnd(); + } +} diff --git a/Asistencia/Models/AsistenciaAlumno.cs b/Asistencia/Models/AsistenciaAlumno.cs new file mode 100644 index 0000000..8d9b5cc --- /dev/null +++ b/Asistencia/Models/AsistenciaAlumno.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class AsistenciaAlumno +{ + public int IdDetalleContrato { get; set; } + + public DateTime FechaAsistencia { get; set; } + + [MaxLength(10)] + public string Hora { get; set; } = string.Empty; + + public int IdTipoAsistencia { get; set; } = 3; + + public DetalleContrato? DetalleContrato { get; set; } +} diff --git a/Asistencia/Models/AsistenciaProfesor.cs b/Asistencia/Models/AsistenciaProfesor.cs new file mode 100644 index 0000000..7703ff6 --- /dev/null +++ b/Asistencia/Models/AsistenciaProfesor.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class AsistenciaProfesor +{ + [Key] + public int Id { get; set; } + + public int IdCursoAbierto { get; set; } + + [MaxLength(20)] + public string RutPasaporte { get; set; } = string.Empty; + + public DateTime FechaAsistencia { get; set; } + + [MaxLength(10)] + public string HoraInicio { get; set; } = string.Empty; + + [MaxLength(10)] + public string HoraFin { get; set; } = string.Empty; + + [MaxLength(20)] + public string Estado { get; set; } = "Activa"; + + public bool Temporal { get; set; } + + public CursoAbierto? CursoAbierto { get; set; } +} diff --git a/Asistencia/Models/Curso.cs b/Asistencia/Models/Curso.cs new file mode 100644 index 0000000..a9f7270 --- /dev/null +++ b/Asistencia/Models/Curso.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class Curso +{ + [Key] + public int IdCursos { get; set; } + + [MaxLength(200)] + public string NombreCurso { get; set; } = string.Empty; +} diff --git a/Asistencia/Models/CursoAbierto.cs b/Asistencia/Models/CursoAbierto.cs new file mode 100644 index 0000000..185cace --- /dev/null +++ b/Asistencia/Models/CursoAbierto.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class CursoAbierto +{ + [Key] + public int IdCursoAbierto { get; set; } + + public int IdCursos { get; set; } + + [MaxLength(50)] + public string Sala { get; set; } = string.Empty; + + [MaxLength(100)] + public string Sede { get; set; } = string.Empty; + + [MaxLength(10)] + public string HoraInicio { get; set; } = string.Empty; + + [MaxLength(10)] + public string HoraFin { get; set; } = string.Empty; + + public DateTime FechaInicio { get; set; } + + public DateTime FechaTermino { get; set; } + + [MaxLength(20)] + public string ProfesorRun { get; set; } = string.Empty; + + public Curso? Curso { get; set; } +} diff --git a/Asistencia/Models/DetalleContrato.cs b/Asistencia/Models/DetalleContrato.cs new file mode 100644 index 0000000..110c5d8 --- /dev/null +++ b/Asistencia/Models/DetalleContrato.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class DetalleContrato +{ + [Key] + public int IdDetalleContrato { get; set; } + + public int IdCursoAbierto { get; set; } + + [MaxLength(100)] + public string AP_Paterno { get; set; } = string.Empty; + + [MaxLength(100)] + public string AP_Materno { get; set; } = string.Empty; + + [MaxLength(100)] + public string Nombres { get; set; } = string.Empty; + + [MaxLength(200)] + public string Email { get; set; } = string.Empty; + + [MaxLength(20)] + public string Telefono { get; set; } = string.Empty; + + public CursoAbierto? CursoAbierto { get; set; } + + public string NombreCompleto => $"{Nombres} {AP_Paterno} {AP_Materno}".Trim(); +} diff --git a/Asistencia/Models/Profesor.cs b/Asistencia/Models/Profesor.cs new file mode 100644 index 0000000..09c9b23 --- /dev/null +++ b/Asistencia/Models/Profesor.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Asistencia.Models; + +public class Profesor +{ + [Key] + [MaxLength(20)] + public string RutPasaporte { get; set; } = string.Empty; + + [MaxLength(100)] + public string Nombre { get; set; } = string.Empty; + + [MaxLength(100)] + public string Paterno { get; set; } = string.Empty; + + [MaxLength(100)] + public string Materno { get; set; } = string.Empty; + + [MaxLength(200)] + public string Email { get; set; } = string.Empty; + + [MaxLength(20)] + public string Fono { get; set; } = string.Empty; + + [MaxLength(300)] + public string Direccion { get; set; } = string.Empty; + + [MaxLength(500)] + public string Clave { get; set; } = string.Empty; + + public bool CrearClave { get; set; } + + public string NombreCompleto => $"{Nombre} {Paterno} {Materno}".Trim(); +} diff --git a/Asistencia/Models/TipoAsistencia.cs b/Asistencia/Models/TipoAsistencia.cs new file mode 100644 index 0000000..80a8d36 --- /dev/null +++ b/Asistencia/Models/TipoAsistencia.cs @@ -0,0 +1,16 @@ +namespace Asistencia.Models; + +public static class TipoAsistencia +{ + public const int Presente = 1; + public const int Tardanza = 2; + public const int Ausente = 3; + + public static string Nombre(int id) => id switch + { + Presente => "Presente", + Tardanza => "Tardanza", + Ausente => "Ausente", + _ => "Desconocido" + }; +} diff --git a/Asistencia/Program.cs b/Asistencia/Program.cs new file mode 100644 index 0000000..9c88f97 --- /dev/null +++ b/Asistencia/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace Asistencia; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/Asistencia/Services/AsistenciaService.cs b/Asistencia/Services/AsistenciaService.cs new file mode 100644 index 0000000..009e58b --- /dev/null +++ b/Asistencia/Services/AsistenciaService.cs @@ -0,0 +1,48 @@ +using Asistencia.Data.Repositories; +using Asistencia.Models; + +namespace Asistencia.Services; + +public class AsistenciaService +{ + private readonly CursoRepository _cursoRepo; + private readonly AsistenciaRepository _asistenciaRepo; + + public AsistenciaService(CursoRepository cursoRepo, AsistenciaRepository asistenciaRepo) + { + _cursoRepo = cursoRepo; + _asistenciaRepo = asistenciaRepo; + } + + public Task> ObtenerCursosProfesorAsync(string profesorRun) + => _cursoRepo.BuscarCursosPorProfesorAsync(profesorRun); + + public Task ObtenerCursoAsync(int idCursoAbierto) + => _cursoRepo.BuscarCursoAbiertoPorIdAsync(idCursoAbierto); + + public Task> ObtenerAlumnosAsync(int idCursoAbierto) + => _cursoRepo.BuscarAlumnosPorCursoAsync(idCursoAbierto); + + public Task BuscarSesionActivaAsync(int idCursoAbierto) + => _asistenciaRepo.BuscarSesionActivaAsync(idCursoAbierto, DateTime.Today); + + public async Task IniciarSesionAsync(int idCursoAbierto, string rut) + { + var now = DateTime.Now; + return await _asistenciaRepo.IniciarSesionAsync(idCursoAbierto, rut, now.Date, now.ToString("HH:mm")); + } + + public async Task FinalizarSesionAsync(int idSesion) + { + return await _asistenciaRepo.FinalizarSesionAsync(idSesion, DateTime.Now.ToString("HH:mm")); + } + + public Task> ObtenerAsistenciaAlumnosAsync(int idCursoAbierto) + => _asistenciaRepo.ObtenerAsistenciaAlumnosAsync(idCursoAbierto, DateTime.Today); + + public async Task GuardarAsistenciaAlumnoAsync(int idDetalleContrato, int tipoAsistencia) + { + await _asistenciaRepo.GuardarAsistenciaAlumnoAsync( + idDetalleContrato, DateTime.Today, DateTime.Now.ToString("HH:mm"), tipoAsistencia); + } +} diff --git a/Asistencia/Services/AuthService.cs b/Asistencia/Services/AuthService.cs new file mode 100644 index 0000000..6988582 --- /dev/null +++ b/Asistencia/Services/AuthService.cs @@ -0,0 +1,96 @@ +using Asistencia.Data.Repositories; +using Asistencia.Models; + +namespace Asistencia.Services; + +public class AuthService +{ + private readonly ProfesorRepository _profesorRepo; + private Profesor? _currentUser; + + public AuthService(ProfesorRepository profesorRepo) + { + _profesorRepo = profesorRepo; + } + + public Profesor? CurrentUser => _currentUser; + + public async Task<(bool Success, string Message)> LoginAsync(string rut, string password) + { + if (string.IsNullOrWhiteSpace(rut) || string.IsNullOrWhiteSpace(password)) + return (false, "Ingrese usuario y contraseña"); + + var profesor = await _profesorRepo.BuscarPorRutAsync(rut); + if (profesor == null) + return (false, "Usuario no encontrado"); + + if (profesor.CrearClave) + return (false, "Debe crear su contraseña primero"); + + var authenticated = await _profesorRepo.AutenticarAsync(rut, password); + if (authenticated == null) + return (false, "Contraseña incorrecta"); + + _currentUser = authenticated; + return (true, "Inicio de sesión exitoso"); + } + + public async Task<(bool Success, string Message)> CrearClaveAsync(string rut, string nuevaClave, string confirmarClave) + { + if (string.IsNullOrWhiteSpace(nuevaClave)) + return (false, "Ingrese una contraseña"); + + if (nuevaClave.Length < 6) + return (false, "La contraseña debe tener al menos 6 caracteres"); + + if (nuevaClave != confirmarClave) + return (false, "Las contraseñas no coinciden"); + + var encrypted = Helpers.CryptoHelper.Encriptar(nuevaClave); + var result = await _profesorRepo.ActualizarClaveAsync(rut, encrypted); + return result ? (true, "Contraseña creada exitosamente") : (false, "Error al crear contraseña"); + } + + public async Task<(bool Success, string Message)> ActualizarEmailAsync(string email) + { + if (_currentUser == null) return (false, "No hay sesión activa"); + + if (string.IsNullOrWhiteSpace(email) || !email.Contains("@")) + return (false, "Ingrese un email válido"); + + var result = await _profesorRepo.ActualizarEmailAsync(_currentUser.RutPasaporte, email); + if (result) + { + _currentUser.Email = email; + return (true, "Email actualizado exitosamente"); + } + return (false, "Error al actualizar email"); + } + + public async Task<(bool Success, string Message)> CambiarClaveAsync(string claveActual, string nuevaClave, string confirmarClave) + { + if (_currentUser == null) return (false, "No hay sesión activa"); + + if (string.IsNullOrWhiteSpace(claveActual)) + return (false, "Ingrese la contraseña actual"); + + if (string.IsNullOrWhiteSpace(nuevaClave) || nuevaClave.Length < 6) + return (false, "La nueva contraseña debe tener al menos 6 caracteres"); + + if (nuevaClave != confirmarClave) + return (false, "Las contraseñas no coinciden"); + + var authenticated = await _profesorRepo.AutenticarAsync(_currentUser.RutPasaporte, claveActual); + if (authenticated == null) + return (false, "La contraseña actual es incorrecta"); + + var encrypted = Helpers.CryptoHelper.Encriptar(nuevaClave); + var result = await _profesorRepo.ActualizarClaveAsync(_currentUser.RutPasaporte, encrypted); + return result ? (true, "Contraseña cambiada exitosamente") : (false, "Error al cambiar contraseña"); + } + + public void Logout() + { + _currentUser = null; + } +} diff --git a/Asistencia/Styles/Cards.axaml b/Asistencia/Styles/Cards.axaml new file mode 100644 index 0000000..a5db8bf --- /dev/null +++ b/Asistencia/Styles/Cards.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Asistencia/ViewLocator.cs b/Asistencia/ViewLocator.cs new file mode 100644 index 0000000..f413711 --- /dev/null +++ b/Asistencia/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Asistencia.ViewModels; + +namespace Asistencia; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/Asistencia/ViewModels/AsistenciaViewModel.cs b/Asistencia/ViewModels/AsistenciaViewModel.cs new file mode 100644 index 0000000..6cd95d0 --- /dev/null +++ b/Asistencia/ViewModels/AsistenciaViewModel.cs @@ -0,0 +1,167 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Asistencia.Models; +using Asistencia.Services; +using System.Collections.ObjectModel; + +namespace Asistencia.ViewModels; + +public partial class AsistenciaViewModel : ObservableObject +{ + private readonly AsistenciaService _asistenciaService; + private readonly AuthService _authService; + private readonly MainWindowViewModel _mainVm; + private readonly CursoAbierto _curso; + + [ObservableProperty] + private string _nombreCurso = string.Empty; + + [ObservableProperty] + private string _infoCurso = string.Empty; + + [ObservableProperty] + private bool _enSesion; + + [ObservableProperty] + private int _totalAlumnos; + + [ObservableProperty] + private int _presentes; + + [ObservableProperty] + private int _tardanzas; + + [ObservableProperty] + private int _ausentes; + + [ObservableProperty] + private string _mensajeEstado = string.Empty; + + public ObservableCollection Alumnos { get; } = new(); + + public AsistenciaViewModel(AsistenciaService asistenciaService, AuthService authService, CursoAbierto curso, MainWindowViewModel mainVm) + { + _asistenciaService = asistenciaService; + _authService = authService; + _mainVm = mainVm; + _curso = curso; + + NombreCurso = curso.Curso?.NombreCurso ?? "Curso"; + InfoCurso = $"{curso.Sala} | {curso.Sede} | {curso.HoraInicio} - {curso.HoraFin}"; + + _ = InitAsync(); + } + + private async Task InitAsync() + { + var sesion = await _asistenciaService.BuscarSesionActivaAsync(_curso.IdCursoAbierto); + EnSesion = sesion != null; + + var alumnos = await _asistenciaService.ObtenerAlumnosAsync(_curso.IdCursoAbierto); + var asistencias = await _asistenciaService.ObtenerAsistenciaAlumnosAsync(_curso.IdCursoAbierto); + + Alumnos.Clear(); + foreach (var alumno in alumnos) + { + var tipo = asistencias.TryGetValue(alumno.IdDetalleContrato, out var t) ? t : TipoAsistencia.Ausente; + Alumnos.Add(new AlumnoAsistenciaViewModel(alumno, tipo)); + } + + TotalAlumnos = Alumnos.Count; + ActualizarContadores(); + + MensajeEstado = EnSesion + ? "Sesion en curso - Puede registrar asistencia" + : "Inicie la sesion para registrar asistencia"; + } + + [RelayCommand] + private async Task IniciarSesionAsync() + { + if (_authService.CurrentUser == null) return; + + await _asistenciaService.IniciarSesionAsync(_curso.IdCursoAbierto, _authService.CurrentUser.RutPasaporte); + EnSesion = true; + MensajeEstado = "Sesion iniciada - Registre la asistencia de los alumnos"; + } + + [RelayCommand] + private async Task FinalizarSesionAsync() + { + var sesion = await _asistenciaService.BuscarSesionActivaAsync(_curso.IdCursoAbierto); + if (sesion != null) + { + await _asistenciaService.FinalizarSesionAsync(sesion.Id); + } + EnSesion = false; + MensajeEstado = "Sesion finalizada"; + } + + [RelayCommand] + private async Task MarcarAlumnoAsync(AlumnoAsistenciaViewModel? alumno) + { + if (alumno == null || !EnSesion) return; + + alumno.CiclarTipoAsistencia(); + await _asistenciaService.GuardarAsistenciaAlumnoAsync(alumno.IdDetalleContrato, alumno.TipoAsistencia); + ActualizarContadores(); + } + + [RelayCommand] + private void Volver() + { + _mainVm.NavigateToDashboard(); + } + + private void ActualizarContadores() + { + Presentes = Alumnos.Count(a => a.TipoAsistencia == Models.TipoAsistencia.Presente); + Tardanzas = Alumnos.Count(a => a.TipoAsistencia == Models.TipoAsistencia.Tardanza); + Ausentes = Alumnos.Count(a => a.TipoAsistencia == Models.TipoAsistencia.Ausente); + } +} + +public partial class AlumnoAsistenciaViewModel : ObservableObject +{ + public int IdDetalleContrato { get; } + public string NombreCompleto { get; } + + [ObservableProperty] + private int _tipoAsistencia; + + [ObservableProperty] + private string _tipoAsistenciaTexto; + + [ObservableProperty] + private string _tipoAsistenciaColor; + + public AlumnoAsistenciaViewModel(DetalleContrato alumno, int tipoInicial) + { + IdDetalleContrato = alumno.IdDetalleContrato; + NombreCompleto = alumno.NombreCompleto; + TipoAsistencia = tipoInicial; + ActualizarDisplay(); + } + + public void CiclarTipoAsistencia() + { + TipoAsistencia = TipoAsistencia switch + { + Models.TipoAsistencia.Presente => Models.TipoAsistencia.Tardanza, + Models.TipoAsistencia.Tardanza => Models.TipoAsistencia.Ausente, + _ => Models.TipoAsistencia.Presente + }; + ActualizarDisplay(); + } + + private void ActualizarDisplay() + { + TipoAsistenciaTexto = Models.TipoAsistencia.Nombre(TipoAsistencia); + TipoAsistenciaColor = TipoAsistencia switch + { + Models.TipoAsistencia.Presente => "#28A745", + Models.TipoAsistencia.Tardanza => "#FFC107", + _ => "#DC3545" + }; + } +} diff --git a/Asistencia/ViewModels/DashboardViewModel.cs b/Asistencia/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..92e5ae2 --- /dev/null +++ b/Asistencia/ViewModels/DashboardViewModel.cs @@ -0,0 +1,61 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Asistencia.Models; +using Asistencia.Services; +using System.Collections.ObjectModel; + +namespace Asistencia.ViewModels; + +public partial class DashboardViewModel : ObservableObject +{ + private readonly AuthService _authService; + private readonly AsistenciaService _asistenciaService; + private readonly MainWindowViewModel _mainVm; + + [ObservableProperty] + private string _nombreProfesor = string.Empty; + + [ObservableProperty] + private int _totalCursos; + + public ObservableCollection Cursos { get; } = new(); + + public DashboardViewModel(AuthService authService, AsistenciaService asistenciaService, MainWindowViewModel mainVm) + { + _authService = authService; + _asistenciaService = asistenciaService; + _mainVm = mainVm; + NombreProfesor = authService.CurrentUser?.Nombre ?? ""; + _ = LoadCursosAsync(); + } + + private async Task LoadCursosAsync() + { + if (_authService.CurrentUser == null) return; + + var cursos = await _asistenciaService.ObtenerCursosProfesorAsync(_authService.CurrentUser.RutPasaporte); + Cursos.Clear(); + foreach (var c in cursos) Cursos.Add(c); + TotalCursos = Cursos.Count; + } + + [RelayCommand] + private void AbrirAsistencia(CursoAbierto? curso) + { + if (curso != null) + _mainVm.NavigateToAsistencia(curso); + } + + [RelayCommand] + private void AbrirPerfil() + { + _mainVm.NavigateToPerfil(); + } + + [RelayCommand] + private void CerrarSesion() + { + _authService.Logout(); + _mainVm.NavigateToLogin(); + } +} diff --git a/Asistencia/ViewModels/LoginViewModel.cs b/Asistencia/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..9f5092a --- /dev/null +++ b/Asistencia/ViewModels/LoginViewModel.cs @@ -0,0 +1,53 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Asistencia.Services; + +namespace Asistencia.ViewModels; + +public partial class LoginViewModel : ObservableObject +{ + private readonly AuthService _authService; + private readonly MainWindowViewModel _mainVm; + + [ObservableProperty] + private string _rut = string.Empty; + + [ObservableProperty] + private string _password = string.Empty; + + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _hasError; + + [ObservableProperty] + private bool _isLoading; + + public LoginViewModel(AuthService authService, MainWindowViewModel mainVm) + { + _authService = authService; + _mainVm = mainVm; + } + + [RelayCommand] + private async Task LoginAsync() + { + IsLoading = true; + HasError = false; + + var (success, message) = await _authService.LoginAsync(Rut, Password); + + if (success) + { + _mainVm.NavigateToDashboard(); + } + else + { + ErrorMessage = message; + HasError = true; + } + + IsLoading = false; + } +} diff --git a/Asistencia/ViewModels/MainWindowViewModel.cs b/Asistencia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..f7ab338 --- /dev/null +++ b/Asistencia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,58 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Asistencia.Models; +using Asistencia.Services; +using Avalonia.Controls; +using Asistencia.Views; +using System.Collections.ObjectModel; + +namespace Asistencia.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + private readonly AuthService _authService; + private readonly AsistenciaService _asistenciaService; + private object? _currentView; + + [ObservableProperty] + private string _currentViewTitle = "Login"; + + public object? CurrentView + { + get => _currentView; + set => SetProperty(ref _currentView, value); + } + + public MainWindowViewModel(AuthService authService, AsistenciaService asistenciaService) + { + _authService = authService; + _asistenciaService = asistenciaService; + CurrentView = new LoginViewModel(authService, this); + } + + public void NavigateToLogin() + { + CurrentViewTitle = "Login"; + CurrentView = new LoginViewModel(_authService, this); + } + + public void NavigateToDashboard() + { + CurrentViewTitle = "Panel Principal"; + CurrentView = new DashboardViewModel(_authService, _asistenciaService, this); + } + + public void NavigateToAsistencia(CursoAbierto curso) + { + CurrentViewTitle = "Asistencia"; + CurrentView = new AsistenciaViewModel(_asistenciaService, _authService, curso, this); + } + + public void NavigateToPerfil() + { + CurrentViewTitle = "Mi Perfil"; + CurrentView = new PerfilViewModel(_authService, this); + } + + public string UsuarioActual => _authService.CurrentUser?.NombreCompleto ?? ""; +} diff --git a/Asistencia/ViewModels/PerfilViewModel.cs b/Asistencia/ViewModels/PerfilViewModel.cs new file mode 100644 index 0000000..a9c42db --- /dev/null +++ b/Asistencia/ViewModels/PerfilViewModel.cs @@ -0,0 +1,122 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Asistencia.Services; + +namespace Asistencia.ViewModels; + +public partial class PerfilViewModel : ObservableObject +{ + private readonly AuthService _authService; + private readonly MainWindowViewModel _mainVm; + + [ObservableProperty] + private string _rut = string.Empty; + + [ObservableProperty] + private string _nombreCompleto = string.Empty; + + [ObservableProperty] + private string _email = string.Empty; + + [ObservableProperty] + private string _fono = string.Empty; + + [ObservableProperty] + private string _direccion = string.Empty; + + [ObservableProperty] + private string _mensajeExito = string.Empty; + + [ObservableProperty] + private bool _tieneExito; + + [ObservableProperty] + private string _mensajeError = string.Empty; + + [ObservableProperty] + private bool _tieneError; + + [ObservableProperty] + private bool _mostrarCambioClave; + + [ObservableProperty] + private string _claveActual = string.Empty; + + [ObservableProperty] + private string _nuevaClave = string.Empty; + + [ObservableProperty] + private string _confirmarClave = string.Empty; + + public PerfilViewModel(AuthService authService, MainWindowViewModel mainVm) + { + _authService = authService; + _mainVm = mainVm; + + var user = authService.CurrentUser; + if (user != null) + { + Rut = user.RutPasaporte; + NombreCompleto = user.NombreCompleto; + Email = user.Email; + Fono = user.Fono; + Direccion = user.Direccion; + } + } + + [RelayCommand] + private async Task GuardarEmailAsync() + { + TieneExito = false; + TieneError = false; + + var (success, message) = await _authService.ActualizarEmailAsync(Email); + if (success) + { + MensajeExito = message; + TieneExito = true; + } + else + { + MensajeError = message; + TieneError = true; + } + } + + [RelayCommand] + private async Task CambiarClaveAsync() + { + TieneExito = false; + TieneError = false; + + var (success, message) = await _authService.CambiarClaveAsync(ClaveActual, NuevaClave, ConfirmarClave); + if (success) + { + MensajeExito = message; + TieneExito = true; + MostrarCambioClave = false; + ClaveActual = string.Empty; + NuevaClave = string.Empty; + ConfirmarClave = string.Empty; + } + else + { + MensajeError = message; + TieneError = true; + } + } + + [RelayCommand] + private void ToggleCambioClave() + { + MostrarCambioClave = !MostrarCambioClave; + TieneExito = false; + TieneError = false; + } + + [RelayCommand] + private void Volver() + { + _mainVm.NavigateToDashboard(); + } +} diff --git a/Asistencia/ViewModels/ViewModelBase.cs b/Asistencia/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..e2f47b7 --- /dev/null +++ b/Asistencia/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Asistencia.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/Asistencia/Views/AsistenciaView.axaml b/Asistencia/Views/AsistenciaView.axaml new file mode 100644 index 0000000..4a3f475 --- /dev/null +++ b/Asistencia/Views/AsistenciaView.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Asistencia/Views/AsistenciaView.axaml.cs b/Asistencia/Views/AsistenciaView.axaml.cs new file mode 100644 index 0000000..b36721b --- /dev/null +++ b/Asistencia/Views/AsistenciaView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Asistencia.Views; + +public partial class AsistenciaView : UserControl +{ + public AsistenciaView() + { + InitializeComponent(); + } +} diff --git a/Asistencia/Views/DashboardView.axaml b/Asistencia/Views/DashboardView.axaml new file mode 100644 index 0000000..329b8ef --- /dev/null +++ b/Asistencia/Views/DashboardView.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + +