# Plan — Grupos por curso/periodo, resoluciones, nombre de curso libre y kiosko sin registro libre

## Contexto

Pedido del usuario (2026-09-20, discutido antes de tocar código):

1. `courses.name` deja de ser único — solo `code` lo es.
2. `courses` gana un FK opcional a una nueva tabla `resolutions`.
3. Nueva tabla `class_groups` (catálogo fijo, **sin CRUD**, solo sembrado):
   `U` (Único), `A`, `B`, `C`, `D`, `E`.
4. Cada curso, por cada periodo académico, declara explícitamente **cuáles**
   de esos grupos tendrá (ej. Matemática 2027-I → grupos `A` y `B`; Ciencia
   2027-I → solo `U`). Nueva regla de negocio: **un curso+grupo+periodo solo
   puede tener un docente asignado**. Un mismo docente sí puede repetir
   curso+grupo en varios días/horarios (ej. lunes y miércoles), pero **no**
   puede tener dos grupos distintos del mismo curso en el mismo periodo. Para
   "liberar" un grupo ocupado, el mecanismo es **eliminar** la asignación
   vieja — y eso ya funciona tal cual hoy: `Assignments::delete()` ya
   bloquea el borrado con FK restrict si hay `attendance_records` colgando
   (mensaje "No se puede eliminar: hay registros que usan esta asignación").
   No hace falta infraestructura nueva para "liberar", solo reusar esta.
5. Kiosko en modo manual (identificación por DNI — hoy es el **único** modo
   que existe, ver `CLAUDE.md` §10): el docente **ya no puede** registrar
   nada fuera de sus asignaciones de hoy. Si no tiene ninguna asignación
   disponible (cero asignadas, o todas ya registradas), se le muestra un
   mensaje y no se le deja avanzar a ningún formulario.

Evidencia verificada contra el código real (no se asumió desde docs):
- `courses`: migración `2026_09_13_100003_create_courses_table.php` —
  `name` con `->unique()`, sin `resolution_id`.
- `assignments`: migración `2026_09_13_100005_90_create_assignments_table.php`
  — sin `class_group_id`; único índice real es
  `assignments_teacher_day_active` (no impide dos docentes en el mismo
  curso+grupo).
- `CatalogSimple::TYPES` (líneas 89-91) solo registra `cycles`/`courses`;
  `routes/web.php` línea 34 solo permite esos dos en `{type}`.
- `Assignments::delete()` (líneas 161-180) ya bloquea el borrado con
  `QueryException` código `23000` si hay dependientes — mecanismo de
  "liberar" reusable tal cual.
- `Kiosk::continueToForm()` (líneas 108-134): 0 asignaciones →
  `applyEmptyFormDefaults()` (formulario en blanco, sin bloqueo); 1 →
  `applyAssignment()` directo **sin revisar si ya está registrada**; 2+ →
  `selecting_assignment` con `continueWithoutAssignment()` como escape.

## Alcance

**Incluye:**
- Migraciones nuevas: `resolutions`, `class_groups`,
  `course_academic_period_groups` (pivot curso+periodo+grupo).
- Migraciones existentes editadas in place (seguimos en desarrollo, sin
  `migrate:fresh` hasta que el usuario lo autorice en su entorno local):
  `courses` (quita `unique` de `name`, agrega `resolution_id` nullable),
  `assignments` (agrega `class_group_id`, obligatorio).
- Modelos nuevos: `Resolution`, `ClassGroup`, `CourseAcademicPeriodGroup`.
- Nueva pantalla admin para configurar qué grupos tiene cada curso en cada
  periodo (`course_academic_period_groups`).
- CRUD de Resoluciones (mismo patrón que Ciclos/Cursos, vía `CatalogSimple`).
- `Assignments`: campo grupo obligatorio, poblado según curso+periodo
  elegidos, con la regla de un-docente-por-curso-y-grupo.
- Kiosko: elimina el camino "sin asignación"; pantalla nueva de "no tienes
  nada que registrar hoy"; corrige el caso real detectado (una sola
  asignación ya registrada hoy no debe dejar avanzar al formulario).
- Seeders, tests, `MODELO-DATOS.md`, `CAMBIOS.md`, `CLAUDE.md` (décima
  pasada).

**No incluye:**
- **Huella dactilar / ZKTeco ZK9500** — queda explícitamente para después
  ("dejemos lo de la huella para el final"), no tocar nada de Fase 6 en
  este plan.
