# CLAUDE.md — DocenTrack (sistema real, registro de horas lectivas docentes)

Ver primero `../CLAUDE.md` (workspace) y `../mockup-unamba/CLAUDE.md` /
`../mockup-unamba/docentrack/CLAUDE.md`. Este archivo es específico de
este repo — **incluye, repetida aquí a propósito, la regla de git de
abajo**, porque este repo se abre como proyecto raíz en Cursor y no
siempre carga `../CLAUDE.md`.

## Regla dura de git — commits y push (sin excepción)

**Prohibido incluir cualquier mención de coautoría en commits o pushes**
hechos en este repo — ni `Co-Authored-By: Claude...`, ni
`Co-authored-by: Cursor...`, ni ninguna variante, aunque la herramienta
la inyecte sola al correr `git commit`. Quitarla antes de confirmar o
de hacer push. El autor del historial es solo Ricardo.

## Qué es este proyecto

Sistema real (Laravel + Livewire) para digitalizar el registro de horas
lectivas de los docentes de la Facultad de Administración UNAMBA — hoy
se hace en un cuaderno físico con firma manuscrita. Dos experiencias:

- **Kiosko de fichaje**: PC de mesa, identificación por huella dactilar
  1:N, formulario mínimo de la clase ya dictada (curso, aula,
  alumnos, hora entrada/salida declaradas).
- **Panel administrativo**: CRUD de docentes (+ enrolamiento de huella),
  cursos + plan de estudios PDF, ciclos, periodos académicos, aulas,
  horarios/asignaciones, registros de asistencia (+ corrección con
  motivo y auditoría), reportes.

**Referencia de diseño, no fuente de verdad**: el mockup visual en
`../mockup-unamba/docentrack/` (HTML/Tailwind/JS sin backend) ya maqueta
el flujo completo del kiosko y el panel admin — pantallas, estados del
lector, identidad visual (azul navy + naranja), tokens de tema
claro/oscuro. Cuando el mockup y este repo difieran, gana este repo. El
mockup ya trae su propio `docs/ESPECIFICACION.md` con el detalle de
pantallas — no reescribirlo acá, referenciarlo.

## Estado actual

**En producción real** (confirmado por el usuario 2026-10-01, primera
presentación del sistema el 2026-09-30). Fases 0 a 7 completas: login
de administradores, catálogos, gestión de docentes, asignaciones/
horarios, kiosko de fichaje (`/`, público, sin auth) **con huella real
1:N** (lector ZKTeco ZK9500 + agente local Python en la laptop del
kiosko — ver "Fase 6" más abajo), fallback a documento si el lector no
responde, registros de asistencia con corrección auditada, dashboard,
reportes exportables (Excel/PDF), archivos subidos en disco privado
(Fase 7) y despliegue a cPanel. **118/118 tests en verde** (781
aserciones, confirmado 2026-10-01).

**Foco de la primera presentación (2026-10-01) — los 4 temas, completos:**
ver `docs/CAMBIOS.md` para el detalle de cada uno.

1. **Pre-registro diario de asistencia con estados — completo** (Fase
   8, ver "Fase 8" en el historial de fases más abajo y
   `docs/CAMBIOS.md`). `attendance_records.status`, comando
   `attendance:sync`, botón "Sincronizar" en el panel. **129/129 tests
   en verde.**
2. **Permisos de identificación — completo** (2026-10-01, diseñado vía
   `/grill-me`, ver `docs/CAMBIOS.md` y
   `docs/plans/permisos-identificacion-kiosko.md`). Hueco de seguridad
   real encontrado al diseñar esto: el servidor no tenía ningún candado
   — se cerró con `system_configuration.allow_fingerprint`/
   `allow_document`, exigido en `Kiosk::identify()`/`startIdentifying()`
   (no solo ocultando un botón). **141/141 tests en verde.**
3. **Panel directo post-identificación — completo** (2026-10-01, ver
   `docs/CAMBIOS.md`). Se fusionó la pantalla "identificado" (con
   botón "Continuar") con la de elegir clase: ahora `identify()` va
   directo a mostrar foto + nombre + documento + clases de hoy, sin
   pedir confirmar "¿eres tú?". **133/133 tests en verde.**
4. **Multisede — completo** (2026-10-01, diseñado vía `/grill-me` en
   dos rondas, ver "Decisiones de arquitectura" §6 y
   `docs/plans/multisede.md`). `campuses` nueva, sede derivada del aula
   (`classrooms.campus_id`/`Assignment::campus()`), grupos por
   curso+periodo+sede, choque de horario (docente/aula) validado y
   bloqueante, filtros de sede en 5 pantallas. **151/151 tests en
   verde.**

Verificación de la primera revisión general (2026-09-13): `migrate:fresh
--seed` limpio, **60/60 tests en verde**, `./vendor/bin/pint --test`
en verde (79 archivos — un detalle de estilo menor encontrado y
corregido), las 13 rutas nombradas tienen su enlace correspondiente en
el sidebar (confirmado por comparación directa, no a ojo), 15 commits
en el historial — autor único (Ricardo Vargas V.), sin coautoría en
ninguno.

**Segunda pasada de revisión, mismo día** — 2 hallazgos reales, ambos
de documentación (no de código, el código ya estaba limpio):
`README.md` seguía siendo el genérico de Laravel del scaffold de Fase
0, nunca actualizado — reemplazado por uno real (cómo levantar el
proyecto, URLs y credenciales de prueba, docentes de ejemplo para el
kiosko). `docs/MODELO-DATOS.md` no distinguía qué tablas ya estaban
migradas de cuál seguía siendo solo diseño (`fingerprints`, Fase 6) —
se agregó una columna "Estado" a su tabla resumen. Sin hallazgos de
código: `git status` limpio, sin `dd()`/`dump()`/`console.log`
olvidados, `.env`/`.env.example` consistentes, sin tablas de más ni de
menos en la BD real contra lo documentado.

**Tercera pasada, a raíz de que el usuario empezó su propia revisión de
seguridad** — 1 hallazgo real de código, corregido: **el login no
tenía límite de intentos** — la contraseña del admin quedaba expuesta
a fuerza bruta sin ningún freno. Importante para quien toque esto
después: `throttle` en la ruta `/login` **no habría alcanzado**,
porque el login corre como acción Livewire (endpoint interno de
actualización), no como un POST normal a esa ruta — el límite tiene
que vivir dentro del propio componente. Corregido en
`Login::login()` con `RateLimiter` directo (5 intentos, 60s de espera,
mismo criterio que usa Laravel Fortify para sus formularios Livewire),
con test que prueba el bloqueo real (`test_login_is_rate_limited_after_repeated_failures`).
61/61 tests en verde.

