# Plan — Fase 2b: kiosko en modo manual (sin lector todavía)

## Contexto

Fase 0, Fase 1 y Fase 2a están completas, revisadas y commiteadas
(`b63c910`, `d9a03f1`) — ver `../../CLAUDE.md`. Ya existen los
catálogos (`cycles`, `academic_periods`, `classrooms`, `courses`,
`topics`), el switch `system_configuration`, y `users` con
`role: ADMIN|TEACHER` + autenticación.

Esta fase construye **el flujo real de fichaje sin huella** — hoy
DocenTrack no tiene lector todavía (llega en Fase 6, bloqueada hasta
comprar el ZK9500), así que este modo **no es una excepción, es el
único modo que existe** por ahora (`../../CLAUDE.md` §10). Al terminar
esta fase, el sistema es usable en producción real: un docente puede
pararse frente al kiosko, identificarse por documento y registrar una
clase dictada.

**Dependencia dura**: necesita `users` con `role=TEACHER` (Fase 2a) —
por eso no se hizo antes.

**No hay `assignments` todavía** (eso es Fase 3) — el formulario del
kiosko **no tiene prellenado**, el docente elige todo manualmente vía
selects, y `attendance_records.assignment_id` queda fuera de esta
migración por completo (no una columna nullable sin usar — **ni
siquiera existe la columna todavía**, porque no hay tabla `assignments`
a la cual apuntar la FK). Cuando llegue la Fase 3, se **edita esta
misma migración** para agregar `assignment_id` — mismo criterio de
"editar la migración `create`, no crear una `alter`" que ya usa el
resto del proyecto en desarrollo activo. Como no hay asignación contra
la cual comparar, `out_of_schedule` se guarda siempre en `false` en
esta fase — no es un bug, es que la función que le da sentido
(comparar contra un horario asignado) todavía no existe.

**Diferencia deliberada frente al mockup visual**: `formulario.html`
del mockup (`../../../mockup-unamba/docentrack/pages/kiosko/`) no
muestra un campo de fecha de clase explícito — pero el modelo de datos
que se cerró con el usuario sí lo exige (`class_date`, separado de
`created_at`, CLAUDE.md §5) porque el docente puede fichar hoy una
clase de un día anterior. Esta fase agrega ese campo al formulario real
aunque el mockup no lo muestre — es una evolución posterior al mockup,
no un error a corregir.

## Alcance

**Incluye:**
- Migraciones `attendance_records` (sin `assignment_id`, ver arriba) y
  `attendance_evidence` — columnas exactas de `../MODELO-DATOS.md` §10-11
  salvo esa columna.
- Modelos `AttendanceRecord`, `AttendanceEvidence` + relaciones.
- Cambiar la ruta raíz `/` para que sea el **kiosko** (pública, sin
  auth) — hoy redirige a admin/login, eso se mueve a un enlace
  explícito "Administrador" dentro del kiosko (el mockup ya lo pide
  así: botón en el topbar del kiosko que enlaza a `/login`).
- `Livewire\Kiosk\Kiosk` — **un solo componente con máquina de estados
  interna** (`$step`), no una ruta por pantalla. Motivo: cambiar de URL
  entre pasos mataría el stream de cámara (`getUserMedia`) que debe
  seguir activo durante todo el flujo. Estados: `idle` (reposo,
  chequea `system_configuration.is_open`) → `closed` (si no está
  abierto) → `identifying` (pide documento) → `not_found` (documento
  sin coincidencia) → `identified` (confirmación breve, botón
  Continuar) → `form` (registro de la clase) → `confirming` (modal
  resumen + fotos) → `success` (guardado, vuelve a `idle` sola).
  `Cancelar` disponible en todo paso posterior a `idle`, vuelve a
  `idle` sin guardar nada.
- Identificación: input de número de documento, busca coincidencia
  exacta contra `users` (`role=TEACHER`, `active=true`),
  `identification_method = manual` guardado en el registro final.
- Formulario: selects de `course`, `cycle`, `classroom`,
  `academic_period` (todos los catálogos activos de Fase 1), `topic`
  recargado según el `course` elegido (mismo patrón que `CourseTopics`
  de Fase 1, pero de solo lectura acá — el kiosko no crea temas), input
  numérico de alumnos, hora de entrada/salida declaradas, **fecha de
  clase** (nuevo respecto al mockup, ver Contexto) con valor por
  defecto hoy pero editable.
