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/application_signals.py b/app/interfaces/web/presentation/application_signals.py new file mode 100644 index 0000000..0a20621 --- /dev/null +++ b/app/interfaces/web/presentation/application_signals.py @@ -0,0 +1,130 @@ +"""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(application: dict, signals: list[dict]) -> dict | None: + current_status = application.get("estado") + + if current_status == "Pending": + non_redundant_signal = next( + ( + signal + for signal in signals + if signal.get("compact") and signal.get("label") != "Pendiente por aplicar" + ), + None, + ) + if non_redundant_signal is not None: + return non_redundant_signal + return 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/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 0b7035e..d95a3f5 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 @@ -9,6 +11,12 @@ from app.infrastructure.persistence.repositories.application_repository import ( 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 @@ -17,14 +25,17 @@ PAGE_SIZE_OPTIONS = (10, 20, 50) application_repository = ApplicationRepository() +analysis_repository = AnalysisRepository() FOLLOW_UP_STATES = [ "Pending", "Applied", "Technical Test", "In Interview", "Open Offer", + "Done", "Rejected", ] +TERMINAL_STATES = ("Done", "Rejected") STATUS_LABELS = { "Pending": "Pendiente por aplicar", @@ -67,7 +78,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 +105,53 @@ 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 _set_public_push_url( + response: HTMLResponse, + *, + selected: int | None, + flash: str | None, + q: str | None, + state: str, + page: int, + page_size: int, +) -> HTMLResponse: + response.headers["HX-Push-Url"] = _build_applications_url( + selected=selected, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ) + return response + + 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 @@ -137,6 +201,30 @@ 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"))), + "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, item["follow_up_signals"]), + } + for item in paged_applications + ] selected_application = None if paged_applications: @@ -150,6 +238,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 "", @@ -177,6 +266,7 @@ def applications_index( "active_nav": "applications", "nav_items": _build_nav("applications"), "metrics": _build_metrics(), + "hide_global_metrics": True, **context, }, ) @@ -193,11 +283,20 @@ def applications_shell_partial( page_size: int = DEFAULT_PAGE_SIZE, ): context = _build_tracking_context(selected=selected, flash=flash, q=q, state=state, page=page, page_size=page_size) - return templates.TemplateResponse( + response = templates.TemplateResponse( request=request, name="applications/_shell.html", context={"request": request, **context}, ) + return _set_public_push_url( + response, + selected=selected, + flash=flash, + q=q, + state=state, + page=page, + page_size=page_size, + ) @router.get("/app/applications/{application_id}/detail", response_class=HTMLResponse) @@ -224,17 +323,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 +366,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 +379,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 +411,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/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index fe1151c..9630a61 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -17,12 +17,14 @@ 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 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() @@ -37,35 +39,31 @@ 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 = ["Todas", "Recientes", "Analizadas", "En seguimiento", "Sin analizar"] +INBOX_VIEWS = [ + DEFAULT_INBOX_VIEW, + "Todas", + "Recientes", + "Analizadas", + "Sin analizar", + "En seguimiento", + "Descartadas", +] ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} - - -def _safe_score(value) -> float | None: - try: - return float(value) if value is not None else None - except (TypeError, ValueError): - return None - - def _score_meta(analysis: dict | None) -> dict: - if not analysis: - return {**ANALYSIS_TONE_DEFAULT, "value": None} - - score = _safe_score(analysis.get("score_total")) - if score is None: - return {**ANALYSIS_TONE_DEFAULT, "value": None} - if score >= 80: - tone = "green" - elif score >= 60: - tone = "amber" - else: - tone = "red" - return {"tone": tone, "label": "Score", "value": round(score)} + 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: @@ -80,6 +78,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} @@ -95,42 +105,83 @@ def _affinity_meta(analysis: dict | None) -> dict: def _decision_meta(analysis: dict | None) -> dict: - if not analysis: - return {"tone": "gray", "label": "-", "raw": None} + signal = _build_decision_signal(analysis) + return { + "tone": signal["decision_tone"], + "label": signal["decision_label"], + "raw": signal["decision_raw"], + "normalized": signal["decision_normalized"], + } - 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()} +def _decision_visual_tone(score_meta: dict, decision_meta: dict) -> str: + return decision_meta["tone"] -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: + return _build_decision_signal(analysis)["decision_compact_label"] -def _build_vacancy_items(limit: int | None = None) -> list[dict]: + +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 _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 _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] 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"]) 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) decision_meta = _decision_meta(analysis) @@ -138,18 +189,28 @@ 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", "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": tracking_lookup.get(vacancy["id"]), } ) 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() @@ -159,14 +220,23 @@ 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 == "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": 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 @@ -202,8 +272,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: @@ -212,7 +282,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: @@ -226,7 +300,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: @@ -237,7 +311,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}") @@ -253,6 +327,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": @@ -269,14 +347,24 @@ 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_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 = 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, @@ -288,8 +376,16 @@ 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 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 active_items if item["has_application"]), + }, + metrics or [], + ), } @@ -299,13 +395,26 @@ 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, ): - context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) metrics = _build_metrics() - metric_lookup = {item["label"]: item["value"] for item in 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}, + ) return templates.TemplateResponse( request=request, name="vacancies/index.html", @@ -315,18 +424,37 @@ 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, }, ) +@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 = DEFAULT_INBOX_VIEW, + 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(), + ) + 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( @@ -386,7 +514,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", @@ -394,7 +523,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": current_view, "pagination": { "page": int(request.query_params.get("page", "1")), "page_size": int(request.query_params.get("page_size", str(DEFAULT_PAGE_SIZE))), @@ -408,7 +537,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, ): @@ -421,12 +550,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, ) @@ -440,19 +582,37 @@ 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") 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/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index b930856..726373e 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); } @@ -356,7 +371,8 @@ a { .action-grid button, .danger-action, -.secondary-action { +.secondary-action, +.tertiary-action { min-height: 40px; padding: 0 14px; border: 0; @@ -373,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; @@ -408,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; @@ -679,13 +717,6 @@ a { color: #b91c1c; } -.detail-header-badges { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - .analysis-detail-grid .detail-card { background: #ffffff; } @@ -760,45 +791,287 @@ 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); +/* Layer 7: Vacancies feature */ +.workspace-toolbar { + display: grid; + gap: 10px; + margin-bottom: 12px; } -.description-disclosure > p { - margin: 0; - padding: 16px; +.workspace-toolbar .context-strip { + gap: 8px; + padding: 0 0 10px; + margin-bottom: 0; +} + +.workspace-toolbar .context-item { + gap: 5px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.82); +} + +.workspace-toolbar .context-value { + font-size: 0.92rem; +} + +.workspace-toolbar .context-label { + font-size: 0.76rem; +} + +.workspace-toolbar .filter-panel { + margin-bottom: 0; +} + +.workspace-toolbar .filter-form-compact { + grid-template-columns: minmax(0, 2.2fr) minmax(150px, 0.8fr) minmax(102px, 0.5fr) auto; + gap: 8px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.85); +} + +.workspace-toolbar .filter-form label { + gap: 4px; +} + +.workspace-toolbar .filter-form label span { + font-size: 0.72rem; + letter-spacing: 0.03em; +} + +.workspace-toolbar .filter-form input, +.workspace-toolbar .filter-form select { + min-height: 38px; + padding: 8px 10px; +} + +.workspace-toolbar .filter-submit { + min-height: 38px; + padding: 0 12px; + border-radius: 10px; + font-weight: 700; } -/* Layer 7: Vacancies feature */ .inbox-workspace { align-items: start; } +.workspace-shell { + align-items: start; +} + .workspace-panel { min-height: 0; } +.workspace-list, +.workspace-detail { + min-width: 0; +} + +.workspace-detail, +#vacancy-detail { + min-height: 0; +} + .inbox-list-panel { - padding: 16px 18px 18px; + padding: 14px 16px 16px; +} + +.opportunity-card { + position: relative; +} + +.opportunity-list { + gap: 4px; +} + +.list-section-label { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 0 2px 6px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; +} + +.opportunity-list .table-row-shell { + 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: 10px; + align-items: center; + padding: 9px 10px; +} + +.opportunity-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.opportunity-topline { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.opportunity-role-line { + min-width: 0; +} + +.opportunity-role { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.28; + color: #354158; +} + +.opportunity-meta-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px 8px; + min-width: 0; +} + +.opportunity-meta-line .secondary-cell { + margin-top: 0; + font-size: 0.74rem; + color: #778196; +} + +.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: 190px; + 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-recommendation-label { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 130px; +} + +.opportunity-recommendation-score { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + min-height: 22px; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: currentColor; + font-size: 0.73rem; + font-weight: 800; + flex-shrink: 0; +} + +.opportunity-state { + display: inline-flex; + align-items: center; + gap: 5px; + color: #8791a6; + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} + +.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 { @@ -835,20 +1108,39 @@ a { overflow: hidden; } +.decision-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + padding: 0; + min-height: 0; +} + .vacancy-card-header { - display: flex; - justify-content: space-between; - align-items: start; - gap: 12px; - padding: 18px 22px 16px; + 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; @@ -881,16 +1173,31 @@ a { display: flex; flex-wrap: wrap; align-items: center; - gap: 10px 14px; + gap: 8px 12px; font-size: 0.78rem; 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: 8px; + gap: 6px; align-items: center; - flex-shrink: 0; + flex-wrap: wrap; } .vacancy-card-actions form { @@ -898,157 +1205,134 @@ a { } .vacancy-action-button { - min-height: 34px; - padding: 0 12px; + min-height: 32px; + padding: 0 11px; border-radius: 10px; - font-size: 0.78rem; + font-size: 0.76rem; box-shadow: none; } -.vacancy-close-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 10px; - border: 1px solid var(--border); - color: var(--muted); - text-decoration: none; +.vacancy-card-actions .secondary-link { background: transparent; - font-size: 1rem; - line-height: 1; } -.vacancy-close-button:hover { - background: #fee2e2; - border-color: #fecaca; +.vacancy-card-actions .danger-action { + background: transparent; + border: 1px solid #fecaca; color: #b91c1c; } -.vacancy-card-divider { - height: 1px; - background: var(--border); +.vacancy-card-actions .danger-action:hover { + background: #fff5f5; } -.vacancy-score-strip { - display: flex; - align-items: center; - gap: 18px; - padding: 16px 22px; - background: #f8fbff; +.decision-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 16px; + align-items: start; + padding: 14px 18px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + border-top: 1px solid var(--border); + border-bottom: 1px solid rgba(215, 223, 236, 0.75); } -.vacancy-score-value { - flex-shrink: 0; +.decision-copy { + display: grid; + gap: 8px; + min-width: 0; } -.vacancy-score-label { - margin-bottom: 4px; - font-size: 0.7rem; - color: var(--muted); +.decision-label { + font-size: 0.72rem; + font-weight: 700; + 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.12rem; + font-weight: 760; + line-height: 1.24; + letter-spacing: -0.01em; } -.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; +.decision-support { + display: flex; + flex-wrap: wrap; + gap: 6px; min-width: 0; } -.vacancy-score-scale { - display: flex; - justify-content: space-between; - margin-bottom: 6px; - font-size: 0.72rem; - color: var(--muted); +.opportunity-score-pill { + min-width: auto; + padding: 6px 10px; + font-size: 0.74rem; } -.vacancy-score-gauge { - height: 6px; - background: #dbe3f0; - border-radius: 999px; - overflow: hidden; +.decision-support .soft-badge { + padding: 5px 9px; + font-size: 0.74rem; + font-weight: 600; } -.vacancy-score-fill { - height: 100%; - border-radius: 999px; - background: currentColor; +.decision-actions { + justify-content: flex-end; + align-items: flex-start; + align-content: flex-start; } -.vacancy-verdict { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 6px 10px; - border-radius: 10px; +.decision-actions .primary-action { + min-height: 36px; + padding: 0 14px; font-size: 0.78rem; - font-weight: 700; - background: currentColor; - color: #fff; - white-space: nowrap; } -.vacancy-verdict.tone-green { - background: #dcfce7; - color: #166534; -} - -.vacancy-verdict.tone-amber { - background: #fef3c7; - color: #b45309; +.decision-actions .danger-action, +.decision-actions .secondary-link { + min-height: 32px; + font-size: 0.74rem; } -.vacancy-verdict.tone-red { - background: #fee2e2; - color: #b91c1c; +.tracking-link { + border-color: #d7dfec; + color: #314056; } -.vacancy-verdict.tone-gray { - background: #f3f4f6; - color: #4b5563; +.vacancy-card-body { + display: grid; + gap: 10px; + min-height: 0; + padding: 14px 18px 18px; } -.vacancy-card-body { - padding: 18px 22px; +.decision-panel-body { + min-height: 0; + overflow: visible; } .vacancy-skills { display: flex; gap: 6px; flex-wrap: wrap; - margin-bottom: 12px; + margin-bottom: 0; } .vacancy-skill { @@ -1064,33 +1348,158 @@ a { font-weight: 700; } +.decision-inline-section { + display: grid; + gap: 6px; +} + .vacancy-analysis-text { margin: 0; - font-size: 0.88rem; - line-height: 1.7; + font-size: 0.86rem; + line-height: 1.55; color: #4b5563; } -.vacancy-description { +.analysis-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.analysis-priority-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.decision-evidence-grid { + gap: 8px; +} + +.analysis-note { + 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-top-color: #86efac; +} + +.analysis-note-caution { + border-top-color: #fca5a5; +} + +.analysis-summary-compact { margin-top: 0; + padding-top: 2px; + border: 0; + background: transparent; +} + +.analysis-summary-compact h3 { + margin: 0 0 6px; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.analysis-block { + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.detail-list { + margin: 8px 0 0; + padding-left: 18px; + display: grid; + gap: 6px; + color: #4b5563; + font-size: 0.83rem; +} + +.detail-list li { + line-height: 1.45; +} + +.vacancy-description { + margin-top: 4px; border: 0; border-top: 1px solid var(--border); border-radius: 0; background: transparent; } -.vacancy-description > summary { - padding: 14px 22px; +.detail-sheet.decision-panel { + overflow-x: hidden; + overflow-y: auto; +} + +.vacancy-description-summary { + display: grid; + gap: 2px; + width: 100%; + padding: 12px 18px; + padding-right: 34px; background: transparent; - font-size: 0.8rem; + 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 > .vacancy-description-body { - padding: 0 22px 18px; +.vacancy-description-reader[open] .vacancy-description-summary::after { + transform: rotate(180deg); } -.vacancy-description > .vacancy-description-body p { +.description-summary-note { + color: var(--muted); + font-size: 0.72rem; + font-weight: 500; +} + +.vacancy-description-content { + max-height: 320px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 18px 16px; +} + +.vacancy-description-reader[open] .vacancy-description-summary { + border-bottom: 1px solid rgba(215, 223, 236, 0.9); +} + +.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 */ @@ -1118,7 +1527,398 @@ a { } .application-detail-panel { + position: sticky; + top: 18px; align-self: start; + min-height: 0; +} + +.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-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; + 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; + overflow-x: hidden; +} + +.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-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-disclosure { + border: 1px solid var(--border); + border-radius: 16px; + background: #fbfcfe; + overflow: hidden; +} + +.application-origin-summary-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 16px; + align-items: start; + padding: 16px 18px; + cursor: pointer; + list-style: none; + background: linear-gradient(180deg, #fcfdff 0%, #f7faff 100%); + position: relative; +} + +.application-origin-summary-toggle::-webkit-details-marker { + display: none; +} + +.application-origin-summary-toggle::after { + content: "▾"; + position: absolute; + right: 18px; + top: 16px; + color: #667085; + transition: transform 0.18s ease; +} + +.application-origin-disclosure[open] .application-origin-summary-toggle::after { + transform: rotate(180deg); +} + +.application-origin-summary-copy { + display: grid; + gap: 6px; + min-width: 0; + padding-right: 20px; +} + +.application-origin-summary-head { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + align-items: center; +} + +.application-origin-summary-head h3 { + margin: 0; + font-size: 1rem; +} + +.application-origin-summary-toggle .muted-note { + margin: 0; + font-size: 0.86rem; +} + +.application-origin-disclosure[open] .application-origin-summary-toggle { + border-bottom: 1px solid var(--border); +} + +.application-origin-disclosure > .application-origin-card { + border: 0; + border-radius: 0; + background: transparent; +} + +.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-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; +} + +.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-contact-grid { + margin-bottom: 0; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.application-edit-disclosure { + gap: 0; + margin: 0; +} + +.application-edit-disclosure > summary { + background: #fbfcfe; + font-size: 0.96rem; +} + +.application-edit-disclosure[open] > summary { + background: #f8fbff; +} + +.application-edit-disclosure > .application-edit-form { + margin: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.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 */ @@ -1503,6 +2303,36 @@ a { align-items: start; } + .application-detail-header { + flex-direction: column; + } + + .application-origin-hero { + flex-direction: column; + } + + .application-origin-pills { + justify-content: flex-start; + } + + .application-header-meta .muted-note { + 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); } @@ -1513,6 +2343,7 @@ a { .filter-form, .layout-two-columns, .detail-grid, + .application-edit-grid, .profile-grid, .grid-two, .grid-three, @@ -1539,6 +2370,10 @@ a { position: static; } + .application-detail-panel { + position: static; + } + .detail-sheet { max-height: none; } @@ -1551,19 +2386,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/applications/_detail.html b/app/interfaces/web/templates/applications/_detail.html index faff64a..f85d52a 100644 --- a/app/interfaces/web/templates/applications/_detail.html +++ b/app/interfaces/web/templates/applications/_detail.html @@ -1,120 +1,315 @@ {% if selected_application %} -
-
+ + {% if selected_application.follow_up_signals %} +
+
+

Senales de seguimiento

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

Analisis original

+ Recomendacion inicial +
+
+ {{ original_decision.decision_label }} +
+

Contexto historico; no cambia el estado actual.

+
+
+ + 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

+

Contexto historico; no cambia el estado actual.

+
+
+
Analisis original
+

+ Esta vacante no tiene analisis original disponible todavia. +

+
+ {% endif %} +
+ +
+
+

Informacion de la aplicacion

- +
+
+
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 %} +
+
+
+
-
-

Editar datos

-
+ {% set recruiter_name = selected_application.nombre_recruiter|default('', true)|trim %} + {% set recruiter_email = selected_application.email_recruiter|default('', true)|trim %} + {% set recruiter_phone = selected_application.telefono_recruiter|default('', true)|trim %} + {% set notes_text = selected_application.notas|default('', true)|trim %} + {% set has_contact = recruiter_name or recruiter_email or recruiter_phone %} + {% if has_contact %} +
+
+

Contacto

+
+
+ {% if recruiter_name %} +
+
Recruiter
+
{{ recruiter_name }}
+
+ {% endif %} + {% if recruiter_email %} +
+
Email
+
{{ recruiter_email }}
+
+ {% endif %} + {% if recruiter_phone %} +
+
Telefono
+
{{ recruiter_phone }}
+
+ {% endif %} +
+
+ {% endif %} + + {% if notes_text %} +
+
+

Notas

+
+
+

{{ notes_text }}

+
+
+ {% endif %} + +
+ Editar seguimiento + - - - - + + +
+ + + + +
- + -
+ -
-

Eliminar

+
+
+

Eliminar

+
- + + +
-
+ {% else %}
diff --git a/app/interfaces/web/templates/applications/_filters.html b/app/interfaces/web/templates/applications/_filters.html index c10c9af..523ac0f 100644 --- a/app/interfaces/web/templates/applications/_filters.html +++ b/app/interfaces/web/templates/applications/_filters.html @@ -1,6 +1,6 @@ -
+
-
-
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 %} + {% 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) %} @@ -33,7 +54,7 @@ {% set pagination_next_hx_get = "/app/applications/shell?q=%s&state=%s&page=%s&page_size=%s"|format(query, current_state, pagination.next_page, pagination.page_size) %} {% set pagination_hx_target = "#applications-shell" %} {% set pagination_hx_swap = "outerHTML" %} - {% set pagination_hx_push_url = "true" %} + {% set pagination_hx_push_url = "false" %} {% include "components/_pagination.html" %} {% else %}
diff --git a/app/interfaces/web/templates/applications/_shell.html b/app/interfaces/web/templates/applications/_shell.html index 9073819..4772f0a 100644 --- a/app/interfaces/web/templates/applications/_shell.html +++ b/app/interfaces/web/templates/applications/_shell.html @@ -1,19 +1,23 @@ -{% 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..bad83c3 100644 --- a/app/interfaces/web/templates/applications/index.html +++ b/app/interfaces/web/templates/applications/index.html @@ -2,11 +2,10 @@ {% 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" %} -
+{% include "applications/_shell.html" %} {% endblock %} 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..51ae893 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,114 +1,170 @@ {% if selected_vacancy %} -
-
-
+ {% set analysis = selected_vacancy.analisis %} + {% set signal = selected_vacancy.decision_signal %} +
+
+
+
+ {{ selected_vacancy.status_meta.label }} +

{{ 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 selected_vacancy.detail_meta_items %} +
+ {% for item in selected_vacancy.detail_meta_items %} + {{ item }} + {% endfor %} +
+ {% endif %} +
+
+ +
+
+
Decision sugerida
+
+ {{ signal.decision_label }} +
+
+ + Score {{ signal.score_label if signal.score is not none else "-" }} + + {{ selected_vacancy.affinity_meta.label }} + {% if analysis and analysis.encaje_estrategico %} + {{ analysis.encaje_estrategico }} + {% endif %}
- {% if not selected_vacancy.has_application %} -
+
+ {% if not selected_vacancy.has_application and not selected_vacancy.motivo_archivo %} - + + + + +
- +
- × -
- {% else %} -
- Seguimiento - × -
- {% endif %} -
- -
- -
-
-
Score
-
- {{ selected_vacancy.score_meta.value if selected_vacancy.score_meta.value is not none else "-" }} -
-
-
-
- 0 - 100 -
-
-
-
-
-
- {{ selected_vacancy.decision_meta.label }} · {{ selected_vacancy.affinity_meta.label }} + {% 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 + {% 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] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} + {% if analysis.riesgos_principales %} +
+
Ojo con esto
+
    + {% for item in analysis.riesgos_principales[:4] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} -
- {% if selected_vacancy.analisis and selected_vacancy.analisis.skills_match %} -
- {% for skill in selected_vacancy.analisis.skills_match[:6] %} - {{ skill }} - {% endfor %} -
- {% 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

+

{{ analysis.resumen_analisis }}

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

Resumen ejecutivo

+

{{ analysis.justificacion_decision }}

+
+ {% endif %} - {% if selected_vacancy.analisis %} -

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

+ {% if analysis.skills_gap or analysis.ajustes_cv_recomendados %} +
+ {% 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 + Texto original de la vacante + +
+

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

+
+
+
{% 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..cdde03b 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" > - +
diff --git a/app/interfaces/web/templates/vacancies/_list.html b/app/interfaces/web/templates/vacancies/_list.html index e491385..20d4587 100644 --- a/app/interfaces/web/templates/vacancies/_list.html +++ b/app/interfaces/web/templates/vacancies/_list.html @@ -1,80 +1,73 @@ {% if vacancies %} -
-
-
Score
-
Empresa
-
Cargo
-
Fecha
-
Decisión
-
Estado
+
+ {% for item in vacancies %} + {% set signal = item.decision_signal %}
-
- {% if item.score_meta.value is not none %} - {{ item.score_meta.value }} - {% else %} - - - {% endif %} -
-
-
{{ item.empresa }}
-
{{ item.modalidad }}
-
-
-
{{ item.cargo }}
-
{% set pagination_prev_href = "/app/vacancies?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.prev_page, pagination.page_size) %} - {% set pagination_prev_hx_get = "/app/vacancies/list?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.prev_page, pagination.page_size) %} + {% set pagination_prev_hx_get = "/app/vacancies?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.prev_page, pagination.page_size) %} {% set pagination_next_href = "/app/vacancies?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.next_page, pagination.page_size) %} - {% set pagination_next_hx_get = "/app/vacancies/list?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.next_page, pagination.page_size) %} - {% set pagination_hx_target = "#vacancy-list" %} - {% set pagination_hx_swap = "innerHTML" %} + {% set pagination_next_hx_get = "/app/vacancies?q=%s&view=%s&page=%s&page_size=%s"|format(query, current_view, pagination.next_page, pagination.page_size) %} + {% set pagination_hx_target = "#vacancies-shell" %} + {% set pagination_hx_swap = "outerHTML" %} {% set pagination_hx_push_url = "true" %} {% include "components/_pagination.html" %} {% else %} diff --git a/app/interfaces/web/templates/vacancies/_shell.html b/app/interfaces/web/templates/vacancies/_shell.html index 3092f2c..6c745d5 100644 --- a/app/interfaces/web/templates/vacancies/_shell.html +++ b/app/interfaces/web/templates/vacancies/_shell.html @@ -1,15 +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" %} +
+
+
diff --git a/app/interfaces/web/templates/vacancies/index.html b/app/interfaces/web/templates/vacancies/index.html index 37c57ed..cdc90a8 100644 --- a/app/interfaces/web/templates/vacancies/index.html +++ b/app/interfaces/web/templates/vacancies/index.html @@ -2,50 +2,10 @@ {% block content %} {% set page_header_title = "Inbox de Vacantes" %} -{% set page_header_description = "Revisa, prioriza y decide sin salir del workspace." %} +{% set page_header_description = none %} {% set page_header_copy_class = "page-header-copy" %} +{% set page_header_compact = true %} {% include "components/_page_header.html" %} {% include "vacancies/_shell.html" %} - {% endblock %} 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. diff --git a/tests/test_web_applications.py b/tests/test_web_applications.py index 3ce179a..29e1027 100644 --- a/tests/test_web_applications.py +++ b/tests/test_web_applications.py @@ -9,33 +9,166 @@ 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( + *, + 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, + *, + 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, + "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": application_date or date(2026, 4, 1), + "estado": status, + "nombre_recruiter": recruiter, + "email_recruiter": email, + "telefono_recruiter": phone, + "notas": notes, + "fecha_registro": registered_date or date(2026, 4, 1), + } 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) + + 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( + _application_item(1, status="Pending", application_date=date(2026, 5, 10)), + signals, + )["label"], + "Lleva tiempo pendiente", + ) + + def test_pending_generic_signal_is_not_used_as_compact_rail_signal(self): + application = _application_item(1, status="Pending", application_date=date(2026, 5, 24)) + signals = build_follow_up_signals(application, today=date(2026, 5, 25)) + + compact_signal = pick_compact_follow_up_signal(application, signals) + + self.assertIsNotNone(compact_signal) + self.assertNotEqual(compact_signal["label"], "Pendiente por aplicar") + self.assertIn(compact_signal["label"], {"Sin notas", "Sin contacto"}) + + def test_applied_keeps_useful_compact_signal_when_available(self): + application = _application_item(1, status="Applied", notes=" ", application_date=date(2026, 5, 1)) + signals = build_follow_up_signals(application, today=date(2026, 5, 25)) + + self.assertEqual( + pick_compact_follow_up_signal(application, signals)["label"], + "Lleva tiempo aplicada", + ) + + 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") 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") @@ -44,59 +177,57 @@ 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('