Skip to content

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:

StageSignificadoTimestamp guardado
sourcedRecruiter identificó al candidato y lo asignó— (initial)
presentedCandidato enviado formalmente a la empresapresented_at
interviewingEmpresa aprobó; hay entrevistas agendadas/en cursointerviewed_at
finalistEntrevistas concluidas; finalista a considerar
hiredContratado oficialmentehired_at
rejectedRechazado por empresa o recruiterrejected_at
withdrawnCandidato se retirawithdrawn_at

Transiciones permitidas

Máquina de estados en App\Services\AssignmentStageMachine:

  sourced ⇄ presented ⇄ interviewing ⇄ finalist ──▶ hired
     │           │             │             │
     ▼           ▼             ▼             ▼
  rejected   rejected     rejected      rejected
  withdrawn  withdrawn    withdrawn     withdrawn

Reglas:

  • No se puede saltar etapas hacia adelante (ej. sourced → interviewing requiere pasar por presented).
  • 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, withdrawn son 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 rejected o withdrawn.

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:

  1. 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.
  2. Si la columna destino es rejected, en vez de commitear directo el frontend abre el RejectAssignmentDialog que pide motivo y notas. El submit del dialog es el que dispara la mutación.
  3. En cualquier otro caso, dispara la mutación:
PATCH /api/v1/assignments/{assignmentId}
{ "stage": "presented" }

El backend:

  1. Valida la transición con la FSM (AssignmentStageMachine) — segunda línea de defensa.
  2. Aplica el timestampField() correspondiente (presented_at = now(), etc.).
  3. Actualiza stage.
  4. Dispara notificaciones (ver abajo).
  5. 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 (💬) y ScheduleInterviewDialog (📅 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_notes vs company_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ónNotificaciónDestinatarios
sourced → presentedAssignmentPresentedNotificationcompany_user + candidato (Fase 2)
presented → interviewingInterviewScheduledNotification (implícito al agendar)candidato + company_user
interviewing → finalistCandidateFinalistNotificationcompany_user
finalist → hiredCandidateHiredNotificationcandidato + company_user + admin
* → rejectedAssignmentRejectedNotification (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ónVacancy pasa a
Alguien en sourcedcon_candidatos_asignados
Alguien agendado con entrevistaentrevistas_en_curso
Alguien en finalistfinalista_seleccionado
Alguien en hiredcubierta

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 a hired / rejected.
  • GET /admin/reports/time-to-fill → promedio de días desde vacancy activa a cubierta.

Performance

  • El endpoint GET /vacancies/{id}/assignments usa with(['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

ErrorCausa
"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:

EjePesoLógica
Categoría (kind)25Match exacto entre target_candidate_kind (vacante) y candidate_kind (perfil). Si la vacante es any → 60 % parcial.
Áreas de interés25Si la vacante exige functional_area_id y el candidato la tiene como principal → 100 %. Como secundaria → 70 %. Si no la tiene → 0.
Educación15El degree_level_id máximo de las educaciones del candidato debe ser ≥ al requerido.
Experiencia15years_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 %.
Skills15% de vacancy.skills que el candidato también tiene.
Salario5expected_salary_minvacancy.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}/assignments con el candidate_profile_id y crea el assignment en la columna sourced. 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 VacancyAssignment con 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).

  1. Recruiter abre /recruiter/vacantes/{id}/pipeline y hace click en ✨ Ver sugerencias.
  2. Aparecen 5 candidatos. Pablo Sánchez (intern · principal Ingeniería · secundaria Producción · 0 años) lidera con score ~95.
  3. María Torres (intern · principal Sistemas) aparece más abajo con ~70 (categoría sí, área no exacta).
  4. Click "+ Asignar" en la card de Pablo. Aparece toast Pablo Sánchez asignada/o a la vacante. y la card desaparece de las sugerencias.
  5. La columna sourced del 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 →

Manual de usuario HUMAE · Uso interno