From eb7a418af6260d535c530305fc56d32d7a0f7131 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 07:35:59 -0500 Subject: [PATCH 01/27] Backlog and start refactor ui --- .../02-backlog-opportunity-workspace.md | 953 ++++++++++++++++++ 1 file changed, 953 insertions(+) create mode 100644 docs/refactor-ui/02-backlog-opportunity-workspace.md diff --git a/docs/refactor-ui/02-backlog-opportunity-workspace.md b/docs/refactor-ui/02-backlog-opportunity-workspace.md new file mode 100644 index 0000000..a59ec6d --- /dev/null +++ b/docs/refactor-ui/02-backlog-opportunity-workspace.md @@ -0,0 +1,953 @@ +# FRONT-7 — Opportunity Workspace + +## Conceptualización y backlog estratégico UI/UX + +**Proyecto:** Job-Deck / CVs-Optimizator +**Documento:** `docs/refactor-ui/02-backlog-opportunity-workspace.md` +**Estado:** Propuesta estratégica para implementación incremental +**Fecha:** 2026-05-24 +**Fase origen:** posterior al refactor estructural frontend +**Documento anterior relacionado:** `docs/refactor-ui/01-backlog-estrategico-refactor-ui.md` + +--- + +## 1. Contexto + +El refactor estructural frontend ya fue cerrado e integrado a `main` mediante squash merge con el commit: + +```text +Refactor frontend structure with Jinja and HTMX +``` + +Ese refactor dejó la base frontend suficientemente ordenada para abordar ahora un frente distinto: **mejora UI/UX orientada a operación real de búsqueda laboral**. + +El objetivo de este nuevo frente no es solo mejorar la estética de la aplicación. El objetivo es convertirla en una herramienta de trabajo que permita: + +- registrar vacantes con fricción mínima; +- revisar oportunidades rápidamente; +- decidir si una vacante merece atención; +- pasar vacantes a seguimiento; +- descartar vacantes sin ruido; +- gestionar aplicaciones activas; +- entender qué requiere acción; +- mantener el seguimiento laboral de forma clara. + +La UI debe responder de forma rápida y visible: + +- ¿Esta vacante vale la pena? +- ¿Qué tan buena es para mí? +- ¿Por qué aplicar o descartar? +- ¿Qué debo hacer ahora? +- ¿Qué ajuste de CV o acción sigue? +- ¿Qué aplicaciones requieren seguimiento? + +--- + +## 2. Stack vigente + +El frente se debe ejecutar manteniendo el stack actual: + +- FastAPI; +- Jinja2; +- HTMX; +- SQL Server vía `pyodbc`; +- OpenAI para análisis de vacantes; +- extensión Chrome para captura desde LinkedIn; +- puerto local canónico: `8001`. + +No se contempla migración inicial a React, Vue, Next ni otro frontend SPA. + +--- + +## 3. Restricciones de diseño y ejecución + +### 3.1 Restricciones técnicas + +- No migrar de stack salvo razón técnica fuerte y documentada. +- No reescribir toda la aplicación. +- No mezclar rediseño visual con cambios profundos de persistencia. +- No tocar Profile funcionalmente en esta primera etapa. +- No diseñar mobile en esta fase; el objetivo inicial es desktop/navegador. +- No introducir drag-and-drop ni Kanban avanzado todavía. +- No crear campos inventados si no existen en datos reales. +- No cambiar prompts OpenAI salvo que una fase posterior lo justifique. + +### 3.2 Restricciones de producto + +- Reducir ruido visual. +- Eliminar botones repetidos o innecesarios. +- Priorizar acciones útiles. +- Evitar KPIs grandes si no ayudan a decidir. +- Mantener la aplicación simple y operativa. +- El usuario debe poder revisar, decidir y actuar sin recorrer una pantalla larga. + +### 3.3 Restricciones de proceso + +- Cada fase debe implementarse en PR pequeño o mediano. +- Cada fase debe tener validaciones automáticas. +- Cada fase con cambio visual debe tener smoke test manual. +- No avanzar a la siguiente fase si la anterior no está validada. +- Toda implementación debe partir de rama dedicada, inicialmente: + +```text +frontend/decision-ux +``` + +--- + +## 4. Insumos de auditoría + +Antes de definir este backlog se realizaron auditorías read-only sobre: + +1. UI/UX general del frontend renderizado. +2. Contrato de datos de Vacancies / Vacancy Detail. +3. Applications / Seguimiento. +4. App Shell / header persistente. +5. Factibilidad del Opportunity Workspace. + +Conclusiones consolidadas: + +- El stack actual soporta el Opportunity Workspace sin migración. +- `base.html` ya ofrece un punto común para shell global. +- Vacancies es el entrypoint real de operación. +- El detalle inline de Vacancies debe eliminarse. +- Applications no debe pasar todavía a Kanban puro. +- Applications debe evolucionar primero a lista agrupada por estado + panel derecho. +- Profile queda fuera del primer frente, salvo compatibilidad mínima con layout global. +- El header global debe vivir en `base.html` y quedar fuera de swaps HTMX. +- La UI debe moverse hacia un patrón común de workspace con panel derecho. + +--- + +## 5. Concepto principal: Opportunity Workspace + +El nuevo frente se conceptualiza como un **Opportunity Workspace**. + +Una vacante no debe tratarse como un registro aislado de una tabla. Debe tratarse como una oportunidad que fluye por estados de trabajo: + +```text +capturada → evaluada → descartada / en seguimiento → aplicada → entrevista → oferta / cierre +``` + +El frontend debe reflejar ese flujo, pero sin implementar todavía un pipeline pesado. + +La propuesta inicial es: + +```text +Header global persistente ++ toolbar contextual por vista ++ workspace principal + - panel izquierdo: lista, cola o agrupación + - panel derecho: detalle, decisión y acciones +``` + +--- + +## 6. Arquitectura visual objetivo + +### 6.1 Header global + +Debe vivir en `base.html`. + +Contenido recomendado: + +- nombre/marca de la app; +- navegación principal: + - Inbox; + - Seguimiento; + - Mi Perfil; +- CTA global: + - Nueva Vacante. + +No debe contener: + +- KPIs grandes; +- filtros extensos; +- formularios; +- notas; +- acciones de detalle; +- bloques de ayuda largos. + +En la primera fase no debe ser `sticky` ni `fixed`. Debe ser persistente como estructura global. Si más adelante se requiere comportamiento sticky, debe evaluarse solo para una franja superior compacta. + +### 6.2 Toolbar contextual + +Cada vista operativa puede tener una barra contextual compacta. + +Para Vacancies: + +- búsqueda; +- filtros; +- conteos compactos; +- estado de vista actual. + +Para Applications: + +- filtros por estado; +- conteos por grupo; +- búsqueda; +- vista actual. + +La toolbar contextual debe reemplazar, no sumar, encabezados largos y KPIs redundantes. + +### 6.3 Workspace principal + +El workspace debe usar un patrón común: + +```text +workspace-shell + workspace-list / workspace-groups + workspace-detail +``` + +No es obligatorio crear un componente Jinja único desde el primer PR, pero sí debe existir una convención visual y CSS compartida. + +--- + +## 7. Conceptualización por vista + +## 7.1 Inbox / Vacancies como cola de decisión + +### Problema actual + +La vista actual funciona como triage inicial, pero tiene varios problemas: + +- el detalle inline hace crecer verticalmente la lista; +- la tarjeta detalle repite información de la fila madre; +- hay acciones repetidas; +- el usuario debe leer demasiado para decidir; +- la lista mezcla información de selección con información de análisis; +- no existe un panel de decisión claro. + +### Dirección propuesta + +Convertir Inbox en una **cola de decisión** con dos columnas: + +```text +┌──────────────────────────┬──────────────────────────┐ +│ Lista de vacantes │ Panel de decisión │ +│ │ │ +│ [score] Empresa │ Empresa / Cargo │ +│ Cargo │ Score + decisión │ +│ decisión compacta │ Resumen ejecutivo │ +│ fecha / estado │ Por qué sí │ +│ │ Riesgos │ +│ [otra vacante] │ Brechas / ajustes CV │ +│ │ Acciones │ +└──────────────────────────┴──────────────────────────┘ +``` + +### Lista izquierda + +Debe mostrar solo lo necesario para escoger una vacante: + +- score; +- empresa; +- cargo; +- modalidad; +- fecha de registro; +- decisión o afinidad compacta; +- estado compacto. + +No debe mostrar: + +- botones repetidos; +- descripción completa; +- resumen largo; +- skills; +- gauge grande; +- link externo; +- detalle expandido. + +### Panel derecho + +Debe contener lo necesario para decidir: + +- empresa y cargo como contexto; +- score; +- decisión de aplicación; +- resumen ejecutivo; +- fortalezas principales; +- riesgos principales; +- skills coincidentes; +- skills faltantes; +- ajustes CV recomendados; +- descripción completa si aporta; +- acciones principales. + +Acciones recomendadas: + +- primaria: pasar a seguimiento; +- primaria alternativa: descartar; +- secundaria: abrir link de la vacante. + +### Datos que pueden usarse de inmediato + +Para la primera versión no se requiere nuevo análisis OpenAI. Se pueden usar campos ya disponibles en el análisis: + +- `score_total`; +- `decision_aplicacion`; +- `afinidad_general`; +- `fortalezas_principales`; +- `riesgos_principales`; +- `skills_match`; +- `skills_gap`; +- `encaje_estrategico`; +- `resumen_analisis`; +- `justificacion_decision`; +- `ajustes_cv_recomendados`; +- `seniority_inferido`; +- `salario_detectado`; +- `aspiracion_salarial_sugerida`. + +### Datos que no deben inventarse + +No deben introducirse como si fueran datos persistidos: + +- prioridad; +- urgencia; +- fuente; +- fecha de publicación; +- ubicación; +- `message_hint`; +- `next_action` persistido. + +Si se muestra una acción sugerida, debe ser derivada explícitamente desde datos existentes como `decision_aplicacion`, `has_application` y estado operativo. + +--- + +## 7.2 Applications / Seguimiento como workspace operativo + +### Problema actual + +Applications ya intenta usar dos columnas, pero la experiencia no funciona bien: + +- la lista sirve para localizar, no para operar seguimiento; +- el panel derecho mezcla resumen, quick actions, formulario largo y acción destructiva; +- hay acciones duplicadas; +- cambiar estado compite con editar, eliminar y leer notas; +- el análisis de la vacante desaparece al entrar a seguimiento; +- no responde bien qué aplicaciones requieren acción. + +### Dirección propuesta + +No implementar Kanban puro todavía. + +La primera evolución debe ser: + +```text +Lista agrupada por estado + panel derecho persistente +``` + +Ejemplo conceptual: + +```text +┌─────────────────────────────┬──────────────────────────────┐ +│ Pendiente │ Detalle operativo │ +│ - Empresa / Cargo │ Empresa / Cargo │ +│ - Fecha / estado │ Estado actual │ +│ │ Notas │ +│ Aplicada │ Contacto │ +│ - Empresa / Cargo │ Link │ +│ │ Cambiar estado │ +│ Entrevista │ Acciones secundarias │ +└─────────────────────────────┴──────────────────────────────┘ +``` + +### Lista / grupos + +Debe mostrar: + +- empresa; +- cargo; +- estado; +- fecha de aplicación o registro; +- modalidad si aporta; +- indicador compacto de notas/contacto si existe. + +No debe mostrar: + +- formularios; +- muchos botones; +- acciones destructivas; +- notas largas; +- contacto completo. + +### Panel derecho + +Debe contener: + +- empresa y cargo; +- estado actual; +- transición de estado; +- link a la vacante; +- notas operativas; +- contacto/recruiter si existe; +- metadata de fechas; +- acción secundaria de rechazo/cierre; +- eliminación como acción terciaria y poco prominente. + +### Estados actuales + +Los estados actuales permiten agrupación, pero no justifican aún un pipeline visual completo. Estados identificados: + +- `Pending`; +- `Applied`; +- `Technical Test`; +- `In Interview`; +- `Done`; +- `Rejected`; +- `Open Offer`. + +Riesgo conocido: + +- `Done` existe en enum y formulario, pero no está completamente integrado en filtros/quick actions. +- Los datos reales actuales se concentran principalmente en `Pending`, `Applied` y `Rejected`. +- Faltan `updated_at`, `next_action`, `target_date` y señales de urgencia. + +Por eso, el destino inicial debe ser lista agrupada, no Kanban. + +--- + +## 8. Decisiones cerradas para FRONT-7 + +1. **Mantener FastAPI + Jinja2 + HTMX.** +2. **No migrar a frontend SPA.** +3. **No implementar mobile en esta fase.** +4. **No rediseñar Profile funcionalmente en este frente.** +5. **No implementar Kanban/drag-and-drop como primera solución de Seguimiento.** +6. **No meter KPIs grandes en el header global.** +7. **No inventar campos de dominio no existentes.** +8. **No tocar OpenAI ni prompts para el primer rediseño.** +9. **Inbox debe abandonar detalle inline.** +10. **Applications debe evolucionar primero a lista agrupada por estado + panel derecho.** + +--- + +## 9. Backlog estratégico propuesto + +## FRONT-7.1 — Shell común y header persistente + +### Objetivo + +Preparar la estructura visual común del Opportunity Workspace, reduciendo duplicación de encabezados y creando una navegación global más limpia. + +### Alcance + +- Ajustar `base.html` como fuente del header global. +- Mantener el header fuera de swaps HTMX. +- Incluir navegación principal: + - Inbox; + - Seguimiento; + - Mi Perfil. +- Incluir CTA global `Nueva Vacante`. +- Compactar headers internos redundantes en Inbox y Applications. +- Introducir o preparar una barra contextual por vista. +- Mantener Profile con cambios mínimos de compatibilidad. +- No hacer header sticky/fixed todavía. + +### Fuera de alcance + +- Rediseño funcional de Profile. +- Dashboard nuevo. +- Mobile. +- Kanban. +- Cambios de persistencia. + +### Archivos probables + +- `base.html`; +- componentes de header/page header; +- `vacancies/index.html`; +- `applications/index.html`; +- `vacancies/new.html` si requiere compactación visual; +- `app.css`. + +### Riesgo + +Bajo-medio. + +Riesgos principales: + +- duplicar header global + page headers internos; +- afectar spacing de Profile; +- dejar KPIs redundantes; +- tocar estilos globales demasiado amplios. + +### Criterio de cierre + +- Existe header global limpio. +- La navegación principal es consistente. +- `Nueva Vacante` está disponible como CTA global. +- Inbox y Applications no muestran doble encabezado pesado. +- Profile sigue funcionando sin rediseño funcional. + +### Validaciones + +```powershell +.\.venv\Scripts\python.exe -m unittest discover -s tests -q +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m compileall app tests +``` + +Smoke test manual: + +- abrir `/app/vacancies`; +- abrir `/app/applications`; +- abrir `/app/profile`; +- abrir `/app/vacancies/new`; +- verificar navegación principal; +- verificar que no haya duplicación visual grave de headers. + +--- + +## FRONT-7.2 — Inbox master-detail + +### Objetivo + +Convertir Inbox en una cola de decisión con lista izquierda y panel derecho, eliminando el detalle inline. + +### Alcance + +- Reemplazar detalle inline por panel derecho. +- Separar conceptualmente lista y detalle. +- Preservar HTMX y `hx-push-url`. +- Mantener filtros y paginación. +- Compactar la lista de vacantes. +- Mover datos de análisis al panel derecho. +- Eliminar botones repetidos. +- Eliminar botón de cerrar detalle si deja de aplicar. +- Usar solo datos existentes. + +### Lista izquierda debe mostrar + +- score; +- empresa; +- cargo; +- modalidad; +- fecha; +- decisión/afinidad compacta; +- estado compacto. + +### Panel derecho debe mostrar + +- empresa/cargo; +- score; +- decisión; +- resumen ejecutivo; +- fortalezas; +- riesgos; +- skills match/gap; +- ajustes CV recomendados; +- descripción/link; +- acciones principales. + +### Fuera de alcance + +- Cambiar contrato OpenAI. +- Crear nuevos campos SQL. +- Implementar `message_hint`. +- Implementar prioridad/urgencia. +- Rediseñar Applications. +- Rediseñar Profile. + +### Archivos probables + +- `vacancies/index.html`; +- `vacancies/_shell.html`; +- `vacancies/_list.html`; +- `vacancies/_detail.html`; +- `vacancies/_filters.html`; +- `vacancies.py`; +- `app.css`; +- tests web de Vacancies. + +### Riesgo + +Medio. + +Riesgos principales: + +- pérdida de comportamiento de selección; +- cambios en targets HTMX; +- scroll/foco; +- paginación con selección activa; +- duplicación accidental de datos entre lista y panel. + +### Criterio de cierre + +- Seleccionar una vacante actualiza el panel derecho. +- La lista no crece verticalmente al seleccionar. +- El detalle inline anterior queda eliminado o inactivo. +- Los filtros siguen funcionando. +- La paginación sigue funcionando. +- `hx-push-url` se conserva. +- Las acciones `Seguimiento`, `Descartar` y `Abrir` están claras y no duplicadas. + +### Validaciones + +```powershell +.\.venv\Scripts\python.exe -m unittest discover -s tests -q +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m compileall app tests +``` + +Smoke test manual: + +- abrir `/app/vacancies`; +- seleccionar varias vacantes; +- filtrar por texto/estado/decisión si aplica; +- paginar; +- pasar una vacante a seguimiento; +- descartar una vacante; +- abrir link externo; +- verificar que no haya scroll brusco ni detalle inline duplicado. + +--- + +## FRONT-7.3 — Applications agrupado por estado + panel derecho + +### Objetivo + +Convertir Seguimiento en un workspace operativo, agrupando aplicaciones por estado y limpiando el panel derecho. + +### Alcance + +- Reorganizar lista por grupos de estado. +- Mantener panel derecho persistente. +- Reducir acciones repetidas. +- Bajar prioridad visual de eliminar. +- Reorganizar edición de estado/notas/contacto. +- Mantener HTMX. +- No implementar Kanban drag-and-drop. +- No cambiar persistencia. + +### Lista/grupos deben mostrar + +- empresa; +- cargo; +- estado; +- fecha aplicación o registro; +- modalidad si aporta; +- señal compacta de notas/contacto si existe. + +### Panel derecho debe mostrar + +- empresa/cargo; +- estado actual; +- transición de estado; +- link; +- notas; +- contacto/recruiter; +- metadata de fechas; +- acciones secundarias. + +### Fuera de alcance + +- Drag-and-drop. +- Pipeline Kanban completo. +- `next_action` persistido. +- `target_date`. +- `updated_at`. +- rediseño de tabla SQL. +- múltiples aplicaciones por vacante. + +### Archivos probables + +- `applications/index.html`; +- `applications/_shell.html`; +- `applications/_list.html`; +- `applications/_detail.html`; +- `applications/_filters.html`; +- `applications.py`; +- `app.css`; +- tests web de Applications. + +### Riesgo + +Medio. + +Riesgos principales: + +- paginación por grupos; +- selección activa si cambia filtro; +- re-render de shell completo; +- estado `Done` inconsistente; +- panel derecho sobrecargado. + +### Criterio de cierre + +- Las aplicaciones se entienden por estado. +- La vista responde mejor “qué está pendiente”. +- El panel derecho deja de ser un formulario largo dominante. +- Cambiar estado sigue funcionando. +- Editar notas/contacto sigue funcionando. +- Eliminar existe, pero con menor prominencia visual. + +### Validaciones + +```powershell +.\.venv\Scripts\python.exe -m unittest discover -s tests -q +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m compileall app tests +``` + +Smoke test manual: + +- abrir `/app/applications`; +- cambiar filtros por estado; +- seleccionar aplicaciones en distintos estados; +- cambiar estado; +- editar notas; +- verificar contacto/recruiter; +- verificar que el panel derecho conserve contexto; +- verificar que no haya acciones repetidas con la misma prioridad visual. + +--- + +## FRONT-7.4 — Limpieza visual común del Opportunity Workspace + +### Objetivo + +Normalizar el lenguaje visual de Inbox y Applications después de validar los dos layouts principales. + +### Alcance + +- Normalizar botones primarios/secundarios/terciarios. +- Normalizar badges y pills. +- Normalizar cards de oportunidad. +- Normalizar paneles derechos. +- Normalizar toolbars contextuales. +- Revisar estados vacíos. +- Revisar flashes y mensajes de feedback. +- Reducir CSS obsoleto asociado a detalle inline. + +### Fuera de alcance + +- Fragmentar `app.css` en múltiples archivos. +- Rediseñar Profile. +- Dashboard nuevo. +- Mobile. + +### Archivos probables + +- `app.css`; +- componentes compartidos; +- templates de Vacancies; +- templates de Applications; +- tests visuales/manuales. + +### Riesgo + +Medio. + +Riesgos principales: + +- tocar clases globales que afectan Profile; +- mezclar estilos de feature con estilos globales; +- introducir inconsistencias por limpiar demasiado rápido. + +### Criterio de cierre + +- Inbox y Applications se sienten parte del mismo producto. +- Las acciones primarias son visualmente claras. +- Las acciones secundarias no compiten con las primarias. +- Las acciones destructivas tienen baja prominencia. +- No hay duplicación evidente de headers, botones o paneles. + +### Validaciones + +```powershell +.\.venv\Scripts\python.exe -m unittest discover -s tests -q +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m compileall app tests +``` + +Smoke test manual: + +- Inbox completo; +- Applications completo; +- Profile carga sin regresión visual grave; +- Nueva Vacante carga sin regresión visual grave. + +--- + +## FRONT-7.5 — Contrato operativo mínimo de Seguimiento + +### Objetivo + +Preparar una evolución futura de Applications hacia seguimiento operativo real, agregando o exponiendo señales que hoy no existen o no llegan a UI. + +### Motivación + +El rediseño visual puede mejorar mucho la vista actual, pero el seguimiento real requiere datos adicionales que hoy no están presentes o no están bien expuestos. + +Faltantes identificados: + +- última actividad; +- próxima acción; +- fecha objetivo; +- motivo de rechazo/cierre; +- análisis heredado resumido; +- señal de seguimiento vencido; +- normalización de `Done`; +- posible exposición de fecha de captura de vacante. + +### Alcance tentativo + +- Diagnóstico técnico previo. +- Definir contrato mínimo. +- Decidir si requiere migración SQL. +- Exponer análisis heredado mínimo en Applications si aplica. +- Resolver inconsistencia de `Done`. + +### Fuera de alcance + +- Kanban drag-and-drop. +- Automatizaciones complejas. +- Reintentos múltiples por vacante. +- Rediseño completo de persistencia. + +### Riesgo + +Medio-alto. + +### Criterio de cierre + +- Existe una propuesta técnica clara para enriquecer Seguimiento. +- Se sabe qué campos son derivados y cuáles persistidos. +- No se introducen datos ambiguos o inventados. + +--- + +## 10. Orden recomendado de implementación + +Orden recomendado: + +```text +FRONT-7.1 — Shell común y header persistente +FRONT-7.2 — Inbox master-detail +FRONT-7.3 — Applications agrupado por estado + panel derecho +FRONT-7.4 — Limpieza visual común +FRONT-7.5 — Contrato operativo mínimo de Seguimiento +``` + +Si se busca acelerar el primer impacto visible, FRONT-7.1 y FRONT-7.2 pueden ejecutarse en una misma fase controlada, siempre que no se incluya Applications en ese mismo PR. + +--- + +## 11. Primer PR recomendado + +El primer PR seguro debería cubrir: + +```text +Shell común + preparación de workspace visual + Inbox sin detalle inline +``` + +Alcance máximo del primer PR: + +- header global limpio; +- compactación de headers redundantes en Inbox; +- patrón inicial de workspace; +- Inbox en dos columnas; +- panel derecho de decisión; +- eliminación de detalle inline; +- preservación de HTMX y `hx-push-url`. + +No debe incluir: + +- rediseño de Applications; +- Kanban; +- Profile; +- cambios SQL; +- cambios OpenAI; +- dashboard nuevo; +- mobile. + +--- + +## 12. Validaciones estándar por fase + +Comandos obligatorios desde el entorno virtual del proyecto: + +```powershell +.\.venv\Scripts\python.exe -m unittest discover -s tests -q +.\.venv\Scripts\python.exe -m ruff check . +.\.venv\Scripts\python.exe -m compileall app tests +``` + +No usar `pytest` si no está instalado en el venv. + +--- + +## 13. Smoke tests manuales mínimos + +### Para Inbox + +- abrir `/app/vacancies`; +- seleccionar vacantes; +- filtrar; +- paginar; +- pasar a seguimiento; +- descartar; +- abrir link; +- verificar que la lista no crezca verticalmente; +- verificar que no haya acciones duplicadas. + +### Para Applications + +- abrir `/app/applications`; +- seleccionar aplicación; +- filtrar por estado; +- cambiar estado; +- editar notas; +- revisar contacto; +- verificar que eliminar no compita con acciones primarias. + +### Para shell global + +- navegar entre Inbox, Seguimiento, Mi Perfil y Nueva Vacante; +- confirmar que header no se duplica; +- confirmar que Profile carga sin regresión funcional; +- confirmar que no hay errores visibles en consola. + +--- + +## 14. Riesgos transversales + +- Intentar rediseñar demasiadas vistas en un solo PR. +- Convertir Applications en Kanban antes de tener datos operativos suficientes. +- Afectar Profile al tocar clases globales. +- Duplicar header global, page headers y barras contextuales. +- Mantener acciones repetidas en lista y panel. +- Reintroducir formularios largos como superficie dominante. +- Depender de campos inexistentes como prioridad, urgencia o `message_hint`. +- Tocar OpenAI o persistencia antes de validar el nuevo workspace visual. + +--- + +## 15. Criterio de éxito del frente FRONT-7 + +El frente se considera exitoso cuando: + +- Inbox permite decidir rápidamente si una vacante se descarta o pasa a seguimiento. +- La lista de vacantes no crece verticalmente con detalles inline. +- El panel derecho concentra análisis, contexto y acciones. +- Seguimiento permite entender aplicaciones por estado. +- El panel derecho de Applications permite operar sin competir con formularios largos. +- La aplicación tiene header global claro. +- La UI contiene menos ruido y menos duplicación. +- Se mantiene FastAPI + Jinja2 + HTMX. +- Profile no sufre regresiones por cambios globales. + +--- + +## 16. Estado final de este documento + +Este documento reemplaza la idea inicial de un rediseño UI/UX amplio por un frente más específico: + +```text +Opportunity Workspace para Inbox y Seguimiento +``` + +Profile, Dashboard, mobile, Kanban avanzado y enriquecimiento profundo de contrato quedan para fases posteriores. From 1b2fcbb5afed5726a3c2c38e5c87a6ddcea14d4d Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 07:56:52 -0500 Subject: [PATCH 02/27] Implement Opportunity Workspace inbox shell --- app/interfaces/web/routes/vacancies.py | 31 +++- app/interfaces/web/static/css/app.css | 63 +++++++ app/interfaces/web/templates/base.html | 12 +- .../templates/components/_page_header.html | 6 +- .../web/templates/vacancies/_detail.html | 169 ++++++++++++------ .../web/templates/vacancies/_filters.html | 6 +- .../web/templates/vacancies/_list.html | 32 +--- .../web/templates/vacancies/_shell.html | 28 +-- .../web/templates/vacancies/index.html | 48 +---- tests/test_web_vacancies.py | 100 +++++++++-- 10 files changed, 345 insertions(+), 150 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index fe1151c..8ebcc84 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -276,7 +276,12 @@ def _build_inbox_context( filtered_items = _filter_vacancy_items(all_items, q=q, view=normalized_view) resolved_page = _resolve_page_for_selected(filtered_items, selected, normalized_page_size, page) items, pagination = _paginate_items(filtered_items, resolved_page, normalized_page_size) - selected_vacancy = next((item for item in items if item["id"] == selected), None) if selected else None + selected_vacancy = None + if items: + if selected is None: + selected_vacancy = items[0] + else: + selected_vacancy = next((item for item in items if item["id"] == selected), items[0]) return { "vacancies": items, "selected_vacancy": selected_vacancy, @@ -304,6 +309,12 @@ def vacancies_index( page_size: int = DEFAULT_PAGE_SIZE, ): context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + if request.headers.get("HX-Request") == "true": + return templates.TemplateResponse( + request=request, + name="vacancies/_shell.html", + context={"request": request, **context}, + ) metrics = _build_metrics() metric_lookup = {item["label"]: item["value"] for item in metrics} return templates.TemplateResponse( @@ -327,6 +338,24 @@ def vacancies_index( ) +@router.get("/app/vacancies/shell", response_class=HTMLResponse) +def vacancy_shell_partial( + request: Request, + selected: int | None = None, + flash: str | None = None, + q: str | None = None, + view: str = "Todas", + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +): + context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + return templates.TemplateResponse( + request=request, + name="vacancies/_shell.html", + context={"request": request, **context}, + ) + + @router.get("/app/vacancies/new") def vacancy_new(request: Request): return templates.TemplateResponse( diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index b930856..0c1f3ae 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -46,6 +46,11 @@ a { border-bottom: 1px solid var(--border); } +.shell-brand-link { + color: inherit; + text-decoration: none; +} + .shell-brand { position: relative; } @@ -194,6 +199,14 @@ a { color: var(--muted); } +.page-header-compact { + margin-bottom: 14px; +} + +.page-header-compact h1 { + font-size: 1.16rem; +} + .panel { padding: 18px 20px; overflow: hidden; @@ -219,6 +232,7 @@ a { /* Layer 4: Shared surfaces, links and summary components */ .primary-link, +.primary-action, .stack-form button { display: inline-flex; justify-content: center; @@ -339,6 +353,7 @@ a { } .primary-link:hover, +.primary-action:hover, .stack-form button:hover { background: var(--brand-strong); } @@ -789,18 +804,37 @@ a { } /* Layer 7: Vacancies feature */ +.workspace-toolbar { + display: grid; + gap: 14px; + margin-bottom: 14px; +} + .inbox-workspace { align-items: start; } +.workspace-shell { + align-items: start; +} + .workspace-panel { min-height: 0; } +.workspace-list, +.workspace-detail { + min-width: 0; +} + .inbox-list-panel { padding: 16px 18px 18px; } +.opportunity-card { + position: relative; +} + .inline-detail-row { margin: 8px 0 16px; transform-origin: top center; @@ -835,6 +869,11 @@ a { overflow: hidden; } +.decision-panel { + padding: 0; + overflow: hidden; +} + .vacancy-card-header { display: flex; justify-content: space-between; @@ -891,6 +930,8 @@ a { gap: 8px; align-items: center; flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; } .vacancy-card-actions form { @@ -1071,6 +1112,28 @@ a { color: #4b5563; } +.analysis-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.analysis-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.detail-list { + margin: 10px 0 0; + padding-left: 18px; + display: grid; + gap: 8px; + color: #4b5563; +} + +.detail-list li { + line-height: 1.55; +} + .vacancy-description { margin-top: 0; border: 0; diff --git a/app/interfaces/web/templates/base.html b/app/interfaces/web/templates/base.html index 6fffdd5..aa5def7 100644 --- a/app/interfaces/web/templates/base.html +++ b/app/interfaces/web/templates/base.html @@ -9,12 +9,14 @@
-
-
CVs Optimizator
-
Vacantes, analisis y seguimiento
-
+ +
+
CVs Optimizator
+
Vacantes, analisis y seguimiento
+
+
-
diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index f79e39a..a6d2cad 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,55 +1,47 @@ {% if selected_vacancy %} -
+ {% set analysis = selected_vacancy.analisis %} +
+
+ {{ selected_vacancy.status_meta.label }} + {% if selected_vacancy.has_application %} + Ya esta en seguimiento + {% endif %} + {% if analysis and analysis.seniority_inferido %} + {{ analysis.seniority_inferido }} + {% endif %} +

