# Cambios implementados

Este registro documenta los cambios solicitados después de completar las
fases iniciales del proyecto.

## 2026-10-05 — Bug: no se podía agregar un segundo día a un curso+grupo ya asignado

Reportado por el usuario probando asignaciones en producción: tras asignar un
curso y grupo a un docente, al intentar asignarle el mismo curso y grupo otro
día desde «Nueva carga», el grupo salía bloqueado ("ocupado por" ese mismo
docente).

Causa: el select de docente del formulario (`x-ui.teacher-select`) no tenía
`live`, así que el servidor no conocía al docente elegido al armar las
opciones de Grupo (`Assignments::groupOptionsForSelect()`); con `teacherId`
en `null`, cualquier grupo ya asignado contaba como ajeno. El primer día
funcionaba porque el grupo aún no tenía dueño. Existía desde multisede; los
tests no lo veían porque `Livewire::test()->set()` salta el `wire:model` del
navegador.

Arreglo: `:live="true"` en ese select (solo código, sin SQL) + comparación de
ids como enteros en ese método, por si el hosting devuelve los números como
texto. Test nuevo que cubre el caso y que el select sea `live`.
**173/173 tests en verde.**

## 2026-10-05 — Tipo de docente + el ciclo pasa al curso

Pedido de la Facultad con el sistema en producción. Plan y análisis en
`docs/plans/tipo-docente-y-ciclo-en-curso.md`.

**Tipo de docente.** `users.teacher_type` (`NOMBRADO`/`CONTRATADO`/`ASOCIADO`,
enum `TeacherType`). Nullable en BD, obligatorio en el formulario de docentes;
columna, filtro y orden en `/admin/users/teachers`. Los 42 docentes que ya
existían quedan como `CONTRATADO` (decisión del usuario).

**Ciclo en el curso.** Antes curso y ciclo se cruzaban en la asignación; la
Facultad confirmó que cada curso pertenece a un solo ciclo.
- `courses.cycle_id` nuevo; select obligatorio, columna, filtro (incluye
  "SIN CICLO") y orden en `/admin/catalogs/courses`.
- `assignments.cycle_id` y `attendance_records.cycle_id` **eliminados**. Ambos
  modelos exponen `cycle()` como `hasOneThrough` vía el curso, así las vistas,
  el kiosko y el PDF de horario siguen usando `->cycle` sin cambios.
- Fuera el selector de ciclo de asignaciones, registro manual y corrección de
  asistencia. El bloqueo de fichaje duplicado compara docente + curso + fecha
  + horas (el ciclo era redundante con el curso).
- Los 23 cursos ya cargados no tenían ciclo ni de dónde inferirlo: la columna
  queda nullable y los completa el admin al editarlos. Mientras tanto,
  `Course::hasCycleRule()` impide asignarlos o usarlos en asistencia, con un
  mensaje que manda a completarlos.
- Borrar un ciclo usado por un curso queda bloqueado por la FK.

**Limpieza de asignaciones.** Pedida para reasignar desde cero. En el backup
del 2026-10-05 había 3 asignaciones y 0 registros de asistencia, así que no se
perdió historial.

**Producción**: sin migración nueva (se editaron las originales). SQL manual,
probado sobre el backup en MariaDB 10.11 (motor real del hosting) y en MySQL 8
local. Se corre **justo antes** de subir el código, con el sistema cerrado; no
es re-ejecutable.

```sql
-- 1) Tipo de docente (todos los docentes actuales quedan como CONTRATADO)
ALTER TABLE users
  ADD COLUMN teacher_type ENUM('NOMBRADO','CONTRATADO','ASOCIADO') NULL
  AFTER academic_department;

UPDATE users SET teacher_type = 'CONTRATADO' WHERE role = 'TEACHER';

-- 2) Limpiar asignaciones (y la asistencia que dependa de ellas)
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE attendance_corrections;
TRUNCATE TABLE attendance_evidence;
TRUNCATE TABLE attendance_records;
TRUNCATE TABLE assignments;
SET FOREIGN_KEY_CHECKS = 1;
UPDATE system_configuration SET last_generated_date = NULL;

-- 3) Ciclo: entra en cursos, sale de asignaciones y de asistencia
ALTER TABLE courses
  ADD COLUMN cycle_id BIGINT UNSIGNED NULL AFTER type,
  ADD CONSTRAINT courses_cycle_id_foreign
      FOREIGN KEY (cycle_id) REFERENCES cycles (id) ON DELETE RESTRICT;

ALTER TABLE assignments
  DROP FOREIGN KEY assignments_cycle_id_foreign,
  DROP COLUMN cycle_id;

ALTER TABLE attendance_records
  DROP FOREIGN KEY attendance_records_cycle_id_foreign,
  DROP INDEX attendance_overlap_lookup,
  ADD INDEX attendance_overlap_lookup (teacher_id, course_id, class_date),
  DROP COLUMN cycle_id;
```

**171/171 tests en verde** (974 aserciones), Pint limpio (126 archivos).

## 2026-10-05 — Tipo de curso "Específico" + colores de departamento

Dos pedidos de la Facultad con el sistema ya en producción.

**Tipo de curso `ESPECIFICO`.** `CourseType` pasa de 2 a 3 casos (General →
Específico → Especialidad, orden curricular). Filtro, formulario, tabla y
validación iteran el enum, no hubo que tocarlos. `courses.type` es un `ENUM`
nativo de MySQL, así que **requiere cambio de BD**. Decisión del usuario: no
crear migración nueva — se editó la original
(`2026_09_13_100003_create_courses_table.php`, sirve a instalaciones nuevas y
a los tests) y en producción se aplica a mano, **antes** de subir el código:

```sql
ALTER TABLE courses
  MODIFY COLUMN `type` ENUM('GENERAL','ESPECIALIDAD','ESPECIFICO') NOT NULL;
```

El valor va al final del ENUM a propósito: no reescribe la tabla ni toca
filas existentes. Este cambio **no** queda en la tabla `migrations` de
producción — este registro es la única constancia.

**Colores de departamento académico** (`/admin/users/teachers`). Pedido:
Humanidades amarillo, Administración naranja, Ciencias Básicas rojo,
Ingeniería de Sistemas azul. No se reusaron los tonos semánticos del badge:
no existe amarillo, y `danger` (rojo anaranjado) casi no se distingue de
`warning` y ya significa "INACTIVO" en la columna Estado de esa misma tabla.
Se agregaron tokens `--dept-*` (claro y oscuro) y clases `badge-dept-*` en
`app.css`; `AcademicDepartment::tone()` apunta a ellas. Solo código, sin BD.

## 2026-10-02 — Despliegue a producción: secretos que ya no se regeneran solos + instalador del agente todo-en-uno

Disparado por un incidente real: tras importar la BD real (ver entrada de
abajo), el kiosko en producción quedó pidiendo huella con el agente
devolviendo `Sync devolvio HTTP 401` sin parar, cada vez que se preparaba
una laptop nueva o se re-generaba el paquete de entrega. Causa encontrada:
`scripts/build-package.sh` generaba un `APP_KEY` y un `FINGERPRINT_AGENT_TOKEN`
**nuevos en cada corrida** de `entrega.sh`/`entrega.ps1` — cualquier
actualización de código normal (subir el `.zip` de nuevo, sin reimportar la
BD) pisaba en silencio el `.env` real de producción con secretos distintos:
sesiones de admin invalidadas (`APP_KEY`) y todas las laptops del kiosko
desincronizadas de golpe (`FINGERPRINT_AGENT_TOKEN`), sin ningún aviso.

- **`.deploy-secrets.env`** (nuevo, gitignored, en la raíz del repo):
  `APP_KEY`/`FINGERPRINT_AGENT_TOKEN` se generan ahí **una sola vez** — si
  el archivo ya existe, `build-package.sh` los reusa siempre, por más
  veces que se corra `entrega.sh`. Verificado corriendo el build dos veces
  seguidas: mismo `APP_KEY` y mismo token en ambas.