- Captura de evidencia — 3 fotos automáticas vía cámara
  (`getUserMedia` + `canvas` + `$wire.upload()` de Livewire, **no** un
  `<input type="file">`): al identificar, al abrir el formulario, al
  abrir la confirmación. Se guardan como 3 filas en
  `attendance_evidence` (`type`: `identification`/`form`/`confirmation`).
- Modal de confirmación: resumen de todo lo declarado + las 3 fotos,
  Confirmar guarda / Volver a editar.
- Guardado con las 2 validaciones ya acordadas
  (`../../CLAUDE.md` §11): bloquear fichaje casi-duplicado (mismo
  docente+curso+ciclo+fecha con horas que se solapan) con mensaje que
  dirige a pedir corrección al admin; calcular `late_declaration`
  (`class_date` muy alejada de `created_at`, o en el futuro) —
  **no bloqueante**, solo se guarda el flag.
- Pantalla de éxito con resumen breve, vuelve a `idle` sola (temporizador
  corto en el propio Livewire, no JS suelto).
- Enlace "Administrador" visible en el kiosko → `/login`.

**No incluye:**
- `assignments`, prellenado del formulario, cálculo real de
  `out_of_schedule` (siempre `false` en esta fase) — Fase 3.
- Huella / lector real — Fase 6, bloqueada hasta el hardware.
- Panel admin de "Registros de asistencia" (listado, filtros,
  corrección con auditoría) — Fase 4. Esta fase solo **crea**
  registros desde el kiosko; verlos/corregirlos desde el admin es
  fase aparte.
- Reportes — Fase 5.
- Porteo del diseño visual del mockup — sigue pendiente. El kiosko de
  esta fase usa estilos neutros de Tailwind, igual criterio que el
  resto del proyecto hasta ahora.
- Cualquier temporizador de "inactividad prolongada vuelve a reposo" —
  no se pidió, no se agrega por ahora.

## Tareas

- [x] 1. Migración `attendance_records` — columnas de
      `MODELO-DATOS.md` §10 **excepto `assignment_id`** (no existe
      todavía, ver Contexto): `teacher_id`, `course_id`, `cycle_id`,
      `classroom_id`, `academic_period_id`, `topic_id`,
      `student_count`, `check_in_time`, `check_out_time`,
      `class_date`, `identification_method` (enum), `out_of_schedule`
      (bool), `late_declaration` (bool), timestamps.
- [x] 2. Migración `attendance_evidence` — `attendance_record_id`,
      `type` (enum `identification`/`form`/`confirmation`),
      `file_path`, `created_at` (sin `updated_at`, es inmutable).
- [x] 3. Modelos `AttendanceRecord` (relaciones a `User`/`Course`/
      `Cycle`/`Classroom`/`AcademicPeriod`/`Topic`, `hasMany`
      evidencias) y `AttendanceEvidence` (`belongsTo` registro).
- [x] 4. Cambiar `routes/web.php`: `/` → `Livewire\Kiosk\Kiosk`,
      pública (sin `auth`/`role` middleware). Agregar el enlace/botón
      "Administrador" dentro de la vista del kiosko apuntando a
      `route('login')` — ya no hace falta que `/` decida a dónde
      mandar según sesión, eso lo resuelve el propio botón.
- [x] 5. Layout `layouts.kiosk` — sin sidebar/topbar admin, pantalla
      completa, estructura mínima para las distintas vistas del
      componente.
- [x] 6. `Livewire\Kiosk\Kiosk`: estado `idle`/`closed` — en `mount()`
      o `render()`, leer `SystemConfiguration::current()->is_open`; si
      es `false`, mostrar "SISTEMA CERRADO" y no exponer ninguna
      acción de identificación.
- [x] 7. Estado `identifying`/`not_found`: acción que recibe el número
      de documento, busca `User::where('role', Role::TEACHER)
      ->where('active', true)->where('document_number', ...)`; si no
      hay match, pasa a `not_found` con botón "Volver a inicio"
      (vuelve a `idle`); si hay match, guarda el docente identificado
      en el estado del componente y pasa a `identified`.
- [x] 8. Estado `identified`: muestra nombre del docente, botón
      "Continuar" pasa a `form`. (Decisión: botón explícito, no avance
      automático por temporizador — más confiable para probar y para
      que el docente confirme que es él antes de seguir; el mockup usa
      avance automático porque ahí el gesto de la huella ya fue la
      confirmación, acá no hay gesto biométrico equivalente.)