**Cuarta pasada — contraseña de administradores** (2026-09-13, decisión
tomada con el usuario mientras probaba `/admin/users/administrators`):
nadie que crea un administrador le pone la contraseña a otro. Al crear,
el sistema la fija en su número de documento y marca
`users.must_change_password = true` (columna agregada a la migración
existente de `users`, no una nueva — seguimos en desarrollo). El
middleware `password.change` (`RequirePasswordChange`) envuelve todas
las rutas `/admin/*` **excepto** `/admin/change-password` y redirige
ahí a cualquier admin con la marca pendiente, sin excepción de qué URL
haya pedido — no se puede "navegar alrededor" del cambio obligatorio.
`ChangePassword` (Livewire, mismo layout que `Login`) es la única
pantalla alcanzable en ese estado; al guardar limpia la marca y manda a
`/admin/dashboard`. Reutilizado el mismo patrón de confirmación
(`askX`/`confirmAction`) para agregar "Restablecer contraseña" en la
edición de un administrador (mismo efecto: vuelve a su documento +
marca pendiente — para cuando un admin se bloquea y otro admin necesita
desbloquearlo). El admin sembrado por `UserSeeder`
(`admin@unamba.edu.pe`/`password`) **no** lleva la marca, a propósito —
si no, cada `migrate:fresh --seed` rompería las credenciales de
desarrollo documentadas en el README. 63/63 tests en verde.

**Quinta pasada — grado académico y foto de referencia de docentes**
(2026-09-13, mismo día): dos pedidos del usuario mientras probaba
`/admin/users/teachers`. (1) `users.academic_degree` — enum nullable
agregado a la misma migración de `users` (seguimos en desarrollo, sin
migración nueva); solo se muestra en el formulario/tabla/filtro de
docentes, aunque la columna no está restringida por rol a nivel de BD.
Valores finales, de menor a mayor según la escala real (SUNEDU):
`BACH`/`LIC`/`ING`/`MG`/`DR` — se descartó un `OTRO` (redundante con
"Sin especificar" en blanco, que ya cubre "no aplica") y se confirmó
que no existe nada formal por encima de `DR` (un postdoctorado no es
grado reconocido en Perú, un honoris causa es honorífico) — `DR` es
el techo real, a propósito. (2) La foto de referencia pasó de
"solo docentes, opcional de hecho" a **obligatoria para `TEACHER`**
(nunca debe quedar sin ninguna: es la comparación contra las fotos de
cada fichaje) y **opcional para `ADMIN`** (antes ni siquiera existía
el campo en ese formulario). Regla de validación: obligatoria al crear
un docente; al editar, solo si todavía no tiene una guardada — no hace
falta resubirla por cambiar otro campo. Rediseño de UI/UX del
selector: círculo de vista previa clicable (con la foto ya guardada,
o la recién elegida vía `temporaryUrl()` de Livewire, sin esperar a
guardar) + spinner durante la subida + botón "Quitar" (solo visible
para admin, que sí puede quedarse sin foto). Se agregó columna "Foto"
(avatar chico) a la tabla de ambos tipos, y columna "Grado" + su
filtro solo en docentes. 64/64 tests en verde (incluye el caso de
crear un docente sin foto → falla con `referencePhoto: required`, y
editar sin tocar la foto → no la vuelve a pedir).

**Sexta pasada — bloqueo de campos por asignación + registro manual por
admin** (2026-09-14, discutido antes de tocar código). Dos cambios:

**(1) El kiosko ahora bloquea de verdad, no solo avisa.** Antes, elegir
una de las clases de hoy ("¿cuál clase vas a registrar?") prellenaba
curso/ciclo/aula/periodo pero los dejaba editables a cualquier valor —
lo cual desarmaba el propósito de haber elegido. Ahora, cuando viene de
una asignación (`assignmentId !== null`): **curso, ciclo y periodo
académico quedan fijos** (solo texto, sin `<select>`, `.kiosk-static`
nuevo) — son "cuál asignación es esta". **Aula, hora, tema y número de
alumnos siguen editables** — son detalle de la sesión (se cambió de
salón, se atrasó, el tema de hoy). `fecha de clase` se dejó libre a
propósito, sin bloquear — bloquearla habría roto la regularización
tardía ("declarado tarde") para clases con asignación. Defensa en
profundidad: `enforceAssignmentLock()` revierte curso/ciclo/periodo en
el servidor (antes de confirmar y antes de guardar) por si alguien
arma la petición a mano en vez de usar la UI — no basta con esconder
el control en el HTML.

Además, "ya registrada hoy" en el selector de asignación pasó de badge
(se podía ignorar) a **bloqueo real** (`@disabled`, y `selectAssignment()`
también lo rechaza en el servidor) — es seguro bloquear ahí porque cada
`assignment_id` es único (docente+curso+ciclo+aula+horario), a
diferencia del intento anterior de bloquear por `course_id` solo en el
select libre (sin asignación), que sí tenía falso-positivo real: mismo
curso a dos ciclos distintos el mismo día son clases legítimas, y
bloquear por curso las confundía. Ese camino (sin asignación, el
docente arma todo a mano) se dejó como **aviso no bloqueante** — no hay
con qué comparar de forma precisa ahí.

**(2) Admin puede registrar manualmente por un docente que se olvidó de
fichar.** Hasta ahora la única puerta para crear un `attendance_record`
era el kiosko — el panel admin solo podía *corregir* uno que ya
existía. Agregado a `/admin/attendance-records` → "Registrar
manualmente": mismo patrón que el resto del CRUD admin (modal, sin
confirmación extra), Tom Select de docente (extraído a
`x-ui.teacher-select`, reusado también en Asignaciones — antes estaba
duplicado ahí). El admin llena **todo libremente** (sin los bloqueos
del kiosko — no hay asignación de por medio, `assignment_id` siempre
`null`, `out_of_schedule` siempre `false` por la misma razón que el
camino "sin asignación" del kiosko), pero respeta el mismo bloqueo de
duplicado (mismo docente+curso+ciclo+fecha+horas que se solapan → error,
dirige a corregir el existente en vez de duplicar).

Columna nueva `attendance_records.created_by` (FK nullable a `users`,
mismo patrón que `attendance_corrections.corrected_by` /
`system_configuration.updated_by`) — agregada a la migración existente
de `attendance_records`, sin migración nueva. `null` = vino del kiosko;
con valor = lo escribió un admin. Visible siempre, mismo criterio
no-negociable que "modo manual siempre visible": badge "ADMIN" en la
tabla y "CREADO POR ADMIN" en el detalle, tarjeta de resumen y filtro
propios en `/admin/attendance-records`. Decisión importante: la
tarjeta/filtro de **"Modo manual" ahora excluye explícitamente los
creados por admin** (`identification_method = manual AND created_by IS
NULL`) — antes de esta pasada, un registro creado por admin también
contaba como "modo manual" sin distinción, diluyendo esa señal
específica (que es sobre el docente auto-identificándose sin huella en
el kiosko, un riesgo distinto al de que un admin regularice algo).
74/74 tests en verde.