- **`.env-prod`** (nuevo, gitignored): los valores que solo el usuario
  conoce — `APP_URL`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` — ya no
  se editan a mano en cPanel después de cada entrega; se completan una
  vez en este archivo local y `build-package.sh` los copia al `.env` del
  `.zip`. Si el archivo no existe, esas 4 variables quedan **vacías** en
  el `.zip` (nunca con el placeholder de `.env.cpanel.example`) — plantilla
  en `.env-prod.example` (sí versionada). De paso se corrigió un typo real
  que tenía el `APP_URL` ya puesto en producción (`https:https://...`,
  doble prefijo).
- **`DEPLOY-CPANEL.md` eliminado** — el usuario no lo usa (solo quiere el
  `.zip` listo para subir); `entrega.sh`/`entrega.ps1` ya no lo referencian.
- **`fingerprint-agent/instalar.bat` reescrito, todo-en-uno**: antes eran
  varios pasos manuales (instalar deps, copiar `agent.env.example`,
  editarlo a mano, generar certificado, confiar certificado, iniciar).
  Ahora un solo doble clic: borra cualquier configuración anterior (seguro
  de correr en una laptop nueva o en una que ya tenía algo mal
  configurado), instala dependencias, genera el certificado, y pide
  **solo el token** — verificándolo de verdad contra el servidor real
  (`fingerprint-agent/verificar_token.py`, nuevo) antes de guardarlo; si
  el token está mal o no hay conexión, lo vuelve a pedir sin reiniciar
  todo el proceso. Al final deja el agente corriendo y registrado para
  arrancar solo con Windows — antes eso era un script aparte
  (`instalar-tarea.bat`, que sigue existiendo como utilidad suelta).
  Encontrado y corregido durante la implementación: una llamada a
  PowerShell para cortar un `agent.py` que hubiera quedado corriendo
  estaba partida en varias líneas con `^`, lo que en `cmd.exe` manda
  argumentos separados en vez de un solo comando — fallaba en silencio.
- **`fingerprint-agent/README.md` reescrito completo** — el usuario lo
  describió como "una porquería, no se entiende nada". Un solo camino
  (antes mezclaba instalación nueva, arranque automático y diagnóstico
  sin orden claro), acotado 100% a Windows 10/11, con una sección nueva
  explicando por qué el certificado autofirmado es necesario (contenido
  mixto: una página `https://` no puede hablarle a `http://`, ni siquiera
  en `127.0.0.1`) y que el token resuelve un problema distinto (quién
  llama a Laravel) — confusión real que causó parte del incidente.

`docentrack-deploy.zip` regenerado con este `.env-prod` real y subido a
`/home/ricardo/Desktop/Unamba/` para la prueba en producción. **165/165
tests en verde** (939 aserciones), `./vendor/bin/pint --test` limpio (125
archivos) — sin cambios de código de aplicación en esta pasada, solo
herramientas de despliegue.

## 2026-10-02 — Nombre real de la sede principal + import listo para producción

La Facultad confirmó el nombre oficial de las 2 sedes: **SEDE ABANCAY**
y **FILIAL TAMBOBAMBA**. "Tamburco" era un nombre de ejemplo usado
desde que se diseñó multisede (2026-10-01) — nunca fue el real, así que
se corrigió en todo el repo, no solo en los datos: las 4 migraciones de
multisede (crean/buscan la sede por nombre), `CatalogSeeder`,
comentarios explicativos en `Assignments.php`/`CatalogSimple.php`/
`ClassroomSchedulePdfBuilder.php`, `MultisedeTest.php`/
`ClassroomSchedulePdfTest.php`, y la documentación (`CLAUDE.md` de este
repo y del workspace, `docs/MODELO-DATOS.md`, `docs/plans/multisede.md`).
**165/165 tests en verde** tras el cambio, `./vendor/bin/pint --test`
limpio.

Con el nombre corregido, se preparó el primer **corte real a
multisede**: Ricardo bajó `backup.sql` (volcado real de
`syscodec_unamba`, producción, tomado 2026-10-02 — el sistema llevaba
desde el 2026-09-30 corriendo en mono-sede, batch de migraciones
anterior a Fase 8 y multisede). Se restauró en una base temporal
(`docentrack_import`, mismo contenedor MySQL, sin tocar la BD de
desarrollo) y se corrió `php artisan migrate` real contra los 10 datos
de producción — las 8 migraciones corrieron limpio sin ajustes
manuales (ninguna tiene nombres de aula/curso de prueba hardcodeados,
solo el nombre de la sede, ya corregido). Decisiones tomadas con el
usuario antes de tocar nada (`/grill-me`, dos rondas):

- **Todas las aulas/asignaciones reales son de SEDE ABANCAY** — no hay
  nada todavía en Tambobamba (confirmado por el usuario, coincide con
  el backfill por defecto de la migración).
- **No se migra nada de asistencia**: `attendance_records`,
  `attendance_evidence`, `attendance_corrections` quedan vacías en la
  base nueva — es un corte de catálogos/horario, no de historial.
- **Huellas también se vacían** (`fingerprints`, decisión explícita del
  usuario: "que se enrolen de nuevo") — los 2 docentes que ya tenían
  huella en producción van a tener que volver a pasar por el lector
  ZK9500. El resto de su perfil (foto, datos académicos, asignaciones)
  no se toca.
- **Se eliminó al docente DNI 73009155 (KELVIN GARCIA ZANABRIA)**
  — pedido explícito del usuario. En el backup no tenía ninguna
  asignación ni asistencia todavía, así que fue solo su fila de
  `users` + su huella.
- **Grupos**: el backup solo traía U/A/B — se agregaron C/D/E (pedido
  del usuario) para que `class_groups` quede igual que en desarrollo.
- **`system_configuration.is_open = 0`** en la base nueva (en
  producción estaba en `1`, el kiosko seguía recibiendo fichajes reales
  al momento del backup) — queda cerrado a propósito hasta que el
  usuario confirme que todo (sedes de cada aula, asignaciones) se ve
  bien antes de dejar que un docente intente fichar.

**Entregable**: `docentrack-prod-import.sql` (en la raíz del
workspace, fuera de este repo git — mismo lugar que `backup.sql`).
Export vía `mysqldump --add-drop-table` de las 16 tablas de dominio
(sin `sessions`/`cache`/`jobs`/`failed_jobs`/`job_batches`/
`password_reset_tokens` — estado efímero de Laravel, nunca se migra).
Cada tabla lleva su propio `DROP TABLE IF EXISTS` antes del `CREATE
TABLE`, así que al importarlo por phpMyAdmin **reemplaza por completo**
lo que haya en la base de datos de producción en ese momento —
pensado para no requerir SSH/`artisan` ahí (`DEPLOY-CPANEL.md` ya
documentaba que el plan básico de cPanel no tiene shell). Nota para
cuando se suba: cualquier fichaje o alta hecha en producción entre el
momento del backup y el momento real de la importación se pierde (es
exactamente lo que se pidió para las asistencias; para altas de
catálogo — un docente o asignación nueva — convendría evitarlas
mientras tanto).

No se tocó la base de datos de desarrollo (`docentrack`, la que se usa
para seguir probando funcionalidades) en ningún momento — todo el
trabajo ocurrió en `docentrack_import`, una base temporal en el mismo
contenedor MySQL, borrada al terminar.

## 2026-10-02 — Reportes rediseñado: bitácora cronológica + un solo botón de exportar

`/admin/reports` dejó de ser una tabla resumen agrupada por docente (que
el usuario pidió quitar directamente) y pasó a ser una **bitácora**,
igual que iría anotando el cuaderno físico: una fila por registro
individual, en el **orden real de registro** (`created_at` ascendente —
no la hora de clase declarada, que puede ser tardía), con su estado.
Diseñado con `/grill-me` antes de tocar código (dos rondas de preguntas
sobre dónde viven los filtros y qué estados mostrar).

- **La tabla agrupada por docente se eliminó.** `Reports::rows()` (que
  hacía `groupBy('teacher_id')` y sumaba horas) se reemplazó por
  `filteredQuery()`, una query normal sobre `AttendanceRecord` paginada
  directamente (como ya hacía `/admin/attendance-records`) — el log
  muestra cada fichaje, no un totalizador.