- [x] 9. Estado `form`: selects de `course`/`cycle`/`classroom`/
      `academic_period` (solo catálogos `active=true`), `topic`
      recargado al cambiar `course` (solo `topics` `active=true` de
      ese curso), `student_count`, `check_in_time`, `check_out_time`,
      `class_date` (default hoy). Botón "Cancelar" vuelve a `idle` sin
      guardar nada. Botón "Continuar" pasa a `confirming`.
- [x] 10. Captura de evidencia — implementar con `getUserMedia` +
      `canvas.toBlob()` + `$wire.upload()` (ver Notas para quien
      implemente, es la parte más técnica de esta fase): captura
      automática en 3 momentos (entrar a `identified`, entrar a
      `form`, entrar a `confirming`), cada una sube a su propia
      propiedad Livewire (`WithFileUploads`) antes de que la acción
      PHP correspondiente corra su lógica.
      **Código escrito; captura de cámara real en navegador NO
      verificada** (no se puede con curl/tests — queda pendiente del
      usuario).
- [x] 11. Estado `confirming`: modal con el resumen completo de lo
      declarado + las 3 fotos capturadas. "Volver a editar" regresa a
      `form` sin perder lo ya llenado. "Confirmar" ejecuta el guardado.
- [x] 12. Guardado (`save()` del estado `confirming`):
      - Validar duplicado casi-exacto: mismo `teacher_id`+`course_id`+
        `cycle_id`+`class_date`, horas declaradas que se solapan con
        un `attendance_record` ya existente (comparar rangos:
        `nuevo.check_in < existente.check_out AND existente.check_in <
        nuevo.check_out`) → bloquear, mensaje "ya existe un registro
        para esta clase, contacta a administración para corregirlo",
        vuelve a `confirming` sin guardar.
      - Si no hay duplicado: crear `attendance_records` (`
        identification_method = manual`, `out_of_schedule = false`,
        `late_declaration` calculado comparando `class_date` contra
        `now()`), crear las 3 filas de `attendance_evidence` con las
        rutas de las fotos ya subidas, pasar a `success`.
- [x] 13. Estado `success`: resumen breve, temporizador (Livewire
      `wire:poll` o similar) que vuelve solo a `idle` a los pocos
      segundos; botón "Volver ahora" para no esperar.
- [x] 14. Enlace "Administrador" en la pantalla de reposo del kiosko
      → `route('login')`.

## Criterios de aceptación

- [x] `docker compose exec app php artisan migrate:fresh --seed` sin
      errores.
- [x] `docker compose exec app php artisan test` en verde, incluye
      casos nuevos para:
      - Kiosko muestra "SISTEMA CERRADO" cuando `is_open=false` y no
        permite identificarse.
      - Identificación con documento válido de un `TEACHER` activo
        avanza correctamente; con documento inválido/inactivo/de un
        `ADMIN` muestra `not_found`.
      - Flujo completo feliz: identificar → llenar formulario →
        confirmar → guarda `attendance_records` con
        `identification_method=manual`, `out_of_schedule=false`, y 3
        `attendance_evidence` asociadas (usar `UploadedFile::fake()`
        para las fotos, igual que ya hizo Fase 2a con
        `reference_photo_path` — no depende de cámara real).
      - Duplicado casi-exacto (mismo docente/curso/ciclo/fecha, horas
        solapadas) se bloquea al guardar.
      - **Caso de control**: mismo docente/curso/ciclo/fecha pero con
        horas que NO se solapan (otro bloque horario el mismo día) SÍ
        se guarda — confirma que el bloqueo compara horas, no solo la
        fecha.
      - `late_declaration` se calcula correctamente para una
        `class_date` muy en el pasado y para una en el futuro.
- [x] Verificado con HTTP real contra `:8020`: `/` carga el kiosko
      (no el panel admin), muestra "SISTEMA CERRADO" cuando corresponde,
      el enlace "Administrador" lleva a `/login`.
- [x] **Aviso explícito para el cierre de esta fase**: la parte de
      captura de cámara real (`getUserMedia` en el navegador) **no se
      puede verificar por HTTP/tests** — eso requiere un navegador real
      con permiso de cámara. Verificar con tests/HTTP todo lo que sea
      verificable así (todo el flujo de datos, validaciones, guardado)
      y dejar anotado en el plan que la prueba de cámara en vivo queda
      pendiente de que el usuario la pruebe manualmente — no fingir que
      se probó.