**Séptima pasada — datos curriculares de cursos** (2026-09-14, ampliación
del catálogo de cursos):
Se ampliaron los cursos con datos curriculares reales en la migración
existente de `courses` (seguimos en desarrollo, sin migración nueva):
`code` (string único, obligatorio), `type` (enum `CourseType`: `GENERAL` /
`ESPECIALIDAD`), `credits`, `theory_hours`, `practice_hours` y `total_hours`
(enteros obligatorios), y `prerequisite_id` (FK nullable a `courses`,
restricción al eliminar). `name` conserva su restricción única.
- Modelo `Course` actualizado con casts, relaciones `prerequisite` /
  `dependentCourses` y método `displayName()` (`CÓDIGO — NOMBRE`).
- CRUD admin en `CatalogSimple`: formulario modal ancho con campos para HT,
  HP, TH, tipo y créditos; selector de prerrequisito (excluye al propio curso
  al editar para impedir autorreferencia); búsqueda por código o nombre;
  ordenamiento por columnas nuevas; y captura de integridad referencial para
  bloquear borrado si el curso es requisito de otro.
- Kiosko (`Kiosk`), asignaciones (`Assignments`), registros de asistencia
  (`AttendanceRecords`) y auditoría de correcciones (`AttendanceRecordDetail`)
  unificados para mostrar `CÓDIGO — NOMBRE` en selects, tablas y confirmaciones.
- Documentación actualizada en `docs/CAMBIOS.md` y `docs/MODELO-DATOS.md`.
- Cobertura de tests: 76/76 tests en verde (552 aserciones), `./vendor/bin/pint --test`
  limpio en 87 archivos.
- Base de datos local MySQL pendiente de `migrate:fresh --seed` (a la espera de
  indicación del usuario).

**Octava pasada — departamento académico de docentes** (2026-09-14):
Se amplió `users` con `academic_department` nullable (enum
`HUMANIDADES` / `ADMINISTRACION` / `CIENCIAS_BASICAS` /
`INGENIERIA_SISTEMAS`) en la migración existente — sin migración nueva.
Enum PHP `AcademicDepartment` con `label()`. En el CRUD de docentes:
filtro, columna con badge y campo modal **obligatorio**; no aparece en
administradores. Seeder de docentes de ejemplo asigna departamentos.
Documentación en `docs/MODELO-DATOS.md` y `docs/CAMBIOS.md`. Tests de
esquema, CRUD y filtro ampliados. **77/77 tests en verde** (559
aserciones), `./vendor/bin/pint --test` limpio en 88 archivos. BD local
MySQL pendiente de `migrate:fresh --seed` (espera indicación del
usuario).

**Novena pasada — plan de estudios PDF + retiro del temario**
(2026-09-14):
`courses.study_plan_path` nullable (PDF opcional). Se eliminó la tabla
`topics`, el CRUD embebido `CourseTopics`, el campo “Tema” del kiosko y
de asistencia, y `attendance_records.topic_id`. El plan vive a nivel de
curso, no de sesión. Docs y tests actualizados. **77/77 tests en verde**
(560 aserciones), `./vendor/bin/pint --test` limpio en 85 archivos. BD
local MySQL pendiente de `migrate:fresh --seed`.

**Décima pasada — grupos, resoluciones y kiosko sin registro libre**
(2026-09-20):
Tablas nuevas `class_groups`, `resolutions`,
`course_academic_period_groups`. `courses.name` ya no es único;
`resolution_id` opcional. `assignments.class_group_id` obligatorio con
regla aplicativa de un docente por curso+grupo+periodo. Pantalla
`/admin/course-groups` y CRUD de Resoluciones. Kiosko: eliminado el
camino sin asignación; nuevo estado `no_assignments_today` (cubre
también el bug de “una sola asignación ya registrada” que dejaba
avanzar al formulario). Biometría/ZKTeco sigue fuera de alcance.
Docs actualizados. Suite + Pint verificados al cerrar esta pasada:
**83/83 tests en verde** (602 aserciones), `./vendor/bin/pint --test`
limpio en 93 archivos. BD local MySQL pendiente de
`migrate:fresh --seed`.

**Fase 6 — biometría real (ZK9500), completa** (2026-09-21 a
2026-09-30, varios commits: `a38c042`, `5efa48d`, `efccb31`, `2d5e7bd`,
`db059b3`, más `instalar.bat`/arranque automático el 2026-09-30). Dos
piezas:
- **Laravel** (`fingerprints`, endpoint `POST /admin/api/fingerprints/sync`
  con token compartido, enrolamiento guiado desde `/admin/users/teachers`,
  botón de huella en el kiosko) — 114/114 tests en el momento del handoff.
- **Agente local** (`fingerprint-agent/`, Python/Flask fuera de este
  repo Laravel, corre en la laptop del kiosko en Windows): envuelve el
  SDK ZKFinger, expone HTTPS en `127.0.0.1:8765` (`/status`,
  `/identify`, `/sync`, `/enroll/start`+`/enroll/progress`), matching
  1:N ocurre ahí, solo sube el `template` a Laravel, nunca imagen.
  Arranque automático — ver `fingerprint-agent/README.md` para el
  detalle completo (instalación, contrato de endpoints, arranque
  automático).

**Corrección del arranque automático — carpeta de Inicio en vez de Tarea
Programada** (2026-10-02). El arranque automático se registraba con una
Tarea Programada de Windows (`schtasks /create` desde `instalar.bat`), y
eso **fallaba con "Acceso denegado"** en la laptop del kiosko: `schtasks
/create` escribe en `C:\Windows\System32\Tasks`, que exige administrador
— la cuenta del kiosko es estándar. Se reemplazó por la **carpeta de
Inicio del usuario** (`shell:startup`), que nunca pide administrador:
`instalar_arranque.py` (nuevo) deja ahí un único `DocenTrack-Agente.vbs`
que lanza el agente oculto (`pythonw.exe`, sin ventana) y lo **relanza
solo cada 5 s si se cae** (mismo efecto que el auto-reinicio de la tarea).
`instalar-tarea.bat`/`desinstalar-tarea.bat` ahora usan ese método;
`instalar.bat` además verifica la versión de Python (`verificar_entorno.py`,
≥ 3.8 y detecta el stub de la Microsoft Store) y mantiene el `pip install`
idempotente (no reinstala si ya está). Segundo arreglo de la misma pasada:
cuando el agente no podía abrir el lector, moría en silencio dejando
`agent.log` **vacío** (corriendo oculto, el stderr se descarta) — ahora
`agent.py` registra siempre `"No se pudo abrir el lector ZK9500: ..."`
con un mensaje accionable antes de salir. **Recordatorio operativo**: el
agente es *fail-closed* a propósito (no abre el puerto 8765 sin lector);
el error `"Failed to initialize the algorithm library"` significa que el
SDK ZKFinger no está instalado en esa laptop, no que falte el lector.