- **Alcance ampliado a todos los estados.** Antes el reporte solo
  incluía `ATTENDED`/`REGULARIZED` (horas efectivamente dictadas). Ahora
  también se ven `PENDING`/`ABSENT` del rango filtrado, con su badge de
  estado — el usuario lo pidió explícitamente ("también poner el estado
  del registro"), y por defecto el rango es **hoy** (antes no tenía
  fecha por defecto), para que la pantalla nazca mostrando el día en
  curso, no todo el histórico.
- **Filtros ampliados y movidos a componentes reusables**: se agregó
  filtro de Curso (`x-ui.course-select`, no existía antes en Reportes) y
  de Método (`fingerprint`/`manual`, nuevo). El campo de texto libre
  "Buscar docente" se quitó — `x-ui.teacher-select` ya trae su propio
  buscador por nombre/documento, duplicaba la misma función. Filtros:
  Sede, Periodo, Curso, Docente, Método, Desde, Hasta — exactamente el
  set pedido.
- **Estados para marcar, como checkboxes** (pedido explícito, mismo
  patrón que ya usa `/admin/attendance-records`: `alertPending`/
  `alertAbsent`/`alertOk`/`alertExpired`, no excluyentes, ninguno
  marcado = sin restricción). Se filtran **solo la bitácora y la
  exportación** — los KPI de arriba (tarjetas resumen) se calculan sin
  ellos a propósito, para que marcar "solo pendientes" no haga parecer
  que ya no existen los demás registros del filtro.
- **Exportar: un solo botón → modal con PDF/Excel.** Reemplaza los dos
  botones separados "Exportar Excel"/"Exportar PDF". El modal no
  duplica los filtros (ya viven arriba, en pantalla — decisión tomada
  con el usuario): exporta exactamente lo que está filtrado en ese
  momento, mismo orden cronológico que el log. `AttendanceReportExport`
  (Excel) y `reports/attendance-pdf.blade.php` (PDF) se reescribieron
  para la misma forma plana (Fecha, Horario declarado, Docente,
  Documento, Curso, Grupo, **Sede** — nueva columna, antes no se
  distinguía —, Estado, Método) en vez del resumen por docente.
- **`AttendanceRecord::methodLabel()`** (nuevo helper en el modelo):
  HUELLA / ADMIN / MANUAL / "—", mismo criterio que ya usaba el
  `@switch` de `attendance-records.blade.php` — se extrajo para no
  triplicar la lógica entre el log, el Excel y el PDF.
- **Hallazgo real corregido al verificar, no de este cambio**:
  `storage/framework/testing/disks/local` había quedado con dueño
  `root` (de una corrida previa de algún comando sin `--user`), lo que
  tumbaba toda la suite con `Permission denied` al limpiar discos fake
  entre tests — no es nada de este cambio, solo bloqueaba poder
  verificarlo. Se borró el directorio como root; Docker lo recrea solo
  con el dueño correcto en la siguiente corrida.

`tests/Feature/Fase5DashboardReportsTest.php` reescrito: el test que
verificaba agrupación/suma de horas por docente se reemplazó por
cobertura de orden cronológico, filtros nuevos (curso, método),
checkboxes de estado (incluye el caso "no afectan los KPI"), rango por
defecto "hoy" y `clearFilters()` con los nuevos campos. **165/165 tests
en verde** (939 aserciones), `./vendor/bin/pint --test` limpio (125
archivos).

## 2026-10-01 — Multisede real (Sede Abancay / Filial Tambobamba)

Reemplaza la decisión original de mono-sede (`CLAUDE.md` §6, 2026-09-13):
al presentar el sistema, la Facultad confirmó que necesita una sola
instancia con dos sedes reales, no un clon independiente por sede como
se había planeado — el caso real que lo dispara: mismo curso, mismo
periodo, 2 grupos en Abancay y solo 1 en Tambobamba. Diseñado con el
usuario vía `/grill-me` en dos rondas antes de tocar código —
`docs/plans/multisede.md`.

- **`campuses`** (nueva): catálogo de sedes, CRUD vía `CatalogSimple`
  (`/admin/catalogs/campuses`). Sembradas "SEDE ABANCAY" y "FILIAL
  TAMBOBAMBA".
- **`classrooms.campus_id`**: un aula pertenece a una sola sede — es la
  fuente real de la sede de una asignación. Único cambiado de `name`
  solo a `(campus_id, name)` — dos sedes pueden tener cada una su
  propia "Aula 202".
- **`course_academic_period_groups.campus_id`**: mismo curso+periodo
  puede tener distinta cantidad de grupos por sede (el problema real).
  Único cambiado a `(course_id, academic_period_id, campus_id,
  class_group_id)`. El modal de grupos (`CatalogSimple`) ganó un
  selector de sede.
- **`assignments` sin columna nueva**: la sede se deriva del aula
  (`Assignment::campus()`), nunca un campo propio — decisión
  deliberada para que sea imposible que quede inconsistente con el
  aula elegida. El formulario de Asignaciones ganó un selector de Sede
  que filtra en cascada el aula y el grupo disponibles.
- **`attendance_records.campus_id`**: copiado al generar/fichar/corregir
  (mismo criterio que `course_id` etc.) — filtro directo para
  reportes/dashboard sin join por consulta.
- **Choque de horario — nuevo, bloqueante**: `Assignments::save()`
  ahora rechaza una asignación si el mismo docente, o la misma aula,
  ya tiene otro bloque con horas que se solapan dentro del mismo
  periodo académico. Esto ya estaba pendiente desde Fase 3 (nunca se
  construyó, independiente de multisede) — se cerró junto con esto a
  pedido del usuario. Mismo cálculo de solape que ya usa
  `Kiosk::hasOverlappingRecord()`.
- **Kiosko**: no filtra por sede — un docente ve todas sus clases de
  hoy de cualquier sede donde dicte, la tarjeta de cada clase ahora
  muestra de qué sede es (chip nuevo, mismo patrón que grupo/ciclo/hora).
- **Filtro de sede agregado** a Asignaciones, Registros de asistencia,
  Reportes, Dashboard y la configuración de grupos por curso+periodo.
  "Docentes activos" en el Dashboard queda sin filtro a propósito — el
  docente no se amarra a una sede, no tiene sentido filtrarlo por una.
- **Dos bugs reales encontrados y corregidos al implementar**:
  - Reportes/Dashboard todavía usaban el criterio viejo de
    `created_by` null/no-null para "sin observación"/"vencidas" en vez
    del `status` real de Fase 8 — contaban filas `PENDING`/`ABSENT`
    como si fueran asistencias reales (el gap que Fase 8 había dejado
    señalado como "no auditado todavía"). Corregido: ambas pantallas
    ahora filtran por `status` real.
  - La migración de `course_academic_period_groups` fallaba en MySQL
    real (no en sqlite, que no lo exige): no dejaba soltar el único
    viejo mientras era el índice que respaldaba la FK de `course_id`.
    Se resolvió creando el índice nuevo antes de soltar el viejo, no
    después — encontrado al verificar contra MySQL real, no solo con
    la suite de tests.
- Tests nuevos: `tests/Feature/MultisedeTest.php` (10 casos: aulas con
  mismo nombre en sedes distintas, grupos distintos por sede mismo
  curso+periodo, formulario resetea aula/grupo al cambiar sede, choque
  de horario docente/aula bloqueante, horas no solapadas permitidas,
  kiosko muestra ambas sedes sin filtrar, filtros de Registros y
  Dashboard). **151/151 tests en verde** (889 aserciones),
  `./vendor/bin/pint --test` limpio (123 archivos). Verificado también
  con `migrate:fresh --seed` contra MySQL real del contenedor (ahí es
  donde se encontró el bug de la migración).

## 2026-10-01 — Code review de Codex sobre multisede: 8 hallazgos reales corregidos

Antes de probar manualmente lo de la entrada anterior, se pidió a Codex
un code review de solo lectura sobre el diff de multisede (ya
commiteado en `7322c50`). Encontró 8 problemas; los 8 se verificaron
contra el código real (no se aceptó ninguno a ciegas) y resultaron
reales. Corregidos:

- **`Assignments::conflictsWithOtherTeacher()`/`teacherAlreadyHasOtherGroup()`
  no scopeaban por sede**: comparaban curso+grupo+periodo sin tocar el
  aula — como "grupo A" es una etiqueta genérica compartida entre
  sedes, asignar el grupo A de un curso a un docente en Abancay y a
  otro en Tambobamba se rechazaba como si fuera el mismo grupo. Justo
  el caso real que motivó todo el diseño. Corregido agregando
  `whereHas('classroom', ...)` por `campus_id` en los 4 métodos
  relacionados (los dos de arriba más `occupantTeacherName()` y
  `teacherOtherGroupAbbreviation()`, para que el mensaje de error
  también sea correcto).
- **`Assignments::openAddBlock()` asumía siempre Abancay**: si la
  carga real vivía en Tambobamba, `render()` borraba `classGroupId` al
  no encontrarlo en la sede por defecto, y como el campo queda
  bloqueado en modo `addBlock`, no se podía guardar. Corregido: ahora
  busca un bloque existente de esa carga y usa su sede real.
- **`Assignments::scheduleConflict()` no excluía bloques inactivos**:
  tras desactivar un bloque para reemplazar su horario, seguía
  bloqueando el reemplazo en el mismo horario. Corregido agregando
  `where('active', true)` a ambas consultas (docente y aula).
- **`AttendanceRecords`/`AttendanceRecordDetail`: el grupo no se
  validaba contra la sede del aula elegida** — se podía guardar un aula
  de una sede con un grupo configurado solo en la otra. Corregido: la
  regla de `classGroupId` ahora exige `campus_id` (derivado del aula
  elegida) al validar contra `course_academic_period_groups`.
- **Contadores de `AttendanceRecords` ignoraban el filtro de sede**: la
  tabla sí filtraba, los 7 contadores de arriba no. Corregido con un
  `summaryCounters()` que aplica el mismo filtro.
- **`SyncAttendance` N+1 nuevo**: precargaba `classroom` pero
  `Assignment::campus()` accede además a `classroom->campus` — una
  query extra por asignación. Corregido a `with('classroom.campus')`.
- **`CatalogSimple`: nombre de sede sin validar como único** en el
  formulario — solo `cycles` tenía `Rule::unique`, así que duplicar
  nombre de sede llegaba crudo al único de MySQL en vez de dar un error
  de formulario. Corregido agregando `campuses` a esa regla.
- **Rollback de las migraciones de `classrooms`/`course_academic_period_groups`
  no es seguro con datos reales multisede** (aulas con el mismo nombre
  en dos sedes, o el mismo grupo en dos sedes) — el `down()` recrea el
  único viejo antes de soltar `campus_id` y chocaría por duplicados. No
  es un bug de orden de DDL (eso ya se había resuelto en el `up()`); es
  un conflicto de datos real que exige una decisión manual. Se dejó
  documentado con un comentario en ambos `down()` en vez de "arreglarlo"
  en falso.
- Pendiente, no corregido a propósito (severidad baja, el usuario lo
  dejó para después): el PDF de horarios y los selectores de aula
  muestran solo `classroom->name` sin indicar la sede — ambiguo si dos
  sedes llegan a tener un aula con el mismo nombre.

5 tests nuevos en `tests/Feature/MultisedeTest.php` que cubren
exactamente estos escenarios (antes del fix, cada uno fallaba
reproduciendo el hallazgo de Codex). **156/156 tests en verde** (902
aserciones), `./vendor/bin/pint --test` limpio (123 archivos).

## 2026-10-01 — Observaciones de la primera prueba manual de multisede

Encontrado por el usuario probando en el navegador real (no detectable por
tests automatizados — los tres son cosas que la suite nunca ejercita: una
ruta, un límite de PHP, y el diseño visual de una tabla):

- **`/admin/catalogs/campuses` daba 404**: la ruta `admin/catalogs/{type}`
  tiene un `whereIn('type', [...])` que nunca se actualizó al agregar el
  tipo `campuses` a `CatalogSimple` — los tests llaman al componente
  Livewire directo (`Livewire::test(CatalogSimple::class, ['type' =>
  'campuses'])`), nunca pasan por la ruta real, así que el candado de
  `abort_unless` del propio componente (que sí incluía `campuses`) nunca
  detectó el hueco. Corregido agregando `campuses` al `whereIn`.
- **Subir el plan de estudios de un curso fallaba**: la imagen PHP trae
  `upload_max_filesize=2M`/`post_max_size=8M` por defecto, pero la regla
  de validación del formulario declara permitir hasta 10 MB
  (`CatalogSimple::rules()` → `studyPlan` → `max:10240`) — un PDF real
  (sílabo escaneado) de más de 2 MB nunca llegaba ni a la validación de
  Laravel, PHP lo cortaba antes. Corregido con
  `docker/php/uploads.ini` (12 MB) montado en `docker-compose.yml` sin
  necesidad de rebuildear la imagen. **Pendiente para producción**: el
  hosting de cPanel real necesita el mismo ajuste en su propio
  `php.ini`/`.user.ini`, esto solo cubre el contenedor de desarrollo.
- **Tabla de Cursos rediseñada** (`/admin/catalogs/courses`), a pedido del
  usuario vía `/frontend-design`:
  - Las 4 columnas sueltas Cr./HT/HP/TH se consolidaron en una sola
    columna "Horas" — créditos en la línea principal, desglose
    teoría/práctica/total en una línea secundaria más chica, con el
    detalle completo en el `title` al pasar el mouse. 14 columnas → 11.
  - La celda de grupos por sede pasó de chips en una sola línea
    separados por una rayita vertical a una fila por sede (apiladas),
    cada una con un pill compacto del nombre de sede (ej. "ABANCAY" en
    vez de "SEDE ABANCAY:") — más legible cuando hay 2 sedes con varios
    grupos cada una.
- Al crear los 3 docentes de prueba de la ronda anterior se detectó que
  `44444444`/`55555555` ya estaban tomados por fixtures de otros tests
  (`Fase2aAuthUsersTest`, `Fase2bKioskManualTest`) que no pasan por
  `UserSeeder` — chocaban con el único real de `document_number` al
  correr la suite completa. Reasignados a `77777777`
  (DOCENTE EJEMPLO CUATRO) y `50505050` (DOCENTE EJEMPLO CINCO);
  `66666666` (SEIS) no cambió.

**156/156 tests en verde** (902 aserciones), Pint limpio.

**Aprobado por el usuario tras probar en el navegador real**: pantalla de
Sedes (`/admin/catalogs/campuses`) y creación de aulas con nombre
repetido entre sedes distintas.

## 2026-10-01 — Segundo ajuste de diseño en la tabla de Cursos