### Verificación independiente (Claude)

Código revisado archivo por archivo (migraciones, modelos, `Kiosk.php`,
vista + JS de cámara, layout, tests) — fiel al plan, sin desvíos
injustificados. Corrido de nuevo por mí, no solo confiando en el
reporte de Cursor: `migrate:fresh --seed` sin errores, `php artisan
test` → **30/30 en verde (254 assertions)**, coincide exactamente con
lo reportado. HTTP real contra `:8020`: `/` sirve "SISTEMA CERRADO" por
defecto (estado del seeder), el enlace "Administrador" apunta a
`/login`, `/admin/*` sigue redirigiendo sin sesión. Alterné
`is_open` por `tinker` y confirmé que el HTML servido cambia
correctamente entre el estado cerrado y la pantalla de identificación
(y lo dejé de vuelta en `false`, el estado por defecto del seeder).
Sin hallazgos que corregir — a diferencia de la Fase 2a, esta vez el
reporte de Cursor coincidió con la realidad. Pendiente, como ya quedó
anotado: la captura de cámara real en navegador la prueba el usuario.

## Notas para quien implemente

- **Arquitectura de captura de cámara** (la pieza más nueva de esta
  fase): `navigator.mediaDevices.getUserMedia({video: true})` una vez
  al entrar al kiosko, stream a un `<video>` (puede estar oculto si no
  hace falta mostrarlo). Para capturar: dibujar el frame actual del
  `<video>` en un `<canvas>`, `canvas.toBlob(blob => ..., 'image/jpeg')`,
  envolver el blob en un `File`, y subirlo con el API JS de Livewire
  `$wire.upload('nombrePropiedad', file, onSuccess, onError)` — **no**
  usar `<input type="file">`, no hay archivo que el usuario elija, la
  app genera el archivo sola. Tres propiedades separadas con
  `WithFileUploads` (una por momento de captura), cada una subida
  justo antes de que la acción Livewire correspondiente se ejecute.
- Identificadores en inglés, comentarios en español solo si aportan un
  porqué no obvio, UI 100% en español — igual que fases anteriores.
- `identification_method` es siempre `manual` en esta fase — el valor
  `fingerprint` del enum no se usa hasta Fase 6, no escribir lógica
  condicional para elegirlo, es innecesaria todavía.
- No construir ningún prellenado ni comparación de horario — no hay
  `assignments`, sería lógica muerta hasta Fase 3.
- Regla transversal (`../../CLAUDE.md` §11): el bloqueo de duplicado
  es sobre solapamiento de horas, no sobre "misma fecha" a secas — un
  bloqueo mal hecho impediría registrar sesiones legítimas distintas
  el mismo día. Cubrir el caso de control en los tests, no solo el
  caso bloqueado.
- Recordar la advertencia del criterio de aceptación: la cámara real
  no se prueba con curl/tests, eso lo prueba el usuario en su
  navegador — no reportar esa parte como "verificada" si no lo fue.

## Notas de implementación (Cursor, 2026-09-13)

- **Cámara real NO verificada.** El JS de `getUserMedia` + `canvas` +
  `$wire.upload()` está escrito en `livewire/kiosk/kiosk.blade.php`,
  pero no se probó en un navegador con permiso de cámara. Los tests
  usan `UploadedFile::fake()`. Esa prueba la hace el usuario.
- Índice de lookup de duplicados: el nombre automático de Laravel
  superaba el límite de 64 caracteres de MySQL
  (`attendance_records_teacher_id_course_id_cycle_id_class_date_index`).
  Quedó como `attendance_overlap_lookup`. SQLite en tests no lo
  detectó; falló en `migrate:fresh` contra MySQL y se corrigió.
- Enlace "Administrador" vive en `layouts.kiosk` (header de todas las
  pantallas, incluido SISTEMA CERRADO), no solo en `idle` — si solo
  estuviera en reposo, con el kiosko cerrado no habría forma de entrar
  al login desde `/`.
- `late_declaration` es `true` cuando `class_date` no es hoy (pasado
  o futuro). No bloquea el guardado. `out_of_schedule` siempre `false`.
- Confirmación: pantalla completa con resumen + 3 fotos (no el
  componente modal del admin). Mismo contenido pedido por el plan.
- Tests existentes de `/` (Fase 1 y `ExampleTest`) actualizados: `/`
  ya no redirige a login; es el kiosko público.