{{ selected_vacancy.empresa }} - {{ selected_vacancy.cargo }} - {% if selected_vacancy.link %} - Abrir - {% endif %}

{{ selected_vacancy.fecha_registro.strftime("%d/%m/%Y") if selected_vacancy.fecha_registro else "-" }} {{ selected_vacancy.modalidad }} - {{ selected_vacancy.status_meta.label }} + {% if analysis and analysis.salario_detectado %} + {{ analysis.salario_detectado }} + {% endif %}
- {% if not selected_vacancy.has_application %} -
+
+ {% if selected_vacancy.link %} + Abrir vacante + {% endif %} + {% if not selected_vacancy.has_application %}
- +
- +
- × -
- {% else %} -
- Seguimiento - × -
- {% endif %} + {% else %} + Ir a seguimiento + {% endif %} +
@@ -81,34 +73,111 @@

- {% if selected_vacancy.analisis and selected_vacancy.analisis.skills_match %} + {% if analysis and analysis.resumen_analisis %} +
+

Resumen ejecutivo

+

{{ analysis.resumen_analisis }}

+
+ {% elif analysis and analysis.justificacion_decision %} +
+

Resumen ejecutivo

+

{{ analysis.justificacion_decision }}

+
+ {% endif %} + + {% if analysis and analysis.skills_match %}
- {% for skill in selected_vacancy.analisis.skills_match[:6] %} + {% for skill in analysis.skills_match[:8] %} {{ skill }} {% endfor %}
{% endif %} - {% if selected_vacancy.analisis %} -

- {{ selected_vacancy.analisis.resumen_analisis or selected_vacancy.analisis.justificacion_decision or "Sin resumen aun." }} -

+ {% if analysis %} +
+ {% if analysis.encaje_estrategico %} +
+
Encaje estrategico
+
{{ analysis.encaje_estrategico }}
+
+ {% endif %} + {% if analysis.seniority_inferido %} +
+
Seniority
+
{{ analysis.seniority_inferido }}
+
+ {% endif %} + {% if analysis.aspiracion_salarial_sugerida %} +
+
Aspiracion sugerida
+
{{ analysis.aspiracion_salarial_sugerida }}
+
+ {% endif %} +
+ + {% if analysis.fortalezas_principales or analysis.riesgos_principales or analysis.skills_gap or analysis.ajustes_cv_recomendados %} +
+ {% if analysis.fortalezas_principales %} +
+
Fortalezas principales
+
    + {% for item in analysis.fortalezas_principales[:4] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} + {% if analysis.riesgos_principales %} +
+
Riesgos principales
+
    + {% for item in analysis.riesgos_principales[:4] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} + {% if analysis.skills_gap %} +
+
Skills faltantes
+
    + {% for item in analysis.skills_gap[:5] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} + {% if analysis.ajustes_cv_recomendados %} +
+
Ajustes CV recomendados
+
    + {% for item in analysis.ajustes_cv_recomendados[:5] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} {% else %} -

- Esta vacante aun no tiene analisis disponible. -

+
+

Analisis pendiente

+

+ Esta vacante aun no tiene analisis disponible. +

+
{% endif %} -
-
- Descripcion completa -
-

{{ selected_vacancy.descripcion }}

-
-
+
+ Descripcion completa +
+

{{ selected_vacancy.descripcion }}

+
+
+

{% else %} -
+

Sin detalle

Selecciona una vacante para ver su analisis y decidir si te interesa.

diff --git a/app/interfaces/web/templates/vacancies/_filters.html b/app/interfaces/web/templates/vacancies/_filters.html index 190a796..0f5acd7 100644 --- a/app/interfaces/web/templates/vacancies/_filters.html +++ b/app/interfaces/web/templates/vacancies/_filters.html @@ -3,9 +3,9 @@ class="filter-form filter-form-compact" method="get" action="/app/vacancies" - hx-get="/app/vacancies/list" - hx-target="#vacancy-list" - hx-swap="innerHTML" + hx-get="/app/vacancies" + hx-target="#vacancies-shell" + hx-swap="outerHTML" hx-push-url="true" >
{% endif %} {% endif %} @@ -168,7 +164,7 @@

Analisis pendiente

{% endif %} -
+
Descripcion completa

{{ selected_vacancy.descripcion }}

diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index f47435a..007492c 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -1,12 +1,8 @@ {% if vacancies %} -
-
-
Score
-
Empresa
-
Cargo
-
Fecha
-
Decisión
-
Estado
+
+ {% for item in vacancies %}
-
- {% if item.score_meta.value is not none %} - {{ item.score_meta.value }} - {% else %} - - - {% endif %} -
-
-
{{ item.empresa }}
-
{{ item.modalidad }}
-
-
-
{{ item.cargo }}
-
- {% if item.score_meta.value is not none %} - {{ item.affinity_meta.label }} - {% else %} - Pendiente de analisis - {% endif %} +
+
+
{{ item.empresa }}
+ + {% if item.score_meta.value is not none %} + {{ item.score_meta.value }} + {% else %} + - + {% endif %} + +
+
+
{{ item.cargo }}
+
+
+ {{ item.modalidad }} + {{ item.fecha_registro.strftime("%d/%m/%Y") if item.fecha_registro else "-" }} + + {% if item.score_meta.value is not none %} + {{ item.affinity_meta.label }} + {% else %} + Pendiente de analisis + {% endif %} +
-
- {{ item.fecha_registro.strftime("%d/%m/%Y") if item.fecha_registro else "-" }} -
-
{% endfor %} From 4fdc645ccf4db06cc2544f51a311c0e667bccd26 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 10:01:59 -0500 Subject: [PATCH 04/27] Refine Opportunity Workspace decision panel --- app/interfaces/web/static/css/app.css | 249 ++++++++---------- .../web/templates/vacancies/_detail.html | 105 +++----- 2 files changed, 151 insertions(+), 203 deletions(-) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index e9c008f..b7c2de8 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -694,13 +694,6 @@ a { color: #b91c1c; } -.detail-header-badges { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - .analysis-detail-grid .detail-card { background: #ffffff; } @@ -1028,15 +1021,31 @@ a { } .vacancy-card-header { - padding: 16px 18px 10px; + padding: 14px 18px 8px; } .vacancy-card-copy { min-width: 0; } +.decision-top { + padding-bottom: 6px; +} + +.decision-top-copy { + display: grid; + gap: 6px; +} + +.decision-eyebrow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .vacancy-card-title { - margin: 0 0 6px; + margin: 0; display: flex; align-items: center; gap: 8px; @@ -1074,6 +1083,21 @@ a { color: var(--muted); } +.decision-meta-line { + gap: 4px 10px; + font-size: 0.74rem; +} + +.decision-meta-line span { + position: relative; +} + +.decision-meta-line span + span::before { + content: "•"; + margin-right: 10px; + color: #98a2b3; +} + .vacancy-card-actions { display: flex; gap: 6px; @@ -1107,164 +1131,93 @@ a { background: #fff5f5; } -.vacancy-score-strip { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 18px; - background: #f8fbff; - border-top: 1px solid var(--border); -} - -.vacancy-score-snapshot { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - min-width: 0; -} - -.opportunity-score-pill { - min-width: auto; - padding: 6px 10px; - font-size: 0.76rem; -} - -.vacancy-score-inline { +.decision-hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - padding: 0 18px 14px; - background: #f8fbff; + gap: 12px 16px; + align-items: start; + padding: 14px 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + border-top: 1px solid var(--border); + border-bottom: 1px solid rgba(215, 223, 236, 0.75); } -.vacancy-inline-facts { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; +.decision-copy { + display: grid; + gap: 8px; min-width: 0; } -.vacancy-inline-facts span { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 0 9px; - border-radius: 999px; - background: #fff; - border: 1px solid var(--border); - color: var(--muted); +.decision-label { font-size: 0.72rem; font-weight: 700; -} - -.vacancy-score-value { - flex-shrink: 0; -} - -.vacancy-score-label { - margin-bottom: 4px; - font-size: 0.7rem; - color: var(--muted); + letter-spacing: 0.06em; text-transform: uppercase; - letter-spacing: 0.08em; + color: var(--muted); } -.vacancy-score-number { - font-size: 1.9rem; - font-weight: 700; - line-height: 1; +.decision-headline { + font-size: 1.2rem; + font-weight: 800; + line-height: 1.2; } -.vacancy-score-number.tone-green, -.vacancy-score-fill.tone-green, -.vacancy-verdict.tone-green { +.decision-headline.tone-green { color: #166534; } -.vacancy-score-number.tone-amber, -.vacancy-score-fill.tone-amber, -.vacancy-verdict.tone-amber { +.decision-headline.tone-amber { color: #b45309; } -.vacancy-score-number.tone-red, -.vacancy-score-fill.tone-red, -.vacancy-verdict.tone-red { +.decision-headline.tone-red { color: #b91c1c; } -.vacancy-score-number.tone-gray, -.vacancy-score-fill.tone-gray, -.vacancy-verdict.tone-gray { +.decision-headline.tone-gray { color: #4b5563; } -.vacancy-score-gauge-block { - flex: 1; - min-width: 0; -} - -.vacancy-score-scale { +.decision-support { display: flex; - justify-content: space-between; - margin-bottom: 6px; - font-size: 0.72rem; - color: var(--muted); -} - -.vacancy-score-gauge { - height: 5px; - background: #dbe3f0; - border-radius: 999px; - overflow: hidden; -} - -.vacancy-score-fill { - height: 100%; - border-radius: 999px; - background: currentColor; + flex-wrap: wrap; + gap: 6px; + min-width: 0; } -.vacancy-verdict { - display: inline-flex; - align-items: center; - justify-content: center; +.opportunity-score-pill { + min-width: auto; padding: 6px 10px; - border-radius: 10px; - font-size: 0.76rem; - font-weight: 700; - background: currentColor; - color: #fff; - white-space: nowrap; + font-size: 0.74rem; } -.vacancy-verdict.tone-green { - background: #dcfce7; - color: #166534; +.decision-support .soft-badge { + padding: 5px 9px; + font-size: 0.74rem; + font-weight: 600; } -.vacancy-verdict.tone-amber { - background: #fef3c7; - color: #b45309; +.decision-actions { + justify-content: flex-end; + align-items: flex-start; + align-content: flex-start; } -.vacancy-verdict.tone-red { - background: #fee2e2; - color: #b91c1c; +.decision-actions .primary-action { + min-height: 36px; + padding: 0 14px; + font-size: 0.78rem; } -.vacancy-verdict.tone-gray { - background: #f3f4f6; - color: #4b5563; +.decision-actions .danger-action, +.decision-actions .secondary-link { + min-height: 32px; + font-size: 0.74rem; } .vacancy-card-body { display: grid; - gap: 12px; + gap: 10px; padding: 14px 18px 18px; } @@ -1288,10 +1241,15 @@ a { font-weight: 700; } +.decision-inline-section { + display: grid; + gap: 6px; +} + .vacancy-analysis-text { margin: 0; font-size: 0.86rem; - line-height: 1.6; + line-height: 1.55; color: #4b5563; } @@ -1307,24 +1265,33 @@ a { gap: 10px; } +.decision-evidence-grid { + gap: 8px; +} + .analysis-note { - padding: 12px 14px; - border-radius: 12px; - background: #fbfcfe; - border: 1px solid var(--border); + padding: 10px 0 0; + border-radius: 0; + background: transparent; + border: 0; + border-top: 1px solid var(--border); +} + +.decision-evidence-card .detail-label { + margin-bottom: 2px; } .analysis-note-positive { - border-left: 3px solid #86efac; + border-top-color: #86efac; } .analysis-note-caution { - border-left: 3px solid #fca5a5; + border-top-color: #fca5a5; } .analysis-summary-compact { margin-top: 0; - padding: 0; + padding-top: 2px; border: 0; background: transparent; } @@ -1338,7 +1305,7 @@ a { } .analysis-block { - padding-top: 10px; + padding-top: 8px; border-top: 1px solid var(--border); } @@ -1356,7 +1323,7 @@ a { } .vacancy-description { - margin-top: 2px; + margin-top: 4px; border: 0; border-top: 1px solid var(--border); border-radius: 0; @@ -1835,19 +1802,19 @@ a { padding-right: 12px; } - .vacancy-card-header, - .vacancy-score-strip { + .vacancy-card-header { flex-direction: column; align-items: start; } - .vacancy-card-actions { - width: 100%; - flex-wrap: wrap; + .decision-hero { + grid-template-columns: 1fr; } - .vacancy-score-gauge-block { + .vacancy-card-actions, + .decision-actions { width: 100%; + flex-wrap: wrap; } .record-header, diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 1c4998a..e07d76e 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,47 +1,47 @@ {% if selected_vacancy %} {% set analysis = selected_vacancy.analisis %}
-
-
-
+
+
+
{{ selected_vacancy.status_meta.label }} - {% if selected_vacancy.has_application %} - Ya esta en seguimiento - {% endif %} - {% if analysis and analysis.seniority_inferido %} - {{ analysis.seniority_inferido }} - {% endif %}

{{ selected_vacancy.empresa }} - {{ selected_vacancy.cargo }}

-
+
{{ selected_vacancy.fecha_registro.strftime("%d/%m/%Y") if selected_vacancy.fecha_registro else "-" }} {{ selected_vacancy.modalidad }} + {% if analysis and analysis.seniority_inferido %} + {{ analysis.seniority_inferido }} + {% endif %} {% if analysis and analysis.salario_detectado %} {{ analysis.salario_detectado }} {% endif %} + {% if analysis and analysis.aspiracion_salarial_sugerida %} + {{ analysis.aspiracion_salarial_sugerida }} + {% endif %}
-
-
- - Score {{ selected_vacancy.score_meta.value if selected_vacancy.score_meta.value is not none else "-" }} - - +
+
+
Decision sugerida
+
{{ selected_vacancy.decision_meta.label }} - - {{ selected_vacancy.affinity_meta.label }} - {% if analysis and analysis.encaje_estrategico %} - {{ analysis.encaje_estrategico }} - {% endif %} +
+
+ + Score {{ selected_vacancy.score_meta.value if selected_vacancy.score_meta.value is not none else "-" }} + + {{ selected_vacancy.affinity_meta.label }} + {% if analysis and analysis.encaje_estrategico %} + {{ analysis.encaje_estrategico }} + {% endif %} +
-
- {% if selected_vacancy.link %} - Abrir vacante - {% endif %} +
{% if not selected_vacancy.has_application %}
@@ -56,40 +56,18 @@

{% else %} Ir a seguimiento {% endif %} + {% if selected_vacancy.link %} + Abrir vacante + {% endif %}

-
- -
-
-
- 0 - 100 -
-
-
-
-
- {% if analysis and (analysis.seniority_inferido or analysis.aspiracion_salarial_sugerida) %} -
- {% if analysis.seniority_inferido %} - {{ analysis.seniority_inferido }} - {% endif %} - {% if analysis.aspiracion_salarial_sugerida %} - {{ analysis.aspiracion_salarial_sugerida }} - {% endif %} -
- {% endif %} -
+
{% if analysis %} {% if analysis.fortalezas_principales or analysis.riesgos_principales %} -
+
{% if analysis.fortalezas_principales %} -
+
Por que si
    {% for item in analysis.fortalezas_principales[:4] %} @@ -99,7 +77,7 @@

{% endif %} {% if analysis.riesgos_principales %} -
+
Ojo con esto
    {% for item in analysis.riesgos_principales[:4] %} @@ -111,6 +89,17 @@

{% endif %} + {% if analysis and analysis.skills_match %} +
+
Skills que ya traes
+
+ {% for skill in analysis.skills_match[:8] %} + {{ skill }} + {% endfor %} +
+
+ {% endif %} + {% if analysis and analysis.resumen_analisis %}

Resumen ejecutivo

@@ -123,14 +112,6 @@

Resumen ejecutivo

{% endif %} - {% if analysis and analysis.skills_match %} -
- {% for skill in analysis.skills_match[:8] %} - {{ skill }} - {% endfor %} -
- {% endif %} - {% if analysis.skills_gap or analysis.ajustes_cv_recomendados %}
{% if analysis.skills_gap %} From 7be361a43fdf8aa252985dd857b197b91c31cac3 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 11:11:09 -0500 Subject: [PATCH 05/27] Refine Opportunity Workspace vacancy list --- app/interfaces/web/static/css/app.css | 144 ++++++++++++++---- .../web/templates/vacancies/_list.html | 28 ++-- 2 files changed, 127 insertions(+), 45 deletions(-) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index b7c2de8..392b80c 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -884,7 +884,7 @@ a { } .opportunity-list { - gap: 6px; + gap: 4px; } .list-section-label { @@ -892,38 +892,38 @@ a { justify-content: space-between; align-items: center; gap: 12px; - padding: 0 2px 4px; + padding: 0 2px 6px; color: var(--muted); - font-size: 0.74rem; + font-size: 0.72rem; font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; + letter-spacing: 0.03em; } .opportunity-list .table-row-shell { - border-radius: 12px; - background: rgba(251, 252, 254, 0.92); + border-radius: 10px; + border-color: rgba(215, 223, 236, 0.7); + background: rgba(255, 255, 255, 0.7); + box-shadow: none; } .opportunity-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; + gap: 10px; align-items: center; - padding: 10px 12px; + padding: 9px 10px; } .opportunity-main { display: grid; - gap: 4px; + gap: 3px; min-width: 0; } .opportunity-topline { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; + gap: 8px; min-width: 0; } @@ -932,53 +932,131 @@ a { } .opportunity-role { - font-size: 0.84rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.82rem; font-weight: 600; - line-height: 1.35; + line-height: 1.28; color: #354158; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .opportunity-meta-line { display: flex; align-items: center; flex-wrap: wrap; - gap: 4px 10px; + gap: 3px 8px; min-width: 0; } .opportunity-meta-line .secondary-cell { margin-top: 0; + font-size: 0.74rem; +} + +.opportunity-side { + display: grid; + justify-items: end; + align-items: center; + min-width: 0; +} + +.opportunity-recommendation { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 180px; + padding: 6px 9px; + border-radius: 999px; + border: 1px solid transparent; font-size: 0.76rem; + font-weight: 700; + line-height: 1.2; + text-align: right; } -.opportunity-score { +.opportunity-recommendation-label { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.opportunity-recommendation-score { display: inline-flex; align-items: center; justify-content: center; - min-width: 32px; + min-width: 26px; min-height: 22px; - padding: 0 8px; + padding: 0 6px; border-radius: 999px; - background: #f8fafc; - border: 1px solid var(--border); - font-size: 0.74rem; - font-weight: 700; + background: rgba(255, 255, 255, 0.72); + color: currentColor; + font-size: 0.73rem; + font-weight: 800; flex-shrink: 0; } -.opportunity-signals { - display: grid; - justify-items: end; - gap: 6px; - min-width: 0; +.opportunity-state { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; } -.opportunity-decision { - font-size: 0.78rem; - padding: 6px 9px; +.opportunity-state::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + opacity: 0.8; +} + +.opportunity-state.state-blue { + color: #5473b7; +} + +.opportunity-state.state-green { + color: #5d7c68; +} + +.opportunity-state.state-gray { + color: #6b7280; +} + +.opportunity-state.state-red { + color: #9f5d5d; +} + +.opportunity-state.state-amber { + color: #9b7448; +} + +.opportunity-state.state-violet { + color: #7563b8; +} + +.opportunity-list .table-row-shell:hover { + border-color: rgba(191, 219, 254, 0.95); + background: #f8fbff; + transform: none; +} + +.opportunity-list .table-row-shell.is-selected { + border-color: rgba(179, 201, 255, 0.95); + background: rgba(238, 244, 255, 0.92); + box-shadow: inset 2px 0 0 #1d4ed8; +} + +.opportunity-list .primary-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .inline-detail-row { diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index 007492c..3d76ce7 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -1,8 +1,8 @@ {% if vacancies %}
{% for item in vacancies %}
{{ item.empresa }}
- - {% if item.score_meta.value is not none %} - {{ item.score_meta.value }} - {% else %} - - - {% endif %} -
{{ item.cargo }}
@@ -41,11 +34,22 @@ Pendiente de analisis {% endif %} + {{ item.status_meta.label }}
-
- {{ item.decision_meta.label }} - {{ item.status_meta.label }} +
+ + + {% if item.score_meta.value is not none %} + {{ item.decision_meta.label }} + {% else %} + Pendiente de analisis + {% endif %} + + {% if item.score_meta.value is not none %} + {{ item.score_meta.value }} + {% endif %} +
From dec4e89aa0d0526fae4f1e52dfa7f2dfab08505b Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 11:31:38 -0500 Subject: [PATCH 06/27] Polish Opportunity Workspace inbox behavior --- app/interfaces/web/routes/vacancies.py | 30 ++++++++++++- app/interfaces/web/static/css/app.css | 22 ++++++--- .../web/templates/vacancies/_detail.html | 9 +++- .../web/templates/vacancies/_list.html | 13 +++--- .../web/templates/vacancies/_shell.html | 38 ++++++++-------- .../web/templates/vacancies/index.html | 4 +- tests/test_web_vacancies.py | 45 ++++++++++++++++++- 7 files changed, 121 insertions(+), 40 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 8ebcc84..3f1416d 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -118,6 +118,31 @@ def _application_ids_with_tracking() -> set[int]: return {item["vacante_id"] for item in application_repository.list_all()} +def _application_tracking_lookup() -> dict[int, int]: + return { + item["vacante_id"]: item["id"] + for item in application_repository.list_all() + if item.get("vacante_id") and item.get("id") + } + + +def _compact_decision_label(analysis: dict | None) -> str: + if not analysis: + return "Pendiente de analisis" + + decision = (analysis.get("decision_aplicacion") or "").strip() + lowered = decision.lower() + if not decision: + return "Pendiente de analisis" + if "aplicar si sobra tiempo" in lowered: + return "Si hay tiempo" + if "revis" in lowered or "evalu" in lowered: + return "Revisar" + if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: + return "Descartar" + return decision + + def _build_vacancy_items(limit: int | None = None) -> list[dict]: vacancies = vacancy_repository.list_all() vacancies = [item for item in vacancies if not item.get("motivo_archivo")] @@ -125,7 +150,8 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: visible_vacancies = vacancies[:limit] if limit else vacancies vacancy_ids = [item["id"] for item in visible_vacancies] analyses_by_vacancy = analysis_repository.get_by_vacancy_ids(vacancy_ids) - tracked_vacancy_ids = _application_ids_with_tracking() + tracking_lookup = _application_tracking_lookup() + tracked_vacancy_ids = set(tracking_lookup) items = [] for vacancy in visible_vacancies: analysis = analyses_by_vacancy.get(vacancy["id"]) @@ -144,7 +170,9 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: "score_meta": score_meta, "affinity_meta": affinity_meta, "decision_meta": decision_meta, + "decision_compact_label": _compact_decision_label(analysis), "has_application": has_application, + "tracking_application_id": tracking_lookup.get(vacancy["id"]), } ) return items diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 392b80c..3908380 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -953,6 +953,7 @@ a { .opportunity-meta-line .secondary-cell { margin-top: 0; font-size: 0.74rem; + color: #778196; } .opportunity-side { @@ -966,7 +967,7 @@ a { display: inline-flex; align-items: center; gap: 7px; - max-width: 180px; + max-width: 190px; padding: 6px 9px; border-radius: 999px; border: 1px solid transparent; @@ -981,6 +982,7 @@ a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 130px; } .opportunity-recommendation-score { @@ -1002,8 +1004,8 @@ a { display: inline-flex; align-items: center; gap: 5px; - color: var(--muted); - font-size: 0.72rem; + color: #8791a6; + font-size: 0.7rem; font-weight: 600; white-space: nowrap; } @@ -1215,7 +1217,7 @@ a { gap: 12px 16px; align-items: start; padding: 14px 18px; - background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); border-top: 1px solid var(--border); border-bottom: 1px solid rgba(215, 223, 236, 0.75); } @@ -1235,9 +1237,10 @@ a { } .decision-headline { - font-size: 1.2rem; - font-weight: 800; - line-height: 1.2; + font-size: 1.12rem; + font-weight: 760; + line-height: 1.24; + letter-spacing: -0.01em; } .decision-headline.tone-green { @@ -1293,6 +1296,11 @@ a { font-size: 0.74rem; } +.tracking-link { + border-color: #d7dfec; + color: #314056; +} + .vacancy-card-body { display: grid; gap: 10px; diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index e07d76e..8d5b7a7 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -53,8 +53,13 @@

- {% else %} - Ir a seguimiento + {% endif %} + {% if selected_vacancy.has_application %} + {% if selected_vacancy.tracking_application_id %} + Ver seguimiento + {% else %} + Ver seguimiento + {% endif %} {% endif %} {% if selected_vacancy.link %} Abrir vacante diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index 3d76ce7..f1ac29f 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -38,14 +38,11 @@

- - - {% if item.score_meta.value is not none %} - {{ item.decision_meta.label }} - {% else %} - Pendiente de analisis - {% endif %} - + + {{ item.decision_compact_label }} {% if item.score_meta.value is not none %} {{ item.score_meta.value }} {% endif %} diff --git a/app/interfaces/web/templates/vacancies/_shell.html b/app/interfaces/web/templates/vacancies/_shell.html index 231b4e8..6c745d5 100644 --- a/app/interfaces/web/templates/vacancies/_shell.html +++ b/app/interfaces/web/templates/vacancies/_shell.html @@ -1,23 +1,25 @@ -
- {% include "vacancies/_summary.html" %} +
+
+ {% include "vacancies/_summary.html" %} - {% if flash_message %} - {% set flash_tone = flash_message[0] %} - {% set flash_text = flash_message[1] %} - {% include "components/_flash.html" %} - {% endif %} + {% if flash_message %} + {% set flash_tone = flash_message[0] %} + {% set flash_text = flash_message[1] %} + {% include "components/_flash.html" %} + {% endif %} - {% include "vacancies/_filters.html" %} -
+ {% include "vacancies/_filters.html" %} +
+ +
+
+
+ {% include "vacancies/_list.html" %} +
+
-
-
-
- {% include "vacancies/_list.html" %} +
+ {% include "vacancies/_detail.html" %}
- -
- {% include "vacancies/_detail.html" %} -
-
+
diff --git a/app/interfaces/web/templates/vacancies/index.html b/app/interfaces/web/templates/vacancies/index.html index 55c55fb..cdc90a8 100644 --- a/app/interfaces/web/templates/vacancies/index.html +++ b/app/interfaces/web/templates/vacancies/index.html @@ -7,7 +7,5 @@ {% set page_header_compact = true %} {% include "components/_page_header.html" %} -
- {% include "vacancies/_shell.html" %} -
+{% include "vacancies/_shell.html" %} {% endblock %} diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 40eb372..10f430c 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -250,6 +250,36 @@ def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_b self.assertIn('name="page_size" value="10"', response.text) self.assertIn("Pasar a seguimiento", response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application(self, mock_build_items): + mock_build_items.return_value = [ + { + "id": 25, + "empresa": "Empresa 25", + "cargo": "Cargo 25", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 25", + "link": "https://example.com/vacancy/25", + "analisis": None, + "status_label": "En seguimiento", + "status_meta": {"tone": "blue", "label": "En seguimiento"}, + "score_label": "Sin score", + "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, + "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "has_application": True, + "tracking_application_id": 77, + } + ] + + response = self.client.get("/app/vacancies/25/detail?q=data&view=Todas&page=3&page_size=10") + + self.assertEqual(response.status_code, 200) + self.assertIn("Ver seguimiento", response.text) + self.assertIn('href="/app/applications?selected=77"', response.text) + self.assertNotIn("Ir a seguimiento", response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_build_items): mock_build_items.return_value = [ @@ -285,6 +315,7 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu response = self.client.get("/app/vacancies/shell?selected=2&q=Empresa&view=Todas&page=1&page_size=20") self.assertEqual(response.status_code, 200) + self.assertIn('id="vacancies-shell"', response.text) self.assertIn('class="layout-two-columns workspace-shell inbox-workspace"', response.text) self.assertIn('id="vacancy-detail"', response.text) self.assertIn("Resumen ejecutivo", response.text) @@ -320,6 +351,7 @@ def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_buil ) self.assertEqual(response.status_code, 200) + self.assertIn('id="vacancies-shell"', response.text) self.assertIn('class="workspace-toolbar"', response.text) self.assertIn('id="vacancy-detail"', response.text) self.assertNotIn("", response.text.lower()) @@ -340,13 +372,14 @@ def test_build_vacancy_items_batches_analysis_and_tracking_queries( mock_analysis_repository.get_by_vacancy_ids.return_value = { 1: {"score_total": 87}, } - mock_application_repository.list_all.return_value = [{"vacante_id": 2}] + mock_application_repository.list_all.return_value = [{"id": 12, "vacante_id": 2}] result = vacancies_routes._build_vacancy_items() self.assertEqual(len(result), 2) self.assertEqual(result[0]["status_label"], "Analizada") self.assertEqual(result[1]["status_label"], "En seguimiento") + self.assertEqual(result[1]["tracking_application_id"], 12) self.assertEqual(result[0]["score_meta"]["tone"], "green") mock_analysis_repository.get_by_vacancy_ids.assert_called_once_with([1, 2]) mock_application_repository.list_all.assert_called_once() @@ -408,3 +441,13 @@ def test_affinity_and_decision_meta_map_to_expected_tones(self): self.assertEqual(vacancies_routes._decision_meta({"decision_aplicacion": "Aplicar"})["tone"], "green") self.assertEqual(vacancies_routes._decision_meta({"decision_aplicacion": "Revisar mas a fondo"})["tone"], "amber") self.assertEqual(vacancies_routes._decision_meta({"decision_aplicacion": "No aplicar"})["tone"], "red") + + def test_compact_decision_label_uses_short_copy_for_long_recommendation(self): + self.assertEqual( + vacancies_routes._compact_decision_label({"decision_aplicacion": "Aplicar si sobra tiempo"}), + "Si hay tiempo", + ) + self.assertEqual( + vacancies_routes._compact_decision_label({"decision_aplicacion": "Aplicar sí o sí"}), + "Aplicar sí o sí", + ) From 5c0159aee943844e5f410f6500b983645484b311 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 12:00:25 -0500 Subject: [PATCH 07/27] Fix Opportunity Workspace inbox polish --- app/interfaces/web/routes/vacancies.py | 57 ++++++++--- app/interfaces/web/static/css/app.css | 18 +++- .../web/templates/vacancies/_detail.html | 14 ++- .../web/templates/vacancies/_filters.html | 2 +- .../web/templates/vacancies/_list.html | 13 ++- tests/test_web_vacancies.py | 97 +++++++++++++++++-- 6 files changed, 168 insertions(+), 33 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 3f1416d..2f52312 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -114,8 +114,10 @@ def _decision_meta(analysis: dict | None) -> dict: return {"tone": tone, "label": decision.strip(), "raw": decision.strip()} -def _application_ids_with_tracking() -> set[int]: - return {item["vacante_id"] for item in application_repository.list_all()} +def _decision_visual_tone(score_meta: dict, decision_meta: dict) -> str: + if score_meta.get("value") is not None: + return score_meta["tone"] + return decision_meta["tone"] def _application_tracking_lookup() -> dict[int, int]: @@ -143,6 +145,17 @@ def _compact_decision_label(analysis: dict | None) -> str: return decision +def _build_context_bar(summary: dict, metrics: list[dict]) -> list[dict]: + metric_lookup = {item["label"]: item["value"] for item in metrics} + return [ + {"label": "Vacantes visibles", "value": summary["total"]}, + {"label": "Analizadas", "value": summary["analizadas"]}, + {"label": "En seguimiento", "value": summary["seguimiento"]}, + {"label": "Aplicaciones", "value": metric_lookup.get("Aplicaciones", 0)}, + {"label": "Rechazadas", "value": metric_lookup.get("Rechazadas", 0)}, + ] + + def _build_vacancy_items(limit: int | None = None) -> list[dict]: vacancies = vacancy_repository.list_all() vacancies = [item for item in vacancies if not item.get("motivo_archivo")] @@ -170,6 +183,7 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: "score_meta": score_meta, "affinity_meta": affinity_meta, "decision_meta": decision_meta, + "decision_visual_tone": _decision_visual_tone(score_meta, decision_meta), "decision_compact_label": _compact_decision_label(analysis), "has_application": has_application, "tracking_application_id": tracking_lookup.get(vacancy["id"]), @@ -297,6 +311,7 @@ def _build_inbox_context( view: str, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, + metrics: list[dict] | None = None, ) -> dict: normalized_view = view if view in INBOX_VIEWS else "Todas" normalized_page_size = _normalize_page_size(page_size) @@ -323,6 +338,14 @@ def _build_inbox_context( "analizadas": sum(1 for item in filtered_items if item["analisis"]), "seguimiento": sum(1 for item in filtered_items if item["has_application"]), }, + "context_bar": _build_context_bar( + { + "total": len(filtered_items), + "analizadas": sum(1 for item in filtered_items if item["analisis"]), + "seguimiento": sum(1 for item in filtered_items if item["has_application"]), + }, + metrics or [], + ), } @@ -336,15 +359,22 @@ def vacancies_index( page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): - context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + metrics = _build_metrics() + context = _build_inbox_context( + selected=selected, + flash=flash, + q=q, + view=view, + page=page, + page_size=page_size, + metrics=metrics, + ) if request.headers.get("HX-Request") == "true": return templates.TemplateResponse( request=request, name="vacancies/_shell.html", context={"request": request, **context}, ) - metrics = _build_metrics() - metric_lookup = {item["label"]: item["value"] for item in metrics} return templates.TemplateResponse( request=request, name="vacancies/index.html", @@ -354,13 +384,6 @@ def vacancies_index( "nav_items": _build_nav("vacancies"), "metrics": metrics, "hide_global_metrics": True, - "context_bar": [ - {"label": "Vacantes visibles", "value": context["summary"]["total"]}, - {"label": "Analizadas", "value": context["summary"]["analizadas"]}, - {"label": "En seguimiento", "value": context["summary"]["seguimiento"]}, - {"label": "Aplicaciones", "value": metric_lookup.get("Aplicaciones", 0)}, - {"label": "Rechazadas", "value": metric_lookup.get("Rechazadas", 0)}, - ], **context, }, ) @@ -376,7 +399,15 @@ def vacancy_shell_partial( page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): - context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + context = _build_inbox_context( + selected=selected, + flash=flash, + q=q, + view=view, + page=page, + page_size=page_size, + metrics=_build_metrics(), + ) return templates.TemplateResponse( request=request, name="vacancies/_shell.html", diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 3908380..3771236 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -856,6 +856,7 @@ a { min-height: 38px; padding: 0 12px; border-radius: 10px; + font-weight: 700; } .inbox-workspace { @@ -1417,17 +1418,32 @@ a { } .vacancy-description > summary { + display: grid; + gap: 2px; padding: 12px 18px; background: transparent; font-size: 0.78rem; } +.description-summary-note { + color: var(--muted); + font-size: 0.72rem; + font-weight: 500; +} + .vacancy-description > .vacancy-description-body { + max-height: 280px; + overflow-y: auto; padding: 0 18px 16px; } -.vacancy-description > .vacancy-description-body p { +.vacancy-description-text { margin: 0; + color: #4b5563; + font-size: 0.86rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; } /* Layer 8: Applications feature */ diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 8d5b7a7..19aa35b 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -28,11 +28,14 @@

Decision sugerida
-
+
{{ selected_vacancy.decision_meta.label }}
- + Score {{ selected_vacancy.score_meta.value if selected_vacancy.score_meta.value is not none else "-" }} {{ selected_vacancy.affinity_meta.label }} @@ -151,9 +154,12 @@

Analisis pendiente

{% endif %}
- Descripcion completa + + Descripcion completa + Texto original de la vacante +
-

{{ selected_vacancy.descripcion }}

+

{{ selected_vacancy.descripcion or "Sin descripcion disponible." }}

diff --git a/app/interfaces/web/templates/vacancies/_filters.html b/app/interfaces/web/templates/vacancies/_filters.html index 0f5acd7..cdde03b 100644 --- a/app/interfaces/web/templates/vacancies/_filters.html +++ b/app/interfaces/web/templates/vacancies/_filters.html @@ -29,6 +29,6 @@ - +
diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index f1ac29f..038177c 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -39,12 +39,17 @@

- {{ item.decision_compact_label }} + + {{ item.decision_compact_label }} + {% if item.score_meta.value is not none %} - {{ item.score_meta.value }} + {{ item.score_meta.value }} {% endif %}
diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 10f430c..620e850 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -187,23 +187,62 @@ def test_inbox_moves_to_page_of_selected_vacancy(self, mock_build_items, _mock_n def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, mock_build_items): mock_build_items.return_value = [ { - "id": item_id, - "empresa": f"Empresa {item_id}", - "cargo": f"Cargo {item_id}", + "id": 1, + "empresa": "Empresa 1", + "cargo": "Cargo 1", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 1", + "link": None, + "analisis": {"score_total": 43, "decision_aplicacion": "Descartar"}, + "status_label": "Analizada", + "status_meta": {"tone": "green", "label": "Analizada"}, + "score_label": "43", + "score_meta": {"tone": "red", "label": "43", "value": 43}, + "affinity_meta": {"tone": "red", "label": "Baja", "raw": "Baja"}, + "decision_meta": {"tone": "red", "label": "Descartar", "raw": "Descartar"}, + "decision_visual_tone": "red", + "decision_compact_label": "Descartar", + "has_application": False, + }, + { + "id": 2, + "empresa": "Empresa 2", + "cargo": "Cargo 2", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 2", + "link": None, + "analisis": {"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, + "status_label": "Analizada", + "status_meta": {"tone": "green", "label": "Analizada"}, + "score_label": "64", + "score_meta": {"tone": "amber", "label": "64", "value": 64}, + "affinity_meta": {"tone": "amber", "label": "Media", "raw": "Media"}, + "decision_meta": {"tone": "green", "label": "Aplicar si sobra tiempo", "raw": "Aplicar si sobra tiempo"}, + "decision_visual_tone": "amber", + "decision_compact_label": "Si hay tiempo", + "has_application": False, + }, + { + "id": 3, + "empresa": "Empresa 3", + "cargo": "Cargo 3", "modalidad": "Remoto", "fecha_registro": date(2026, 4, 1), - "descripcion": f"Descripcion {item_id}", + "descripcion": "Descripcion 3", "link": None, "analisis": None, - "status_label": "Registrada", - "status_meta": {"tone": "gray", "label": "Registrada"}, + "status_label": "Sin analizar", + "status_meta": {"tone": "gray", "label": "Sin analizar"}, "score_label": "Sin score", "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_visual_tone": "gray", + "decision_compact_label": "Pendiente de analisis", "has_application": False, - } - for item_id in range(1, 4) + }, ] response = self.client.get("/app/vacancies/list?selected=2&q=Empresa&view=Todas&page=1&page_size=20") @@ -217,6 +256,10 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, response.text, ) self.assertIn('hx-target="#vacancies-shell"', response.text) + self.assertIn('class="opportunity-recommendation tone-amber"', response.text) + self.assertIn('title="Aplicar si sobra tiempo"', response.text) + self.assertIn('title="Score 64"', response.text) + self.assertNotIn('opportunity-recommendation-score" title="Descartar"', response.text) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_build_items): @@ -249,6 +292,10 @@ def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_b self.assertIn('name="page" value="3"', response.text) self.assertIn('name="page_size" value="10"', response.text) self.assertIn("Pasar a seguimiento", response.text) + self.assertIn("Descripcion completa", response.text) + self.assertIn("Texto original de la vacante", response.text) + self.assertIn("Descripcion 25", response.text) + self.assertIn('class="description-disclosure vacancy-description"', response.text) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application(self, mock_build_items): @@ -280,8 +327,9 @@ def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application( self.assertIn('href="/app/applications?selected=77"', response.text) self.assertNotIn("Ir a seguimiento", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") - def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_build_items): + def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_build_items, _mock_metrics): mock_build_items.return_value = [ { "id": 2, @@ -308,6 +356,8 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu "score_meta": {"tone": "green", "label": "88", "value": 88}, "affinity_meta": {"tone": "green", "label": "Alta", "raw": "Alta"}, "decision_meta": {"tone": "green", "label": "Aplicar", "raw": "Aplicar"}, + "decision_visual_tone": "green", + "decision_compact_label": "Aplicar", "has_application": False, } ] @@ -318,13 +368,18 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu self.assertIn('id="vacancies-shell"', response.text) self.assertIn('class="layout-two-columns workspace-shell inbox-workspace"', response.text) self.assertIn('id="vacancy-detail"', response.text) + self.assertIn("Vacantes visibles", response.text) + self.assertIn("Aplicaciones", response.text) self.assertIn("Resumen ejecutivo", response.text) self.assertIn("Buen encaje general", response.text) self.assertIn("Pasar a seguimiento", response.text) self.assertNotIn('data-inline-detail="true"', response.text) + self.assertIn("Descripcion completa", response.text) + self.assertIn("Texto original de la vacante", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") - def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_build_items): + def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_build_items, _mock_metrics): mock_build_items.return_value = [ { "id": 7, @@ -341,6 +396,8 @@ def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_buil "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_visual_tone": "gray", + "decision_compact_label": "Pendiente de analisis", "has_application": False, } ] @@ -353,8 +410,12 @@ def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_buil self.assertEqual(response.status_code, 200) self.assertIn('id="vacancies-shell"', response.text) self.assertIn('class="workspace-toolbar"', response.text) + self.assertIn("Vacantes visibles", response.text) + self.assertIn("Aplicaciones", response.text) self.assertIn('id="vacancy-detail"', response.text) self.assertNotIn("", response.text.lower()) + self.assertIn("Filtrar", response.text) + self.assertNotIn(">Aplicar", response.text) @patch("app.interfaces.web.routes.vacancies.application_repository") @patch("app.interfaces.web.routes.vacancies.analysis_repository") @@ -451,3 +512,19 @@ def test_compact_decision_label_uses_short_copy_for_long_recommendation(self): vacancies_routes._compact_decision_label({"decision_aplicacion": "Aplicar sí o sí"}), "Aplicar sí o sí", ) + + def test_decision_visual_tone_prefers_score_tone_when_available(self): + self.assertEqual( + vacancies_routes._decision_visual_tone( + {"tone": "amber", "value": 64}, + {"tone": "green", "label": "Aplicar si sobra tiempo"}, + ), + "amber", + ) + self.assertEqual( + vacancies_routes._decision_visual_tone( + {"tone": "gray", "value": None}, + {"tone": "red", "label": "Descartar"}, + ), + "red", + ) From adb834dad6021cdfce96f855d8aea4e389acb1e9 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 12:38:46 -0500 Subject: [PATCH 08/27] Fix Opportunity Workspace inbox closure --- app/interfaces/web/routes/vacancies.py | 38 ++++++++++++- app/interfaces/web/static/css/app.css | 54 +++++++++++++++++- .../web/templates/vacancies/_detail.html | 40 ++++++------- .../web/templates/vacancies/_list.html | 14 +++-- tests/test_web_vacancies.py | 57 ++++++++++++++++++- 5 files changed, 169 insertions(+), 34 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 2f52312..4e0c8f9 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -80,6 +80,18 @@ def _keyword_tone(value: str | None, mapping: dict[str, str], default_label: str return {"tone": "gray", "label": normalized or default_label, "raw": normalized} +def _clean_display_value(value, *, max_length: int | None = None) -> str | None: + if value is None: + return None + + text = str(value).strip() + if not text or text.lower() in {"null", "none", "n/a", "na", "-"}: + return None + if max_length is not None and len(text) > max_length: + return None + return text + + def _affinity_meta(analysis: dict | None) -> dict: if not analysis: return {"tone": "gray", "label": "-", "raw": None} @@ -130,18 +142,20 @@ def _application_tracking_lookup() -> dict[int, int]: def _compact_decision_label(analysis: dict | None) -> str: if not analysis: - return "Pendiente de analisis" + return "Pendiente" decision = (analysis.get("decision_aplicacion") or "").strip() lowered = decision.lower() if not decision: - return "Pendiente de analisis" + return "Pendiente" + if "aplicar sí o sí" in lowered or "aplicar si o si" in lowered: + return "Si" if "aplicar si sobra tiempo" in lowered: return "Si hay tiempo" if "revis" in lowered or "evalu" in lowered: return "Revisar" if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: - return "Descartar" + return "No" return decision @@ -156,6 +170,22 @@ def _build_context_bar(summary: dict, metrics: list[dict]) -> list[dict]: ] +def _detail_meta_items(vacancy: dict, analysis: dict | None) -> list[str]: + values: list[str | None] = [ + vacancy.get("fecha_registro").strftime("%d/%m/%Y") if vacancy.get("fecha_registro") else None, + _clean_display_value(vacancy.get("modalidad"), max_length=30), + ] + if analysis: + values.extend( + [ + _clean_display_value(analysis.get("seniority_inferido"), max_length=40), + _clean_display_value(analysis.get("salario_detectado"), max_length=50), + _clean_display_value(analysis.get("aspiracion_salarial_sugerida"), max_length=50), + ] + ) + return [value for value in values if value] + + def _build_vacancy_items(limit: int | None = None) -> list[dict]: vacancies = vacancy_repository.list_all() vacancies = [item for item in vacancies if not item.get("motivo_archivo")] @@ -177,6 +207,8 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: { **vacancy, "analisis": analysis, + "detail_meta_items": _detail_meta_items(vacancy, analysis), + "modalidad_display": _clean_display_value(vacancy.get("modalidad"), max_length=30), "status_label": status_label, "status_meta": STATUS_META[status_label], "score_label": f"{score_meta['value']:.0f}" if score_meta["value"] is not None else "Sin analisis", diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 3771236..f26e3e8 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -876,6 +876,11 @@ a { min-width: 0; } +.workspace-detail, +#vacancy-detail { + min-height: 0; +} + .inbox-list-panel { padding: 14px 16px 16px; } @@ -1097,6 +1102,8 @@ a { } .decision-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); padding: 0; overflow: hidden; } @@ -1305,9 +1312,15 @@ a { .vacancy-card-body { display: grid; gap: 10px; + min-height: 0; padding: 14px 18px 18px; } +.decision-panel-body { + min-height: 0; + overflow: visible; +} + .vacancy-skills { display: flex; gap: 6px; @@ -1417,12 +1430,37 @@ a { background: transparent; } -.vacancy-description > summary { +.vacancy-description-summary { display: grid; gap: 2px; + width: 100%; padding: 12px 18px; + padding-right: 34px; background: transparent; + color: inherit; + cursor: pointer; font-size: 0.78rem; + font-weight: 700; + position: relative; + text-align: left; + text-decoration: none; +} + +.vacancy-description-summary::-webkit-details-marker { + display: none; +} + +.vacancy-description-summary::after { + content: "▾"; + position: absolute; + right: 18px; + top: 14px; + color: #667085; + transition: transform 0.18s ease; +} + +.vacancy-description-reader:target .vacancy-description-summary::after { + transform: rotate(180deg); } .description-summary-note { @@ -1431,12 +1469,22 @@ a { font-weight: 500; } -.vacancy-description > .vacancy-description-body { - max-height: 280px; +.vacancy-description-content { + max-height: 320px; + min-height: 0; overflow-y: auto; + overscroll-behavior: contain; padding: 0 18px 16px; } +.vacancy-description-reader:not(:target) .vacancy-description-content { + display: none; +} + +.vacancy-description-reader:target .vacancy-description-content { + display: block; +} + .vacancy-description-text { margin: 0; color: #4b5563; diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 19aa35b..ac19c9c 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -9,26 +9,20 @@

{{ selected_vacancy.empresa }} - {{ selected_vacancy.cargo }}

-
- {{ selected_vacancy.fecha_registro.strftime("%d/%m/%Y") if selected_vacancy.fecha_registro else "-" }} - {{ selected_vacancy.modalidad }} - {% if analysis and analysis.seniority_inferido %} - {{ analysis.seniority_inferido }} - {% endif %} - {% if analysis and analysis.salario_detectado %} - {{ analysis.salario_detectado }} - {% endif %} - {% if analysis and analysis.aspiracion_salarial_sugerida %} - {{ analysis.aspiracion_salarial_sugerida }} - {% endif %} -
+ {% if selected_vacancy.detail_meta_items %} +
+ {% for item in selected_vacancy.detail_meta_items %} + {{ item }} + {% endfor %} +
+ {% endif %}
Decision sugerida
-
+
{{ selected_vacancy.decision_meta.label }}
@@ -70,7 +64,7 @@

-
+
{% if analysis %} {% if analysis.fortalezas_principales or analysis.riesgos_principales %}
@@ -153,15 +147,21 @@

Analisis pendiente

{% endif %} -
- +
+ Descripcion completa Texto original de la vacante -
-
+ +

{{ selected_vacancy.descripcion or "Sin descripcion disponible." }}

-
+
{% else %} diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index 038177c..ba06cd5 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -25,13 +25,17 @@
{{ item.cargo }}
- {{ item.modalidad }} - {{ item.fecha_registro.strftime("%d/%m/%Y") if item.fecha_registro else "-" }} + {% if item.modalidad_display %} + {{ item.modalidad_display }} + {% endif %} + {% if item.fecha_registro %} + {{ item.fecha_registro.strftime("%d/%m/%Y") }} + {% endif %} {% if item.score_meta.value is not none %} {{ item.affinity_meta.label }} {% else %} - Pendiente de analisis + Pendiente {% endif %} {{ item.status_meta.label }} @@ -39,14 +43,14 @@
- {{ item.decision_compact_label }} + {{ item.decision_compact_label or item.decision_meta.label or 'Pendiente' }} {% if item.score_meta.value is not none %} {{ item.score_meta.value }} diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 620e850..0f60a58 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -257,6 +257,8 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, ) self.assertIn('hx-target="#vacancies-shell"', response.text) self.assertIn('class="opportunity-recommendation tone-amber"', response.text) + self.assertIn("Si hay tiempo", response.text) + self.assertIn('title="Score 64">64', response.text) self.assertIn('title="Aplicar si sobra tiempo"', response.text) self.assertIn('title="Score 64"', response.text) self.assertNotIn('opportunity-recommendation-score" title="Descartar"', response.text) @@ -295,7 +297,8 @@ def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_b self.assertIn("Descripcion completa", response.text) self.assertIn("Texto original de la vacante", response.text) self.assertIn("Descripcion 25", response.text) - self.assertIn('class="description-disclosure vacancy-description"', response.text) + self.assertIn('class="description-disclosure vacancy-description vacancy-description-reader"', response.text) + self.assertIn('class="vacancy-description-body vacancy-description-content"', response.text) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application(self, mock_build_items): @@ -327,6 +330,43 @@ def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application( self.assertIn('href="/app/applications?selected=77"', response.text) self.assertNotIn("Ir a seguimiento", response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_vacancy_detail_partial_hides_null_metadata_values(self, mock_build_items): + mock_build_items.return_value = [ + { + "id": 31, + "empresa": "Empresa 31", + "cargo": "Cargo 31", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 31", + "link": None, + "analisis": { + "seniority_inferido": "null", + "salario_detectado": "", + "aspiracion_salarial_sugerida": None, + }, + "detail_meta_items": ["01/04/2026", "Remoto"], + "status_label": "Analizada", + "status_meta": {"tone": "green", "label": "Analizada"}, + "score_label": "70", + "score_meta": {"tone": "amber", "label": "70", "value": 70}, + "affinity_meta": {"tone": "amber", "label": "Media", "raw": "Media"}, + "decision_meta": {"tone": "amber", "label": "Revisar", "raw": "Revisar"}, + "decision_visual_tone": "amber", + "decision_compact_label": "Revisar", + "has_application": False, + } + ] + + response = self.client.get("/app/vacancies/31/detail") + + self.assertEqual(response.status_code, 200) + self.assertIn("01/04/2026", response.text) + self.assertIn("Remoto", response.text) + self.assertNotIn(">null<", response.text) + self.assertNotIn(">None<", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_build_items, _mock_metrics): @@ -350,6 +390,7 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu "ajustes_cv_recomendados": ["Destacar analitica"], "encaje_estrategico": "Alto", }, + "detail_meta_items": ["01/04/2026", "Remoto"], "status_label": "Analizada", "status_meta": {"tone": "green", "label": "Analizada"}, "score_label": "88", @@ -357,7 +398,7 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu "affinity_meta": {"tone": "green", "label": "Alta", "raw": "Alta"}, "decision_meta": {"tone": "green", "label": "Aplicar", "raw": "Aplicar"}, "decision_visual_tone": "green", - "decision_compact_label": "Aplicar", + "decision_compact_label": "Si", "has_application": False, } ] @@ -510,7 +551,11 @@ def test_compact_decision_label_uses_short_copy_for_long_recommendation(self): ) self.assertEqual( vacancies_routes._compact_decision_label({"decision_aplicacion": "Aplicar sí o sí"}), - "Aplicar sí o sí", + "Si", + ) + self.assertEqual( + vacancies_routes._compact_decision_label({"decision_aplicacion": "Descartar"}), + "No", ) def test_decision_visual_tone_prefers_score_tone_when_available(self): @@ -528,3 +573,9 @@ def test_decision_visual_tone_prefers_score_tone_when_available(self): ), "red", ) + + def test_clean_display_value_filters_garbage(self): + self.assertIsNone(vacancies_routes._clean_display_value(None)) + self.assertIsNone(vacancies_routes._clean_display_value("null")) + self.assertIsNone(vacancies_routes._clean_display_value(" ")) + self.assertEqual(vacancies_routes._clean_display_value("Remoto"), "Remoto") From 69ca22f2480a255e321d6e41cc22a2e1dea85b71 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sun, 24 May 2026 13:05:08 -0500 Subject: [PATCH 09/27] Fix vacancy detail description reveal --- app/interfaces/web/static/css/app.css | 33 +++++-------------- .../web/templates/vacancies/_detail.html | 14 +++----- tests/test_web_vacancies.py | 17 +++++++++- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index f26e3e8..25998f1 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -768,34 +768,18 @@ a { } .description-disclosure { - border: 1px solid var(--border); - border-radius: 14px; - background: #fbfcfe; padding: 0; - overflow: hidden; } .description-disclosure > summary { cursor: pointer; list-style: none; - padding: 14px 16px; - font-weight: 700; - background: #f8fbff; } .description-disclosure > summary::-webkit-details-marker { display: none; } -.description-disclosure[open] > summary { - border-bottom: 1px solid var(--border); -} - -.description-disclosure > p { - margin: 0; - padding: 16px; -} - /* Layer 7: Vacancies feature */ .workspace-toolbar { display: grid; @@ -1105,7 +1089,7 @@ a { display: grid; grid-template-rows: auto auto minmax(0, 1fr); padding: 0; - overflow: hidden; + min-height: 0; } .vacancy-card-header { @@ -1430,6 +1414,11 @@ a { background: transparent; } +.detail-sheet.decision-panel { + overflow-x: hidden; + overflow-y: auto; +} + .vacancy-description-summary { display: grid; gap: 2px; @@ -1459,7 +1448,7 @@ a { transition: transform 0.18s ease; } -.vacancy-description-reader:target .vacancy-description-summary::after { +.vacancy-description-reader[open] .vacancy-description-summary::after { transform: rotate(180deg); } @@ -1477,12 +1466,8 @@ a { padding: 0 18px 16px; } -.vacancy-description-reader:not(:target) .vacancy-description-content { - display: none; -} - -.vacancy-description-reader:target .vacancy-description-content { - display: block; +.vacancy-description-reader[open] .vacancy-description-summary { + border-bottom: 1px solid rgba(215, 223, 236, 0.9); } .vacancy-description-text { diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index ac19c9c..38298a3 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -147,21 +147,15 @@

Analisis pendiente

{% endif %} -
- +
+ Descripcion completa Texto original de la vacante - +

{{ selected_vacancy.descripcion or "Sin descripcion disponible." }}

-
+
{% else %} diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 0f60a58..b23debc 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -4,6 +4,7 @@ import unittest from datetime import date +from pathlib import Path from unittest.mock import patch from fastapi.testclient import TestClient @@ -16,6 +17,12 @@ class WebVacanciesTests(unittest.TestCase): def setUp(self): self.client = TestClient(api.app) + def test_vacancy_description_css_uses_details_instead_of_target_reveal(self): + css = Path("app/interfaces/web/static/css/app.css").read_text(encoding="utf-8") + + self.assertNotIn(".vacancy-description-reader:target", css) + self.assertIn(".vacancy-description-reader[open] .vacancy-description-summary::after", css) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) @patch("app.interfaces.web.routes.vacancies.analyze_vacancy_use_case") @@ -297,8 +304,13 @@ def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_b self.assertIn("Descripcion completa", response.text) self.assertIn("Texto original de la vacante", response.text) self.assertIn("Descripcion 25", response.text) - self.assertIn('class="description-disclosure vacancy-description vacancy-description-reader"', response.text) + self.assertIn('
', response.text) + self.assertIn('', response.text) self.assertIn('class="vacancy-description-body vacancy-description-content"', response.text) + self.assertNotIn("#vacancy-description-25", response.text) + self.assertNotIn('href="/app/vacancies?selected=25', response.text) + self.assertNotIn("modal", response.text.lower()) + self.assertNotIn("overlay", response.text.lower()) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application(self, mock_build_items): @@ -417,6 +429,9 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu self.assertNotIn('data-inline-detail="true"', response.text) self.assertIn("Descripcion completa", response.text) self.assertIn("Texto original de la vacante", response.text) + self.assertIn('
', response.text) + self.assertIn('', response.text) + self.assertNotIn("#vacancy-description-2", response.text) @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") From fd8c9fc9f0b88bff22eef9b3f20d68cf115f1bd9 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 07:53:44 -0500 Subject: [PATCH 10/27] Normalize vacancy decision signals --- app/interfaces/web/routes/vacancies.py | 200 ++++++++++++++---- .../web/templates/vacancies/_detail.html | 11 +- .../web/templates/vacancies/_list.html | 13 +- tests/test_web_vacancies.py | 132 ++++++++++-- 4 files changed, 280 insertions(+), 76 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 4e0c8f9..4585623 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date +import unicodedata from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -43,6 +44,21 @@ } INBOX_VIEWS = ["Todas", "Recientes", "Analizadas", "En seguimiento", "Sin analizar"] ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} +DECISION_TONE_MAP = { + "apply_strong": "green", + "apply_later": "amber", + "discard": "red", + "unknown": "gray", +} +COHERENCE_LABELS = { + "aligned": "Coherente", + "cautious_high_score": "Score alto con decision cauta", + "cautious_discard": "Score competitivo con descarte", + "optimistic_low_score": "Decision optimista con score bajo", + "missing_score": "Decision sin score", + "missing_decision": "Score sin decision", + "unknown_decision": "Decision desconocida", +} def _safe_score(value) -> float | None: @@ -52,20 +68,135 @@ def _safe_score(value) -> float | None: return None -def _score_meta(analysis: dict | None) -> dict: - if not analysis: - return {**ANALYSIS_TONE_DEFAULT, "value": None} +def _normalize_text(value: str | None) -> str: + if not value: + return "" + + collapsed = " ".join(str(value).strip().split()) + normalized = unicodedata.normalize("NFKD", collapsed) + return "".join(char for char in normalized if not unicodedata.combining(char)).lower() - score = _safe_score(analysis.get("score_total")) + +def _score_band(score: float | None) -> str: if score is None: - return {**ANALYSIS_TONE_DEFAULT, "value": None} + return "unknown" if score >= 80: - tone = "green" - elif score >= 60: - tone = "amber" + return "high" + if score >= 60: + return "medium" + return "low" + + +def _score_tone(score_band: str) -> str: + return { + "high": "green", + "medium": "amber", + "low": "red", + "unknown": "gray", + }.get(score_band, "gray") + + +def _normalize_decision(decision: str | None) -> tuple[str | None, str]: + cleaned = _clean_display_value(decision) + normalized = _normalize_text(cleaned) + if not cleaned: + return None, "unknown" + if "aplicar si sobra tiempo" in normalized: + return cleaned, "apply_later" + if "aplicar si o si" in normalized: + return cleaned, "apply_strong" + if any(keyword in normalized for keyword in ("no aplicar", "descartar", "rechazar")): + return cleaned, "discard" + if any(keyword in normalized for keyword in ("revis", "evalu", "consider")): + return cleaned, "apply_later" + if any(keyword in normalized for keyword in ("aplicar", "prior", "avanz")): + return cleaned, "apply_strong" + return cleaned, "unknown" + + +def _coherence_status(score_band: str, decision_normalized: str, *, has_decision: bool) -> str: + if not has_decision and score_band != "unknown": + return "missing_decision" + if has_decision and score_band == "unknown": + return "missing_score" + if decision_normalized == "unknown": + return "unknown_decision" + if decision_normalized == "apply_later" and score_band == "high": + return "cautious_high_score" + if decision_normalized == "discard" and score_band in {"high", "medium"}: + return "cautious_discard" + if decision_normalized == "apply_strong" and score_band == "low": + return "optimistic_low_score" + return "aligned" + + +def _build_decision_signal(analysis: dict | None) -> dict: + score = _safe_score(analysis.get("score_total")) if analysis else None + score_value = round(score) if score is not None else None + score_band = _score_band(score) + score_tone = _score_tone(score_band) + score_label = f"{score_value:.0f}" if score_value is not None else "-" + decision_raw, decision_normalized = _normalize_decision( + analysis.get("decision_aplicacion") if analysis else None + ) + + if decision_normalized == "apply_strong": + decision_label = decision_raw or "Aplicar si o si" + decision_compact_label = "Si" + elif decision_normalized == "apply_later": + decision_label = decision_raw or "Aplicar si sobra tiempo" + decision_compact_label = "Si hay tiempo" + elif decision_normalized == "discard": + decision_label = decision_raw or "Descartar" + decision_compact_label = "No" else: - tone = "red" - return {"tone": tone, "label": "Score", "value": round(score)} + if decision_raw: + decision_label = decision_raw + decision_compact_label = "Sin decision" + else: + decision_label = "Pendiente de analisis" if not analysis else "Sin decision" + decision_compact_label = "Pendiente" + + decision_tone = DECISION_TONE_MAP[decision_normalized] + coherence_status = _coherence_status( + score_band, + decision_normalized, + has_decision=bool(decision_raw), + ) + score_title = f"Score {score_label}" if score_value is not None else "Score pendiente" + title_parts = [decision_label] + if score_value is not None: + title_parts.append(f"Score {score_label}") + + return { + "score": score_value, + "score_label": score_label, + "score_band": score_band, + "score_tone": score_tone, + "decision_raw": decision_raw, + "decision_normalized": decision_normalized, + "decision_label": decision_label, + "decision_compact_label": decision_compact_label, + "decision_tone": decision_tone, + "display_tone": decision_tone, + "coherence_status": coherence_status, + "coherence_label": COHERENCE_LABELS[coherence_status], + "title": " · ".join(title_parts), + "aria_label": " · ".join(title_parts), + "score_title": score_title, + } + + +def _score_meta(analysis: dict | None) -> dict: + signal = _build_decision_signal(analysis) + if signal["score"] is None: + return {**ANALYSIS_TONE_DEFAULT, "value": None, "band": "unknown"} + return { + "tone": signal["score_tone"], + "label": "Score", + "value": signal["score"], + "band": signal["score_band"], + } def _keyword_tone(value: str | None, mapping: dict[str, str], default_label: str) -> dict: @@ -107,28 +238,16 @@ def _affinity_meta(analysis: dict | None) -> dict: def _decision_meta(analysis: dict | None) -> dict: - if not analysis: - return {"tone": "gray", "label": "-", "raw": None} - - decision = analysis.get("decision_aplicacion") - if not decision: - return {"tone": "gray", "label": "-", "raw": None} - - lowered = decision.strip().lower() - if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: - tone = "red" - elif "revis" in lowered or "evalu" in lowered or "consider" in lowered: - tone = "amber" - elif "aplicar" in lowered or "prior" in lowered or "avanz" in lowered: - tone = "green" - else: - tone = "gray" - return {"tone": tone, "label": decision.strip(), "raw": decision.strip()} + signal = _build_decision_signal(analysis) + return { + "tone": signal["decision_tone"], + "label": signal["decision_label"], + "raw": signal["decision_raw"], + "normalized": signal["decision_normalized"], + } def _decision_visual_tone(score_meta: dict, decision_meta: dict) -> str: - if score_meta.get("value") is not None: - return score_meta["tone"] return decision_meta["tone"] @@ -141,22 +260,7 @@ def _application_tracking_lookup() -> dict[int, int]: def _compact_decision_label(analysis: dict | None) -> str: - if not analysis: - return "Pendiente" - - decision = (analysis.get("decision_aplicacion") or "").strip() - lowered = decision.lower() - if not decision: - return "Pendiente" - if "aplicar sí o sí" in lowered or "aplicar si o si" in lowered: - return "Si" - if "aplicar si sobra tiempo" in lowered: - return "Si hay tiempo" - if "revis" in lowered or "evalu" in lowered: - return "Revisar" - if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: - return "No" - return decision + return _build_decision_signal(analysis)["decision_compact_label"] def _build_context_bar(summary: dict, metrics: list[dict]) -> list[dict]: @@ -200,6 +304,7 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: analysis = analyses_by_vacancy.get(vacancy["id"]) has_application = vacancy["id"] in tracked_vacancy_ids status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + decision_signal = _build_decision_signal(analysis) score_meta = _score_meta(analysis) affinity_meta = _affinity_meta(analysis) decision_meta = _decision_meta(analysis) @@ -215,8 +320,9 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: "score_meta": score_meta, "affinity_meta": affinity_meta, "decision_meta": decision_meta, - "decision_visual_tone": _decision_visual_tone(score_meta, decision_meta), - "decision_compact_label": _compact_decision_label(analysis), + "decision_signal": decision_signal, + "decision_visual_tone": decision_signal["display_tone"], + "decision_compact_label": decision_signal["decision_compact_label"], "has_application": has_application, "tracking_application_id": tracking_lookup.get(vacancy["id"]), } diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 38298a3..b88be19 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,5 +1,6 @@ {% if selected_vacancy %} {% set analysis = selected_vacancy.analisis %} + {% set signal = selected_vacancy.decision_signal %}
@@ -22,15 +23,15 @@

Decision sugerida
-
- {{ selected_vacancy.decision_meta.label }} +
+ {{ signal.decision_label }}
- Score {{ selected_vacancy.score_meta.value if selected_vacancy.score_meta.value is not none else "-" }} + Score {{ signal.score_label if signal.score is not none else "-" }} {{ selected_vacancy.affinity_meta.label }} {% if analysis and analysis.encaje_estrategico %} diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index ba06cd5..20d4587 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -5,6 +5,7 @@ {{ pagination.total_items }}
{% for item in vacancies %} + {% set signal = item.decision_signal %}
- {{ item.decision_compact_label or item.decision_meta.label or 'Pendiente' }} + {{ signal.decision_compact_label }} - {% if item.score_meta.value is not none %} - {{ item.score_meta.value }} + {% if signal.score is not none %} + {{ signal.score_label }} {% endif %}
diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index b23debc..1e3a7a8 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -13,6 +13,10 @@ from app.interfaces.web.routes import vacancies as vacancies_routes +def _mock_decision_signal(analysis: dict | None) -> dict: + return vacancies_routes._build_decision_signal(analysis) + + class WebVacanciesTests(unittest.TestCase): def setUp(self): self.client = TestClient(api.app) @@ -77,6 +81,7 @@ def test_inbox_shows_more_than_twenty_vacancies(self, mock_build_items, _mock_na "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": False, } for item_id in range(1, 26) @@ -114,6 +119,7 @@ def test_inbox_paginates_results(self, mock_build_items, _mock_nav, _mock_metric "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": False, } for item_id in range(1, 26) @@ -145,6 +151,7 @@ def test_inbox_supports_custom_page_size(self, mock_build_items, _mock_nav, _moc "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": False, } for item_id in range(1, 26) @@ -176,6 +183,7 @@ def test_inbox_moves_to_page_of_selected_vacancy(self, mock_build_items, _mock_n "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": False, } for item_id in range(1, 26) @@ -208,6 +216,7 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, "score_meta": {"tone": "red", "label": "43", "value": 43}, "affinity_meta": {"tone": "red", "label": "Baja", "raw": "Baja"}, "decision_meta": {"tone": "red", "label": "Descartar", "raw": "Descartar"}, + "decision_signal": _mock_decision_signal({"score_total": 43, "decision_aplicacion": "Descartar"}), "decision_visual_tone": "red", "decision_compact_label": "Descartar", "has_application": False, @@ -227,6 +236,9 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, "score_meta": {"tone": "amber", "label": "64", "value": 64}, "affinity_meta": {"tone": "amber", "label": "Media", "raw": "Media"}, "decision_meta": {"tone": "green", "label": "Aplicar si sobra tiempo", "raw": "Aplicar si sobra tiempo"}, + "decision_signal": _mock_decision_signal( + {"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"} + ), "decision_visual_tone": "amber", "decision_compact_label": "Si hay tiempo", "has_application": False, @@ -246,6 +258,7 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "decision_visual_tone": "gray", "decision_compact_label": "Pendiente de analisis", "has_application": False, @@ -266,7 +279,7 @@ def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, self.assertIn('class="opportunity-recommendation tone-amber"', response.text) self.assertIn("Si hay tiempo", response.text) self.assertIn('title="Score 64">64', response.text) - self.assertIn('title="Aplicar si sobra tiempo"', response.text) + self.assertIn('title="Aplicar si sobra tiempo · Score 64"', response.text) self.assertIn('title="Score 64"', response.text) self.assertNotIn('opportunity-recommendation-score" title="Descartar"', response.text) @@ -288,6 +301,7 @@ def test_vacancy_detail_partial_preserves_query_view_and_pagination(self, mock_b "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": False, } ] @@ -330,6 +344,7 @@ def test_vacancy_detail_partial_uses_view_tracking_cta_for_existing_application( "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "has_application": True, "tracking_application_id": 77, } @@ -365,6 +380,9 @@ def test_vacancy_detail_partial_hides_null_metadata_values(self, mock_build_item "score_meta": {"tone": "amber", "label": "70", "value": 70}, "affinity_meta": {"tone": "amber", "label": "Media", "raw": "Media"}, "decision_meta": {"tone": "amber", "label": "Revisar", "raw": "Revisar"}, + "decision_signal": _mock_decision_signal( + {"score_total": 70, "decision_aplicacion": "Revisar"} + ), "decision_visual_tone": "amber", "decision_compact_label": "Revisar", "has_application": False, @@ -409,6 +427,7 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu "score_meta": {"tone": "green", "label": "88", "value": 88}, "affinity_meta": {"tone": "green", "label": "Alta", "raw": "Alta"}, "decision_meta": {"tone": "green", "label": "Aplicar", "raw": "Aplicar"}, + "decision_signal": _mock_decision_signal({"score_total": 88, "decision_aplicacion": "Aplicar"}), "decision_visual_tone": "green", "decision_compact_label": "Si", "has_application": False, @@ -433,6 +452,46 @@ def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_bu self.assertIn('', response.text) self.assertNotIn("#vacancy-description-2", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_vacancy_detail_panel_uses_decision_tone_instead_of_score_tone(self, mock_build_items, _mock_metrics): + analysis = { + "score_total": 88, + "decision_aplicacion": "Aplicar si sobra tiempo", + "afinidad_general": "Alta", + "resumen_analisis": "Buen encaje, pero no prioritario", + } + mock_build_items.return_value = [ + { + "id": 8, + "empresa": "Empresa 8", + "cargo": "Cargo 8", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 8", + "link": None, + "analisis": analysis, + "detail_meta_items": ["01/04/2026", "Remoto"], + "status_label": "Analizada", + "status_meta": {"tone": "green", "label": "Analizada"}, + "score_label": "88", + "score_meta": {"tone": "green", "label": "88", "value": 88}, + "affinity_meta": {"tone": "green", "label": "Alta", "raw": "Alta"}, + "decision_meta": {"tone": "amber", "label": "Aplicar si sobra tiempo", "raw": "Aplicar si sobra tiempo"}, + "decision_signal": _mock_decision_signal(analysis), + "decision_visual_tone": "amber", + "decision_compact_label": "Si hay tiempo", + "has_application": False, + } + ] + + response = self.client.get("/app/vacancies/shell?selected=8") + + self.assertEqual(response.status_code, 200) + self.assertIn('class="decision-headline tone-amber"', response.text) + self.assertIn("Aplicar si sobra tiempo", response.text) + self.assertIn('class="analysis-pill opportunity-score-pill tone-green"', response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_build_items, _mock_metrics): @@ -452,6 +511,7 @@ def test_vacancies_index_hx_request_returns_workspace_shell_only(self, mock_buil "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), "decision_visual_tone": "gray", "decision_compact_label": "Pendiente de analisis", "has_application": False, @@ -550,6 +610,7 @@ def test_score_meta_maps_thresholds_to_expected_tones(self): self.assertEqual(vacancies_routes._score_meta({"score_total": 68})["tone"], "amber") self.assertEqual(vacancies_routes._score_meta({"score_total": 42})["tone"], "red") self.assertEqual(vacancies_routes._score_meta(None)["tone"], "gray") + self.assertEqual(vacancies_routes._score_meta(None)["band"], "unknown") def test_affinity_and_decision_meta_map_to_expected_tones(self): self.assertEqual(vacancies_routes._affinity_meta({"afinidad_general": "Alta"})["tone"], "green") @@ -559,7 +620,58 @@ def test_affinity_and_decision_meta_map_to_expected_tones(self): self.assertEqual(vacancies_routes._decision_meta({"decision_aplicacion": "Revisar mas a fondo"})["tone"], "amber") self.assertEqual(vacancies_routes._decision_meta({"decision_aplicacion": "No aplicar"})["tone"], "red") - def test_compact_decision_label_uses_short_copy_for_long_recommendation(self): + def test_decision_signal_maps_apply_strong_to_green(self): + signal = vacancies_routes._build_decision_signal( + {"score_total": 93, "decision_aplicacion": "Aplicar sí o sí"} + ) + + self.assertEqual(signal["decision_normalized"], "apply_strong") + self.assertEqual(signal["decision_compact_label"], "Si") + self.assertEqual(signal["display_tone"], "green") + self.assertEqual(signal["score_tone"], "green") + + def test_decision_signal_keeps_apply_later_amber_even_with_high_score(self): + signal = vacancies_routes._build_decision_signal( + {"score_total": 85, "decision_aplicacion": "Aplicar si sobra tiempo"} + ) + + self.assertEqual(signal["decision_compact_label"], "Si hay tiempo") + self.assertEqual(signal["decision_tone"], "amber") + self.assertEqual(signal["display_tone"], "amber") + self.assertEqual(signal["score_tone"], "green") + self.assertEqual(signal["coherence_status"], "cautious_high_score") + + def test_decision_signal_keeps_discard_red_even_with_medium_score(self): + signal = vacancies_routes._build_decision_signal( + {"score_total": 64, "decision_aplicacion": "Descartar"} + ) + + self.assertEqual(signal["decision_compact_label"], "No") + self.assertEqual(signal["display_tone"], "red") + self.assertEqual(signal["score_tone"], "amber") + self.assertEqual(signal["coherence_status"], "cautious_discard") + + def test_decision_signal_handles_missing_score_with_existing_decision(self): + signal = vacancies_routes._build_decision_signal( + {"decision_aplicacion": "Aplicar sí o sí"} + ) + + self.assertEqual(signal["display_tone"], "green") + self.assertEqual(signal["score_band"], "unknown") + self.assertEqual(signal["coherence_status"], "missing_score") + + def test_decision_signal_handles_unknown_decision_with_existing_score(self): + signal = vacancies_routes._build_decision_signal( + {"score_total": 77, "decision_aplicacion": "Tal vez luego"} + ) + + self.assertEqual(signal["decision_normalized"], "unknown") + self.assertEqual(signal["display_tone"], "gray") + self.assertEqual(signal["score_tone"], "amber") + self.assertEqual(signal["coherence_status"], "unknown_decision") + self.assertEqual(signal["decision_compact_label"], "Sin decision") + + def test_compact_decision_label_uses_normalized_contract(self): self.assertEqual( vacancies_routes._compact_decision_label({"decision_aplicacion": "Aplicar si sobra tiempo"}), "Si hay tiempo", @@ -573,22 +685,6 @@ def test_compact_decision_label_uses_short_copy_for_long_recommendation(self): "No", ) - def test_decision_visual_tone_prefers_score_tone_when_available(self): - self.assertEqual( - vacancies_routes._decision_visual_tone( - {"tone": "amber", "value": 64}, - {"tone": "green", "label": "Aplicar si sobra tiempo"}, - ), - "amber", - ) - self.assertEqual( - vacancies_routes._decision_visual_tone( - {"tone": "gray", "value": None}, - {"tone": "red", "label": "Descartar"}, - ), - "red", - ) - def test_clean_display_value_filters_garbage(self): self.assertIsNone(vacancies_routes._clean_display_value(None)) self.assertIsNone(vacancies_routes._clean_display_value("null")) From 8272a4e21c9b4b192482a42e8430b3f650b0606b Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 09:39:50 -0500 Subject: [PATCH 11/27] Show only pending vacancies in default inbox --- app/interfaces/web/routes/vacancies.py | 45 +++++--- tests/test_web_vacancies.py | 150 ++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 4585623..622fc75 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -24,6 +24,7 @@ router = APIRouter(tags=["web-vacancies"]) DEFAULT_PAGE_SIZE = 20 PAGE_SIZE_OPTIONS = (10, 20, 50) +DEFAULT_INBOX_VIEW = "Pendientes" vacancy_repository = VacancyRepository() application_repository = ApplicationRepository() @@ -42,7 +43,7 @@ "Analizada": {"tone": "green", "label": "Analizada"}, "Sin analizar": {"tone": "gray", "label": "Sin analizar"}, } -INBOX_VIEWS = ["Todas", "Recientes", "Analizadas", "En seguimiento", "Sin analizar"] +INBOX_VIEWS = [DEFAULT_INBOX_VIEW, "Todas", "Recientes", "Analizadas", "Sin analizar", "En seguimiento"] ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} DECISION_TONE_MAP = { "apply_strong": "green", @@ -330,6 +331,10 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: return items +def _is_pending_inbox_item(item: dict) -> bool: + return not item["has_application"] + + def _filter_vacancy_items(items: list[dict], *, q: str | None, view: str) -> list[dict]: if q: needle = q.strip().lower() @@ -339,14 +344,18 @@ def _filter_vacancy_items(items: list[dict], *, q: str | None, view: str) -> lis if needle in item["empresa"].lower() or needle in item["cargo"].lower() ] - if view == "Recientes": - items = items[:10] - elif view == "Analizadas": - items = [item for item in items if item["analisis"]] - elif view == "En seguimiento": + if view == "Todas": + return items + if view == "En seguimiento": items = [item for item in items if item["has_application"]] - elif view == "Sin analizar": - items = [item for item in items if not item["analisis"]] + else: + items = [item for item in items if _is_pending_inbox_item(item)] + if view == "Recientes": + items = items[:10] + elif view == "Analizadas": + items = [item for item in items if item["analisis"]] + elif view == "Sin analizar": + items = [item for item in items if not item["analisis"]] return items @@ -406,7 +415,7 @@ def _build_inbox_url( selected: int | None = None, flash: str | None = None, q: str | None = None, - view: str = "Todas", + view: str = DEFAULT_INBOX_VIEW, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ) -> str: @@ -417,7 +426,7 @@ def _build_inbox_url( params.append(f"flash={flash}") if q: params.append(f"q={q}") - if view and view != "Todas": + if view and view != DEFAULT_INBOX_VIEW: params.append(f"view={view}") if page != 1: params.append(f"page={page}") @@ -451,7 +460,7 @@ def _build_inbox_context( page_size: int = DEFAULT_PAGE_SIZE, metrics: list[dict] | None = None, ) -> dict: - normalized_view = view if view in INBOX_VIEWS else "Todas" + normalized_view = view if view in INBOX_VIEWS else DEFAULT_INBOX_VIEW normalized_page_size = _normalize_page_size(page_size) all_items = _build_vacancy_items(limit=None) filtered_items = _filter_vacancy_items(all_items, q=q, view=normalized_view) @@ -474,13 +483,13 @@ def _build_inbox_context( "summary": { "total": len(filtered_items), "analizadas": sum(1 for item in filtered_items if item["analisis"]), - "seguimiento": sum(1 for item in filtered_items if item["has_application"]), + "seguimiento": sum(1 for item in all_items if item["has_application"]), }, "context_bar": _build_context_bar( { "total": len(filtered_items), "analizadas": sum(1 for item in filtered_items if item["analisis"]), - "seguimiento": sum(1 for item in filtered_items if item["has_application"]), + "seguimiento": sum(1 for item in all_items if item["has_application"]), }, metrics or [], ), @@ -493,7 +502,7 @@ def vacancies_index( selected: int | None = None, flash: str | None = None, q: str | None = None, - view: str = "Todas", + view: str = DEFAULT_INBOX_VIEW, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): @@ -533,7 +542,7 @@ def vacancy_shell_partial( selected: int | None = None, flash: str | None = None, q: str | None = None, - view: str = "Todas", + view: str = DEFAULT_INBOX_VIEW, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): @@ -620,7 +629,7 @@ def vacancy_detail_partial(request: Request, vacancy_id: int): "request": request, "selected_vacancy": vacancy, "query": request.query_params.get("q", ""), - "current_view": request.query_params.get("view", "Todas"), + "current_view": request.query_params.get("view", DEFAULT_INBOX_VIEW), "pagination": { "page": int(request.query_params.get("page", "1")), "page_size": int(request.query_params.get("page_size", str(DEFAULT_PAGE_SIZE))), @@ -634,7 +643,7 @@ def vacancy_list_partial( request: Request, selected: int | None = None, q: str | None = None, - view: str = "Todas", + view: str = DEFAULT_INBOX_VIEW, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): @@ -678,7 +687,7 @@ def mark_vacancy_as_interesting(vacancy_id: int): def discard_vacancy( vacancy_id: int, q: str | None = Form(default=None), - view: str = Form(default="Todas"), + view: str = Form(default=DEFAULT_INBOX_VIEW), page: int = Form(default=1), page_size: int = Form(default=DEFAULT_PAGE_SIZE), ): diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 1e3a7a8..9dc042c 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -17,6 +17,44 @@ def _mock_decision_signal(analysis: dict | None) -> dict: return vacancies_routes._build_decision_signal(analysis) +def _vacancy_item( + item_id: int, + *, + company: str | None = None, + role: str | None = None, + analysis: dict | None = None, + has_application: bool = False, +) -> dict: + score_meta = vacancies_routes._score_meta(analysis) + affinity_meta = vacancies_routes._affinity_meta(analysis) + decision_meta = vacancies_routes._decision_meta(analysis) + decision_signal = vacancies_routes._build_decision_signal(analysis) + status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + return { + "id": item_id, + "empresa": company or f"Empresa {item_id}", + "cargo": role or f"Cargo {item_id}", + "modalidad": "Remoto", + "modalidad_display": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": f"Descripcion {item_id}", + "link": None, + "analisis": analysis, + "detail_meta_items": ["01/04/2026", "Remoto"], + "status_label": status_label, + "status_meta": vacancies_routes.STATUS_META[status_label], + "score_label": f"{score_meta['value']:.0f}" if score_meta["value"] is not None else "Sin analisis", + "score_meta": score_meta, + "affinity_meta": affinity_meta, + "decision_meta": decision_meta, + "decision_signal": decision_signal, + "decision_visual_tone": decision_signal["display_tone"], + "decision_compact_label": decision_signal["decision_compact_label"], + "has_application": has_application, + "tracking_application_id": 900 + item_id if has_application else None, + } + + class WebVacanciesTests(unittest.TestCase): def setUp(self): self.client = TestClient(api.app) @@ -198,6 +236,116 @@ def test_inbox_moves_to_page_of_selected_vacancy(self, mock_build_items, _mock_n self.assertIn('id="vacancy-detail"', response.text) self.assertNotIn('title="Cerrar"', response.text) + @patch( + "app.interfaces.web.routes.vacancies._build_metrics", + return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}], + ) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_defaults_to_pending_view_and_excludes_tracking_items( + self, + mock_build_items, + _mock_nav, + _mock_metrics, + ): + mock_build_items.return_value = [ + _vacancy_item(1, company="Pendiente 1", analysis={"score_total": 88, "decision_aplicacion": "Aplicar sí o sí"}), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + _vacancy_item(3, company="Pendiente 3", analysis=None), + ] + + response = self.client.get("/app/vacancies") + + self.assertEqual(response.status_code, 200) + self.assertIn('option value="Pendientes" selected', response.text) + self.assertIn("Pendiente 1", response.text) + self.assertIn("Pendiente 3", response.text) + self.assertNotIn("Tracking 2", response.text) + self.assertIn("Vacantes visibles", response.text) + self.assertIn("En seguimiento", response.text) + self.assertIn('2', response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_all_view_keeps_non_archived_tracking_items_visible(self, mock_build_items, _mock_nav, _mock_metrics): + mock_build_items.return_value = [ + _vacancy_item(1, company="Pendiente 1", analysis={"score_total": 88, "decision_aplicacion": "Aplicar sí o sí"}), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + ] + + response = self.client.get("/app/vacancies?view=Todas") + + self.assertEqual(response.status_code, 200) + self.assertIn("Pendiente 1", response.text) + self.assertIn("Tracking 2", response.text) + self.assertIn('option value="Todas" selected', response.text) + self.assertIn("En seguimiento", response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_pending_selected_tracking_item_falls_back_to_first_visible_pending( + self, + mock_build_items, + _mock_nav, + _mock_metrics, + ): + mock_build_items.return_value = [ + _vacancy_item(1, company="Pendiente 1", analysis={"score_total": 88, "decision_aplicacion": "Aplicar sí o sí"}), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + _vacancy_item(3, company="Pendiente 3", analysis=None), + ] + + response = self.client.get("/app/vacancies?selected=2") + + self.assertEqual(response.status_code, 200) + self.assertIn("Pendiente 1 - Cargo 1", response.text) + self.assertNotIn("Tracking 2", response.text) + self.assertIn('data-selected-row="true"', response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_pending_selected_unknown_item_falls_back_without_breaking( + self, + mock_build_items, + _mock_nav, + _mock_metrics, + ): + mock_build_items.return_value = [ + _vacancy_item(1, company="Pendiente 1", analysis={"score_total": 88, "decision_aplicacion": "Aplicar sí o sí"}), + _vacancy_item(3, company="Pendiente 3", analysis=None), + ] + + response = self.client.get("/app/vacancies?selected=999") + + self.assertEqual(response.status_code, 200) + self.assertIn("Pendiente 1 - Cargo 1", response.text) + self.assertIn('id="vacancy-detail"', response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_pending_pagination_clamps_when_filter_leaves_single_page( + self, + mock_build_items, + _mock_nav, + _mock_metrics, + ): + mock_build_items.return_value = [ + *[_vacancy_item(item_id, has_application=True, analysis={"score_total": 55, "decision_aplicacion": "Descartar"}) for item_id in range(1, 21)], + *[_vacancy_item(item_id, analysis=None) for item_id in range(21, 26)], + ] + + response = self.client.get("/app/vacancies?page=3&page_size=10") + + self.assertEqual(response.status_code, 200) + self.assertIn("Empresa 21", response.text) + self.assertIn("Empresa 25", response.text) + self.assertNotIn("Empresa 1", response.text) + self.assertIn("Empresa 21 - Cargo 21", response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, mock_build_items): mock_build_items.return_value = [ @@ -601,7 +749,7 @@ def test_discard_vacancy_redirects_back_to_inbox_with_next_selected( self.assertEqual(response.status_code, 303) self.assertEqual( response.headers["location"], - "/app/vacancies?selected=9&flash=vacancy_discarded&q=data&page=2&page_size=10", + "/app/vacancies?selected=9&flash=vacancy_discarded&q=data&view=Todas&page=2&page_size=10", ) mock_vacancy_repository.archive.assert_called_once_with(7, "Otro") From 2acee837a7e555f539455e88106a672e253cfdcb Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 10:21:32 -0500 Subject: [PATCH 12/27] Keep inbox flow after vacancy actions --- app/interfaces/web/routes/vacancies.py | 104 +++++++-- .../web/templates/vacancies/_detail.html | 6 +- tests/test_web_vacancies.py | 214 +++++++++++++++++- 3 files changed, 303 insertions(+), 21 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 622fc75..8a0fa30 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -39,11 +39,20 @@ register_application_use_case = RegisterApplicationUseCase(application_repository) STATUS_META = { + "Descartada": {"tone": "gray", "label": "Descartada"}, "En seguimiento": {"tone": "blue", "label": "En seguimiento"}, "Analizada": {"tone": "green", "label": "Analizada"}, "Sin analizar": {"tone": "gray", "label": "Sin analizar"}, } -INBOX_VIEWS = [DEFAULT_INBOX_VIEW, "Todas", "Recientes", "Analizadas", "Sin analizar", "En seguimiento"] +INBOX_VIEWS = [ + DEFAULT_INBOX_VIEW, + "Todas", + "Recientes", + "Analizadas", + "Sin analizar", + "En seguimiento", + "Descartadas", +] ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} DECISION_TONE_MAP = { "apply_strong": "green", @@ -291,9 +300,14 @@ def _detail_meta_items(vacancy: dict, analysis: dict | None) -> list[str]: return [value for value in values if value] -def _build_vacancy_items(limit: int | None = None) -> list[dict]: +def _uses_archived_universe(view: str) -> bool: + return view == "Descartadas" + + +def _build_vacancy_items(limit: int | None = None, *, include_archived: bool = False) -> list[dict]: vacancies = vacancy_repository.list_all() - vacancies = [item for item in vacancies if not item.get("motivo_archivo")] + if not include_archived: + vacancies = [item for item in vacancies if not item.get("motivo_archivo")] vacancies = sorted(vacancies, key=lambda item: item.get("fecha_registro") or "", reverse=True) visible_vacancies = vacancies[:limit] if limit else vacancies vacancy_ids = [item["id"] for item in visible_vacancies] @@ -304,7 +318,12 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: for vacancy in visible_vacancies: analysis = analyses_by_vacancy.get(vacancy["id"]) has_application = vacancy["id"] in tracked_vacancy_ids - status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + if vacancy.get("motivo_archivo"): + status_label = "Descartada" + elif has_application: + status_label = "En seguimiento" + else: + status_label = "Analizada" if analysis else "Sin analizar" decision_signal = _build_decision_signal(analysis) score_meta = _score_meta(analysis) affinity_meta = _affinity_meta(analysis) @@ -344,6 +363,11 @@ def _filter_vacancy_items(items: list[dict], *, q: str | None, view: str) -> lis if needle in item["empresa"].lower() or needle in item["cargo"].lower() ] + if view == "Descartadas": + return [item for item in items if item.get("motivo_archivo")] + + items = [item for item in items if not item.get("motivo_archivo")] + if view == "Todas": return items if view == "En seguimiento": @@ -391,8 +415,8 @@ def _paginate_items(items: list[dict], page: int, page_size: int) -> tuple[list[ } -def _selected_vacancy(selected_id: int | None) -> dict | None: - items = _build_vacancy_items(limit=None) +def _selected_vacancy(selected_id: int | None, *, include_archived: bool = False) -> dict | None: + items = _build_vacancy_items(limit=None, include_archived=include_archived) if not items: return None if selected_id is None: @@ -401,7 +425,11 @@ def _selected_vacancy(selected_id: int | None) -> dict | None: def _next_visible_vacancy_id(current_id: int, *, q: str | None, view: str) -> int | None: - items = _filter_vacancy_items(_build_vacancy_items(limit=None), q=q, view=view) + items = _filter_vacancy_items( + _build_vacancy_items(limit=None, include_archived=_uses_archived_universe(view)), + q=q, + view=view, + ) if not items: return None for item in items: @@ -442,6 +470,10 @@ def _flash_message(flash: str | None) -> tuple[str, str] | None: return ("info", "Vacante registrada. El analisis se omitio porque no hay perfil activo.") if flash == "vacancy_analysis_failed": return ("warning", "Vacante registrada, pero el analisis no pudo completarse.") + if flash == "interest_created": + return ("success", "Vacante enviada a Seguimiento. Continua con la siguiente oportunidad.") + if flash == "already_tracking": + return ("info", "La vacante ya estaba en Seguimiento.") if flash == "interest_error": return ("warning", "No se pudo enviar la vacante a Seguimiento.") if flash == "vacancy_discarded": @@ -462,8 +494,12 @@ def _build_inbox_context( ) -> dict: normalized_view = view if view in INBOX_VIEWS else DEFAULT_INBOX_VIEW normalized_page_size = _normalize_page_size(page_size) - all_items = _build_vacancy_items(limit=None) - filtered_items = _filter_vacancy_items(all_items, q=q, view=normalized_view) + source_items = _build_vacancy_items( + limit=None, + include_archived=_uses_archived_universe(normalized_view), + ) + active_items = [item for item in source_items if not item.get("motivo_archivo")] + filtered_items = _filter_vacancy_items(source_items, q=q, view=normalized_view) resolved_page = _resolve_page_for_selected(filtered_items, selected, normalized_page_size, page) items, pagination = _paginate_items(filtered_items, resolved_page, normalized_page_size) selected_vacancy = None @@ -483,13 +519,13 @@ def _build_inbox_context( "summary": { "total": len(filtered_items), "analizadas": sum(1 for item in filtered_items if item["analisis"]), - "seguimiento": sum(1 for item in all_items if item["has_application"]), + "seguimiento": sum(1 for item in active_items if item["has_application"]), }, "context_bar": _build_context_bar( { "total": len(filtered_items), "analizadas": sum(1 for item in filtered_items if item["analisis"]), - "seguimiento": sum(1 for item in all_items if item["has_application"]), + "seguimiento": sum(1 for item in active_items if item["has_application"]), }, metrics or [], ), @@ -621,7 +657,8 @@ def vacancy_create( @router.get("/app/vacancies/{vacancy_id}/detail", response_class=HTMLResponse) def vacancy_detail_partial(request: Request, vacancy_id: int): - vacancy = _selected_vacancy(vacancy_id) + current_view = request.query_params.get("view", DEFAULT_INBOX_VIEW) + vacancy = _selected_vacancy(vacancy_id, include_archived=_uses_archived_universe(current_view)) return templates.TemplateResponse( request=request, name="vacancies/_detail.html", @@ -629,7 +666,7 @@ def vacancy_detail_partial(request: Request, vacancy_id: int): "request": request, "selected_vacancy": vacancy, "query": request.query_params.get("q", ""), - "current_view": request.query_params.get("view", DEFAULT_INBOX_VIEW), + "current_view": current_view, "pagination": { "page": int(request.query_params.get("page", "1")), "page_size": int(request.query_params.get("page_size", str(DEFAULT_PAGE_SIZE))), @@ -656,12 +693,25 @@ def vacancy_list_partial( @router.post("/app/vacancies/{vacancy_id}/interest") -def mark_vacancy_as_interesting(vacancy_id: int): +def mark_vacancy_as_interesting( + vacancy_id: int, + q: str | None = Form(default=None), + view: str = Form(default=DEFAULT_INBOX_VIEW), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), +): existing = application_repository.list_by_vacancy(vacancy_id) if existing: - application_id = existing[0]["id"] + next_selected = _next_visible_vacancy_id(vacancy_id, q=q, view=view) return RedirectResponse( - url=f"/app/applications?selected={application_id}&flash=already_tracking", + url=_build_inbox_url( + selected=next_selected, + flash="already_tracking", + q=q, + view=view, + page=page, + page_size=page_size, + ), status_code=303, ) @@ -675,12 +725,30 @@ def mark_vacancy_as_interesting(vacancy_id: int): notas="Marcada desde Inbox web como vacante de interes.", ) if result["success"]: + next_selected = _next_visible_vacancy_id(vacancy_id, q=q, view=view) return RedirectResponse( - url=f"/app/applications?selected={result['id']}&flash=interest_created", + url=_build_inbox_url( + selected=next_selected, + flash="interest_created", + q=q, + view=view, + page=page, + page_size=page_size, + ), status_code=303, ) - return RedirectResponse(url="/app/vacancies?flash=interest_error", status_code=303) + return RedirectResponse( + url=_build_inbox_url( + selected=vacancy_id, + flash="interest_error", + q=q, + view=view, + page=page, + page_size=page_size, + ), + status_code=303, + ) @router.post("/app/vacancies/{vacancy_id}/discard") diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index b88be19..51ae893 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -40,8 +40,12 @@

- {% if not selected_vacancy.has_application %} + {% if not selected_vacancy.has_application and not selected_vacancy.motivo_archivo %}
+ + + +
diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 9dc042c..0bb0569 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -24,12 +24,16 @@ def _vacancy_item( role: str | None = None, analysis: dict | None = None, has_application: bool = False, + archived_reason: str | None = None, ) -> dict: score_meta = vacancies_routes._score_meta(analysis) affinity_meta = vacancies_routes._affinity_meta(analysis) decision_meta = vacancies_routes._decision_meta(analysis) decision_signal = vacancies_routes._build_decision_signal(analysis) - status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + if archived_reason: + status_label = "Descartada" + else: + status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") return { "id": item_id, "empresa": company or f"Empresa {item_id}", @@ -52,6 +56,7 @@ def _vacancy_item( "decision_compact_label": decision_signal["decision_compact_label"], "has_application": has_application, "tracking_application_id": 900 + item_id if has_application else None, + "motivo_archivo": archived_reason, } @@ -137,7 +142,7 @@ def test_inbox_shows_more_than_twenty_vacancies(self, mock_build_items, _mock_na self.assertIn('id="vacancies-shell"', response.text) self.assertIn('id="vacancy-detail"', response.text) self.assertIn("Empresa 1 - Cargo 1", response.text) - mock_build_items.assert_called_once_with(limit=None) + mock_build_items.assert_called_once_with(limit=None, include_archived=False) @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) @@ -282,6 +287,59 @@ def test_inbox_all_view_keeps_non_archived_tracking_items_visible(self, mock_bui self.assertIn('option value="Todas" selected', response.text) self.assertIn("En seguimiento", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_en_seguimiento_view_keeps_only_tracking_items(self, mock_build_items, _mock_nav, _mock_metrics): + mock_build_items.return_value = [ + _vacancy_item(1, company="Pendiente 1", analysis=None), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + _vacancy_item(3, company="Tracking 3", analysis={"score_total": 40, "decision_aplicacion": "Descartar"}, has_application=True), + ] + + response = self.client.get("/app/vacancies?view=En+seguimiento") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("Pendiente 1", response.text) + self.assertIn("Tracking 2", response.text) + self.assertIn("Tracking 3", response.text) + self.assertIn('option value="En seguimiento" selected', response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_descartadas_view_shows_archived_items(self, mock_build_items, _mock_nav, _mock_metrics): + mock_build_items.return_value = [ + _vacancy_item(1, company="Activa 1", analysis=None), + _vacancy_item(2, company="Descartada 2", analysis={"score_total": 43, "decision_aplicacion": "Descartar"}, archived_reason="Otro"), + _vacancy_item(3, company="Descartada 3", analysis=None, archived_reason="Salario no acorde"), + ] + + response = self.client.get("/app/vacancies?view=Descartadas") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("Activa 1", response.text) + self.assertIn("Descartada 2", response.text) + self.assertIn("Descartada 3", response.text) + self.assertIn('option value="Descartadas" selected', response.text) + + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_todas_view_keeps_archived_items_out(self, mock_build_items, _mock_nav, _mock_metrics): + mock_build_items.return_value = [ + _vacancy_item(1, company="Activa 1", analysis=None), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + _vacancy_item(3, company="Descartada 3", analysis=None, archived_reason="Otro"), + ] + + response = self.client.get("/app/vacancies?view=Todas") + + self.assertEqual(response.status_code, 200) + self.assertIn("Activa 1", response.text) + self.assertIn("Tracking 2", response.text) + self.assertNotIn("Descartada 3", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") @@ -346,6 +404,29 @@ def test_inbox_pending_pagination_clamps_when_filter_leaves_single_page( self.assertNotIn("Empresa 1", response.text) self.assertIn("Empresa 21 - Cargo 21", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_inbox_pending_empty_state_does_not_break_when_no_pending_items( + self, + mock_build_items, + _mock_nav, + _mock_metrics, + ): + mock_build_items.return_value = [ + _vacancy_item(1, company="Tracking 1", analysis=None, has_application=True), + _vacancy_item(2, company="Tracking 2", analysis={"score_total": 64, "decision_aplicacion": "Aplicar si sobra tiempo"}, has_application=True), + _vacancy_item(3, company="Descartada 3", analysis=None, archived_reason="Otro"), + ] + + response = self.client.get("/app/vacancies") + + self.assertEqual(response.status_code, 200) + self.assertIn("No hay vacantes que coincidan con los filtros actuales.", response.text) + self.assertIn("Sin detalle", response.text) + self.assertIn("Vacantes visibles", response.text) + self.assertIn('option value="Pendientes" selected', response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_list_partial_renders_workspace_rows_without_inline_detail(self, mock_build_items): mock_build_items.return_value = [ @@ -545,6 +626,41 @@ def test_vacancy_detail_partial_hides_null_metadata_values(self, mock_build_item self.assertNotIn(">null<", response.text) self.assertNotIn(">None<", response.text) + @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") + def test_vacancy_detail_partial_hides_interest_actions_for_archived_item(self, mock_build_items): + mock_build_items.return_value = [ + { + "id": 41, + "empresa": "Empresa 41", + "cargo": "Cargo 41", + "modalidad": "Remoto", + "fecha_registro": date(2026, 4, 1), + "descripcion": "Descripcion 41", + "link": None, + "motivo_archivo": "Otro", + "analisis": None, + "detail_meta_items": ["01/04/2026", "Remoto"], + "status_label": "Descartada", + "status_meta": {"tone": "gray", "label": "Descartada"}, + "score_label": "Sin analisis", + "score_meta": {"tone": "gray", "label": "Sin analisis", "value": None}, + "affinity_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_meta": {"tone": "gray", "label": "-", "raw": None}, + "decision_signal": _mock_decision_signal(None), + "decision_visual_tone": "gray", + "decision_compact_label": "Pendiente", + "has_application": False, + "tracking_application_id": None, + } + ] + + response = self.client.get("/app/vacancies/41/detail?view=Descartadas") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("Pasar a seguimiento", response.text) + self.assertNotIn("Descartar", response.text) + self.assertIn("Descripcion completa", response.text) + @patch("app.interfaces.web.routes.vacancies._build_metrics", return_value=[{"label": "Aplicaciones", "value": 9}, {"label": "Rechazadas", "value": 2}]) @patch("app.interfaces.web.routes.vacancies._build_vacancy_items") def test_vacancy_shell_partial_renders_workspace_with_detail_panel(self, mock_build_items, _mock_metrics): @@ -731,6 +847,28 @@ def test_build_vacancy_items_excludes_archived_items( self.assertEqual(result[0]["id"], 1) self.assertEqual(result[0]["status_label"], "Sin analizar") + @patch("app.interfaces.web.routes.vacancies.application_repository") + @patch("app.interfaces.web.routes.vacancies.analysis_repository") + @patch("app.interfaces.web.routes.vacancies.vacancy_repository") + def test_build_vacancy_items_can_include_archived_items_for_descartadas_view( + self, + mock_vacancy_repository, + mock_analysis_repository, + mock_application_repository, + ): + mock_vacancy_repository.list_all.return_value = [ + {"id": 1, "empresa": "A", "cargo": "Role A", "modalidad": "Remoto", "fecha_registro": date(2026, 4, 2), "motivo_archivo": None}, + {"id": 2, "empresa": "B", "cargo": "Role B", "modalidad": "Presencial", "fecha_registro": date(2026, 4, 1), "motivo_archivo": "Otro"}, + ] + mock_analysis_repository.get_by_vacancy_ids.return_value = {} + mock_application_repository.list_all.return_value = [] + + result = vacancies_routes._build_vacancy_items(include_archived=True) + + self.assertEqual(len(result), 2) + self.assertEqual(result[1]["id"], 2) + self.assertEqual(result[1]["status_label"], "Descartada") + @patch("app.interfaces.web.routes.vacancies._next_visible_vacancy_id", return_value=9) @patch("app.interfaces.web.routes.vacancies.vacancy_repository") def test_discard_vacancy_redirects_back_to_inbox_with_next_selected( @@ -753,6 +891,74 @@ def test_discard_vacancy_redirects_back_to_inbox_with_next_selected( ) mock_vacancy_repository.archive.assert_called_once_with(7, "Otro") + @patch("app.interfaces.web.routes.vacancies._next_visible_vacancy_id", return_value=9) + @patch("app.interfaces.web.routes.vacancies.register_application_use_case") + @patch("app.interfaces.web.routes.vacancies.application_repository") + def test_interest_vacancy_redirects_back_to_inbox_with_next_selected( + self, + mock_application_repository, + mock_register_application_use_case, + _mock_next_visible, + ): + mock_application_repository.list_by_vacancy.return_value = [] + mock_register_application_use_case.execute.return_value = {"success": True, "id": 101} + + response = self.client.post( + "/app/vacancies/7/interest", + data={"q": "data", "view": "Pendientes", "page": "2", "page_size": "10"}, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/vacancies?selected=9&flash=interest_created&q=data&page=2&page_size=10", + ) + mock_application_repository.list_by_vacancy.assert_called_once_with(7) + + @patch("app.interfaces.web.routes.vacancies._next_visible_vacancy_id", return_value=8) + @patch("app.interfaces.web.routes.vacancies.application_repository") + def test_interest_vacancy_existing_application_redirects_back_to_inbox( + self, + mock_application_repository, + _mock_next_visible, + ): + mock_application_repository.list_by_vacancy.return_value = [{"id": 77}] + + response = self.client.post( + "/app/vacancies/7/interest", + data={"q": "data", "view": "Pendientes", "page": "2", "page_size": "10"}, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/vacancies?selected=8&flash=already_tracking&q=data&page=2&page_size=10", + ) + + @patch("app.interfaces.web.routes.vacancies.register_application_use_case") + @patch("app.interfaces.web.routes.vacancies.application_repository") + def test_interest_vacancy_error_returns_to_inbox_with_same_selected( + self, + mock_application_repository, + mock_register_application_use_case, + ): + mock_application_repository.list_by_vacancy.return_value = [] + mock_register_application_use_case.execute.return_value = {"success": False, "id": None} + + response = self.client.post( + "/app/vacancies/7/interest", + data={"q": "data", "view": "Pendientes", "page": "2", "page_size": "10"}, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/vacancies?selected=7&flash=interest_error&q=data&page=2&page_size=10", + ) + def test_score_meta_maps_thresholds_to_expected_tones(self): self.assertEqual(vacancies_routes._score_meta({"score_total": 88})["tone"], "green") self.assertEqual(vacancies_routes._score_meta({"score_total": 68})["tone"], "amber") @@ -833,6 +1039,10 @@ def test_compact_decision_label_uses_normalized_contract(self): "No", ) + def test_inbox_views_include_descartadas(self): + self.assertIn("Descartadas", vacancies_routes.INBOX_VIEWS) + self.assertEqual(vacancies_routes.DEFAULT_INBOX_VIEW, "Pendientes") + def test_clean_display_value_filters_garbage(self): self.assertIsNone(vacancies_routes._clean_display_value(None)) self.assertIsNone(vacancies_routes._clean_display_value("null")) From 6a4fa057f4eb204e529a513a6c1676b60cdfea6e Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 11:16:41 -0500 Subject: [PATCH 13/27] Fix applications workspace operational contract --- app/interfaces/web/routes/applications.py | 110 +++++- app/interfaces/web/static/css/app.css | 14 +- .../web/templates/applications/_detail.html | 86 +++-- tests/test_web_applications.py | 360 ++++++++++++------ 4 files changed, 400 insertions(+), 170 deletions(-) diff --git a/app/interfaces/web/routes/applications.py b/app/interfaces/web/routes/applications.py index 0b7035e..831e79b 100644 --- a/app/interfaces/web/routes/applications.py +++ b/app/interfaces/web/routes/applications.py @@ -2,6 +2,8 @@ from __future__ import annotations +from urllib.parse import urlencode + from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -23,8 +25,10 @@ "Technical Test", "In Interview", "Open Offer", + "Done", "Rejected", ] +TERMINAL_STATES = ("Done", "Rejected") STATUS_LABELS = { "Pending": "Pendiente por aplicar", @@ -67,7 +71,13 @@ def _decorate_applications(applications: list[dict]) -> list[dict]: decorated = [] for item in applications: meta = STATUS_META.get(item["estado"], {"tone": "gray", "label": item["estado"]}) - decorated.append({**item, "status_meta": meta}) + decorated.append( + { + **item, + "status_meta": meta, + "is_terminal": item["estado"] in TERMINAL_STATES, + } + ) return decorated @@ -88,6 +98,32 @@ def _normalize_page_size(page_size: int) -> int: return page_size if page_size in PAGE_SIZE_OPTIONS else DEFAULT_PAGE_SIZE +def _build_applications_url( + *, + selected: int | None, + flash: str | None, + q: str | None, + state: str, + page: int, + page_size: int, +) -> str: + params: list[tuple[str, str | int]] = [] + if selected is not None: + params.append(("selected", selected)) + if flash: + params.append(("flash", flash)) + if q is not None: + params.append(("q", q)) + params.extend( + [ + ("state", state), + ("page", page), + ("page_size", page_size), + ] + ) + return "/app/applications?" + urlencode(params) + + def _resolve_page_for_selected(items: list[dict], selected: int | None, page_size: int, requested_page: int) -> int: if selected is None: return requested_page @@ -150,6 +186,7 @@ def _build_tracking_context( "selected_application": selected_application, "flash_message": _build_flash_message(flash), "follow_up_states": FOLLOW_UP_STATES, + "terminal_states": TERMINAL_STATES, "all_statuses": list(APPLICATION_STATUSES), "status_labels": STATUS_LABELS, "query": q or "", @@ -224,17 +261,36 @@ def update_application_status( target_state: str = Form(...), q: str | None = Form(default=None), state: str = Form(default="Todos"), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), ): result = application_repository.update_status(application_id, target_state) flash = "status_updated" if result["success"] else "application_error" if request.headers.get("HX-Request") == "true": - context = _build_tracking_context(selected=application_id, flash=flash, q=q, state=state) + context = _build_tracking_context( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ) return templates.TemplateResponse( request=request, name="applications/_shell.html", context={"request": request, **context}, ) - return RedirectResponse(url=f"/app/applications?selected={application_id}&flash={flash}", status_code=303) + return RedirectResponse( + url=_build_applications_url( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ), + status_code=303, + ) @router.post("/app/applications/{application_id}/update") @@ -248,6 +304,8 @@ def update_application_data( notas: str | None = Form(default=None), q: str | None = Form(default=None), state: str = Form(default="Todos"), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), ): result = application_repository.update( application_id, @@ -259,13 +317,30 @@ def update_application_data( ) flash = "application_updated" if result["success"] else "application_error" if request.headers.get("HX-Request") == "true": - context = _build_tracking_context(selected=application_id, flash=flash, q=q, state=state) + context = _build_tracking_context( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ) return templates.TemplateResponse( request=request, name="applications/_shell.html", context={"request": request, **context}, ) - return RedirectResponse(url=f"/app/applications?selected={application_id}&flash={flash}", status_code=303) + return RedirectResponse( + url=_build_applications_url( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ), + status_code=303, + ) @router.post("/app/applications/{application_id}/delete") @@ -274,16 +349,33 @@ def delete_application( application_id: int, q: str | None = Form(default=None), state: str = Form(default="Todos"), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), ): result = application_repository.delete(application_id) flash = "application_deleted" if result["success"] else "application_error" if request.headers.get("HX-Request") == "true": - context = _build_tracking_context(selected=None, flash=flash, q=q, state=state) + context = _build_tracking_context( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ) return templates.TemplateResponse( request=request, name="applications/_shell.html", context={"request": request, **context}, ) - if result["success"]: - return RedirectResponse(url="/app/applications?flash=application_deleted", status_code=303) - return RedirectResponse(url=f"/app/applications?selected={application_id}&flash=application_error", status_code=303) + return RedirectResponse( + url=_build_applications_url( + selected=application_id, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ), + status_code=303, + ) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 25998f1..414d8a0 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -371,7 +371,8 @@ a { .action-grid button, .danger-action, -.secondary-action { +.secondary-action, +.tertiary-action { min-height: 40px; padding: 0 14px; border: 0; @@ -388,6 +389,17 @@ a { color: #1d4ed8; } +.tertiary-action { + background: #fff; + color: var(--muted); + border: 1px solid var(--border); +} + +.tertiary-action:hover { + background: #f8fafc; + color: var(--text); +} + .danger-action { background: #fee2e2; color: #b91c1c; diff --git a/app/interfaces/web/templates/applications/_detail.html b/app/interfaces/web/templates/applications/_detail.html index faff64a..ed39b1f 100644 --- a/app/interfaces/web/templates/applications/_detail.html +++ b/app/interfaces/web/templates/applications/_detail.html @@ -32,46 +32,58 @@

Notas

Acciones de seguimiento

-
- {% if selected_application.estado == "Pending" %} - - - - - - - {% endif %} - {% if selected_application.estado in ["Pending", "Applied"] %} -
- - - - -
-
- - - - -
- {% endif %} - {% if selected_application.estado in ["Applied", "Technical Test", "In Interview"] %} -
- - - - -
- {% endif %} - {% if selected_application.estado != "Rejected" %} + {% if selected_application.estado in terminal_states %} +

Esta aplicacion ya esta en un estado terminal. Solo puedes ajustar sus datos si hace falta.

+ {% else %} +
+ {% if selected_application.estado == "Pending" %} +
+ + + + + + +
+ {% endif %} + {% if selected_application.estado in ["Pending", "Applied"] %} +
+ + + + + + +
+
+ + + + + + +
+ {% endif %} + {% if selected_application.estado in ["Applied", "Technical Test", "In Interview"] %} +
+ + + + + + +
+ {% endif %}
+ +
- {% endif %} -
+
+ {% endif %}
@@ -79,6 +91,8 @@

Editar datos

+ +
diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index 3ce179a..bd42e23 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -11,6 +11,30 @@ import api +def _application_item( + item_id: int, + *, + status: str = "Pending", + company: str | None = None, + role: str | None = None, +) -> dict: + return { + "id": item_id, + "vacante_id": item_id, + "empresa": company or f"ACME {item_id}", + "cargo": role or f"Role {item_id}", + "modalidad": "Remoto", + "link": "https://example.com", + "fecha_aplicacion": date(2026, 4, 1), + "estado": status, + "nombre_recruiter": None, + "email_recruiter": None, + "telefono_recruiter": None, + "notas": "", + "fecha_registro": date(2026, 4, 1), + } + + class WebApplicationsTests(unittest.TestCase): def setUp(self): self.client = TestClient(api.app) @@ -19,23 +43,9 @@ def setUp(self): @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_index_renders_tracking_page(self, mock_repository, _mock_nav, _mock_metrics): - mock_repository.list_all.return_value = [ - { - "id": 7, - "vacante_id": 3, - "empresa": "ACME", - "cargo": "Data Analyst", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "Pendiente", - "fecha_registro": date(2026, 4, 1), - } - ] + item = _application_item(7, company="ACME", role="Data Analyst") + item["notas"] = "Pendiente" + mock_repository.list_all.return_value = [item] response = self.client.get("/app/applications") @@ -47,56 +57,34 @@ def test_applications_index_renders_tracking_page(self, mock_repository, _mock_n @patch("app.interfaces.web.routes.applications.application_repository") def test_update_status_hx_returns_shell_with_flash(self, mock_repository): mock_repository.update_status.return_value = {"success": True} - mock_repository.list_all.return_value = [ - { - "id": 7, - "vacante_id": 3, - "empresa": "ACME", - "cargo": "Data Analyst", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Applied", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "Aplicada", - "fecha_registro": date(2026, 4, 1), - } - ] + items = [_application_item(item_id, status="Applied") for item_id in range(1, 26)] + items[14]["notas"] = "Aplicada" + mock_repository.list_all.return_value = items response = self.client.post( - "/app/applications/7/status", - data={"target_state": "Applied", "q": "", "state": "Todos"}, + "/app/applications/15/status", + data={ + "target_state": "Applied", + "q": "acme", + "state": "Applied", + "page": "2", + "page_size": "10", + }, headers={"HX-Request": "true"}, ) self.assertEqual(response.status_code, 200) self.assertIn("El estado de la aplicacion fue actualizado.", response.text) - mock_repository.update_status.assert_called_once_with(7, "Applied") + self.assertIn('value="2"', response.text) + self.assertIn('value="10"', response.text) + self.assertIn("/app/applications?selected=15&q=acme&state=Applied&page=2&page_size=10", response.text) + mock_repository.update_status.assert_called_once_with(15, "Applied") @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_index_paginates_results(self, mock_repository, _mock_nav, _mock_metrics): - mock_repository.list_all.return_value = [ - { - "id": item_id, - "vacante_id": item_id, - "empresa": f"ACME {item_id}", - "cargo": f"Role {item_id}", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "", - "fecha_registro": date(2026, 4, 1), - } - for item_id in range(1, 26) - ] + mock_repository.list_all.return_value = [_application_item(item_id) for item_id in range(1, 26)] response = self.client.get("/app/applications?page=2") @@ -110,24 +98,7 @@ def test_applications_index_paginates_results(self, mock_repository, _mock_nav, @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_index_supports_custom_page_size(self, mock_repository, _mock_nav, _mock_metrics): - mock_repository.list_all.return_value = [ - { - "id": item_id, - "vacante_id": item_id, - "empresa": f"ACME {item_id}", - "cargo": f"Role {item_id}", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "", - "fecha_registro": date(2026, 4, 1), - } - for item_id in range(1, 26) - ] + mock_repository.list_all.return_value = [_application_item(item_id) for item_id in range(1, 26)] response = self.client.get("/app/applications?page_size=10&page=3") @@ -141,24 +112,7 @@ def test_applications_index_supports_custom_page_size(self, mock_repository, _mo @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_index_moves_to_page_of_selected_application(self, mock_repository, _mock_nav, _mock_metrics): - mock_repository.list_all.return_value = [ - { - "id": item_id, - "vacante_id": item_id, - "empresa": f"ACME {item_id}", - "cargo": f"Role {item_id}", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "", - "fecha_registro": date(2026, 4, 1), - } - for item_id in range(1, 26) - ] + mock_repository.list_all.return_value = [_application_item(item_id) for item_id in range(1, 26)] response = self.client.get("/app/applications?selected=25&page=1&page_size=10") @@ -169,23 +123,9 @@ def test_applications_index_moves_to_page_of_selected_application(self, mock_rep @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_shell_uses_system_filter_button_and_preserves_shell_target(self, mock_repository): - mock_repository.list_all.return_value = [ - { - "id": 7, - "vacante_id": 3, - "empresa": "ACME", - "cargo": "Data Analyst", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "Pendiente", - "fecha_registro": date(2026, 4, 1), - } - ] + item = _application_item(7, company="ACME", role="Data Analyst") + item["notas"] = "Pendiente" + mock_repository.list_all.return_value = [item] response = self.client.get("/app/applications/shell?q=acme&state=Todos&page=1&page_size=20") @@ -197,23 +137,7 @@ def test_applications_shell_uses_system_filter_button_and_preserves_shell_target @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_detail_partial_preserves_state_query_and_pagination(self, mock_repository): - mock_repository.list_all.return_value = [ - { - "id": 25, - "vacante_id": 25, - "empresa": "ACME 25", - "cargo": "Role 25", - "modalidad": "Remoto", - "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), - "estado": "Pending", - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "", - "fecha_registro": date(2026, 4, 1), - } - ] + mock_repository.list_all.return_value = [_application_item(item_id) for item_id in range(1, 26)] response = self.client.get("/app/applications/25/detail?q=acme&state=Todos&page=3&page_size=10") @@ -221,3 +145,189 @@ def test_applications_detail_partial_preserves_state_query_and_pagination(self, self.assertIn("ACME 25 - Role 25", response.text) self.assertIn('value="acme"', response.text) self.assertIn('value="Todos"', response.text) + self.assertIn('value="3"', response.text) + self.assertIn('value="10"', response.text) + + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_index_includes_done_in_filters_and_treats_it_as_terminal( + self, + mock_repository, + _mock_nav, + _mock_metrics, + ): + mock_repository.list_all.return_value = [_application_item(7, status="Done")] + + response = self.client.get("/app/applications?state=Done") + + self.assertEqual(response.status_code, 200) + self.assertIn('option value="Done" selected', response.text) + self.assertIn("Cerrada", response.text) + self.assertIn("estado terminal", response.text) + self.assertNotIn("Ya aplique", response.text) + self.assertNotIn("Paso a prueba tecnica", response.text) + self.assertNotIn("Paso a entrevista", response.text) + self.assertNotIn("Recibi oferta", response.text) + self.assertNotIn("Marcar rechazada", response.text) + + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_rejected_application_is_terminal_and_delete_is_tertiary( + self, + mock_repository, + _mock_nav, + _mock_metrics, + ): + mock_repository.list_all.return_value = [_application_item(7, status="Rejected")] + + response = self.client.get("/app/applications?state=Rejected") + + self.assertEqual(response.status_code, 200) + self.assertIn("estado terminal", response.text) + self.assertIn('class="tertiary-action"', response.text) + self.assertNotIn("Marcar rechazada", response.text) + self.assertNotIn('class="danger-action"', response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_update_status_redirect_preserves_context_in_classic_flow(self, mock_repository): + mock_repository.update_status.return_value = {"success": True} + + response = self.client.post( + "/app/applications/7/status", + data={ + "target_state": "Done", + "q": "acme", + "state": "Applied", + "page": "2", + "page_size": "10", + }, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/applications?selected=7&flash=status_updated&q=acme&state=Applied&page=2&page_size=10", + ) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_update_application_redirect_preserves_context_in_classic_flow(self, mock_repository): + mock_repository.update.return_value = {"success": True} + + response = self.client.post( + "/app/applications/7/update", + data={ + "estado": "Done", + "nombre_recruiter": "Ada", + "email_recruiter": "", + "telefono_recruiter": "", + "notas": "Cerrar seguimiento", + "q": "acme", + "state": "Pending", + "page": "3", + "page_size": "50", + }, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/applications?selected=7&flash=application_updated&q=acme&state=Pending&page=3&page_size=50", + ) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_update_application_hx_keeps_current_application_selected(self, mock_repository): + mock_repository.update.return_value = {"success": True} + items = [_application_item(item_id, status="Pending") for item_id in range(1, 26)] + items[14]["notas"] = "Seguimiento actualizado" + mock_repository.list_all.return_value = items + + response = self.client.post( + "/app/applications/15/update", + data={ + "estado": "Pending", + "nombre_recruiter": "", + "email_recruiter": "", + "telefono_recruiter": "", + "notas": "Seguimiento actualizado", + "q": "acme", + "state": "Pending", + "page": "2", + "page_size": "10", + }, + headers={"HX-Request": "true"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("Los datos de la aplicacion fueron actualizados.", response.text) + self.assertIn("ACME 15 - Role 15", response.text) + self.assertIn("/app/applications?selected=15&q=acme&state=Pending&page=2&page_size=10", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_delete_application_redirect_preserves_context_in_classic_flow(self, mock_repository): + mock_repository.delete.return_value = {"success": True} + + response = self.client.post( + "/app/applications/7/delete", + data={ + "q": "acme", + "state": "Rejected", + "page": "2", + "page_size": "10", + }, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/app/applications?selected=7&flash=application_deleted&q=acme&state=Rejected&page=2&page_size=10", + ) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_delete_application_hx_reselects_first_visible_application(self, mock_repository): + mock_repository.delete.return_value = {"success": True} + mock_repository.list_all.return_value = [ + _application_item(8, status="Pending"), + _application_item(9, status="Pending"), + ] + + response = self.client.post( + "/app/applications/7/delete", + data={ + "q": "acme", + "state": "Pending", + "page": "1", + "page_size": "10", + }, + headers={"HX-Request": "true"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("La aplicacion fue eliminada.", response.text) + self.assertIn("ACME 8 - Role 8", response.text) + self.assertIn("/app/applications?selected=8&q=acme&state=Pending&page=1&page_size=10", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_delete_application_hx_falls_back_to_empty_state_when_list_is_empty(self, mock_repository): + mock_repository.delete.return_value = {"success": True} + mock_repository.list_all.return_value = [] + + response = self.client.post( + "/app/applications/7/delete", + data={ + "q": "acme", + "state": "Pending", + "page": "1", + "page_size": "10", + }, + headers={"HX-Request": "true"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("La aplicacion fue eliminada.", response.text) + self.assertIn("Sin coincidencias", response.text) + self.assertIn("Sin detalle", response.text) From 7eb570fa979f65a13e124811dc0db4bbeaa9f312 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 11:24:31 -0500 Subject: [PATCH 14/27] Refine applications follow-up detail panel --- app/interfaces/web/static/css/app.css | 140 ++++++++++ .../web/templates/applications/_detail.html | 240 ++++++++++++------ tests/test_web_applications.py | 34 ++- 3 files changed, 337 insertions(+), 77 deletions(-) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 414d8a0..fe4225b 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -435,6 +435,17 @@ a { font: inherit; } +.stack-form .secondary-action { + background: #e8f0fe; + color: #1d4ed8; + box-shadow: none; +} + +.stack-form .secondary-action:hover { + background: #dbeafe; + color: #1d4ed8; +} + /* Layer 6: Shared tables, rows, details and status states */ .simple-table { display: grid; @@ -1519,6 +1530,126 @@ a { align-self: start; } +.application-detail-sheet { + display: grid; + gap: 18px; +} + +.application-detail-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 18px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +.application-title-block { + min-width: 0; +} + +.application-title-block h2 { + margin: 4px 0 0; + font-size: 1.28rem; +} + +.application-role { + margin: 6px 0 0; + color: var(--muted); + font-size: 0.98rem; + line-height: 1.5; +} + +.application-header-meta { + display: grid; + gap: 8px; + justify-items: start; + min-width: 0; +} + +.application-header-meta .muted-note { + margin: 0; + max-width: 260px; + font-size: 0.9rem; + line-height: 1.5; +} + +.application-callout { + display: grid; + gap: 14px; + padding: 18px; + border-radius: 16px; + border: 1px solid #dbeafe; + background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); +} + +.application-section { + display: grid; + gap: 12px; +} + +.application-section-heading { + display: grid; + gap: 4px; +} + +.application-section-heading h3 { + margin: 0; + font-size: 1rem; +} + +.application-section-heading p, +.application-terminal-note { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.application-primary-actions, +.application-secondary-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.application-primary-button { + min-width: 180px; +} + +.application-info-grid, +.application-contact-grid { + margin-bottom: 0; +} + +.application-inline-link { + min-height: 36px; + padding: 0 12px; +} + +.application-notes-card, +.application-edit-form { + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 16px; + background: #fbfcfe; +} + +.application-notes-card p { + margin: 0; + line-height: 1.65; +} + +.application-edit-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.application-danger-zone { + padding-top: 6px; + border-top: 1px dashed var(--border); +} + /* Layer 9: Shared helpers and feedback */ .pill-group { display: flex; @@ -1901,6 +2032,14 @@ a { align-items: start; } + .application-detail-header { + flex-direction: column; + } + + .application-header-meta .muted-note { + max-width: none; + } + .kpi-strip { grid-template-columns: repeat(2, 1fr); } @@ -1911,6 +2050,7 @@ a { .filter-form, .layout-two-columns, .detail-grid, + .application-edit-grid, .profile-grid, .grid-two, .grid-three, diff --git a/app/interfaces/web/templates/applications/_detail.html b/app/interfaces/web/templates/applications/_detail.html index ed39b1f..97cf10e 100644 --- a/app/interfaces/web/templates/applications/_detail.html +++ b/app/interfaces/web/templates/applications/_detail.html @@ -1,79 +1,107 @@ {% if selected_application %} -
-

+ +
+
+

Accion principal

+ {% if current_status == "Pending" %} +

Confirma cuando ya enviaste la aplicacion y deja el resto de cambios para despues.

+ {% elif current_status == "Applied" %} +

Registra el siguiente avance visible del proceso para mantener el seguimiento al dia.

+ {% elif current_status == "Technical Test" %} +

La prueba tecnica ya quedo registrada. Actualiza el resultado cuando el proceso avance.

+ {% elif current_status == "In Interview" %} +

La entrevista ya esta en curso. Marca el siguiente hito cuando tengas una respuesta.

+ {% elif current_status == "Open Offer" %} +

La oferta ya esta registrada. Usa la edicion secundaria para cerrar o documentar el resultado.

+ {% else %} +

Esta aplicacion ya esta cerrada y no muestra acciones rapidas adicionales.

+ {% endif %} +
+ + {% if current_status in terminal_states %} +

No hay transiciones rapidas disponibles para este estado terminal.

{% else %} -
- {% if selected_application.estado == "Pending" %} +
+ {% if current_status == "Pending" %}
- +
- {% endif %} - {% if selected_application.estado in ["Pending", "Applied"] %} + {% elif current_status == "Applied" %}
- + +
+ {% elif current_status == "In Interview" %} +
+ + + + + +
+ {% endif %} +
+ +
+ {% if current_status in ["Pending", "Applied"] %} + {% if current_status != "Technical Test" and current_status != "Applied" %} +
+ + + + + + +
+ {% endif %}
- +
{% endif %} - {% if selected_application.estado in ["Applied", "Technical Test", "In Interview"] %} + + {% if current_status in ["Applied", "Technical Test"] %}
- +
{% endif %} +
@@ -84,45 +112,111 @@

Acciones de seguimiento

{% endif %} -
+
-
-

Editar datos

-
+
+
+

Informacion de la aplicacion

+

Referencia rapida para continuar el seguimiento sin abrir otra vista.

+
+
+
+
Fecha
+
{% if selected_application.fecha_aplicacion %}{{ selected_application.fecha_aplicacion.strftime("%d/%m/%Y") }}{% else %}-{% endif %}
+
+
+
Modalidad
+
{{ selected_application.modalidad }}
+
+
+
Link
+
+ {% if selected_application.link %} + Abrir vacante + {% else %} + Sin link disponible + {% endif %} +
+
+
+
+ +
+
+

Contacto

+

Datos del recruiter o del contacto que necesitas para retomar la conversacion.

+
+
+
+
Recruiter
+
{{ selected_application.nombre_recruiter or "Sin recruiter registrado" }}
+
+
+
Email
+
{{ selected_application.email_recruiter or "Sin email registrado" }}
+
+
+
Telefono
+
{{ selected_application.telefono_recruiter or "Sin telefono registrado" }}
+
+
+
+ +
+
+

Notas

+

Resumen libre del estado actual y de cualquier contexto importante del proceso.

+
+
+

{{ selected_application.notas or "Sin notas aun." }}

+
+
+ +
+
+

Editar seguimiento

+

Usa esta seccion para mantener contacto, notas y estado al dia sin quitar foco al panel operativo.

+
+ - - - - +
+ + + + +
- + -
+ -
-

Eliminar

+
+
+

Eliminar

+

Accion terciaria. Solo usala si el registro ya no debe permanecer en seguimiento.

+
@@ -130,7 +224,7 @@

Eliminar

-
+ {% else %}
diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index bd42e23..7e53bc9 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -119,7 +119,8 @@ def test_applications_index_moves_to_page_of_selected_application(self, mock_rep self.assertEqual(response.status_code, 200) self.assertIn("ACME 25", response.text) self.assertIn("Pagina 3 de 3", response.text) - self.assertIn("ACME 25 - Role 25", response.text) + self.assertIn('

ACME 25

', response.text) + self.assertIn('class="application-role">Role 25

', response.text) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_shell_uses_system_filter_button_and_preserves_shell_target(self, mock_repository): @@ -142,12 +143,34 @@ def test_applications_detail_partial_preserves_state_query_and_pagination(self, response = self.client.get("/app/applications/25/detail?q=acme&state=Todos&page=3&page_size=10") self.assertEqual(response.status_code, 200) - self.assertIn("ACME 25 - Role 25", response.text) + self.assertIn('

ACME 25

', response.text) + self.assertIn('class="application-role">Role 25

', response.text) self.assertIn('value="acme"', response.text) self.assertIn('value="Todos"', response.text) self.assertIn('value="3"', response.text) self.assertIn('value="10"', response.text) + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) + @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_panel_prioritizes_operational_sections(self, mock_repository, _mock_nav, _mock_metrics): + item = _application_item(7, company="ACME", role="Data Analyst") + item["nombre_recruiter"] = "Ada" + item["notas"] = "Pendiente de confirmar recruiter." + mock_repository.list_all.return_value = [item] + + response = self.client.get("/app/applications") + + self.assertEqual(response.status_code, 200) + self.assertIn("Accion principal", response.text) + self.assertIn("Informacion de la aplicacion", response.text) + self.assertIn("Contacto", response.text) + self.assertIn("Notas", response.text) + self.assertIn("Editar seguimiento", response.text) + self.assertIn('class="primary-action application-primary-button"', response.text) + self.assertIn('class="stack-form compact-form application-edit-form"', response.text) + self.assertLess(response.text.index("Accion principal"), response.text.index("Editar seguimiento")) + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") @@ -170,6 +193,7 @@ def test_applications_index_includes_done_in_filters_and_treats_it_as_terminal( self.assertNotIn("Paso a entrevista", response.text) self.assertNotIn("Recibi oferta", response.text) self.assertNotIn("Marcar rechazada", response.text) + self.assertIn("Editar seguimiento", response.text) @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @@ -263,7 +287,8 @@ def test_update_application_hx_keeps_current_application_selected(self, mock_rep self.assertEqual(response.status_code, 200) self.assertIn("Los datos de la aplicacion fueron actualizados.", response.text) - self.assertIn("ACME 15 - Role 15", response.text) + self.assertIn('

ACME 15

', response.text) + self.assertIn('class="application-role">Role 15

', response.text) self.assertIn("/app/applications?selected=15&q=acme&state=Pending&page=2&page_size=10", response.text) @patch("app.interfaces.web.routes.applications.application_repository") @@ -308,7 +333,8 @@ def test_delete_application_hx_reselects_first_visible_application(self, mock_re self.assertEqual(response.status_code, 200) self.assertIn("La aplicacion fue eliminada.", response.text) - self.assertIn("ACME 8 - Role 8", response.text) + self.assertIn('

ACME 8

', response.text) + self.assertIn('class="application-role">Role 8

', response.text) self.assertIn("/app/applications?selected=8&q=acme&state=Pending&page=1&page_size=10", response.text) @patch("app.interfaces.web.routes.applications.application_repository") From 1395e4692c9cb4e47cfba918c46cfe9bc14ee319 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 13:18:52 -0500 Subject: [PATCH 15/27] Refine applications follow-up list --- app/interfaces/web/static/css/app.css | 119 ++++++++++++++++++ .../web/templates/applications/_list.html | 66 ++++++---- tests/test_web_applications.py | 35 ++++++ 3 files changed, 194 insertions(+), 26 deletions(-) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index fe4225b..21754eb 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -1530,6 +1530,111 @@ a { align-self: start; } +.application-rail { + display: grid; + gap: 14px; +} + +.application-rail-group { + display: grid; + gap: 8px; +} + +.application-group-heading { + padding: 0 2px; +} + +.application-rail-list { + display: grid; + gap: 8px; +} + +.application-rail-row { + display: block; + padding: 12px 14px; + border-left: 4px solid transparent; + transition: 0.2s ease; +} + +.application-rail-row.state-gray { + border-left-color: #9ca3af; +} + +.application-rail-row.state-blue { + border-left-color: #60a5fa; +} + +.application-rail-row.state-green { + border-left-color: #4ade80; +} + +.application-rail-row.state-red { + border-left-color: #f87171; +} + +.application-rail-row.state-amber { + border-left-color: #f59e0b; +} + +.application-rail-row.state-violet { + border-left-color: #8b5cf6; +} + +.application-rail-main { + display: grid; + gap: 5px; + min-width: 0; +} + +.application-rail-topline { + display: flex; + align-items: start; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.application-rail-company { + min-width: 0; + font-size: 0.96rem; + font-weight: 700; + line-height: 1.3; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.application-rail-role { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.32; + color: #354158; +} + +.application-rail-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-width: 0; +} + +.application-rail-date-label, +.application-rail-id { + color: #778196; + font-size: 0.74rem; + font-weight: 600; +} + +.application-rail-id { + margin-left: auto; +} + .application-detail-sheet { display: grid; gap: 18px; @@ -2040,6 +2145,20 @@ a { max-width: none; } + .application-rail-topline { + flex-direction: column; + align-items: start; + } + + .application-rail-company, + .application-rail-id { + white-space: normal; + } + + .application-rail-id { + margin-left: 0; + } + .kpi-strip { grid-template-columns: repeat(2, 1fr); } diff --git a/app/interfaces/web/templates/applications/_list.html b/app/interfaces/web/templates/applications/_list.html index a6e507c..1f0f1d3 100644 --- a/app/interfaces/web/templates/applications/_list.html +++ b/app/interfaces/web/templates/applications/_list.html @@ -1,30 +1,44 @@ {% if applications %} -
-
-
ID
-
Empresa
-
Cargo
-
Estado
-
Fecha
-
- {% for item in applications %} - -
#{{ item.id }}
-
-
{{ item.empresa }}
-
{{ item.modalidad }}
-
-
{{ item.cargo }}
-
{{ item.status_meta.label }}
-
{% if item.fecha_aplicacion %}{{ item.fecha_aplicacion.strftime("%d/%m/%Y") }}{% else %}-{% endif %}
-
+ {% set grouped_states = [current_state] if current_state != "Todos" else follow_up_states %} +
+ {% for status in grouped_states %} + {% set status_items = applications | selectattr("estado", "equalto", status) | list %} + {% if status_items %} +
+ + +
+ {% endif %} {% endfor %}
{% set pagination_prev_href = "/app/applications?q=%s&state=%s&page=%s&page_size=%s"|format(query, current_state, pagination.prev_page, pagination.page_size) %} diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index 7e53bc9..e3db796 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -136,6 +136,41 @@ def test_applications_shell_uses_system_filter_button_and_preserves_shell_target self.assertIn('hx-swap="outerHTML"', response.text) self.assertIn('hx-push-url="true"', response.text) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_list_groups_current_page_by_status_and_keeps_selection_links(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME Pending", role="Role Pending"), + _application_item(2, status="Applied", company="ACME Applied", role="Role Applied"), + _application_item(3, status="Rejected", company="ACME Rejected", role="Role Rejected"), + ] + + response = self.client.get("/app/applications/shell?selected=2&q=acme&state=Todos&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertIn('class="application-rail"', response.text) + self.assertIn('class="list-section-label application-group-heading"', response.text) + self.assertIn("ACME Pending", response.text) + self.assertIn("ACME Applied", response.text) + self.assertIn("ACME Rejected", response.text) + self.assertIn('class="application-rail-id">#2', response.text) + self.assertIn("/app/applications?selected=2&q=acme&state=Todos&page=1&page_size=20", response.text) + self.assertNotIn(">ID<", response.text) + self.assertNotIn(">Empresa<", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_list_filtered_state_renders_single_group(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME Pending", role="Role Pending"), + _application_item(2, status="Applied", company="ACME Applied", role="Role Applied"), + ] + + response = self.client.get("/app/applications/shell?state=Applied&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertIn("ACME Applied", response.text) + self.assertNotIn("ACME Pending", response.text) + self.assertEqual(response.text.count('class="list-section-label application-group-heading"'), 1) + @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_detail_partial_preserves_state_query_and_pagination(self, mock_repository): mock_repository.list_all.return_value = [_application_item(item_id) for item_id in range(1, 26)] From b2399f2f4a2dc2b91eb242f4b88e9f41584fd33d Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 14:21:05 -0500 Subject: [PATCH 16/27] Show original vacancy decision in applications --- app/interfaces/web/presentation/__init__.py | 1 + .../web/presentation/decision_signal.py | 162 ++++++++++++++++++ app/interfaces/web/routes/applications.py | 19 ++ app/interfaces/web/routes/vacancies.py | 145 +--------------- app/interfaces/web/static/css/app.css | 51 ++++++ .../web/templates/applications/_detail.html | 72 ++++++++ tests/test_web_applications.py | 123 +++++++++++++ tests/test_web_vacancies.py | 11 ++ 8 files changed, 440 insertions(+), 144 deletions(-) create mode 100644 app/interfaces/web/presentation/__init__.py create mode 100644 app/interfaces/web/presentation/decision_signal.py diff --git a/app/interfaces/web/presentation/__init__.py b/app/interfaces/web/presentation/__init__.py new file mode 100644 index 0000000..f7b2a9c --- /dev/null +++ b/app/interfaces/web/presentation/__init__.py @@ -0,0 +1 @@ +"""Presentation helpers for the web UI.""" diff --git a/app/interfaces/web/presentation/decision_signal.py b/app/interfaces/web/presentation/decision_signal.py new file mode 100644 index 0000000..6b3c65f --- /dev/null +++ b/app/interfaces/web/presentation/decision_signal.py @@ -0,0 +1,162 @@ +"""Shared decision presentation helpers for vacancy analysis.""" + +from __future__ import annotations + +import unicodedata + +DECISION_TONE_MAP = { + "apply_strong": "green", + "apply_later": "amber", + "discard": "red", + "unknown": "gray", +} + +COHERENCE_LABELS = { + "aligned": "Coherente", + "cautious_high_score": "Score alto con decision cauta", + "cautious_discard": "Score competitivo con descarte", + "optimistic_low_score": "Decision optimista con score bajo", + "missing_score": "Decision sin score", + "missing_decision": "Score sin decision", + "unknown_decision": "Decision desconocida", +} + + +def _safe_score(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _normalize_text(value: str | None) -> str: + if not value: + return "" + + collapsed = " ".join(str(value).strip().split()) + normalized = unicodedata.normalize("NFKD", collapsed) + return "".join(char for char in normalized if not unicodedata.combining(char)).lower() + + +def _score_band(score: float | None) -> str: + if score is None: + return "unknown" + if score >= 80: + return "high" + if score >= 60: + return "medium" + return "low" + + +def _score_tone(score_band: str) -> str: + return { + "high": "green", + "medium": "amber", + "low": "red", + "unknown": "gray", + }.get(score_band, "gray") + + +def _normalize_decision(decision: str | None) -> tuple[str | None, str]: + cleaned = str(decision).strip() if decision is not None else "" + if not cleaned or cleaned.lower() in {"null", "none", "n/a", "na", "-"}: + return None, "unknown" + + normalized = _normalize_text(cleaned) + if "aplicar si sobra tiempo" in normalized: + return cleaned, "apply_later" + if "aplicar si o si" in normalized: + return cleaned, "apply_strong" + if any(keyword in normalized for keyword in ("no aplicar", "descartar", "rechazar")): + return cleaned, "discard" + if any(keyword in normalized for keyword in ("revis", "evalu", "consider")): + return cleaned, "apply_later" + if any(keyword in normalized for keyword in ("aplicar", "prior", "avanz")): + return cleaned, "apply_strong" + return cleaned, "unknown" + + +def _coherence_status(score_band: str, decision_normalized: str, *, has_decision: bool) -> str: + if not has_decision and score_band != "unknown": + return "missing_decision" + if has_decision and score_band == "unknown": + return "missing_score" + if decision_normalized == "unknown": + return "unknown_decision" + if decision_normalized == "apply_later" and score_band == "high": + return "cautious_high_score" + if decision_normalized == "discard" and score_band in {"high", "medium"}: + return "cautious_discard" + if decision_normalized == "apply_strong" and score_band == "low": + return "optimistic_low_score" + return "aligned" + + +def _build_decision_signal(analysis: dict | None) -> dict: + score = _safe_score(analysis.get("score_total")) if analysis else None + score_value = round(score) if score is not None else None + score_band = _score_band(score) + score_tone = _score_tone(score_band) + score_label = f"{score_value:.0f}" if score_value is not None else "-" + decision_raw, decision_normalized = _normalize_decision( + analysis.get("decision_aplicacion") if analysis else None + ) + + if decision_normalized == "apply_strong": + decision_label = decision_raw or "Aplicar si o si" + decision_compact_label = "Si" + elif decision_normalized == "apply_later": + decision_label = decision_raw or "Aplicar si sobra tiempo" + decision_compact_label = "Si hay tiempo" + elif decision_normalized == "discard": + decision_label = decision_raw or "Descartar" + decision_compact_label = "No" + else: + if decision_raw: + decision_label = decision_raw + decision_compact_label = "Sin decision" + else: + decision_label = "Pendiente de analisis" if not analysis else "Sin decision" + decision_compact_label = "Pendiente" + + decision_tone = DECISION_TONE_MAP[decision_normalized] + coherence_status = _coherence_status( + score_band, + decision_normalized, + has_decision=bool(decision_raw), + ) + score_title = f"Score {score_label}" if score_value is not None else "Score pendiente" + title_parts = [decision_label] + if score_value is not None: + title_parts.append(f"Score {score_label}") + + return { + "score": score_value, + "score_label": score_label, + "score_band": score_band, + "score_tone": score_tone, + "decision_raw": decision_raw, + "decision_normalized": decision_normalized, + "decision_label": decision_label, + "decision_compact_label": decision_compact_label, + "decision_tone": decision_tone, + "display_tone": decision_tone, + "coherence_status": coherence_status, + "coherence_label": COHERENCE_LABELS[coherence_status], + "title": " · ".join(title_parts), + "aria_label": " · ".join(title_parts), + "score_title": score_title, + } + + +__all__ = [ + "COHERENCE_LABELS", + "DECISION_TONE_MAP", + "_build_decision_signal", + "_coherence_status", + "_normalize_decision", + "_normalize_text", + "_safe_score", + "_score_band", + "_score_tone", +] diff --git a/app/interfaces/web/routes/applications.py b/app/interfaces/web/routes/applications.py index 831e79b..3503c5e 100644 --- a/app/interfaces/web/routes/applications.py +++ b/app/interfaces/web/routes/applications.py @@ -11,6 +11,8 @@ from app.infrastructure.persistence.repositories.application_repository import ( ApplicationRepository, ) +from app.infrastructure.persistence.repositories.analysis_repository import AnalysisRepository +from app.interfaces.web.presentation.decision_signal import _build_decision_signal from app.interfaces.web.routes.dashboard import _build_metrics, _build_nav from app.interfaces.web.templates import templates @@ -19,6 +21,7 @@ PAGE_SIZE_OPTIONS = (10, 20, 50) application_repository = ApplicationRepository() +analysis_repository = AnalysisRepository() FOLLOW_UP_STATES = [ "Pending", "Applied", @@ -173,6 +176,22 @@ def _build_tracking_context( applications = _filter_applications(applications, q=q, state=normalized_state) resolved_page = _resolve_page_for_selected(applications, selected, normalized_page_size, page) paged_applications, pagination = _paginate_items(applications, resolved_page, normalized_page_size) + visible_vacancy_ids = list( + dict.fromkeys( + item["vacante_id"] + for item in paged_applications + if item.get("vacante_id") is not None + ) + ) + analyses_by_vacancy = analysis_repository.get_by_vacancy_ids(visible_vacancy_ids) + paged_applications = [ + { + **item, + "original_analysis": analyses_by_vacancy.get(item.get("vacante_id")), + "decision_signal": _build_decision_signal(analyses_by_vacancy.get(item.get("vacante_id"))), + } + for item in paged_applications + ] selected_application = None if paged_applications: diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 8a0fa30..9630a61 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import date -import unicodedata from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -18,6 +17,7 @@ from app.infrastructure.persistence.repositories.analysis_repository import AnalysisRepository from app.infrastructure.persistence.repositories.profile_repository import ProfileRepository from app.infrastructure.persistence.repositories.vacancy_repository import VacancyRepository +from app.interfaces.web.presentation.decision_signal import _build_decision_signal from app.interfaces.web.routes.dashboard import _build_metrics, _build_nav from app.interfaces.web.templates import templates @@ -54,149 +54,6 @@ "Descartadas", ] ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} -DECISION_TONE_MAP = { - "apply_strong": "green", - "apply_later": "amber", - "discard": "red", - "unknown": "gray", -} -COHERENCE_LABELS = { - "aligned": "Coherente", - "cautious_high_score": "Score alto con decision cauta", - "cautious_discard": "Score competitivo con descarte", - "optimistic_low_score": "Decision optimista con score bajo", - "missing_score": "Decision sin score", - "missing_decision": "Score sin decision", - "unknown_decision": "Decision desconocida", -} - - -def _safe_score(value) -> float | None: - try: - return float(value) if value is not None else None - except (TypeError, ValueError): - return None - - -def _normalize_text(value: str | None) -> str: - if not value: - return "" - - collapsed = " ".join(str(value).strip().split()) - normalized = unicodedata.normalize("NFKD", collapsed) - return "".join(char for char in normalized if not unicodedata.combining(char)).lower() - - -def _score_band(score: float | None) -> str: - if score is None: - return "unknown" - if score >= 80: - return "high" - if score >= 60: - return "medium" - return "low" - - -def _score_tone(score_band: str) -> str: - return { - "high": "green", - "medium": "amber", - "low": "red", - "unknown": "gray", - }.get(score_band, "gray") - - -def _normalize_decision(decision: str | None) -> tuple[str | None, str]: - cleaned = _clean_display_value(decision) - normalized = _normalize_text(cleaned) - if not cleaned: - return None, "unknown" - if "aplicar si sobra tiempo" in normalized: - return cleaned, "apply_later" - if "aplicar si o si" in normalized: - return cleaned, "apply_strong" - if any(keyword in normalized for keyword in ("no aplicar", "descartar", "rechazar")): - return cleaned, "discard" - if any(keyword in normalized for keyword in ("revis", "evalu", "consider")): - return cleaned, "apply_later" - if any(keyword in normalized for keyword in ("aplicar", "prior", "avanz")): - return cleaned, "apply_strong" - return cleaned, "unknown" - - -def _coherence_status(score_band: str, decision_normalized: str, *, has_decision: bool) -> str: - if not has_decision and score_band != "unknown": - return "missing_decision" - if has_decision and score_band == "unknown": - return "missing_score" - if decision_normalized == "unknown": - return "unknown_decision" - if decision_normalized == "apply_later" and score_band == "high": - return "cautious_high_score" - if decision_normalized == "discard" and score_band in {"high", "medium"}: - return "cautious_discard" - if decision_normalized == "apply_strong" and score_band == "low": - return "optimistic_low_score" - return "aligned" - - -def _build_decision_signal(analysis: dict | None) -> dict: - score = _safe_score(analysis.get("score_total")) if analysis else None - score_value = round(score) if score is not None else None - score_band = _score_band(score) - score_tone = _score_tone(score_band) - score_label = f"{score_value:.0f}" if score_value is not None else "-" - decision_raw, decision_normalized = _normalize_decision( - analysis.get("decision_aplicacion") if analysis else None - ) - - if decision_normalized == "apply_strong": - decision_label = decision_raw or "Aplicar si o si" - decision_compact_label = "Si" - elif decision_normalized == "apply_later": - decision_label = decision_raw or "Aplicar si sobra tiempo" - decision_compact_label = "Si hay tiempo" - elif decision_normalized == "discard": - decision_label = decision_raw or "Descartar" - decision_compact_label = "No" - else: - if decision_raw: - decision_label = decision_raw - decision_compact_label = "Sin decision" - else: - decision_label = "Pendiente de analisis" if not analysis else "Sin decision" - decision_compact_label = "Pendiente" - - decision_tone = DECISION_TONE_MAP[decision_normalized] - coherence_status = _coherence_status( - score_band, - decision_normalized, - has_decision=bool(decision_raw), - ) - score_title = f"Score {score_label}" if score_value is not None else "Score pendiente" - title_parts = [decision_label] - if score_value is not None: - title_parts.append(f"Score {score_label}") - - return { - "score": score_value, - "score_label": score_label, - "score_band": score_band, - "score_tone": score_tone, - "decision_raw": decision_raw, - "decision_normalized": decision_normalized, - "decision_label": decision_label, - "decision_compact_label": decision_compact_label, - "decision_tone": decision_tone, - "display_tone": decision_tone, - "coherence_status": coherence_status, - "coherence_label": COHERENCE_LABELS[coherence_status], - "title": " · ".join(title_parts), - "aria_label": " · ".join(title_parts), - "score_title": score_title, - } - - def _score_meta(analysis: dict | None) -> dict: signal = _build_decision_signal(analysis) if signal["score"] is None: diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 21754eb..92c9df6 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -1688,6 +1688,49 @@ a { background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); } +.application-origin-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid #dbeafe; + border-radius: 16px; + background: linear-gradient(180deg, #fcfdff 0%, #f4f8ff 100%); +} + +.application-origin-empty { + gap: 8px; +} + +.application-origin-empty .muted-note { + margin: 0; +} + +.application-origin-hero { + display: flex; + justify-content: space-between; + align-items: start; + gap: 14px; +} + +.application-origin-copy { + min-width: 0; +} + +.application-origin-pills { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.application-origin-summary { + margin: 0; +} + +.application-origin-evidence { + gap: 12px; +} + .application-section { display: grid; gap: 12px; @@ -2141,6 +2184,14 @@ a { flex-direction: column; } + .application-origin-hero { + flex-direction: column; + } + + .application-origin-pills { + justify-content: flex-start; + } + .application-header-meta .muted-note { max-width: none; } diff --git a/app/interfaces/web/templates/applications/_detail.html b/app/interfaces/web/templates/applications/_detail.html index 97cf10e..42f35fe 100644 --- a/app/interfaces/web/templates/applications/_detail.html +++ b/app/interfaces/web/templates/applications/_detail.html @@ -1,5 +1,7 @@ {% if selected_application %} {% set current_status = selected_application.estado %} + {% set original_analysis = selected_application.original_analysis %} + {% set original_decision = selected_application.decision_signal %}
@@ -17,6 +19,76 @@

{{ selected_application.empresa }}

+
+
+

Decision original

+

Resumen del analisis inicial que explico por que esta vacante entro a seguimiento.

+
+ + {% if original_analysis %} +
+
+
+
Decision sugerida
+
+ {{ original_decision.decision_label }} +
+
+
+ + Score {{ original_decision.score_label if original_decision.score is not none else "-" }} + + {{ original_decision.decision_compact_label }} +
+
+ + {% if original_analysis.justificacion_decision or original_analysis.resumen_analisis %} +
+

Justificacion

+

+ {{ original_analysis.justificacion_decision or original_analysis.resumen_analisis }} +

+
+ {% endif %} + + {% if original_analysis.fortalezas_principales or original_analysis.riesgos_principales %} +
+ {% if original_analysis.fortalezas_principales %} +
+
Fortalezas
+
    + {% for item in original_analysis.fortalezas_principales[:4] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} + {% if original_analysis.riesgos_principales %} +
+
Riesgos
+
    + {% for item in original_analysis.riesgos_principales[:4] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} +
+ {% else %} +
+
Analisis original
+

+ Esta vacante no tiene analisis original disponible todavia. +

+
+ {% endif %} +
+

Accion principal

diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index e3db796..67b2bbf 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -11,6 +11,26 @@ import api +def _analysis_item( + *, + score_total=88, + decision_aplicacion="Aplicar si sobra tiempo", + justificacion_decision: str | None = None, + resumen_analisis: str | None = None, + fortalezas_principales: list[str] | None = None, + riesgos_principales: list[str] | None = None, +) -> dict: + analysis = { + "score_total": score_total, + "decision_aplicacion": decision_aplicacion, + "justificacion_decision": justificacion_decision, + "resumen_analisis": resumen_analisis, + "fortalezas_principales": fortalezas_principales or [], + "riesgos_principales": riesgos_principales or [], + } + return analysis + + def _application_item( item_id: int, *, @@ -38,6 +58,10 @@ def _application_item( class WebApplicationsTests(unittest.TestCase): def setUp(self): self.client = TestClient(api.app) + self.analysis_repository_patcher = patch("app.interfaces.web.routes.applications.analysis_repository") + self.mock_analysis_repository = self.analysis_repository_patcher.start() + self.mock_analysis_repository.get_by_vacancy_ids.return_value = {} + self.addCleanup(self.analysis_repository_patcher.stop) @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @@ -197,6 +221,7 @@ def test_applications_detail_panel_prioritizes_operational_sections(self, mock_r response = self.client.get("/app/applications") self.assertEqual(response.status_code, 200) + self.assertIn("Decision original", response.text) self.assertIn("Accion principal", response.text) self.assertIn("Informacion de la aplicacion", response.text) self.assertIn("Contacto", response.text) @@ -206,6 +231,104 @@ def test_applications_detail_panel_prioritizes_operational_sections(self, mock_r self.assertIn('class="stack-form compact-form application-edit-form"', response.text) self.assertLess(response.text.index("Accion principal"), response.text.index("Editar seguimiento")) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_batches_visible_vacancy_analysis_queries(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, company="ACME 1"), + _application_item(2, company="ACME 2"), + ] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = { + 1: _analysis_item(score_total=93, decision_aplicacion="Aplicar si o si"), + 2: _analysis_item(score_total=64, decision_aplicacion="Aplicar si sobra tiempo"), + } + + response = self.client.get("/app/applications?page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.mock_analysis_repository.get_by_vacancy_ids.assert_called_once_with([1, 2]) + self.mock_analysis_repository.get_by_vacancy_id.assert_not_called() + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_renders_original_decision_and_score(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = { + 7: _analysis_item( + score_total=88, + decision_aplicacion="Aplicar si sobra tiempo", + justificacion_decision="Buen match tecnico y alcance realista.", + ) + } + + response = self.client.get("/app/applications?selected=7") + + self.assertEqual(response.status_code, 200) + self.assertIn("Decision original", response.text) + self.assertIn("Aplicar si sobra tiempo", response.text) + self.assertIn("Score 88", response.text) + self.assertIn("Buen match tecnico y alcance realista.", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_uses_resumen_when_justification_is_missing(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = { + 7: _analysis_item( + score_total=74, + decision_aplicacion="Aplicar", + resumen_analisis="La vacante encaja con experiencia reciente.", + ) + } + + response = self.client.get("/app/applications?selected=7") + + self.assertEqual(response.status_code, 200) + self.assertIn("La vacante encaja con experiencia reciente.", response.text) + self.assertIn("Justificacion", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_renders_original_strengths_and_risks(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = { + 7: _analysis_item( + score_total=81, + decision_aplicacion="Aplicar", + fortalezas_principales=["SQL fuerte", "Python aplicado"], + riesgos_principales=["ETL no profundo"], + ) + } + + response = self.client.get("/app/applications?selected=7") + + self.assertEqual(response.status_code, 200) + self.assertIn("Fortalezas", response.text) + self.assertIn("SQL fuerte", response.text) + self.assertIn("Riesgos", response.text) + self.assertIn("ETL no profundo", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_shows_clean_fallback_when_analysis_is_missing(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = {} + + response = self.client.get("/app/applications?selected=7") + + self.assertEqual(response.status_code, 200) + self.assertIn("Analisis original", response.text) + self.assertIn("no tiene analisis original disponible", response.text) + self.assertNotIn("Score 88", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_detail_handles_missing_score_or_decision_without_breaking(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + self.mock_analysis_repository.get_by_vacancy_ids.return_value = { + 7: _analysis_item(score_total=None, decision_aplicacion=None) + } + + response = self.client.get("/app/applications?selected=7") + + self.assertEqual(response.status_code, 200) + self.assertIn("Sin decision", response.text) + self.assertIn("Score -", response.text) + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") diff --git a/tests/test_web_vacancies.py b/tests/test_web_vacancies.py index 0bb0569..1e956e7 100644 --- a/tests/test_web_vacancies.py +++ b/tests/test_web_vacancies.py @@ -10,6 +10,9 @@ from fastapi.testclient import TestClient import api +from app.interfaces.web.presentation.decision_signal import ( + _build_decision_signal as build_shared_decision_signal, +) from app.interfaces.web.routes import vacancies as vacancies_routes @@ -984,6 +987,14 @@ def test_decision_signal_maps_apply_strong_to_green(self): self.assertEqual(signal["display_tone"], "green") self.assertEqual(signal["score_tone"], "green") + def test_inbox_route_reuses_shared_decision_signal_contract(self): + analysis = {"score_total": 88, "decision_aplicacion": "Aplicar si sobra tiempo"} + + self.assertEqual( + vacancies_routes._build_decision_signal(analysis), + build_shared_decision_signal(analysis), + ) + def test_decision_signal_keeps_apply_later_amber_even_with_high_score(self): signal = vacancies_routes._build_decision_signal( {"score_total": 85, "decision_aplicacion": "Aplicar si sobra tiempo"} From 046d882b0e3b7697f70344c4e2cbd06dcb7ec859 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 14:39:49 -0500 Subject: [PATCH 17/27] Add conservative application follow-up signals --- .../web/presentation/application_signals.py | 115 ++++++++++++++++++ app/interfaces/web/routes/applications.py | 12 ++ app/interfaces/web/static/css/app.css | 23 ++++ .../web/templates/applications/_detail.html | 16 +++ .../web/templates/applications/_list.html | 7 ++ tests/test_web_applications.py | 115 +++++++++++++++++- 6 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 app/interfaces/web/presentation/application_signals.py diff --git a/app/interfaces/web/presentation/application_signals.py b/app/interfaces/web/presentation/application_signals.py new file mode 100644 index 0000000..cb7182c --- /dev/null +++ b/app/interfaces/web/presentation/application_signals.py @@ -0,0 +1,115 @@ +"""Presentation helpers for conservative application follow-up signals.""" + +from __future__ import annotations + +from datetime import date, datetime + +TERMINAL_STATES = {"Done", "Rejected"} +AGE_SIGNAL_RULES = { + "Pending": (7, "Lleva tiempo pendiente"), + "Applied": (21, "Lleva tiempo aplicada"), + "Technical Test": (14, "Lleva tiempo en prueba tecnica"), + "In Interview": (14, "Lleva tiempo en entrevista"), + "Open Offer": (14, "Lleva tiempo en oferta"), +} + + +def _as_date(value) -> date | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date() + return value + + +def _reference_date(application: dict) -> date | None: + return _as_date(application.get("fecha_aplicacion")) or _as_date(application.get("fecha_registro")) + + +def _age_days(application: dict, *, today: date) -> int | None: + reference_date = _reference_date(application) + if reference_date is None: + return None + return max((today - reference_date).days, 0) + + +def _has_notes(application: dict) -> bool: + return bool((application.get("notas") or "").strip()) + + +def _has_contact(application: dict) -> bool: + return any( + (application.get(field) or "").strip() + for field in ("nombre_recruiter", "email_recruiter", "telefono_recruiter") + ) + + +def build_follow_up_signals(application: dict, *, today: date | None = None) -> list[dict]: + current_status = application.get("estado") + today_value = today or date.today() + + if current_status in TERMINAL_STATES: + return [ + { + "kind": "terminal", + "label": "Terminal", + "tone": "gray", + "compact": False, + } + ] + + signals: list[dict] = [] + age_days = _age_days(application, today=today_value) + age_rule = AGE_SIGNAL_RULES.get(current_status) + if age_rule and age_days is not None and age_days >= age_rule[0]: + signals.append( + { + "kind": "age", + "label": age_rule[1], + "tone": "amber", + "compact": True, + } + ) + + if current_status == "Pending": + signals.append( + { + "kind": "status", + "label": "Pendiente por aplicar", + "tone": "blue", + "compact": True, + } + ) + + if not _has_notes(application): + signals.append( + { + "kind": "missing", + "label": "Sin notas", + "tone": "gray", + "compact": True, + } + ) + + if not _has_contact(application): + signals.append( + { + "kind": "missing", + "label": "Sin contacto", + "tone": "gray", + "compact": True, + } + ) + + return signals + + +def pick_compact_follow_up_signal(signals: list[dict]) -> dict | None: + return next((signal for signal in signals if signal.get("compact")), None) + + +__all__ = [ + "TERMINAL_STATES", + "build_follow_up_signals", + "pick_compact_follow_up_signal", +] diff --git a/app/interfaces/web/routes/applications.py b/app/interfaces/web/routes/applications.py index 3503c5e..4fa8fdd 100644 --- a/app/interfaces/web/routes/applications.py +++ b/app/interfaces/web/routes/applications.py @@ -12,6 +12,10 @@ ApplicationRepository, ) from app.infrastructure.persistence.repositories.analysis_repository import AnalysisRepository +from app.interfaces.web.presentation.application_signals import ( + build_follow_up_signals, + pick_compact_follow_up_signal, +) from app.interfaces.web.presentation.decision_signal import _build_decision_signal from app.interfaces.web.routes.dashboard import _build_metrics, _build_nav from app.interfaces.web.templates import templates @@ -189,6 +193,14 @@ def _build_tracking_context( **item, "original_analysis": analyses_by_vacancy.get(item.get("vacante_id")), "decision_signal": _build_decision_signal(analyses_by_vacancy.get(item.get("vacante_id"))), + "follow_up_signals": build_follow_up_signals(item), + } + for item in paged_applications + ] + paged_applications = [ + { + **item, + "compact_follow_up_signal": pick_compact_follow_up_signal(item["follow_up_signals"]), } for item in paged_applications ] diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 92c9df6..9b47ecf 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -1616,6 +1616,16 @@ a { color: #354158; } +.application-rail-signal { + display: flex; + align-items: center; +} + +.application-rail-signal .status-badge { + padding: 4px 9px; + font-size: 0.73rem; +} + .application-rail-meta { display: flex; flex-wrap: wrap; @@ -1731,6 +1741,19 @@ a { gap: 12px; } +.application-signals-card { + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 16px; + background: #fbfcfe; +} + +.application-signal-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .application-section { display: grid; gap: 12px; diff --git a/app/interfaces/web/templates/applications/_detail.html b/app/interfaces/web/templates/applications/_detail.html index 42f35fe..78d1bb4 100644 --- a/app/interfaces/web/templates/applications/_detail.html +++ b/app/interfaces/web/templates/applications/_detail.html @@ -89,6 +89,22 @@

Justificacion

{% endif %}
+ {% if selected_application.follow_up_signals %} +
+
+

Senales de seguimiento

+

Indicadores descriptivos basados solo en el estado y los datos ya cargados en esta aplicacion.

+
+
+
+ {% for signal in selected_application.follow_up_signals %} + {{ signal.label }} + {% endfor %} +
+
+
+ {% endif %} +

Accion principal

diff --git a/app/interfaces/web/templates/applications/_list.html b/app/interfaces/web/templates/applications/_list.html index 1f0f1d3..1f3c7f9 100644 --- a/app/interfaces/web/templates/applications/_list.html +++ b/app/interfaces/web/templates/applications/_list.html @@ -25,6 +25,13 @@ {{ item.status_meta.label }}
{{ item.cargo }}
+ {% if item.compact_follow_up_signal %} +
+ + {{ item.compact_follow_up_signal.label }} + +
+ {% endif %}
{% if item.fecha_aplicacion %}Aplicada{% else %}Registrada{% endif %} {% if item.fecha_aplicacion %}{{ item.fecha_aplicacion.strftime("%d/%m/%Y") }}{% elif item.fecha_registro %}{{ item.fecha_registro.strftime("%d/%m/%Y") }}{% else %}-{% endif %} diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index 67b2bbf..b7475c6 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -9,6 +9,10 @@ from fastapi.testclient import TestClient import api +from app.interfaces.web.presentation.application_signals import ( + build_follow_up_signals, + pick_compact_follow_up_signal, +) def _analysis_item( @@ -37,6 +41,12 @@ def _application_item( status: str = "Pending", company: str | None = None, role: str | None = None, + notes: str = "", + recruiter: str | None = None, + email: str | None = None, + phone: str | None = None, + application_date: date | None = None, + registered_date: date | None = None, ) -> dict: return { "id": item_id, @@ -45,13 +55,13 @@ def _application_item( "cargo": role or f"Role {item_id}", "modalidad": "Remoto", "link": "https://example.com", - "fecha_aplicacion": date(2026, 4, 1), + "fecha_aplicacion": application_date or date(2026, 4, 1), "estado": status, - "nombre_recruiter": None, - "email_recruiter": None, - "telefono_recruiter": None, - "notas": "", - "fecha_registro": date(2026, 4, 1), + "nombre_recruiter": recruiter, + "email_recruiter": email, + "telefono_recruiter": phone, + "notas": notes, + "fecha_registro": registered_date or date(2026, 4, 1), } @@ -63,6 +73,70 @@ def setUp(self): self.mock_analysis_repository.get_by_vacancy_ids.return_value = {} self.addCleanup(self.analysis_repository_patcher.stop) + def test_pending_signal_includes_pending_por_aplicar_copy(self): + signals = build_follow_up_signals( + _application_item(1, status="Pending", application_date=date(2026, 5, 24)), + today=date(2026, 5, 25), + ) + + self.assertEqual(signals[0]["label"], "Pendiente por aplicar") + self.assertNotIn("Aplicada hace", " ".join(signal["label"] for signal in signals)) + + def test_old_pending_signal_prioritizes_lleva_tiempo_pendiente(self): + signals = build_follow_up_signals( + _application_item(1, status="Pending", application_date=date(2026, 5, 10)), + today=date(2026, 5, 25), + ) + + self.assertEqual(signals[0]["label"], "Lleva tiempo pendiente") + self.assertEqual(pick_compact_follow_up_signal(signals)["label"], "Lleva tiempo pendiente") + + def test_old_applied_signal_uses_applied_threshold(self): + signals = build_follow_up_signals( + _application_item(1, status="Applied", application_date=date(2026, 5, 1)), + today=date(2026, 5, 25), + ) + + self.assertEqual(signals[0]["label"], "Lleva tiempo aplicada") + + def test_technical_interview_and_offer_signals_use_expected_thresholds(self): + technical = build_follow_up_signals( + _application_item(1, status="Technical Test", application_date=date(2026, 5, 10)), + today=date(2026, 5, 25), + ) + interview = build_follow_up_signals( + _application_item(2, status="In Interview", application_date=date(2026, 5, 10)), + today=date(2026, 5, 25), + ) + offer = build_follow_up_signals( + _application_item(3, status="Open Offer", application_date=date(2026, 5, 10)), + today=date(2026, 5, 25), + ) + + self.assertEqual(technical[0]["label"], "Lleva tiempo en prueba tecnica") + self.assertEqual(interview[0]["label"], "Lleva tiempo en entrevista") + self.assertEqual(offer[0]["label"], "Lleva tiempo en oferta") + + def test_missing_notes_and_contact_are_detected(self): + signals = build_follow_up_signals( + _application_item(1, status="Applied", notes=" "), + today=date(2026, 5, 25), + ) + + labels = [signal["label"] for signal in signals] + self.assertIn("Sin notas", labels) + self.assertIn("Sin contacto", labels) + + def test_done_and_rejected_only_return_terminal_signal(self): + done_signals = build_follow_up_signals(_application_item(1, status="Done"), today=date(2026, 5, 25)) + rejected_signals = build_follow_up_signals( + _application_item(2, status="Rejected"), + today=date(2026, 5, 25), + ) + + self.assertEqual(done_signals, [{"kind": "terminal", "label": "Terminal", "tone": "gray", "compact": False}]) + self.assertEqual(rejected_signals, [{"kind": "terminal", "label": "Terminal", "tone": "gray", "compact": False}]) + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[]) @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) @patch("app.interfaces.web.routes.applications.application_repository") @@ -176,6 +250,8 @@ def test_applications_list_groups_current_page_by_status_and_keeps_selection_lin self.assertIn("ACME Pending", response.text) self.assertIn("ACME Applied", response.text) self.assertIn("ACME Rejected", response.text) + self.assertIn("Lleva tiempo pendiente", response.text) + self.assertIn("Lleva tiempo aplicada", response.text) self.assertIn('class="application-rail-id">#2', response.text) self.assertIn("/app/applications?selected=2&q=acme&state=Todos&page=1&page_size=20", response.text) self.assertNotIn(">ID<", response.text) @@ -222,6 +298,7 @@ def test_applications_detail_panel_prioritizes_operational_sections(self, mock_r self.assertEqual(response.status_code, 200) self.assertIn("Decision original", response.text) + self.assertIn("Senales de seguimiento", response.text) self.assertIn("Accion principal", response.text) self.assertIn("Informacion de la aplicacion", response.text) self.assertIn("Contacto", response.text) @@ -315,6 +392,32 @@ def test_applications_detail_shows_clean_fallback_when_analysis_is_missing(self, self.assertIn("Analisis original", response.text) self.assertIn("no tiene analisis original disponible", response.text) self.assertNotIn("Score 88", response.text) + self.assertIn("Senales de seguimiento", response.text) + self.assertIn("Lleva tiempo pendiente", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_list_shows_only_one_compact_signal_per_row(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME Pending", role="Role Pending"), + ] + + response = self.client.get("/app/applications/shell?selected=1&state=Todos&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.text.count('class="application-rail-signal"'), 1) + self.assertEqual(response.text.count("Lleva tiempo pendiente"), 2) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_terminal_detail_does_not_render_operational_missing_info_signals(self, mock_repository): + mock_repository.list_all.return_value = [_application_item(7, status="Done", company="ACME", role="Data Analyst")] + + response = self.client.get("/app/applications?selected=7&state=Done") + + self.assertEqual(response.status_code, 200) + self.assertIn("Senales de seguimiento", response.text) + self.assertIn("Terminal", response.text) + self.assertNotIn('Sin notas', response.text) + self.assertNotIn('Sin contacto', response.text) @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_detail_handles_missing_score_or_decision_without_breaking(self, mock_repository): From 71c8a9b9d404fd3dddb461d3a413eac25f14427c Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 14:51:31 -0500 Subject: [PATCH 18/27] Fix application rail date copy --- .../web/templates/applications/_list.html | 2 +- tests/test_web_applications.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/interfaces/web/templates/applications/_list.html b/app/interfaces/web/templates/applications/_list.html index 1f3c7f9..a598519 100644 --- a/app/interfaces/web/templates/applications/_list.html +++ b/app/interfaces/web/templates/applications/_list.html @@ -33,7 +33,7 @@
{% endif %}
- {% if item.fecha_aplicacion %}Aplicada{% else %}Registrada{% endif %} + Seguimiento desde {% if item.fecha_aplicacion %}{{ item.fecha_aplicacion.strftime("%d/%m/%Y") }}{% elif item.fecha_registro %}{{ item.fecha_registro.strftime("%d/%m/%Y") }}{% else %}-{% endif %} {% if item.modalidad %} {{ item.modalidad }} diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index b7475c6..c76fb8c 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -252,11 +252,28 @@ def test_applications_list_groups_current_page_by_status_and_keeps_selection_lin self.assertIn("ACME Rejected", response.text) self.assertIn("Lleva tiempo pendiente", response.text) self.assertIn("Lleva tiempo aplicada", response.text) + self.assertIn("Seguimiento desde", response.text) self.assertIn('class="application-rail-id">#2', response.text) self.assertIn("/app/applications?selected=2&q=acme&state=Todos&page=1&page_size=20", response.text) self.assertNotIn(">ID<", response.text) self.assertNotIn(">Empresa<", response.text) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_pending_rail_uses_neutral_tracking_date_copy_instead_of_aplicada(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME Pending", role="Role Pending"), + ] + + response = self.client.get("/app/applications/shell?state=Pending&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertIn("Seguimiento desde", response.text) + self.assertNotIn('Aplicada', response.text) + self.assertIn("ACME Pending", response.text) + self.assertIn("Role Pending", response.text) + self.assertIn("Pendiente por aplicar", response.text) + self.assertIn("Lleva tiempo pendiente", response.text) + @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_list_filtered_state_renders_single_group(self, mock_repository): mock_repository.list_all.return_value = [ From 50af09f47a15892958fa3f2a239ac9c844f11513 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 14:57:04 -0500 Subject: [PATCH 19/27] Fix applications rail selection loading --- .../web/templates/applications/_shell.html | 32 ++++++++------- .../web/templates/applications/index.html | 4 +- tests/test_web_applications.py | 39 +++++++++++++++++++ 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/app/interfaces/web/templates/applications/_shell.html b/app/interfaces/web/templates/applications/_shell.html index 9073819..caec6d3 100644 --- a/app/interfaces/web/templates/applications/_shell.html +++ b/app/interfaces/web/templates/applications/_shell.html @@ -1,19 +1,21 @@ -{% if flash_message %} - {% set flash_tone = flash_message[0] %} - {% set flash_text = flash_message[1] %} - {% include "components/_flash.html" %} -{% endif %} +
+ {% if flash_message %} + {% set flash_tone = flash_message[0] %} + {% set flash_text = flash_message[1] %} + {% include "components/_flash.html" %} + {% endif %} -{% include "applications/_filters.html" %} + {% include "applications/_filters.html" %} -
-
-
- {% include "applications/_list.html" %} +
+
+
+ {% include "applications/_list.html" %} +
+
+ +
+ {% include "applications/_detail.html" %}
- -
- {% include "applications/_detail.html" %} -
-
+
diff --git a/app/interfaces/web/templates/applications/index.html b/app/interfaces/web/templates/applications/index.html index 4075b9b..5836065 100644 --- a/app/interfaces/web/templates/applications/index.html +++ b/app/interfaces/web/templates/applications/index.html @@ -6,7 +6,5 @@ {% set page_header_copy_class = none %} {% include "components/_page_header.html" %} -
- {% include "applications/_shell.html" %} -
+{% include "applications/_shell.html" %} {% endblock %} diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index c76fb8c..f514919 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -229,11 +229,50 @@ def test_applications_shell_uses_system_filter_button_and_preserves_shell_target response = self.client.get("/app/applications/shell?q=acme&state=Todos&page=1&page_size=20") self.assertEqual(response.status_code, 200) + self.assertIn('id="applications-shell"', response.text) self.assertIn('class="secondary-action filter-submit"', response.text) self.assertIn('hx-target="#applications-shell"', response.text) self.assertIn('hx-swap="outerHTML"', response.text) self.assertIn('hx-push-url="true"', response.text) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_shell_selected_item_a_renders_matching_detail_and_selection(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME One", role="Role One"), + _application_item(2, status="Applied", company="ACME Two", role="Role Two"), + ] + + response = self.client.get("/app/applications/shell?selected=1&state=Todos&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertIn('id="applications-shell"', response.text) + self.assertIn("

ACME One

", response.text) + self.assertIn('class="application-role">Role One

', response.text) + self.assertIn( + 'class="row-link application-rail-row application-row state-gray is-selected"', + response.text, + ) + self.assertIn("/app/applications?selected=1&q=&state=Todos&page=1&page_size=20", response.text) + + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_shell_selected_item_b_renders_matching_detail_and_selection(self, mock_repository): + mock_repository.list_all.return_value = [ + _application_item(1, status="Pending", company="ACME One", role="Role One"), + _application_item(2, status="Applied", company="ACME Two", role="Role Two"), + ] + + response = self.client.get("/app/applications/shell?selected=2&state=Todos&page=1&page_size=20") + + self.assertEqual(response.status_code, 200) + self.assertIn('id="applications-shell"', response.text) + self.assertIn("

ACME Two

", response.text) + self.assertIn('class="application-role">Role Two

', response.text) + self.assertIn( + 'class="row-link application-rail-row application-row state-blue is-selected"', + response.text, + ) + self.assertIn("/app/applications?selected=2&q=&state=Todos&page=1&page_size=20", response.text) + @patch("app.interfaces.web.routes.applications.application_repository") def test_applications_list_groups_current_page_by_status_and_keeps_selection_links(self, mock_repository): mock_repository.list_all.return_value = [ From f88d5ce64af64bbca1e3308fda64ab1ab865a05e Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 25 May 2026 15:09:49 -0500 Subject: [PATCH 20/27] Compact applications workspace header --- app/interfaces/web/routes/applications.py | 1 + .../web/templates/applications/index.html | 5 +++-- tests/test_web_applications.py | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app/interfaces/web/routes/applications.py b/app/interfaces/web/routes/applications.py index 4fa8fdd..28ed69a 100644 --- a/app/interfaces/web/routes/applications.py +++ b/app/interfaces/web/routes/applications.py @@ -245,6 +245,7 @@ def applications_index( "active_nav": "applications", "nav_items": _build_nav("applications"), "metrics": _build_metrics(), + "hide_global_metrics": True, **context, }, ) diff --git a/app/interfaces/web/templates/applications/index.html b/app/interfaces/web/templates/applications/index.html index 5836065..bad83c3 100644 --- a/app/interfaces/web/templates/applications/index.html +++ b/app/interfaces/web/templates/applications/index.html @@ -2,8 +2,9 @@ {% block content %} {% set page_header_title = "Seguimiento" %} -{% set page_header_description = "Control simple de vacantes que ya te interesan o a las que ya aplicaste." %} -{% set page_header_copy_class = none %} +{% set page_header_description = none %} +{% set page_header_copy_class = "page-header-copy" %} +{% set page_header_compact = true %} {% include "components/_page_header.html" %} {% include "applications/_shell.html" %} diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index f514919..f518fba 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -152,6 +152,26 @@ def test_applications_index_renders_tracking_page(self, mock_repository, _mock_n self.assertIn("ACME", response.text) self.assertIn("Pendiente por aplicar", response.text) + @patch("app.interfaces.web.routes.applications._build_metrics", return_value=[{"label": "Aplicaciones", "value": 42}]) + @patch("app.interfaces.web.routes.applications._build_nav", return_value=[]) + @patch("app.interfaces.web.routes.applications.application_repository") + def test_applications_index_uses_compact_header_and_hides_global_metrics( + self, + mock_repository, + _mock_nav, + _mock_metrics, + ): + mock_repository.list_all.return_value = [_application_item(7, company="ACME", role="Data Analyst")] + + response = self.client.get("/app/applications") + + self.assertEqual(response.status_code, 200) + self.assertIn('