- `migrate:fresh` en MySQL local — pendiente de autorización expresa del
  usuario, mismo criterio que las pasadas anteriores.
- Migraciones nuevas para `courses`/`assignments` (se editan las
  existentes, política vigente del proyecto).
- Cambios en el registro manual por admin (`AttendanceRecords` →
  "Registrar manualmente"): sigue sin pasar por `assignments`, el admin
  sigue escribiendo curso/ciclo libremente — no lleva grupo.
- Agregar `class_group_id` a `attendance_records`: se deriva de
  `assignment_id` cuando existe; no se pidió para reportes/filtros, no se
  agrega sin que se pida.
- CRUD propio para `class_groups` (confirmado: sin pantalla, solo tabla +
  sembrado).
- Comparativo de cupos/aforo por grupo, notificaciones al liberar un
  grupo, historial de quién tuvo un grupo antes — no pedido.

## Decisiones (no reabrir al implementar)

1. **Nombre de tabla**: `class_groups`, no `groups` — `GROUPS` es palabra
   reservada en MySQL 8 (window functions), forzaría comillas invertidas en
   cada query. Confirmado con el usuario.
2. **`class_groups`**: `id`, `abbreviation` (string, único, ej. `U`, `A`),
   `name` (string, ej. "ÚNICO", "GRUPO A" — mayúsculas, mismo criterio que
   el resto del sistema), `active` (boolean — es el "estatus" que pidió el
   usuario, se usa el mismo campo/UI ACTIVO-INACTIVO que ya tienen todos
   los demás catálogos, no un campo nuevo con otro nombre). Sembrado fijo:
   `U`/Único, `A`/Grupo A, `B`/Grupo B, `C`/Grupo C, `D`/Grupo D, `E`/Grupo E.
3. **`resolutions`**: `id`, `name` (string 150, requerido, **no** único —
   dos resoluciones pueden compartir texto descriptivo), `resolution_number`
   (string, nullable, único cuando tiene valor — es el dato que sí
   identifica una resolución real), `active` (boolean). CRUD propio vía
   `CatalogSimple` (mismo patrón ya usado para extender `courses` con
   campos propios — agregar una rama `isResolution()` análoga a
   `isCourse()`).
4. **`courses.resolution_id`**: nullable, FK a `resolutions`,
   `restrictOnDelete()` (mismo criterio que `prerequisite_id` en la misma
   tabla — nunca borrado físico con dependientes).
5. **`course_academic_period_groups`**: `id`, `course_id` (FK, cascade),
   `academic_period_id` (FK, cascade), `class_group_id` (FK, cascade),
   `timestamps`, único compuesto `(course_id, academic_period_id,
   class_group_id)`. Es **selección explícita** de cuáles grupos del
   catálogo aplican (no un número suelto) — es lo único que sostiene sin
   ambigüedad la regla de "un docente por curso y grupo": el select de
   grupo en Asignaciones solo puede ofrecer los que estén aquí.
6. **Quitar un grupo ya configurado para un curso+periodo**: si existe
   alguna `assignment` con ese curso+grupo+periodo exacto, esa fila
   puntual **no se puede desmarcar/quitar** — error claro dirigiendo a
   liberar la asignación primero (eliminarla, ver decisión 8). El resto de
   grupos que sí se pueden quitar en el mismo guardado, se quitan
   normalmente (no es todo-o-nada).
7. **`assignments.class_group_id`**: obligatorio (no nullable) desde ya —
   el proyecto sigue en datos de prueba, se resiembra con grupo asignado.
8. **Regla "un docente por curso y grupo"**: se valida en código
   (`Assignments::save()`), no con un `UNIQUE` de BD — porque el mismo
   docente sí puede tener varias filas (distintos días) para el mismo
   curso+grupo+periodo, algo que un índice único simple no permitiría.
   Antes de guardar, rechazar si:
   - existe otra asignación con el mismo `course_id`+`class_group_id`+
     `academic_period_id` pero **distinto** `teacher_id` (grupo ocupado
     por otro docente), o
   - existe otra asignación del **mismo** `teacher_id` con el mismo
     `course_id`+`academic_period_id` pero **distinto** `class_group_id`
     (un docente no puede tener dos grupos del mismo curso a la vez).
   Ambos chequeos excluyen `editingId` al editar. "Liberar" un grupo =
   eliminar la asignación que lo ocupa (ya bloqueado automáticamente si
   tiene fichajes, ver Contexto) — no se construye ningún botón
   "Liberar" nuevo, se reusa Eliminar tal cual existe.
