Apariencia
Pipeline kanban
El pipeline es el tablero donde el recruiter mueve candidatos por las etapas del proceso de reclutamiento. Es un kanban de 7 columnas (5 activas + 2 terminales Rechazado y Retirado) con scroll horizontal.
URL y acceso
URL: /recruiter/vacantes/{vacancyId}/pipelineQuién: role:recruiter o role:admin.
Cada vacante tiene su propio pipeline. Un recruiter puede cambiar de vacante desde un dropdown en el header.
Las 7 columnas
┌──────────┬───────────┬──────────────┬───────────┬────────┬──────────┬───────────┐
│Identific.│ Presentado│ Entrevistas │ Finalista │Contrat.│Rechazado │ Retirado │
├──────────┼───────────┼──────────────┼───────────┼────────┼──────────┼───────────┤
│ Card 1 │ Card 3 │ Card 5 │ Card 6 │ Card 7 │ Card 8 │ Card 9 │
│ Card 2 │ Card 4 │ │ │ │ │ │
└──────────┴───────────┴──────────────┴───────────┴────────┴──────────┴───────────┘
scroll horizontal →Las 5 primeras son las etapas activas del pipeline. Las 2 últimas (Rechazado y Retirado) son terminales — una vez ahí, la asignación queda inmutable. La columna Contratado también es terminal.
Qué representa cada etapa
Definido en App\Enums\AssignmentStage:
| Stage | Significado | Timestamp guardado |
|---|---|---|
sourced | Recruiter identificó al candidato y lo asignó | — (initial) |
presented | Candidato enviado formalmente a la empresa | presented_at |
interviewing | Empresa aprobó; hay entrevistas agendadas/en curso | interviewed_at |
finalist | Entrevistas concluidas; finalista a considerar | — |
hired | Contratado oficialmente | hired_at |
rejected | Rechazado por empresa o recruiter | rejected_at |
withdrawn | Candidato se retira | withdrawn_at |
Transiciones permitidas
Máquina de estados en App\Services\AssignmentStageMachine:
sourced ⇄ presented ⇄ interviewing ⇄ finalist ──▶ hired
│ │ │ │
▼ ▼ ▼ ▼
rejected rejected rejected rejected
withdrawn withdrawn withdrawn withdrawnReglas:
- No se puede saltar etapas hacia adelante (ej.
sourced → interviewingrequiere pasar porpresented). - Se puede retroceder un paso entre etapas activas para corregir movimientos erróneos (
presented → sourced,interviewing → presented,finalist → interviewing). No se puede retroceder más de un paso ni desde una terminal. hired,rejected,withdrawnson terminales (no se puede salir). Para "revivir" un descarte, eliminar la asignación y crearla de nuevo.- Desde cualquier etapa no terminal se puede ir a
rejectedowithdrawn.
Timestamps de auditoría
Retroceder no resetea los timestamps ya guardados (presented_at, interviewed_at, etc.). Eso preserva la auditoría: presented_at significa "primera vez que llegó a presentado", aunque la asignación esté ahora de vuelta en sourced.
El servicio valida antes de persistir:
php
if (! AssignmentStageMachine::canTransition($from, $to)) {
throw new RuntimeException('Transición no permitida');
}Cómo mover un candidato
Drag & drop
El board usa @dnd-kit/core (Pointer + Keyboard sensors). Cada PipelineCard es useDraggable y cada PipelineColumn es useDroppable. Al soltar:
- El frontend valida en el cliente que
targetStage ∈ assignment.allowed_transitions. Si no, muestra un toast"Transición no permitida desde esta etapa"y no llama al backend. - Si la columna destino es
rejected, en vez de commitear directo el frontend abre elRejectAssignmentDialogque pide motivo y notas. El submit del dialog es el que dispara la mutación. - En cualquier otro caso, dispara la mutación:
PATCH /api/v1/assignments/{assignmentId}
{ "stage": "presented" }El backend:
- Valida la transición con la FSM (
AssignmentStageMachine) — segunda línea de defensa. - Aplica el
timestampField()correspondiente (presented_at = now(), etc.). - Actualiza
stage. - Dispara notificaciones (ver abajo).
- Responde con el assignment actualizado.
UX del drop zone
Mientras se arrastra, las columnas que no están en allowed_transitions se atenúan a 50% opacidad y muestran "No permitido" en el placeholder. La columna válida bajo el cursor se resalta con borde primario y mensaje "Soltar aquí". El drag handle (⋮⋮) sólo aparece en hover sobre la card; el resto de la card sigue siendo clickable para acciones.
Activación del drag
PointerSensor con activationConstraint: { distance: 6 } — el drag solo arranca cuando el puntero se mueve más de 6px, así los clicks en los botones internos (notas, entrevista, menú ⋯) no se interpretan como drag por error. KeyboardSensor activo para a11y (Tab a la card, Space para tomarla, flechas para mover, Enter para soltar).
Desde el menú contextual
Cada card tiene un menú ⋯ (esquina superior derecha) con: agregar nota, ver notas, eliminar asignación. El cambio de etapa ya no vive en un dropdown — se hace exclusivamente por drag-and-drop o teclado.
Las tarjetas
Cada PipelineCard (en src/features/pipeline/components/pipeline-card.tsx) muestra:
┌─────────────────────────────────────┐
│ ⋮⋮ [Foto] Ana Pérez [⋯] │
│ Backend Senior │
│ │
│ [7+ años] [high] │
│ │
│ [💬] [📅 Entrevista] │
└─────────────────────────────────────┘⋮⋮— drag handle (sólo visible en hover).- Avatar + nombre + headline — datos del candidato.
- Badges — años de experiencia y prioridad (cuando existen).
- Acciones inline — toggle de nota rápida (
💬) yScheduleInterviewDialog(📅 Entrevista). ⋯— menú contextual: agregar nota, ver notas, eliminar asignación.
El layout del board es scroll horizontal con columnas de ancho fijo (~320px) y altura completa de la pantalla. Las 7 etapas (incluidas Rechazado y Retirado) viven todas en el board — ya no hay sección separada para terminales.
Prioridad
Enum App\Enums\Priority:
low(gris)normal(azul)high(naranja)urgent(rojo)
El recruiter la puede editar desde la tarjeta. Útil para marcar los candidatos más prometedores.
Score
Número del 1 al 10 que el recruiter asigna subjetivamente. Aparece con estrellas en la UI.
Motivo de rechazo
Al mover a rejected, se abre un modal obligatorio con:
- Motivo — enum:
perfil_no_afin,salario_fuera_rango,no_respondio,empresa_no_interesada,otro. - Notas — texto libre.
Esto se guarda en rejection_reason y aparece en el reporte de rechazos.
Notas por asignación
Modelo: VacancyAssignmentNoteEndpoint: POST /vacancies/{vid}/assignments/{aid}/notes
- Notas libres, markdown permitido.
- Separadas por autor:
recruiter_notesvscompany_notes. - Visibilidad:
recruiter_notes— solo recruiter + admin.company_notes— company_user + recruiter + admin.
- Inline en la tarjeta con un contador de notas pendientes (sin leer).
Notificaciones disparadas
| Transición | Notificación | Destinatarios |
|---|---|---|
| sourced → presented | AssignmentPresentedNotification | company_user + candidato (Fase 2) |
| presented → interviewing | InterviewScheduledNotification (implícito al agendar) | candidato + company_user |
| interviewing → finalist | CandidateFinalistNotification | company_user |
| finalist → hired | CandidateHiredNotification | candidato + company_user + admin |
| * → rejected | AssignmentRejectedNotification (opt-in) | candidato |
| * → withdrawn | — | — |
Por defecto, el candidato no recibe notificación de rechazo (para evitar spam negativo). Un admin puede habilitarlo globalmente en Configuración.
Impacto en el estado de la vacante
El estado de la vacante (VacancyState) se ajusta automáticamente según el pipeline:
| Primera asignación | Vacancy pasa a |
|---|---|
Alguien en sourced | con_candidatos_asignados |
| Alguien agendado con entrevista | entrevistas_en_curso |
Alguien en finalist | finalista_seleccionado |
Alguien en hired | cubierta |
Ver Máquinas de estado.
Vista agregada del recruiter
Un recruiter puede ver sus asignaciones activas en todas las vacantes en /recruiter/asignaciones (opcional, no siempre habilitado):
- Listado con vacante, candidato, etapa, última actualización.
- Filtros por etapa, vacante, rango de fecha.
Reportes relacionados
GET /admin/reports/vacancies-by-state→ conteo por estado.GET /admin/reports/recruiter-effectiveness→ por recruiter, cuántos ha movido ahired/rejected.GET /admin/reports/time-to-fill→ promedio de días desde vacancy activa a cubierta.
Performance
- El endpoint
GET /vacancies/{id}/assignmentsusawith(['candidateProfile.user', 'candidateProfile.skills', 'candidateProfile.languages', 'interviews']). - Índices:
(vacancy_id, stage)y(candidate_profile_id, stage)aceleran tanto el kanban como la vista por candidato. - Los moves (cambios de etapa) son una sola query de UPDATE.
Errores comunes
| Error | Causa |
|---|---|
| "Transición no permitida desde esta etapa" | Frontend bloqueó el drop (no estaba en allowed_transitions) |
| "Transición no permitida" | FSM del backend rechazó (segunda línea de defensa) |
| "El motivo de rechazo es obligatorio" | Cancelaste el RejectAssignmentDialog sin completarlo |
| "Vacante cerrada" | La vacante ya está cubierta o cancelada |
Sugerencias de candidatos (matching)
En el header del pipeline existe un botón ✨ Ver sugerencias que despliega un panel con los mejores candidatos compatibles con la vacante, ordenados por un score 0–100 calculado por el MatchingService (PDF cosasfaltanteshumae, "Ajuste en la lógica de matching").
Cómo funciona el score
Endpoint: GET /api/v1/vacancies/{vacancyId}/suggested-candidates?min_score=0&limit=20
Pesos del score total:
| Eje | Peso | Lógica |
|---|---|---|
Categoría (kind) | 25 | Match exacto entre target_candidate_kind (vacante) y candidate_kind (perfil). Si la vacante es any → 60 % parcial. |
| Áreas de interés | 25 | Si la vacante exige functional_area_id y el candidato la tiene como principal → 100 %. Como secundaria → 70 %. Si no la tiene → 0. |
| Educación | 15 | El degree_level_id máximo de las educaciones del candidato debe ser ≥ al requerido. |
| Experiencia | 15 | years_of_experience del candidato dentro del rango [min, max] de la vacante. Bajo el mínimo → score proporcional × 0.5; sobre el máximo → 70 %. |
| Skills | 15 | % de vacancy.skills que el candidato también tiene. |
| Salario | 5 | expected_salary_min ≤ vacancy.salary_max. |
Si la vacante no exige un eje (ej. degree_level_id = null), se asume cumplido (puntaje completo). Las únicas reglas que anulan un eje son las explícitas (categoría incompatible, área no presente, salario por encima del techo).
El panel de sugerencias
✨ Candidatos sugeridos [ Todos ] [ ≥50 ] [ ≥70 ] [ ≥85 ]
Score basado en categoría, áreas, estudios, experiencia, skills y salario.
┌──────────────────────────────────────────────────────────────────────────┐
│ [Foto] Pablo Sánchez [Practicante] │
│ Practicante con interés en Ingeniería │
│ ⭐ Ingeniería Producción │
│ ━━━━━━━━━ 95 │
│ score 95 │
│ [+ Asignar] │
├──────────────────────────────────────────────────────────────────────────┤
│ [Foto] María Torres [Practicante] │
│ ⭐ Sistemas │
│ ━━━━━━━━ 78 │
│ score 78 │
│ [+ Asignar] │
└──────────────────────────────────────────────────────────────────────────┘- El selector de score mínimo (
Todos / ≥50 / ≥70 / ≥85) reduce la lista al ajustar el umbral. - La barra de score cambia de color: ≥80 verde, ≥60 marca, ≥40 ámbar, debajo neutro.
- Hover sobre la barra muestra el breakdown detallado (
Categoría: 25 · Áreas: 25 · Educación: 15 · …). - Botón "Asignar" llama a
POST /vacancies/{vacancyId}/assignmentscon elcandidate_profile_idy crea el assignment en la columnasourced. Tras asignar, la card desaparece de las sugerencias (el endpoint excluye candidatos ya asignados a esa vacante).
Filtros aplicados por el endpoint (no se exponen en la UI)
- Solo candidatos con membresía activa.
- Solo candidatos en estados visibles:
activo,en_proceso,presentado_empresa,entrevistado. - Excluye candidatos que ya tengan un
VacancyAssignmentcon esta vacante.
Caso de uso: llenar una vacante de practicante en 30 segundos
Vacante: Practicante de Ingeniería Industrial (target_candidate_kind = intern, functional_area_id = manufacturing, min_years = 0, max_years = 1).
- Recruiter abre
/recruiter/vacantes/{id}/pipeliney hace click en✨ Ver sugerencias. - Aparecen 5 candidatos. Pablo Sánchez (intern · principal Ingeniería · secundaria Producción · 0 años) lidera con score ~95.
- María Torres (intern · principal Sistemas) aparece más abajo con ~70 (categoría sí, área no exacta).
- Click "+ Asignar" en la card de Pablo. Aparece toast
Pablo Sánchez asignada/o a la vacante.y la card desaparece de las sugerencias. - La columna
sourceddel kanban ahora tiene a Pablo. Continuar con el flujo normal.
Score reproducible
El matching es determinista: las mismas reglas, mismos datos, mismo resultado. No hay ML ni randomness. Si un candidato sale arriba, el breakdown te dice exactamente por qué — útil para auditar y para explicar al cliente la elección.
Siguiente
Detalle de cómo crear y gestionar asignaciones: Asignaciones →