El primer rediseño de la columna Horas y la celda de Grupos (entrada
anterior) no convenció al usuario ("no me gusta las columnas horas ni
grupos") — pidió explícitamente: mejorar el diseño de Horas, mover el
botón de Grupos al costado de Editar (que la columna Grupos quede solo
informativa), y diferenciar los grupos por sede con color o formato de
lista. Resuelto vía `/frontend-design`, dentro del sistema de diseño
existente (sin inventar una paleta nueva):

- **Horas**: de un bloque de 2 líneas de texto concatenado a una grilla
  2×2 con etiqueta + valor (Cr./HT/HP/TH), el total (TH) resaltado en
  navy porque es el número que más se compara entre cursos.
- **Grupos**: el botón "Configurar grupos" se movió a la columna
  Acciones, junto a Editar — la columna Grupos quedó 100% informativa.
  Cada sede es ahora una fila-lista con un acento de color propio
  (barra izquierda + texto + chips del mismo tono): navy para la
  primera sede, un acento morado nuevo (`--campus-accent-2`, con
  variante para modo oscuro) para la segunda. El acento es por
  posición (CSS `:nth-of-type`), no por nombre de sede en PHP — escala
  solo si algún día hay una tercera.

**156/156 tests en verde**, Pint limpio.

## 2026-10-01 — Color elegible por sede

A pedido del usuario ("tal vez puedas poner a sedes un color para elegir
de tipo hex, y así poder diferenciar en grupos"): se evaluó input hex
libre vs. paleta curada, y se eligió paleta curada (8 colores) para
evitar que alguien elija un color ilegible sobre blanco o que ya
signifique algo en el sistema (verde=activo, rojo=inactivo,
naranja=acento). Confirmado por el usuario antes de implementar.

- `campuses.color` (nueva, string, default `navy`) — `App\Enums\CampusColor`
  (8 casos: Navy, Plum, Teal, Amber, Rose, Indigo, Forest, Slate), casteado
  nativo en `Campus` igual que `CourseType` en `Course`. Backfill:
  Abancay→navy, Tambobamba→plum (mismo acento que ya tenían antes de
  ser elegible).
- Selector de color en el modal de Sedes: 8 botones circulares, paleta
  curada — no un input de texto. Columna "Color" nueva en la tabla de
  Sedes (swatch + nombre).
- La celda de Grupos (tabla de Cursos) ahora usa el color REAL de cada
  sede (antes era por posición vía `:nth-of-type`, un color fijo sin
  relación con lo que el admin elija).
- 8 tokens CSS nuevos `--campus-color-{navy,plum,teal,amber,rose,indigo,forest,slate}`
  (con variante más clara para modo oscuro), reemplazan el
  `--campus-accent-2` de la iteración anterior.

**156/156 tests en verde**, Pint limpio.

**Aprobado por el usuario tras probar en el navegador real**: tabla de
Cursos completa (columna Horas, columna Grupos, botón de grupos al
costado de Editar). El selector de color en el modal de Sedes y la
columna "Color" de la tabla de Sedes son nuevos en este mismo cambio —
**todavía no confirmados en el navegador**, quedan en la lista de
pendientes.

## 2026-10-01 — Orden del formulario de Asignaciones: Sede primero

El usuario probó `/admin/assignments` y encontró que el orden estaba
mal: Sede aparecía casi al final del formulario, ya con un valor por
defecto (Abancay) elegido en silencio, mientras Aula/Curso/Grupo
arriba ya estaban habilitados sin que el admin hubiera elegido sede
todavía — "el orden sí importa". Pedido explícito: Sede primero,
deshabilitar lo demás hasta elegirla, y si cambia, limpiar todo lo ya
avanzado.

- `Assignments::openCreate()` ya no preselecciona una sede — abre en
  blanco (antes: Abancay por ser la de menor id).
- `Assignments::updatedCampusId()` ampliado: antes solo limpiaba
  `classroomId`/`classGroupId`; ahora limpia **todo** lo que viene
  después en el formulario (`teacherId`, `courseId`, `classGroupId`,
  `cycleId`, `classroomId`, `dayOfWeek`, `startTime`, `endTime`) —
  cambiar de sede a mitad de llenar el formulario ya no deja una mezcla
  a medias de la sede anterior.
- Orden del formulario reescrito: **Sede → Aula → Curso → Grupo →
  Docente → Ciclo → Día → Horas → Estado** (antes: Docente, Curso,
  Grupo, Ciclo, Día, Sede, Aula, Horas, Estado — Sede casi al final).
- Todo detrás de Sede queda deshabilitado (`disabled`) mientras no haya
  sede elegida. Los selectores con buscador (Docente, Curso — Tom
  Select dentro de un `wire:ignore`) necesitaron un mecanismo nuevo
  (`watch-property` + `x-effect` sobre `$wire.<propiedad>`) porque
  `wire:ignore` evita que Livewire vuelva a tocar ese HTML en renders
  posteriores — un `disabled` fijo en el Blade solo habría cubierto el
  estado inicial, no el cambio en vivo.
- En modo "Agregar bloque" (addBlock), Sede queda bloqueada (no
  editable) porque ya se deriva de la carga existente — permitir
  cambiarla ahí rompería la validez del grupo ya fijado (curso+grupo
  bloqueados, pero su configuración por sede es específica).

**156/156 tests en verde** (909 aserciones, +1 test reescrito que ahora
cubre el reset completo, no solo aula/grupo), Pint limpio.

**Bug real encontrado al probar "Nueva carga" en el navegador**: el
toggle de Estado usaba `@disabled($campusId === null)` dentro de la
etiqueta autocerrada `<x-ui.toggle ... />`. Un `@directive(...)` suelto
adentro de una etiqueta de componente Blade (`<x-...>`, a diferencia de
una etiqueta HTML normal) rompe el parser de componentes — Blade deja
toda la etiqueta sin compilar y la pasa literal al HTML, que el
navegador interpreta como un custom element desconocido. Síntoma en
consola: `Alpine Expression Error: $active is not defined` (porque
`:checked="$active"` tampoco llegó a evaluarse). Los tests automatizados
no lo detectaron porque leen el HTML compilado vía Livewire sin
ejecutar Alpine en un navegador real. Corregido cambiando a
`:disabled="$campusId === null"` (binding de prop, sí válido dentro de
una etiqueta de componente). Los demás `@disabled(...)` del formulario
están en `<select>`/`<input>` normales — ahí sí es sintaxis válida, no
se tocaron.

## 2026-10-01 — El select de Grupo avisa antes de guardar, no después

El usuario reportó el mensaje "Este docente ya tiene el grupo A de este
curso..." saliendo recién al guardar, y pidió opinión sobre mostrarlo
antes: ocultar la opción o mostrarla bloqueada. Se eligió bloqueada +
motivo (más informativo que ocultarla). Esto reveló una dependencia de
orden: el aviso depende de qué DOCENTE se elige, y en el orden fijado
en la entrada anterior Grupo iba antes que Docente — así que Docente se
movió antes de Grupo (confirmado con el usuario antes de implementar).

- Orden final: **Sede → Aula → Curso → Docente → Grupo → Ciclo → Día →
  Horas → Estado**.
- `Assignments::groupOptionsForSelect()` (nuevo): por cada grupo
  disponible para el curso+periodo+sede, busca si ya tiene un docente
  asignado (cualquiera, excluyendo el bloque que se esté editando) y
  marca:
  - **Ocupado por otro docente** → deshabilitado, "ocupado por
    {nombre}" (prioridad sobre el siguiente caso, es más específico).
  - **El docente elegido ya tiene otro grupo de este curso** →
    deshabilitado, "el docente ya tiene el grupo {X}" — el grupo que
    SÍ tiene el docente elegido queda habilitado normalmente.
- `updatedTeacherId()` (nuevo): cambiar de docente limpia `classGroupId`
  — la disponibilidad de grupos depende de quién se elija, una
  selección previa puede dejar de tener sentido.
- El select sigue validando lo mismo en `save()` (esto no cambia
  reglas, solo las muestra antes) — por eso no hacía falta tocar
  `conflictsWithOtherTeacher()`/`teacherAlreadyHasOtherGroup()`.

**158/158 tests en verde** (911 aserciones), Pint limpio.

## 2026-10-01 — PDF de horarios: filtro de sede + separado por sedes

Pedido del usuario: agregar filtro de sede al modal "Descargar horario
PDF" e indicar en el PDF de qué sede es cada horario, separado por
sedes si hay varias seleccionadas. De paso resuelve el pendiente P3 que
había quedado abierto del code review de Codex ("el selector de aula
no indica sede, ambiguo si dos aulas de sedes distintas comparten
nombre").

- **Modal**: nuevo selector "Sede" (`pdfCampusFilter`) que filtra la
  lista de aulas a elegir — no limita el PDF en sí, solo qué checkboxes
  se ofrecen. "Todas"/"Ninguna" respetan el filtro activo. Cambiar de
  sede limpia lo ya marcado (una selección de otra sede no debe quedar
  oculta pero aun marcada). Con "Todas las sedes" elegido, cada aula
  muestra su sede en chico debajo del nombre (antes solo el nombre —
  ambiguo si dos sedes comparten nombre de aula).
- **`ClassroomSchedulePdfBuilder`**: las páginas ahora se ordenan por
  sede primero, luego por nombre de aula — con aulas de más de una
  sede, quedan agrupadas (todas las de una sede juntas), no mezcladas
  solo alfabéticamente por aula.
- **PDF**: cada página muestra la sede del aula bajo su nombre (siempre,
  sin importar cuántas sedes se eligieron). Cuando hay más de una sede
  entre las aulas seleccionadas, además aparece un banner "Sede: X"
  antes de la primera página de cada grupo — con una sola sede no se
  repite el aviso en cada página.

3 tests nuevos (`ClassroomSchedulePdfTest`): agrupado+marcado por sede
con 2 sedes, sin marcar con 1 sola, y el filtro del modal narrowing la
lista de aulas. **161/161 tests en verde** (923 aserciones), Pint
limpio.

## 2026-10-01 — La foto y la sede del docente no deberían desaparecer al elegir clase

El usuario mostró una captura del paso "Registro de la clase" (tras
tocar una tarjeta de clase): solo quedaba un texto chico con el nombre
del docente — la foto grande y el sello de verificado que sí se veían
un paso antes (al elegir la clase) desaparecían. Pedido: mostrar foto y
sede también ahí, "como en el confirmar".

- `Kiosk::render()`: `teacherPhotoUrl` ya no se limita a los pasos
  `selecting_assignment`/`no_assignments_today` — también se calcula en
  `form` y `confirming` (una sola vez por submit, no por tecla — esos
  campos no usan `wire:model.live`).
- Nuevo `teacherCampusName`: la sede del aula de la clase YA elegida
  (`classroomId` ya está fijo en estos dos pasos) — antes de elegir
  clase no hay "la" sede (el docente puede dictar en varias), por eso
  no se agregó en la pantalla de elegir clase.
- `<x-ui.kiosk-identity>` ganó un prop `campus` opcional (línea nueva
  bajo "Documento N°") y una variante `.is-compact` — la versión grande
  (foto de 5.5rem) no entraba en la cabecera del formulario sin empujar
  los 8 campos fuera de la vista; la compacta (foto de 2.75rem, sin el
  kicker "Docente identificado") sí.
- Se eliminó `<x-ui.kiosk-teacher-tag>` (el texto chico que reemplazó) —
  quedó sin ningún uso en el código.

Nuevo test en `KioskIdentityPanelTest`: confirma foto + sede visibles
tanto en "form" como en "confirming". **162/162 tests en verde** (929
aserciones), Pint limpio.

## 2026-10-01 — Permisos de identificación en el kiosko (huella/documento)

Hueco de seguridad real encontrado al revisar el código (no reportado
por el usuario, se descubrió al diseñar esto): `Kiosk::identify()`/
`startIdentifying()` no tenían **ningún candado del lado del
servidor** — el botón "Identificarse con documento" solo se ocultaba
en el navegador (`agentAvailable`, una variable de Alpine). Cualquiera
con las devtools abiertas, o con un simple fallo de permiso de cámara
(que también mostraba el botón), podía identificarse por documento
aunque el lector estuviera conectado y funcionando. Diseñado con el
usuario vía `/grill-me` antes de tocar código —
`docs/plans/permisos-identificacion-kiosko.md`.

- `system_configuration.allow_fingerprint`/`allow_document` (boolean,
  default `true` ambos) — mismo patrón que `is_open`.
- **El candado real vive en el servidor, no en esconder un botón**:
  `Kiosk::startIdentifying()` no avanza a `identifying` si
  `! allow_document`. `Kiosk::identify()` rechaza según
  `identificationMethod` (`manual`→requiere `allow_document`,
  `fingerprint`→requiere `allow_fingerprint`), con mensaje claro en el
  caso real (el docente ya estaba en la pantalla de documento cuando el
  admin deshabilitó ese método).
- **Decisión de diseño deliberada**: el servidor **no** intenta saber
  si el lector físico sigue conectado (eso requeriría que el agente
  reportara estado a Laravel en tiempo real — más complejo y sigue
  siendo manipulable). Es una política pura: si un método no está
  permitido, se rechaza siempre, sin importar la razón. Resuelve el
  mismo caso que pediste el usuario ("huella sí, documento no + lector
  desconectado = nadie puede fichar") sin esa complejidad.
- Alpine (`kioskCamera`) recibe la política del servidor — si
  `! allow_fingerprint`, ni siquiera intenta conectarse al agente
  (evita confundir a un docente que lee bien su huella y el servidor se
  la rechazaría igual). Mensaje "El lector de huella no está disponible
  — contacta a administración" cuando huella es obligatoria y no hay
  lector disponible, en vez del texto normal que invita a usar
  documento.
- Panel admin: dos toggles nuevos junto a `is_open`/ventana de registro
  (`/admin/system-configuration`). **No se puede apagar el último
  método activo** — toast de error, no se guarda. Para cerrar el
  autoservicio del todo ya existe `is_open`.
- **Fuera de alcance a propósito** (ruling del diseño): verificar
  criptográficamente que un `identificationMethod = fingerprint`
  realmente vino de un match 1:N real — limitación ya conocida y
  aceptada del diseño actual (`CLAUDE.md` §10), no se resuelve acá.
- Tests nuevos: `tests/Feature/KioskIdentificationPolicyTest.php` (8
  casos, incluida la condición de carrera real: documento permitido al
  entrar al formulario, deshabilitado antes de enviarlo).
  **141/141 tests en verde** (861 aserciones), `./vendor/bin/pint --test`
  limpio (117 archivos). Verificado también con `migrate:fresh --seed`
  contra MySQL real del contenedor.

## Pendiente de probar manualmente (2026-10-01)

Todo lo de más abajo pasó por tests automatizados y revisión de código.
**Ya confirmado en el navegador real por el usuario**: pantalla de
Sedes (CRUD, color por sede), aulas con nombre repetido entre sedes,
tabla de Cursos completa, y **Asignaciones completo** (orden
Sede→Aula→Curso→Docente→Grupo con todo deshabilitado hasta elegir
sede, cascada al cambiar de sede, choque de horario real, el select de
Grupo marcando ocupado/bloqueado, los fixes del code review de Codex
relacionados — addBlock en Tambobamba, mismo grupo en sedes distintas,
bloque inactivo reemplazado —, y el PDF de horarios con filtro de sede
y separado por sedes). Lo que sigue abajo es lo que falta:

**Multisede:**
- Confirmar en el panel real cuáles aulas existentes son en realidad
  de Tambobamba — el backfill de la migración las puso todas en
  Abancay por defecto (decisión tomada al implementar, corrección
  manual esperada).
- Ver la tarjeta de sede en el kiosko real con un docente que dicte en
  ambas sedes el mismo día (sin probar todavía — el kiosko en general
  sigue pendiente de probar en navegador/hardware real, ver más abajo).
- Revisar que los filtros de sede nuevos de Registros, Reportes,
  Dashboard y grupos por curso se vean bien en pantallas chicas/tablet
  — no se verificó responsive (el de Asignaciones y el modal del PDF ya
  se probaron).

**Fase 8 (estados de asistencia):**
- Botón "Sincronizar" en `/admin/attendance-records`: verlo de verdad
  en el navegador (toast, texto de "última sincronización"), y correrlo
  al menos una vez en el entorno de producción/cPanel real (ahí sigue
  sin cron, es 100% manual por ahora).
- Flujo completo real: dejar una clase sin fichar → esperar a que venza
  la ventana → apretar "Sincronizar" → confirmar que pasa a
  "Inasistencia" → regularizarla desde el panel → confirmar que pide
  observación obligatoria y queda "Regularizado".
- Editar un registro que SÍ fichó el docente (`ATTENDED`) — confirmar
  que el flujo de corrección de siempre (Fase 4) se sigue viendo y
  comportando igual, sin romperse con los cambios de estado.
- Revisar visualmente el listado nuevo (columna "Estado", filtros
  Pendientes/Inasistencias/Asistieron/Regularizadas, tarjetas de
  resumen) en pantallas chicas/tablet — no se verificó responsive.
- **No auditado todavía** (marcado a propósito en el plan, no se tocó):
  si el dashboard o los reportes de Fase 5 cuentan filas `PENDING`/
  `ABSENT` como si fueran asistencias reales.

**Permisos de identificación (huella/documento):**
- Probar en el panel admin apagar cada toggle y confirmar en el
  kiosko real que el método deshabilitado de verdad no deja pasar
  (no solo que el botón desaparece).
- Probar el caso real con el lector físico: `allow_fingerprint=true`,
  `allow_document=false`, desconectar el ZK9500 de verdad y confirmar
  que el kiosko queda inoperable con el mensaje "fuera de servicio" (no
  solo simulado apagando el toggle).
- Confirmar que el toast de error al intentar apagar el último método
  activo se ve bien en el panel real.

**Rediseño del kiosko (identidad + horario en una sola pantalla):**
- Ver en navegador real que la foto del docente se recorte/centre bien
  con fotos de proporciones distintas (hoy en dev todos los docentes
  seedeados tienen `reference_photo_path = null`, nunca se vio una foto
  real ahí).
- Probar con el lector de huella real (fingerprint-agent): el punto
  donde se captura la foto "formPhoto" se movió del botón "Continuar"
  (ya no existe) a tocar la tarjeta de la clase — confirmar que la
  cámara captura bien en ese momento nuevo, con huella real.
- Verlo en la tablet/pantalla táctil real del kiosko, no solo en
  navegador de escritorio — que el bloque de identidad no empuje las
  tarjetas de clase fuera de la vista sin scroll.
- Confirmar que el modo oscuro del kiosko (toggle existente) se ve bien
  con los elementos nuevos (foto circular, sello de verificado) — las
  clases CSS usan los tokens que ya soportan ambos temas, pero no se
  vio con los ojos.
- Confirmar la sensación real de "menos pasos" con un docente de una
  sola clase al día (antes: identify → Continuar → tocar la única
  tarjeta; ahora: identify → tocar la tarjeta).

## 2026-10-01 — Rediseño del kiosko: identidad + horario sin pantalla de confirmación redundante

A pedido del usuario (punto 3 de los temas surgidos en la primera
presentación): la pantalla "identificado" (foto/nombre + botón
"Continuar" para confirmar "¿eres tú?") era redundante — la propia
huella o documento ya es la confirmación. Se fusionó con la pantalla de
"elegir clase":

- `Kiosk::identify()` ahora resuelve directo a `no_assignments_today` o
  `selecting_assignment` (mismo cálculo que antes vivía en
  `continueToForm()`, que se eliminó). Un toque menos en el camino
  feliz.
- Nuevo componente `<x-ui.kiosk-identity>`: foto circular (anillo
  navy, igual que `.kiosk-fingerprint.is-ok`) + sello de verificado
  (ícono check en naranja/`--stamp`, mismo color que el resto del
  sistema usa para "confirmado") + nombre + documento. Sin botón — es
  confirmación pasiva, no una decisión que tomar.
- La foto se embebe como `data:` URI calculada en `Kiosk::render()`
  (`teacherPhotoDataUri()`), **no** una ruta pública nueva — el kiosko
  no tiene auth, y una ruta tipo `/files/reference-photo/{user}` ahí
  habría permitido listar la foto de cualquier docente por id sin
  verificar nada. Con el data URI, solo se expone la foto de quien ya
  se identificó de verdad en esa sesión. Si el docente no tiene foto
  (`reference_photo_path` null), cae a un ícono genérico — no se asumió
  que todos la tienen.
- El punto donde se captura la foto de evidencia "formPhoto" se movió
  del botón "Continuar" (ya no existe) a tocar la tarjeta de la clase
  (`selectAssignment`) — se mantienen las 3 capturas de evidencia
  (identificación/formulario/confirmación), solo cambia el gesto que
  dispara la segunda.
- CSS nuevo (`.kiosk-identity*`) reusa los tokens/el lenguaje visual que
  ya existía (navy + stamp, ledger/paper) — no se inventó una paleta
  nueva, se extendió la existente.
- Limpieza mecánica: 29 llamadas a `->call('continueToForm')` en los
  tests existentes (ya no existe el método) + 2 aserciones que
  esperaban el step `identified` (ya no existe el estado).
- Tests nuevos: `tests/Feature/KioskIdentityPanelTest.php` (4 casos: se
  salta la pantalla redundante, muestra la foto real cuando existe,
  cae al ícono genérico sin foto, la pantalla "sin clases hoy" también
  muestra identidad).
- **Ajuste siguiente, mismo día**: la pantalla final "Registro
  guardado" pasó de una tarjeta grande (8s, con botón "Volver ahora")
  a una confirmación compacta y pasiva (`.kiosk-card-compact`, solo
  ícono + "Registro guardado" + nombre del docente, sin resumen de
  curso/horario que ya se vio en la pantalla de confirmación previa) —
  2 segundos, sin botón, se desvanece sola (`kiosk-success-pop`).
  Mismo criterio que la identidad: sin nada que decidir, un botón para
  salir antes es un paso de más. `returnNow()` se eliminó (ya no hay
  botón que lo dispare); el test que lo cubría pasó a probar el
  auto-retorno (`returnToIdleIfExpired`, con `successStartedAt`
  manipulado para no depender de un `sleep` real).
- `.interface-design/system.md` creado con el sistema de diseño del
  kiosko (navy+stamp, escala táctil, patrones de componente).
- **133/133 tests en verde** (842 aserciones), `./vendor/bin/pint --test`
  limpio (115 archivos).

## 2026-10-01 — Fase 8: estados de asistencia con pre-registro diario

A raíz de la primera presentación del sistema en producción
(2026-09-30), el usuario detectó que `attendance_records` solo existía
cuando alguien fichaba — no había forma de saber qué clases programadas
(`assignments`) no se dictaron. Diseñado con el usuario antes de tocar
código (`docs/plans/fase-8-estados-asistencia.md`), implementado
directamente por Claude (pedido explícito).

- Nuevo enum `AttendanceStatus` (`PENDING`/`ATTENDED`/`ABSENT`/`REGULARIZED`)
  en `attendance_records.status`. `check_in_time`, `check_out_time`,
  `student_count` e `identification_method` pasan a nullable (una fila
  `PENDING` no tiene esto todavía). Índice único `(assignment_id,
  class_date)`. Backfill de las filas existentes: `ATTENDED` si
  `created_by` es null, `REGULARIZED` si no. `created_by` cambia de
  significado: ya no se setea al crear la fila (ahora casi todas las
  crea el comando), se setea en el momento en que un admin regulariza.
- Nuevo comando `php artisan attendance:sync [--date=] [--dry-run]`:
  genera filas `PENDING` desde `assignments` activos (con catch-up
  automático del rango desde `last_generated_date`, nueva columna de
  `system_configuration`, hasta la fecha pedida — tolerante a que el
  botón no se use todos los días) y después barre las `PENDING`
  vencidas a `ABSENT`. Idempotente. Botón "Sincronizar" en
  `/admin/attendance-records` llama al mismo comando — nunca dos
  implementaciones de la misma lógica.
- **Ajuste encontrado al probarlo** (no estaba en el plan original): la
  primera corrida en la vida del sistema (`last_generated_date` null)
  por poco retrocedía 30 días por defecto, generando y barriendo un mes
  de historial fabricado de una sola vez. Corregido: sin fecha previa,
  arranca en la fecha pedida; el tope de 30 días solo protege cuando ya
  hay una fecha real pero muy vieja.
- Kiosko: `save()` ahora busca la fila `PENDING` de la asignación+fecha
  y la actualiza (antes creaba una fila nueva siempre). `recordedAssignmentIds()`
  filtra por `status` (`ATTENDED`/`REGULARIZED`), no por existencia —
  con pre-registro, la fila ya existe antes de fichar.
- Panel admin (`AttendanceRecordDetail`): candado de edición por **hora
  real** (fin de clase + ventana), no por el estado guardado — sin cron
  real, una fila puede seguir `PENDING` en BD aunque ya venció de
  verdad. Editar una fila `ATTENDED` sigue usando `attendance_corrections`
  (Fase 4, sin tocar). Editar una `PENDING`(vencida)/`ABSENT` pasa a
  `REGULARIZED` reusando `admin_observation` (ya existía desde el
  20-09, pensado para esto). Listado con columna/filtros de estado y
  resumen (`Pendientes`/`Inasistencias`/`Asistieron`/`Regularizadas`).
- `Assignment::registrationOpensAt()`/`registrationClosesAt()` ganan un
  parámetro opcional `$classDate` (default hoy, no rompe los callers
  del kiosko) — bug real encontrado al implementar: calculaban la
  ventana siempre contra "hoy", lo que habría dado ventanas incorrectas
  al barrer filas `PENDING` de días anteriores (catch-up).
- Alcance recortado a propósito: la opción de "crear asistencia manual
  con asignación todavía sin generar" (enlace opcional) quedó fuera de
  esta pasada — el flujo principal (editar la fila `PENDING`/`ABSENT`
  ya generada) cubre el caso normal; la creación manual sin asignación
  sigue igual que antes, ahora nace directo en `REGULARIZED`.
- Tests nuevos: `tests/Feature/Fase8AttendanceStatusTest.php` (10
  casos: generación, idempotencia, catch-up, barrido, candado por hora
  real, regularización, corrección sobre `ATTENDED`, botón admin).
  **129/129 tests en verde** (832 aserciones), `./vendor/bin/pint --test`
  limpio (114 archivos). Verificado también contra MySQL real del
  contenedor (`migrate:fresh --seed` + `attendance:sync` manual, dos
  corridas seguidas sin duplicar).

## 2026-09-22 — Fase 7: archivos subidos en disco privado (sin storage:link)

- Fotos de referencia, evidencia de fichaje y PDF del plan de estudios
  pasan del disco `public` al disco `local` (`storage/app/private`).
- Nuevo `PrivateFileController` + rutas `admin.files.*` (auth + role
  ADMIN + password.change) sirven los archivos en streaming; ya no hace
  falta `storage:link` ni URL abierta adivinable.
- Vistas admin usan `route('admin.files.*')`; la vista previa
  `temporaryUrl()` de Livewire no se tocó.
- Tests actualizados a `Storage::fake('local')` + nuevo
  `Fase7PrivateFilesTest` (invitado → login, admin → 200 imagen,
  sin archivo → 404). Docs: quitado el paso `_link.php` de
  `DEPLOY-CPANEL.md` y el pendiente de `storage:link` en `CLAUDE.md`.

## 2026-09-20 — Grupos por curso/periodo, resoluciones y kiosko sin registro libre

- `courses.name` deja de ser único; solo `code` identifica el curso.
  Nuevo FK opcional `courses.resolution_id` → `resolutions`.
- Catálogo `class_groups` (U/A–E, sin CRUD, solo sembrado) y pivot
  `course_academic_period_groups` para declarar qué grupos tiene cada
  curso en cada periodo. Pantalla admin `/admin/course-groups`.
- CRUD de Resoluciones vía `CatalogSimple` (`/admin/catalogs/resolutions`).
- `assignments.class_group_id` obligatorio. Regla aplicativa: un
  docente por curso+grupo+periodo (mismo docente puede repetir el
  mismo grupo en otros días; no puede tener dos grupos del mismo curso).
- Kiosko en modo manual: sin camino “continuar sin asignación”. Si no
  hay clases disponibles hoy (cero, o todas ya registradas) → estado
  `no_assignments_today`. El formulario/confirmación muestra el grupo.
- Seeders, tests, `MODELO-DATOS.md` y `CLAUDE.md` (décima pasada)
  actualizados. **83/83 tests** (602 aserciones), Pint limpio (93
  archivos).
- **No** se ejecutó `migrate:fresh` (pendiente de autorización).

## 2026-09-15 — Corrección de diseño del modal de cursos + endurecimiento del kiosko

- El modal de "Agregar/Editar curso" (`/admin/catalogs/courses`) tenía dos
  `<select>` (Tipo, Requisito) con una clase `form-select` que **no existe**
  en el sistema de diseño — caían al estilo nativo del navegador sin
  ningún token propio, mientras el resto del formulario sí usa
  `.form-input`. Corregido: mismo `.form-input` que todo lo demás.
- Rediseñado el selector de "Plan de estudios" (PDF): antes era un
  `<input type="file">` nativo puro ("Seleccionar archivo · Ningún archivo
  seleccionado"). Ahora es un botón propio del sistema (ícono de
  documento, nombre del archivo, spinner mientras sube, borde punteado)
  — mismo criterio que ya se usaba para la foto de referencia de
  docentes, adaptado a un documento en vez de una imagen (sin miniatura,
  con ícono).
- `Kiosk::enforceAssignmentLock()` (bloqueo de curso/ciclo/periodo tras
  elegir asignación, agregado el 2026-09-14) ahora devuelve `bool`: si la
  asignación desaparece justo entre elegirla y confirmar/guardar (ventana
  chica, pero real), corta el flujo y vuelve a la pantalla de
  identificación en vez de dejar pasar los campos sin nada real contra
  qué fijarlos.
- Verificación completa: `./vendor/bin/pint --test` limpio (85/85
  archivos) y suite completa en verde (78/78 tests, 564 aserciones).

## 2026-09-14 — Plan de estudios PDF + retiro del temario

- Se amplió `courses` con `study_plan_path` nullable (PDF opcional del
  plan de estudios) en la migración existente — disco `local` (tras
  Fase 7; originalmente `public`), carpeta `course-study-plans/`.
- CRUD de cursos: subir / ver / quitar PDF (máx. 10 MB, `mimes:pdf`).
- Se retiró por completo el dominio temario: tabla `topics`, modelo
  `Topic`, Livewire `CourseTopics`, selects de “Tema” en kiosko y panel
  de asistencia, y columna `attendance_records.topic_id`.
- Seeders, tests y `MODELO-DATOS.md` actualizados.
- Verificación completa: `./vendor/bin/pint --test` limpio (85/85 archivos)
  y suite completa de pruebas en verde (77/77 tests, 560 aserciones).

No se ejecutó `migrate:fresh`, conforme a la indicación del usuario.

## 2026-09-14 — Departamento académico de docentes

- Se amplió la migración existente de `users` (el proyecto sigue en
  desarrollo) con `academic_department` nullable: enum
  `HUMANIDADES` / `ADMINISTRACION` / `CIENCIAS_BASICAS` /
  `INGENIERIA_SISTEMAS`.
- Nuevo enum PHP `AcademicDepartment` con método `label()` para la UI.
- En el CRUD de docentes (`Users`): filtro, columna con badge, campo
  modal obligatorio al crear/editar. No aparece en administradores.
- Seeder de docentes de ejemplo asigna departamentos distintos
  (Administración, Humanidades, Ciencias Básicas).
- Documentación y tests actualizados (esquema, CRUD, filtro).
- Verificación completa: `./vendor/bin/pint --test` limpio (88/88 archivos)
  y suite completa de pruebas en verde (77/77 tests, 559 aserciones).

No se ejecutó `migrate:fresh`, conforme a la indicación del usuario.

## 2026-09-14 — Datos curriculares de cursos

- Se amplió la migración existente de `courses` (el proyecto sigue en
  desarrollo) con `code` único, `type`, `credits`, `theory_hours`,
  `practice_hours`, `total_hours` y `prerequisite_id`.
- `name` conserva su restricción de valor único. Todos los nuevos campos
  son obligatorios, salvo `prerequisite_id`.
- `type` admite exclusivamente `GENERAL` y `ESPECIALIDAD`; en la interfaz
  se presentan como “General” y “Especialidad”.
- `prerequisite_id` referencia otro curso, impide que un curso sea su
  propio requisito y restringe borrar un curso utilizado como requisito.
- Se actualizó el modelo `Course` con casts, relaciones (`prerequisite`,
  `dependentCourses`) y una etiqueta reutilizable `displayName()` (`CÓDIGO — NOMBRE`).
- El CRUD de cursos (`CatalogSimple`) ahora permite crear, editar, validar
  todos los campos requeridos, buscar por código o nombre, ordenar por las
  nuevas columnas y mostrar los datos curriculares y el requisito.
- Las selecciones y vistas que muestran cursos (kiosko, asignaciones,
  registros de asistencia y el detalle/auditoría de correcciones) ahora usan
  de forma uniforme `CÓDIGO — NOMBRE`.
- Los datos de ejemplo (`CatalogSeeder`), el modelo de datos (`MODELO-DATOS.md`)
  y las pruebas fueron ampliados para cubrir todos los campos, obligatoriedad,
  unicidad del código, restricción de autorrequisito y bloqueo de borrado de
  prerrequisitos en uso.
- Verificación completa: `./vendor/bin/pint --test` limpio (87/87 archivos)
  y suite completa de pruebas en verde (76/76 tests, 552 aserciones).

No se ejecutó `migrate:fresh`, conforme a la indicación del usuario (pendiente
para cuando el usuario lo autorice en su entorno local).