**Fase 8 — estados de asistencia con pre-registro diario** (2026-10-01,
diseñado con el usuario antes de tocar código —
`docs/plans/fase-8-estados-asistencia.md` — e implementado directamente
por Claude, pedido explícito). Resuelve el gap detectado en la primera
presentación: antes, `attendance_records` solo existía cuando alguien
fichaba — no había forma de reportar "qué clases no se dictaron".
Detalle completo en `docs/CAMBIOS.md` (2026-10-01); resumen: nuevo
enum `AttendanceStatus` (`PENDING`/`ATTENDED`/`ABSENT`/`REGULARIZED`)
en `attendance_records.status`, comando `attendance:sync` (genera +
barre, idempotente, con catch-up tolerante a que el botón no se use
todos los días), botón "Sincronizar" en el panel admin, candado de
edición por hora real (no por estado guardado) en
`AttendanceRecordDetail`. Bug real encontrado y corregido durante la
implementación (no en el plan original): la primera corrida del
sistema no debe retroceder 30 días por defecto — ver `docs/CAMBIOS.md`
para el detalle. **129/129 tests en verde** (832 aserciones),
`./vendor/bin/pint --test` limpio (114 archivos). Alcance recortado a
propósito: "crear asistencia manual con enlace opcional a una
asignación sin generar" quedó fuera de esta pasada (edge case raro,
cubierto igual por el flujo principal de editar la fila ya generada).

**Fase 7 — archivos en disco privado** (2026-09-22, commit `5efa48d`).
Fotos de referencia, evidencia de fichaje y PDF de plan de estudios
pasaron de disco `public` a `local` (`storage/app/private`),
servidos por `PrivateFileController` (auth + role ADMIN +
password.change) — ya no hace falta `storage:link`.

**Cambios post-producción del 2026-10-05** (pedidos de la Facultad,
detalle y SQL en `docs/CAMBIOS.md`): tipo de curso `ESPECIFICO`, colores
propios por departamento académico, `users.teacher_type`
(Nombrado/Contratado/Asociado) y **el ciclo pasó al curso**
(`courses.cycle_id`; `assignments` y `attendance_records` ya no tienen
`cycle_id`, lo exponen vía `hasOneThrough`). Desde que hay producción
real el esquema se cambia **editando la migración original + SQL manual
probado contra el backup** (no migraciones nuevas, decisión del usuario;
`entrega.sh` no corre `migrate`). El hosting es **MariaDB 10.11**, no
MySQL 8 como el contenedor local: el SQL de producción se prueba en un
MariaDB temporal. **171/171 tests en verde.**

## Historial de fases (detalle)

Detalle completo de cada fase, en el orden en que se construyeron —
útil para entender el *porqué* de una decisión puntual, no hace falta
leerlo para trabajar en el día a día (para eso ver "Estado actual"
arriba y "Decisiones de arquitectura" más abajo).

**Fase 0 + Fase 1 — completas y verificadas** (2026-09-13,
primer commit `b63c910`). Repo git inicializado en esta misma sesión —
no existía antes. Fase 1 implementada por Cursor contra
`docs/plans/fase-1-catalogos-base.md`, revisada por Claude contra el
código real (no solo el resumen de Cursor): migraciones/modelos/
Livewire/tests leídos uno por uno, `migrate:fresh --seed` y
`php artisan test` (10 passed) corridos de nuevo de forma independiente,
más verificación HTTP real contra `:8020` (no solo `Livewire::test()`).
Sin hallazgos que corregir. Detalle de lo construido: 6 migraciones
(`cycles`, `academic_periods` sin fechas, `classrooms` con `capacity`,
`courses` sin `cycle_id`, `topics` anidado a `courses`,
`system_configuration` de fila única), 5 componentes Livewire
(`CatalogSimple` genérico para `cycles`/`courses`, `AcademicPeriods`
con activación exclusiva, `Classrooms`, `CourseTopics` anidado,
`SystemConfiguration`), sin autenticación (a propósito, llega en Fase
2). **Nota de continuidad para Fase 2**: `system-configuration.blade.php`
referencia `$config->updater?->name` — válido hoy porque `users` sigue
siendo el esqueleto por defecto de Laravel (`name`), pero Fase 2 va a
reemplazar ese campo por `names`/`paternal_surname`/`maternal_surname`
(`MODELO-DATOS.md` §1) — actualizar esa vista cuando se toque `users`.