9. **Kiosko sin asignaciones disponibles hoy**: nuevo estado
   `step = 'no_assignments_today'`, mismo estilo visual que el estado
   `not_found` ya existente, mensaje: *"No tienes clases asignadas para
   hoy. Si crees que es un error, contacta a administración."* + botón
   para volver al inicio. Aplica en tres casos, todos redirigen ahí en vez
   de mostrar un formulario o una lista vacía/inútil:
   - cero asignaciones hoy (antes: formulario en blanco sin bloqueo),
   - exactamente una asignación hoy, pero ya registrada (antes: dejaba
     avanzar al formulario igual, y solo fallaba al guardar con "Ya existe
     un registro..." — bug real, se corrige de paso),
   - dos o más asignaciones hoy, pero **todas** ya registradas (antes:
     mostraba la lista con todo deshabilitado).
   Se elimina `continueWithoutAssignment()` y el botón "Continuar sin
   asignación"; se evalúa si `applyEmptyFormDefaults()` queda sin ningún
   llamador y se elimina si es el caso.
10. **Confirmar registro (kiosko)**: se agrega el grupo (`Grupo: A`) al
    bloque ya bloqueado de curso/ciclo/periodo, mismo criterio visual que
    ya se usa ahí — es dato fijo de la asignación, no editable.
11. **Ubicación en el sidebar**: la pantalla de configuración de grupos
    por curso+periodo entra al dropdown "Catálogos" (junto a Ciclos,
    Cursos, Resoluciones) — es configuración de cursos, no un registro
    operativo del día a día como Asignaciones.

## Tareas

### Dominio y esquema
- [x] 1. Nueva migración `create_class_groups_table`: `abbreviation`
  (string 5, único), `name` (string 50), `active` (boolean default true).
- [x] 2. Nueva migración `create_resolutions_table`: `name` (string 150),
  `resolution_number` (string, nullable, único), `active` (boolean
  default true).
- [x] 3. Nueva migración `create_course_academic_period_groups_table`:
  `course_id`, `academic_period_id`, `class_group_id` (FKs, cascade),
  único compuesto de los tres.
- [x] 4. Editar `2026_09_13_100003_create_courses_table.php`: quitar
  `->unique()` de `name`; agregar `resolution_id` nullable FK a
  `resolutions` con `restrictOnDelete()`.
- [x] 5. Editar `2026_09_13_100005_90_create_assignments_table.php`:
  agregar `class_group_id` (FK a `class_groups`, `restrictOnDelete()`,
  obligatorio) junto a `course_id`/`cycle_id`.
- [x] 6. Modelo `ClassGroup` (`abbreviation`, `name`, `active` en
  fillable+casts).
- [x] 7. Modelo `Resolution` (`name`, `resolution_number`, `active`).
- [x] 8. Modelo `CourseAcademicPeriodGroup` (`course_id`,
  `academic_period_id`, `class_group_id`; relaciones `course()`,
  `academicPeriod()`, `classGroup()`).
- [x] 9. `Course.php`: agregar `resolution_id` a fillable; relación
  `resolution(): BelongsTo`; relación `courseAcademicPeriodGroups():
  HasMany`.
- [x] 10. `Assignment.php`: agregar `class_group_id` a fillable; relación
  `classGroup(): BelongsTo`.

### Configuración de grupos por curso y periodo (pantalla nueva)
- [x] 11. Nuevo componente `App\Livewire\Admin\CourseGroups`: selects de
  curso + periodo académico; checkboxes con el catálogo completo de
  `class_groups` activos; al guardar, sincroniza `course_academic_period_groups`
  (agrega los marcados que faltan, quita los desmarcados que ya no
  tienen asignación — ver decisión 6 para el caso bloqueado).
- [x] 12. Vista `course-groups.blade.php`: mismo lenguaje visual que el
  resto del panel (`.catalog-panel`, `.form-input`, etc.), indicar junto
  a cada grupo si ya tiene un docente asignado (con quién, opcional pero
  útil) para que el admin no tenga que adivinar por qué no puede
  desmarcarlo.
- [x] 13. Ruta `admin.course-groups` (`/admin/course-groups`) + entrada en
  el sidebar dentro de "Catálogos" (ver decisión 11), con su propio
  ícono.

### Catálogo de Resoluciones
- [x] 14. `CatalogSimple::TYPES`: agregar `'resolutions' => ['model' =>
  Resolution::class, 'label' => 'Resoluciones', 'singular' => 'resolución']`.
- [x] 15. `CatalogSimple`: `isResolution()` (análogo a `isCourse()`);
  reglas (`resolution_number` nullable único ignorando `editingId`);
  `save()`/`edit()` incluyen el campo nuevo.
- [x] 16. `routes/web.php`: agregar `'resolutions'` al `whereIn('type',
  [...])`.
- [x] 17. `catalog-simple.blade.php`: rama para resoluciones — campo "N°
  de resolución" (opcional) en el modal, columna en la tabla.
- [x] 18. Sidebar: link "Resoluciones" dentro de "Catálogos".

### Cursos — resolución opcional + nombre libre
- [x] 19. `catalog-simple.blade.php` (rama cursos): select "Resolución"
  (opcional, "Sin resolución" como opción vacía) usando
  `resolutions` activas.
- [x] 20. `CatalogSimple::rules()`: quitar la unicidad de `name` **solo**
  para cursos (cycles la conserva); agregar `resolutionId` nullable con
  `Rule::exists('resolutions', 'id')`.

### Asignaciones — grupo obligatorio + regla un-docente-por-curso-y-grupo
- [x] 21. `Assignments.php`: propiedad `classGroupId`; en `rules()`
  agregar `classGroupId` requerido, `Rule::exists('class_groups',
  'id')->where('active', true)`.
- [x] 22. `Assignments::save()`: antes de `updateOrCreate`, chequeo
  aplicativo (mismo estilo que `hasOverlappingRecord()` en Kiosk/
  AttendanceRecords, no un `Rule` de validación) de las dos condiciones
  de la decisión 8; si falla, `addError`/toast con mensaje claro (a quién
  ya tiene ese grupo, o qué otro grupo ya tiene ese docente) y no guarda.
- [x] 23. `assignments.blade.php`: select "Grupo", poblado solo con lo
  configurado en `course_academic_period_groups` para el curso+periodo
  elegidos — recomputar en `render()` según `courseId`/
  `academicPeriodId` actuales; limpiar `classGroupId` si deja de ser
  válido al cambiar cualquiera de los dos (mismo criterio que ya limpia
  `prerequisiteId` en cursos al auto-referenciarse).
- [x] 24. Columna "Grupo" en la tabla de Asignaciones + filtro por grupo
  (mismo patrón que los demás filtros: `groupFilter`,
  `updatingGroupFilter()`, incluido en `clearFilters()`).
- [x] 25. `openCreate()`/`edit()`/`resetFormFields()`: incluir
  `classGroupId`.

### Kiosko — sin registro libre en modo manual
- [x] 26. `Kiosk.php`: nuevo `step = 'no_assignments_today'`; aplicar en
  los tres casos de la decisión 9 (`continueToForm()` para 0 y 1-ya-
  registrada; `selectAssignment()`/render de `assignmentOptions()` para
  "todas ya registradas" en 2+).
- [x] 27. Eliminar `continueWithoutAssignment()`; revisar si
  `applyEmptyFormDefaults()` queda sin uso y eliminarla si es el caso.
- [x] 28. `kiosk.blade.php`: nueva pantalla para `no_assignments_today`
  (mismo estilo que `not_found`); quitar el botón "Continuar sin
  asignación" de `selecting_assignment`; agregar "Grupo" al bloque
  bloqueado de curso/ciclo/periodo en `form` y `confirming`.
- [x] 29. `summaryPayload()`: incluir `classGroup` (vía
  `$assignment->classGroup?->name`, resuelto en `applyAssignment()`/
  `enforceAssignmentLock()` igual que `courseId`/`cycleId`).

### Seeders
- [x] 30. `CatalogSeeder`: sembrar las 6 filas de `class_groups`
  (decisión 2); 2-3 resoluciones de ejemplo; `course_academic_period_groups`
  de ejemplo cubriendo ambos casos (un curso con 2+ grupos, otro con solo
  Único) para que Asignaciones y Kiosko tengan datos reales que probar.
- [x] 31. Seeder de asignaciones existentes: asignar `class_group_id` a
  cada fila sembrada (obligatorio, no puede quedar sin valor).

### Tests
- [x] 32. Cursos: crear 2 cursos con el mismo `name` (código distinto)
  debe pasar; `resolution_id` opcional se guarda/valida.
- [x] 33. CRUD de Resoluciones (creación, edición, unicidad de
  `resolution_number` cuando tiene valor, borrado bloqueado si algún
  curso la referencia).
- [x] 34. `CourseGroups`: guardar selección de grupos; desmarcar uno sin
  asignaciones (permitido); desmarcar uno con una asignación activa
  (bloqueado, mensaje claro, el resto de cambios sí se guarda).
- [x] 35. `Assignments`: grupo obligatorio al guardar; bloqueo cruzado —
  otro docente mismo curso+grupo+periodo → rechazado; mismo docente dos
  grupos del mismo curso+periodo → rechazado; mismo docente mismo
  curso+grupo+periodo en otro día/horario → permitido; reasignar un
  grupo liberado (eliminar la asignación vieja sin fichajes, crear una
  nueva para otro docente) → éxito.
- [x] 36. Kiosko: 0 asignaciones hoy → `no_assignments_today`; 1
  asignación ya registrada → `no_assignments_today` (cubre el bug
  encontrado); 2+ asignaciones, todas ya registradas →
  `no_assignments_today`; ya no debe quedar ningún test que ejercite
  `continueWithoutAssignment()` ni el formulario en blanco sin
  asignación.
- [x] 37. Suite completa en verde + `./vendor/bin/pint --test`.

### Documentación
- [x] 38. `docs/MODELO-DATOS.md`: tablas nuevas (`class_groups`,
  `resolutions`, `course_academic_period_groups`), columnas nuevas en
  `courses`/`assignments`, la regla de negocio de la decisión 8.
- [x] 39. `docs/CAMBIOS.md`: entrada nueva con fecha real de
  implementación.
- [x] 40. `CLAUDE.md`: décima pasada (mismo formato que las anteriores:
  qué se agregó, decisiones clave, conteo final de tests).

## Criterios de aceptación

- [x] En `/admin/catalogs/courses`: dos cursos pueden compartir nombre (no
  código); se puede elegir una resolución opcional.
- [x] `/admin/catalogs/resolutions` existe, con CRUD completo.
- [x] Existe una pantalla donde el admin marca qué grupos (`U`/`A`/`B`/
  `C`/`D`/`E`) tiene un curso en un periodo específico.
- [x] En `/admin/assignments`: el select de grupo solo ofrece los
  configurados para el curso+periodo elegidos; asignar el mismo
  curso+grupo+periodo a un segundo docente se rechaza con mensaje claro;
  asignar a un docente un segundo grupo del mismo curso+periodo se
  rechaza; eliminar una asignación sin fichajes libera el grupo para
  otro docente.
- [x] En el kiosko (modo manual/DNI): un docente sin ninguna clase
  asignada hoy ve el mensaje de "no tienes clases asignadas", sin
  formulario. Un docente con su única clase de hoy ya registrada ve el
  mismo mensaje (no llega a un formulario que fallará al guardar). No
  existe ningún camino para registrar una clase fuera de sus
  asignaciones de hoy.
- [x] `./vendor/bin/pint --test` limpio.
- [x] `docker compose run --rm app php artisan test` en verde.
- [x] **No** se ejecuta `migrate:fresh` hasta orden expresa del usuario.
- [x] Docs actualizados (`MODELO-DATOS`, `CAMBIOS`, `CLAUDE`).

## Notas para quien implemente

- Identificadores en inglés (`class_groups`, `resolution_number`,
  `classGroupId`); UI 100% en español ("Grupo", "Resolución").
- Comentarios en español solo si explican un *por qué* no obvio (ej. por
  qué la regla de un-docente-por-grupo es aplicativa y no un `UNIQUE` de
  BD — ver decisión 8).
- **No tocar nada de biometría/huella/ZKTeco** — explícitamente fuera de
  este plan, se retoma después.
- Reusar patrones ya existentes en vez de inventar nuevos: el bloqueo de
  borrado con dependientes (`QueryException` 23000, ya en
  `Assignments::delete()`), el estilo de "aviso temprano antes de llenar
  todo el formulario" que ya tiene el kiosko, el patrón de filtros
  (`updating<Campo>Filter()` + `resetPage()`) que ya usan Assignments y
  AttendanceRecords.
- Grepear `applyEmptyFormDefaults`, `continueWithoutAssignment` y
  `sin asignación` en todo el repo (código, vistas y tests) antes de dar
  por cerrado el punto 5 — no debe quedar ningún resto.
- No tocar `mockup-unamba/`; gana este repo.
- No mencionar coautoría en commits si más adelante se pide commit.