**Fase 2 dividida en dos planes** (el tamaño real de "users + auth +
kiosko" era demasiado para un solo plan revisable). **Fase 2a —
completa y verificada** (2026-09-13,
`docs/plans/fase-2a-usuarios-autenticacion.md`): `users` con esquema
real (`document_type`+`document_number` único compuesto, `role` enum
`ADMIN`/`TEACHER`, soft delete), login/logout, middleware `role:ADMIN`
envolviendo **todas** las rutas `/admin/*` (incluidas las de Fase 1,
que hasta acá no tenían auth), gestión de administradores/docentes
(`Livewire\Admin\Users`, mismo patrón parametrizado que `CatalogSimple`),
seeder de desarrollo (`admin@unamba.edu.pe` / `password` + 3 docentes
placeholder). Cursor dejó el código escrito pero no llegó a correr
`migrate`/tests/HTTP (contexto cortado a mitad de tarea) — Claude
terminó la verificación y encontró 2 fallas reales que corrigió: un
test mal adaptado (`/admin/catalogs/unknown` con `type` inválido da
404 por el matching de rutas, no redirige a login — se restauró la
aserción correcta) y un gap de infraestructura de **Fase 0** (el
`Dockerfile` compiló GD sin soporte JPEG, nadie lo había notado porque
nada generaba imágenes hasta esta fase — corregido con
`libjpeg62-turbo-dev` + `--with-jpeg`, imagen reconstruida). 22/22
tests en verde tras las correcciones. La nota de continuidad de más
arriba (`updater()->displayName()`) ya quedó resuelta.

**Fase 2b — completa y verificada, commit `b3381d8`** (2026-09-13,
`docs/plans/fase-2b-kiosko-modo-manual.md`): kiosko en modo manual
completo — `/` es el kiosko público (sin auth), máquina de estados en
un solo componente Livewire (`idle`→`closed`/`identifying`→
`not_found`/`identified`→`form`→`confirming`→`success`),
identificación por documento contra `users`/`TEACHER`, captura de 3
fotos por `getUserMedia`+`$wire.upload()`, guardado con bloqueo de
duplicado casi-exacto (compara solape de horas, no solo fecha) y
cálculo de `late_declaration`. `attendance_records`/`attendance_evidence`
nuevas — `attendance_records` **sin** `assignment_id` todavía (se
agrega editando esta misma migración en Fase 3, ya planificada).
`out_of_schedule` siempre `false` por ahora. Esta vez el reporte de
Cursor coincidió exactamente con la verificación independiente de
Claude (30/30 tests, migrate limpio, HTTP real) — sin hallazgos.
**Pendiente sin confirmar**: la captura de cámara real
(`getUserMedia`) no se puede verificar por HTTP/tests — quedó anotado
como responsabilidad del usuario probarla en su navegador, y hasta
ahora no hay confirmación de que se haya hecho.

**Fase 3 — completa y verificada, commit `0ed04ef`** (2026-09-13,
`docs/plans/fase-3-asignaciones-horario.md`): `assignments`, migración
de `attendance_records` de Fase 2b editada para agregar `assignment_id`,
CRUD admin en `/admin/assignments`. Prellenado del kiosko: 0
asignaciones hoy = sin prellenar, 1 = prellena directo, 2+ = estado
`selecting_assignment` (lista curso+horario, indicador "ya registrada
hoy" no bloqueante, opción de continuar sin asignación).
`out_of_schedule` real con 15 min de tolerancia. Verificación
independiente de Claude: 40/40 tests, `migrate:fresh --seed` limpio,
HTTP real — sin bugs. **Resuelto** (2026-09-13, fix directo de Claude,
sin pasar por Cursor — cambio chico y acotado): `assignment_id` ahora
se **mantiene** aunque el docente corrija `course`/`cycle`/`classroom`/
`academic_period` tras el prellenado, tal como dice `MODELO-DATOS.md`
§10 — `out_of_schedule` solo compara horas, así que perder el vínculo
al corregir otro campo apagaba la alerta justo cuando el docente más
estaba corrigiendo. Se quitaron los `updated*()` que ponían
`assignmentId = null` en `Kiosk.php` (se conservó el que limpia
`topicId` al cambiar de curso, ese sí es necesario) y se agregó
`test_correcting_prefilled_classroom_keeps_assignment_id_and_out_of_schedule_check`
para que no se repita la duda. Pendiente real sin resolver, sin
agregar sin que se pida:
prevenir doble-reserva en `assignments` (mismo docente/aula con
horarios solapados).

**Fase 4 — completa y verificada, commit `b9d86e0`**
(2026-09-13, `docs/plans/fase-4-registros-correcciones.md`) —
**implementada directamente por Claude, sin pasar por Cursor** (pedido
explícito del usuario). Panel de "Registros de asistencia" — listado
con filtros (docente, curso, periodo, rango de fecha, alertas) en
`/admin/attendance-records`, detalle + corrección en
`/admin/attendance-records/{record}`. `attendance_corrections` (una
fila por campo corregido, motivo obligatorio, solo-alta). Cumple el
requisito no negociable de `CLAUDE.md` §10: summary card de "modo
manual" siempre visible. Refactor: la lógica de tolerancia de 15 min
de `out_of_schedule` salió de `Kiosk.php` a `Assignment::isOutOfSchedule()`
(reusable, la corrección la necesita también) — suite completa corrida
después del refactor antes de seguir, sin romper nada. `teacher_id`,
`identification_method` y `assignment_id` quedan de solo lectura (ni
siquiera existe propiedad pública para ellos en el componente de
corrección). Extendido `x-ui.summary-cards` con un prop `cards`
opcional (array de tarjetas custom) sin romper su uso existente en
`Assignments`/`Classrooms`/etc. **51/51 tests en verde.**

**Hallazgo real encontrado en esta fase**: `php artisan storage:link`
nunca se había corrido en este proyecto — sin él, ninguna foto
guardada (evidencias del kiosko, foto de referencia de docentes) era
accesible por URL pese a existir en disco. No se notó antes porque
esta es la primera pantalla que muestra esas fotos ya guardadas (no
solo la vista previa temporal del kiosko). Corrido ahora; **pendiente
real para cuando se prepare producción**: automatizar este paso en el
proceso de despliegue.

**Fase 5 — completa y verificada, commit `2d85b3a`**
(2026-09-13, `docs/plans/fase-5-dashboard-reportes.md`) —
**implementada directamente por Claude, sin pasar por Cursor** (mismo
criterio que Fase 4, pedido explícito del usuario). Dashboard
(`/admin/dashboard`, nueva pantalla de aterrizaje tras login —
`Login.php`, `bootstrap/app.php` `redirectUsersTo` y el logo del
sidebar apuntan acá ahora, ya no a catálogos) y Reportes exportables
(`/admin/reports`, Excel vía `maatwebsite/excel`, PDF vía
`barryvdh/laravel-dompdf`, agrupado por docente con horas dictadas
sumadas en PHP). Sin migraciones nuevas. **3 cosas fuera de alcance a
propósito**: comparativo horas programadas vs. dictadas, concepto de
"alerta revisada/no revisada", widget de "huella pendiente" (no aplica
hasta Fase 6). **60/60 tests en verde.**

**Desviación real encontrada al verificar**: el cambio de landing
post-login rompió una aserción de `Fase2aAuthUsersTest` (esperaba
redirigir a `admin.catalogs`, ahora es `admin.dashboard`) — corregida,
es la consecuencia esperada de un cambio de comportamiento
intencional, no un bug nuevo.

## Docker (obligatorio para desarrollo — no instalar PHP/Composer local)

Mismo criterio que `../TUTORIA/tutoria-fichas/`: ni PHP ni Composer están
instalados en el host, todo corre en contenedores.

Puertos elegidos para no chocar con `tutor-backend` (8000/3307) ni
`tutoria-fichas` (8010/3308/5183):

| Servicio | Descripción | Puerto host |
|----------|-------------|-------------|
| `app` | PHP 8.4 + Apache, sirve Laravel | `8020` |
| `db` | MySQL 8 (`docentrack`) | `3309` |
| `node` | Vite dev server (Tailwind + HMR) | `5193` (interno 5173) |

```bash
docker compose up -d              # levanta app + db + node
docker compose logs -f app        # logs de Apache/PHP
docker compose logs -f node       # logs de Vite

# Comandos artisan/composer: correr con el uid del host para que los
# archivos que se generan (vendor/, cache, migraciones nuevas) no queden
# con dueño root en el filesystem del host.
docker compose exec --user "$(id -u):$(id -g)" app php artisan migrate
docker compose exec --user "$(id -u):$(id -g)" app composer require paquete/x
```

**Detalle real de cómo se armó (2026-09-13, para no repetir el
descubrimiento)**:
- Laravel se scaffoldeó con la imagen oficial `composer:2` en un
  directorio temporal (no se puede `composer create-project` sobre un
  directorio no vacío) y se movió acá, preservando `CLAUDE.md`/`docs/`
  ya existentes. Se descartaron `CLAUDE.md`/`AGENTS.md` que trae el
  skeleton de Laravel por defecto (instrucciones de Laravel Boost, no
  aplican a este workspace).
- **`vite.config.js` necesita `server.host: '0.0.0.0'` +
  `origin`/`hmr.clientPort` apuntando al puerto externo (5193)** — sin
  esto Vite escucha solo en el loopback del contenedor y el navegador
  del host no puede cargar `http://localhost:5193/@vite/client` ni el
  HMR, aunque el puerto esté bien mapeado en `docker-compose.yml`.
  Mismo fix que ya tenía `tutoria-fichas/vite.config.js` — cualquier
  proyecto nuevo del workspace con este patrón de Docker+Vite necesita
  este bloque desde el inicio, no como fix posterior.
- Verificado con `curl` real (no solo "los contenedores están *Up*"):
  `app` responde `HTTP 200` con `<title>DocenTrack</title>`, `db`
  acepta migración real (`php artisan migrate` contra MySQL, no
  sqlite), `node` sirve `/@vite/client` con `HTTP 200` y el HTML de
  `app` referencia correctamente `http://localhost:5193/...`.

## Decisiones de arquitectura ya acordadas (2026-09-13)

### 1. Proyecto separado — sin BD ni esquema compartido con tutor-backend/tutoria-fichas

Se consideró explícitamente integrarlo a `tutor-backend` (una sola API
con módulos DocenTrack + TutorTrack) y se descartó por ahora. Razones:

- **Dominio de negocio distinto**: DocenTrack es asistencia/horas
  lectivas de aula (proceso de RRHH/Académico); TutorTrack es
  acompañamiento personal del tutor; tutoria-fichas digitaliza el
  reglamento de tutoría 2026. Comparten institución, no proceso.
- **`tutor-backend` es un activo potencialmente vendible a otra
  universidad** (ver `../TUTORIA/tutor-backend/CLAUDE.md`). Soldarlo a un
  sistema con requisitos de hardware local específicos de esta Facultad
  (lector de huella, PC de mesa en modo kiosko) ata su roadmap comercial
  a necesidades operativas de UNAMBA.
- **Paradigmas de frontend distintos**: `tutor-backend` es API pura
  consumida por React/Sanctum; DocenTrack (ver stack abajo) es
  Livewire/Blade server-rendered. Forzarlos a compartir una sola API
  termina en dos apps cohabitando mal el mismo código.
- **`pagina-web` queda fuera de cualquier análisis de identidad
  compartida**: hoy no tiene backend ni cuentas de usuario, es
  contenido público — no hay identidad que compartir ahí.
- Mismo criterio que ya se aplicó al separar `tutoria-fichas` de
  `tutor-backend` (ver `../TUTORIA/tutoria-fichas/CLAUDE.md`), con más razón
  todavía porque el dominio está más lejos y hay hardware de por medio.

**Plan de integración pospuesto, no descartado**: primero terminar los
3 sistemas (DocenTrack, TutorTrack, tutoria-fichas) cada uno en Livewire
+ Laravel, estables/aprobados — recién ahí evaluar integración liviana
(ver punto 2). Explícitamente **no** tocar esquema ni acoplar sistemas
mientras cualquiera de los 3 esté en desarrollo activo — el riesgo de
"romper algo en pleno desarrollo" pesa más que el ahorro de no
duplicar un registro de docente todavía.

### 2. Convención de identidad — llave por documento desde el día 1

Para que una integración futura sea un join limpio y no una
reconciliación de nombres mal escritos, el modelo de persona de
DocenTrack usa desde su primera migración el mismo criterio que
`tutor-backend.users`: **único compuesto `(document_type, document_number)`**
como llave natural de identidad — no nombre, no email como llave.
**Ajuste tras el diseño detallado** (`docs/MODELO-DATOS.md`): sin tabla
de catálogo `document_types` — `document_type` es un enum embebido
directo en `users` (`DNI`/`CE`/`PASSPORT`), porque es un dato que casi
no cambia; no hace falta una tabla CRUD para eso.

Integración futura (cuando los 3 sistemas estén estables): endpoint de
solo lectura tipo `GET /people?document=...` entre sistemas, o import
puntual (Excel, igual patrón que `tutor-backend` ya tiene para
docentes/estudiantes/receptores) — nunca esquema/BD compartida.

### 3. Stack

- **Laravel 13 + Livewire/Blade + MySQL 8** — single-stack, sin API/SPA
  separada. Mismo patrón que `../TUTORIA/tutoria-fichas/` (no React+Postgres
  como decía el `CLAUDE.md` original del mockup — se actualizó el
  criterio: consistencia con el resto del workspace, menos piezas
  móviles para desplegar en el PC/servidor de la Facultad).
- **Identificadores en inglés** (tablas, columnas, clases, rutas) —
  confirmado explícitamente por el usuario, a diferencia de
  `tutoria-fichas` (cuyo código real quedó en español pese a que su
  propio `CLAUDE.md` dice "inglés" — inconsistencia detectada, no se
  corrige retroactivamente, pero DocenTrack sí sigue la regla escrita
  desde el día 1). Comentarios en el código: español, solo cuando
  aportan un porqué no obvio.
- **Roles: enum `role` en `users` (`ADMIN`/`TEACHER`), no tablas de
  perfil separadas.** Se evaluó el patrón multi-perfil de
  `tutoria-fichas` (tablas colgando de `usuarios`) y se descartó: con
  solo 2 roles reales, mutuamente excluyentes en el uso del sistema, un
  enum alcanza y es más simple. **Límite consciente aceptado**: una
  persona no puede ser `ADMIN` y `TEACHER` a la vez bajo este modelo —
  si el/los administradores del sistema también dictan clases, tendrían
  que elegir un rol. Sin Spatie (mismo motivo que `tutoria-fichas`: muy
  pocos roles reales para justificar un paquete de permisos).
- Detalle completo, tabla por tabla, en `docs/MODELO-DATOS.md`
  (**hecho** — 12 tablas de dominio, revisadas una por una con el
  usuario).

### 4. Biometría — arquitectura y hardware

**No existe API estándar de navegador para lectores de huella USB
genéricos** (a diferencia de la cámara, que sí tiene `getUserMedia`
estándar). Arquitectura acordada:

- **Agente/servicio local** en el PC de la mesa de registro: envuelve el
  SDK del fabricante del lector, expone `POST /enrolar` y
  `POST /identificar` en `http://127.0.0.1:PUERTO`.
- La página Livewire del kiosko hace `fetch` a ese `localhost` para el
  paso biométrico, y habla por red con el backend real (mismo Laravel)
  para todo lo demás. Funciona sin problema de mixed-content aunque el
  kiosko esté en HTTPS — los navegadores tratan `127.0.0.1`/`localhost`
  como origen confiable incluso por HTTP plano (mismo patrón que usan
  sistemas POS web para impresoras térmicas locales).
- **Guardar solo template, nunca imagen de la huella** (ya estaba en los
  "próximos pasos" del mockup, se confirma como regla).

**Lector recomendado: ZKTeco ZK9500.** SDK ZKFinger para Windows y
Linux, soporta identificación 1:N, distribuidor local en Perú
(zkperu.com) — el criterio que más pesa no es el SDK (comparable entre
marcas) sino soporte/reemplazo físico rápido si el hardware falla en
producción un día cualquiera. Alternativas evaluadas:
- ZKTeco ZK4500 — mismo SDK, más económico/antiguo, opción de bajo
  presupuesto.
- HID DigitalPersona 4500 — SDK muy bien documentado (1:N explícito en
  la documentación oficial), pero menor presencia de distribuidor local
  en Perú que ZKTeco.

**Riesgos/límites conocidos del enfoque biométrico** (documentados para
no vender el sistema como infalible):
- La huella prueba que el docente pasó por el kiosko, **no** prueba que
  las horas de entrada/salida que declara (no son timestamp en tiempo
  real) sean exactas — eso queda como riesgo residual de proceso, no de
  tecnología.
- 1:N es viable sin problema al tamaño de una sola Facultad (decenas/
  pocos cientos de docentes). Si esto escala a toda la universidad,
  revisar tasa de falsos positivos antes de asumir que escala gratis.
- Las 3 capturas de cámara (identificar/formulario/confirmar) son
  **evidencia de auditoría para revisión humana**, no deben usarse como
  matching facial automático — eso exigiría liveness detection robusto
  que no está en alcance.

**Alternativas a biometría evaluadas y descartadas por ahora**:
- WebAuthn/passkeys — es verificación 1:1 contra una credencial ya
  vinculada a un dispositivo, no identificación 1:N anónima. No aplica
  al kiosko compartido; sí aplicaría si una fase futura pasa a "un
  dispositivo por docente" (la fase 2 tablet-por-sede que menciona el
  mockup podría acercarse a esto — evaluar entonces).
- RFID/NFC (tarjeta/carnet) — más barato, pero se puede prestar a un
  colega; pierde la propiedad anti-suplantación que es la razón de ser
  del sistema.
- Reconocimiento facial por webcam — única alternativa sin hardware/
  agente local adicional (`getUserMedia` es API estándar), pero requiere
  liveness detection real para no ser más débil que la huella ante una
  foto impresa. Queda como posible evolución, no como reemplazo de la
  huella hoy.

## Decisiones agregadas (ronda 2, 2026-09-13)

### 5. Timestamp real del sistema vs. hora declarada por el docente

Todo registro ya trae `created_at` (Laravel) — ese campo **es** el
momento real en que el docente tocó el lector y culminó el registro (en
este flujo de un solo toque, tocar y guardar ocurren en la misma
visita). Se guarda como campo/señal adicional, **no** como validación
dura: prueba cuándo se *presentó* el registro, no prueba que las horas
de clase declaradas sean exactas (la clase ya ocurrió antes de que el
docente toque el lector — para verificar eso se necesitarían dos toques
en tiempo real, descartado por fricción). Implica dos cosas para el
modelo de datos:
- `fecha_clase` como campo propio, **separado** de `created_at` (el
  docente puede registrar hoy una clase de un día anterior).
- Alerta visual no bloqueante adicional (mismo estilo que "fuera de
  horario"): declarado mucho después de la fecha de la clase, o con
  horas en el futuro respecto a `created_at` — señal para que el admin
  revise, nunca bloquea el guardado.

### 6. Multisede real (reemplaza la decisión original de mono-sede, 2026-10-01)

**Decisión original (2026-09-13) superada**: se había descartado modelar
sede en la BD, proponiendo un clon independiente por sede si hacía
falta expandir. Al presentar el sistema, la Facultad confirmó que
necesita **una sola instancia con dos sedes reales** (Sede Abancay,
Filial Tambobamba) con el mismo curso ofreciendo distinta cantidad de
grupos por sede en el mismo periodo — el clon independiente no sirve
para ese caso. Diseñado con el usuario vía `/grill-me` (dos rondas)
antes de implementar — ver `docs/plans/multisede.md` y
`docs/CAMBIOS.md` para el detalle completo.

Reglas acordadas:
- **Curso y resolución: catálogo compartido**, igual para cualquier
  sede. Lo que varía por sede es la cantidad de grupos que tiene un
  curso en un periodo (`course_academic_period_groups.campus_id`).
- **La sede vive en el aula** (`classrooms.campus_id`), nunca en el
  docente ni en el curso. La asignación obtiene su sede derivándola del
  aula elegida (`Assignment::campus()`), sin campo propio.
- **El docente no se amarra a una sede** — se infiere mirando en qué
  sede están sus asignaciones; puede dictar en una, la otra, o ambas.
- **El kiosko no filtra por sede** — el docente ve todas sus clases de
  hoy de cualquier sede donde dicte y elige la correcta (la tarjeta
  muestra de qué sede es cada una), igual criterio que ya usa para
  elegir entre varias clases del mismo día.
- **Choque de horario** (mismo docente o misma aula con horas que se
  solapan, dentro del mismo periodo académico) se valida y bloquea
  desde esta pasada — ya estaba pendiente en mono-sede (nunca se
  construyó), se cerró junto con multisede.
- Filtro de sede agregado a Asignaciones, Registros de asistencia,
  Reportes, Dashboard y la configuración de grupos por curso+periodo.

Gaps que quedaban documentados con el modelo de clon independiente
(reportes consolidados manuales, enrolamiento duplicado por sede) ya no
aplican — una sola BD, un solo enrolamiento, filtros de sede nativos.

### 7. Sin factores de autenticación adicionales — solo foto de referencia en enrolamiento

Se evaluó sumar PIN/contraseña/OTP como capa extra de verificación y se
descartó: rompe el diseño de "mínimos toques" y no suma seguridad real
contra colusión (un PIN se presta igual que una tarjeta). Único agregado
aceptado: capturar una **foto de referencia** del docente durante el
enrolamiento (una sola vez, con el admin presente, mismo momento que las
3 muestras de huella) para que el admin pueda compararla visualmente
contra las 3 fotos tomadas en cada fichaje al revisar un registro
dudoso — barato, sin fricción nueva, convierte las fotos de auditoría en
una comparación real en vez de "a ojo sin referencia".

### 8. Cliente del kiosko: sitio web en modo kiosko del navegador, no Electron

El kiosko **no** se construye como app de escritorio (Electron u
similar) — es la misma web Livewire (kiosko + admin, un solo código)
abierta en Chrome/Edge en modo kiosko (`--kiosk`, pantalla completa,
navegación bloqueada) en la laptop/PC de la Facultad. Razones: evita
mantener dos aplicaciones (Livewire + app de escritorio) con dos
pipelines de build/despliegue; la cámara usa `getUserMedia` (API
estándar de navegador, permiso pre-autorizable por política empresarial
del navegador para que no vuelva a preguntar en producción); el
bloqueo de pantalla completa ya lo resuelve el flag de kiosko del
navegador sin necesidad de un framework de escritorio.

La **única** pieza de software nativo fuera del navegador es el agente
local que envuelve el SDK del lector (ver punto 4) — inevitable exista
o no Electron de por medio, porque el SDK no corre dentro del sandbox
de ningún navegador. Si se amplía a otra sede (punto 6), esa sede
repite la misma receta: su propia laptop, su propio lector, su propio
agente local, su propio despliegue de la app.

**Viabilidad confirmada**: el agente es una pieza chica y acotada (2-3
endpoints HTTP envolviendo el SDK, con código de ejemplo del fabricante
como base — típicamente C#/.NET en Windows o C/Python en Linux). No es
investigación, sí es la única parte del proyecto que no es Laravel/
Livewire puro — tratarla como tarea aparte, no delegable al mismo
contexto que el CRUD. Requiere el lector físico en mano para probar
(no se puede simular sin hardware real).

### 9. Licencia del SDK ZKFinger

Confirmado por búsqueda: ZKFinger SDK tiene versión gratuita ("Lite")
con descarga de licencia libre para lectores ZKTeco — sin indicio de
costo recurrente ni límite relevante para ~60 plantillas. Verificar
términos exactos al momento de descargar (página "Free License
Download" de ZKTeco) antes de comprometerse más, pero no se anticipa
costo de licencia más allá del hardware.

### 10. Modo manual (sin lector) — switch abierto/cerrado, sin PIN ni horario

El kiosko tiene un estado global `abierto`/`cerrado`, **manual, sin
calendario/horario** — lo prende y apaga el administrador cuando
quiere, así de simple (`configuracion_sistema.abierto`, fila única).
Si está cerrado, el kiosko muestra "SISTEMA CERRADO" y no intenta leer
huella ni hace peticiones — chequeo antes que cualquier otra cosa.

Con el sistema abierto, el método de identificación depende de si el
lector responde:
- **Lector detectado y funcionando** → huella obligatoria (1:N),
  `identification_method = fingerprint`.
- **Lector ausente o falla** → el kiosko pide **solo número de
  documento** (sin PIN, sin paso adicional), busca coincidencia exacta
  contra `users` (`role = TEACHER`), `identification_method = manual`.

**Decisión de diseño consciente, no un descuido**: se evaluó (y se
descartó) un PIN de contingencia de 8 horas con tabla de activaciones y
atribución — se simplificó porque generaba fricción real (docentes que
no recuerdan un PIN) y complejidad de estado (tokens/expiración) que no
compensaba el beneficio. El respaldo que queda es **detectivo, no
preventivo**: sin huella ni PIN, cualquiera podría fichar por otra
persona sabiendo su documento — se acepta ese riesgo porque el registro
queda marcado (`identification_method = manual`) y las 3 fotos del
fichaje (`attendance_evidence`) se pueden comparar contra
`users.reference_photo_path` si algo se pone en duda. **Requisito
no negociable de esta decisión**: el panel admin debe mostrar visible
(dashboard o filtro) cuántos registros están en modo manual, para que
el uso del modo débil nunca pase desapercibido.

**Secuencia de construcción acordada**: se arranca el sistema **sin
lector todavía** — hoy, sin hardware, el modo manual no es la
excepción, es el único modo que existe. El sistema se puede usar en
producción real desde ya (sistema abierto + modo manual) mientras se
compra y se integra el ZK9500 (punto 4). Cuando llegue el lector, se
construye el agente local y el kiosko empieza a pedir huella cuando
esté disponible — el modo manual queda como falla, no como flujo
principal.

### 11. Reglas transversales de la última ronda de diseño (2026-09-13)

- **Nunca se elimina físicamente algo que tiene una dependencia** — ni
  catálogos (`courses`, `classrooms`, `cycles`, etc.) ni `users`. Se
  bloquea el borrado y se dirige a desactivar (`active = false`). Una
  sola regla para todo el sistema, sin excepciones por tabla — mismo
  criterio que ya aplica `tutoria-fichas` a sus catálogos, extendido acá
  también a `users`.
- **Fichaje casi-duplicado se bloquea, no solo se alerta**: mismo
  docente + curso + ciclo + fecha de clase, con horas declaradas que se
  solapan con un `attendance_record` ya existente → no se guarda,
  mensaje dirige a pedir corrección al admin (reusa
  `attendance_corrections`, sin infraestructura nueva). No bloquea
  sesiones legítimas distintas el mismo día porque compara también las
  horas, no solo la fecha.

## Qué falta

**Fase 6 completa — ya no bloquea nada** (ver "Fase 6" arriba). El
sistema está en producción con huella real.

**Completo (2026-10-01)**: los 4 temas surgidos en la primera
presentación — pre-registro diario de asistencia (Fase 8), permisos de
identificación huella/documento, panel directo post-identificación, y
multisede (ver "Decisiones de arquitectura" §6) — están cerrados.
**151/151 tests en verde.**

**No bloqueantes — conocidos, señalados a propósito, no se agregan sin que se pida:**

- **Porteo del diseño visual real del mockup** (azul navy + naranja,
  tipografía, sidebar/topbar con la identidad de DocenTrack) — todo el
  admin y el kiosko siguen con estilos neutros de Tailwind desde Fase 1.
  Nunca se abordó, en ninguna fase.
- **Prevenir doble-reserva en `assignments`** (mismo docente o misma
  aula con horarios que se solapan) — señalado en Fase 3, nunca
  confirmado ni construido.
- **Comparativo "horas programadas vs. horas dictadas"** en Reportes —
  el propio mockup lo marca como ampliación futura, no alcance mínimo
  (Fase 5).
- **Concepto de "alerta revisada/no revisada"** — el dashboard y los
  reportes muestran conteos totales, no hay flag de "ya lo revisé" en
  `attendance_records` (no estaba en `MODELO-DATOS.md`, se evaluó
  agregar en Fase 5 y se decidió no hacerlo sin que se pida).
- **Widget de "docentes con huella pendiente de enrolar"** en el
  dashboard — lo menciona el mockup, no aplica hasta que exista
  enrolamiento real (Fase 6).
- **Prueba de cámara real en navegador (`getUserMedia`) sin confirmar
  por el usuario** — quedó pendiente desde el cierre de Fase 2b; el
  sistema avanzó 3 fases más sin que se reportara ningún problema, pero
  nunca hubo una confirmación explícita de que se haya probado en un
  navegador real con permiso de cámara concedido.

## Idioma de trabajo

Español, igual que el resto del workspace.
