Add project with dist

This commit is contained in:
Isaac Aracena
2026-08-26 16:31:12 -04:00
commit 81ee69b753
62 changed files with 9765 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
VITE_N8N_BASE_URL=
+5
View File
@@ -0,0 +1,5 @@
node_modules/
# dist/
.env
.env.local
*.log
+76
View File
@@ -0,0 +1,76 @@
# Portal de Requisición de Vacantes · GLM
Frontend responsive en **Vite + React + TypeScript** para gestionar requisiciones, aprobaciones, reclutamiento, SLA y capacidad del equipo de RRHH de GomezLee Marketing.
## Ejecutar
Requiere Node.js 20.19+ o 22.12+.
```bash
npm install
npm run dev
```
Comandos de validación:
```bash
npm run lint
npm run check
npm run build
npm run preview
```
La aplicación se publica bajo `/requisicion-vacantes/`, configurado en `vite.config.ts` mediante:
```ts
base: '/requisicion-vacantes/'
```
## Experiencias disponibles
- **Dirección de RRHH:** tablero global, aprobaciones, detalle y audit trail, activación de reclutamiento, reportes con filtros y planeación de capacidad con reasignación.
- **Administración:** cola financiera, detalle de salario y presupuesto, aprobación o rechazo final.
- **Coordinación / Reclutamiento:** nueva requisición, borradores y estados, pipeline e historial de cada solicitud.
- **Acceso:** pantalla de Google simulada. El botón permite entrar al prototipo; no realiza autenticación real.
El selector de rol del encabezado permite recorrer las tres experiencias mientras se valida el frontend.
## Lógica representada
- Flujo secuencial: Solicitante → RRHH → Administración → activación por Dirección de RRHH → reclutamiento.
- El SLA inicia al enviar la requisición y separa tiempo de aprobación de tiempo de reclutamiento.
- Solicitudes con varias vacantes generan IDs individuales dentro de un ID de grupo.
- Solicitudes con fecha futura distante se evalúan como **cumple / no cumple**.
- Semáforo SLA verde, amarillo y rojo.
- Etapas: Búsqueda, 1.ª entrevista, 2.ª entrevista, Referencias, Exámenes médicos y Seleccionado.
- Datos financieros ocultos para Coordinación.
- Audit trail por requisición y notificaciones accionables.
## Preparación para Supabase y n8n
El prototipo utiliza datos demostrativos compactos y `sessionStorage` para que todos los flujos sean navegables sin backend. El estado está centralizado en `src/state/PortalContext.tsx`; el contrato de sustitución se encuentra en `src/services/portal-gateway.ts`.
Al integrar el backend:
1. Implementar `PortalGateway` con Supabase.
2. Reemplazar las operaciones de `PortalContext` por llamadas al gateway.
3. Resolver el rol desde la sesión de Google/Supabase y aplicar las políticas RLS equivalentes.
4. Disparar webhooks de n8n después de enviar, aprobar, rechazar, activar, reasignar o cerrar.
5. Sustituir los catálogos locales por tablas de países, ciudades, clientes, posiciones, feriados y SLA.
Variables sugeridas están documentadas en `.env.example`. Ninguna credencial debe incluirse en el repositorio.
## Estructura
```text
src/
components/ Componentes de navegación, estados, modales y tableros
data/ Catálogos y dataset demostrativo mínimo
pages/ Vistas por función y rol
services/ Contrato para la futura integración
state/ Estado y acciones del prototipo
types.ts Modelo de dominio compartido
utils.ts Etiquetas, SLA, fechas y formato
```
El diseño usa Arial y la paleta oficial GLM: azul `#4F758B`, verde `#6CC24A`, acero `#6B8FA3`, naranja `#FF6A13` y dorado `#F0C75E`.
+1
View File
@@ -0,0 +1 @@
import{u as p,r as n,j as e,L as a,f as l}from"./index-kE4_6-nI.js";import{M as r,C as x,P as u}from"./DashboardParts-nkkWHQei.js";import{P as j}from"./PageHeader-BIrj68Xg.js";import{S as m}from"./StatusBadge-B-6mWO83.js";import{R as b,B as f,C as g,X as y,Y as v,T as A,a as R}from"./BarChart-DxUPBeMP.js";const P=[{territorio:"RD",monto:18400},{territorio:"PA",monto:12600},{territorio:"CO",monto:9800},{territorio:"GT",monto:7200},{territorio:"MX",monto:6100}];function k(){const{requisitions:o}=p(),[c,d]=n.useState("Pendientes"),t=n.useMemo(()=>o.filter(s=>s.status==="pendiente_administracion"),[o]),h=t.reduce((s,i)=>s+(i.currency==="USD"||i.currency==="PAB"?i.budget:i.budget/4e3),0);return e.jsxs("div",{className:"page-stack",children:[e.jsx(j,{eyebrow:"Vista financiera · Administración",title:"Decisiones de presupuesto",description:"Revisa el impacto económico y resuelve las solicitudes que ya validó RRHH.",actions:e.jsx(a,{className:"button button--primary",to:"/aprobaciones",children:"Revisar aprobaciones"})}),e.jsxs("section",{className:"metrics-grid metrics-grid--three",children:[e.jsx(r,{label:"Pendientes de decisión",value:t.length,note:"Validadas por RRHH",tone:"orange"}),e.jsx(r,{label:"Impacto estimado",value:l(h,"USD"),note:"Equivalente mensual"}),e.jsx(r,{label:"Tiempo de respuesta",value:"1.8 d",note:"Meta interna: ≤ 2 días",tone:"green"})]}),e.jsxs("div",{className:"dashboard-grid dashboard-grid--lead",children:[e.jsxs("section",{className:"data-panel approval-preview",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("h2",{children:"Cola de aprobación"}),e.jsx("p",{children:"Ordenada por riesgo de SLA"})]}),e.jsx(a,{to:"/aprobaciones",children:"Abrir cola"})]}),e.jsx("div",{className:"table-wrap",children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Requisición"}),e.jsx("th",{children:"Presupuesto"}),e.jsx("th",{children:"Estado"}),e.jsx("th",{children:e.jsx("span",{className:"sr-only",children:"Acción"})})]})}),e.jsx("tbody",{children:t.map(s=>e.jsxs("tr",{children:[e.jsx("td",{"data-label":"Requisición",children:e.jsxs(a,{to:`/requisiciones/${s.id}`,children:[e.jsx("strong",{children:s.position}),e.jsxs("small",{children:[s.id," · ",s.country]})]})}),e.jsx("td",{"data-label":"Presupuesto",children:l(s.budget,s.currency)}),e.jsx("td",{"data-label":"Estado",children:e.jsx(m,{status:s.status})}),e.jsx("td",{children:e.jsx(a,{className:"text-link",to:`/requisiciones/${s.id}`,children:"Revisar"})})]},s.id))})]})})]}),e.jsx(x,{title:"Impacto por territorio",description:"Equivalente mensual de paquetes abiertos",filter:e.jsx(u,{label:"Filtrar impacto",value:c,onChange:d,options:["Pendientes","Aprobados","Todos"]}),children:e.jsx("div",{className:"chart-height chart-height--compact",children:e.jsx(b,{width:"100%",height:"100%",children:e.jsxs(f,{data:P,layout:"vertical",margin:{top:8,right:18,bottom:0,left:-10},children:[e.jsx(g,{horizontal:!1,stroke:"#E4E8EA"}),e.jsx(y,{type:"number",axisLine:!1,tickLine:!1,tick:{fill:"#6B7780",fontSize:11}}),e.jsx(v,{type:"category",dataKey:"territorio",axisLine:!1,tickLine:!1,tick:{fill:"#4A4A4A",fontSize:11},width:35}),e.jsx(A,{contentStyle:{border:"1px solid #D0D0D0",borderRadius:4,fontFamily:"Arial"}}),e.jsx(R,{dataKey:"monto",fill:"#4F758B"})]})})})})]}),e.jsxs("section",{className:"policy-strip",children:[e.jsx("span",{children:"REGLA"}),e.jsx("strong",{children:"Administración solo recibe solicitudes aprobadas por RRHH."}),e.jsx("p",{children:"Una decisión actualiza la auditoría y notifica al solicitante."})]})]})}export{k as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{j as e}from"./index-kE4_6-nI.js";function i({label:s,value:a,note:r,tone:n="blue"}){return e.jsxs("article",{className:`metric metric--${n}`,children:[e.jsx("span",{className:"metric__line","aria-hidden":"true"}),e.jsx("p",{children:s}),e.jsx("strong",{children:a}),e.jsx("small",{children:r})]})}function t({title:s,description:a,filter:r,children:n,className:l=""}){return e.jsxs("section",{className:`chart-panel ${l}`,children:[e.jsxs("header",{className:"chart-panel__header",children:[e.jsxs("div",{children:[e.jsx("h2",{children:s}),e.jsx("p",{children:a})]}),e.jsx("div",{className:"chart-panel__filter",children:r})]}),e.jsx("div",{className:"chart-panel__body",children:n})]})}function h({label:s,value:a,onChange:r,options:n}){return e.jsxs("label",{className:"compact-select",children:[e.jsx("span",{className:"sr-only",children:s}),e.jsx("select",{value:a,onChange:l=>r(l.target.value),"aria-label":s,children:n.map(l=>e.jsx("option",{value:l,children:l},l))})]})}function d({title:s,detail:a,action:r}){return e.jsxs("div",{className:"empty-state",children:[e.jsx("span",{"aria-hidden":"true",children:"00"}),e.jsx("h2",{children:s}),e.jsx("p",{children:a}),r]})}export{t as C,d as E,i as M,h as P};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as i,j as e}from"./index-kE4_6-nI.js";function m({open:s,title:o,description:l,onClose:a,children:t,footer:n}){return i.useEffect(()=>{if(!s)return;const r=d=>{d.key==="Escape"&&a()};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[s,a]),s?e.jsx("div",{className:"modal-backdrop",role:"presentation",onMouseDown:a,children:e.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-labelledby":"modal-title",onMouseDown:r=>r.stopPropagation(),children:[e.jsxs("header",{className:"modal__header",children:[e.jsxs("div",{children:[e.jsx("p",{className:"eyebrow",children:"Acción registrada"}),e.jsx("h2",{id:"modal-title",children:o}),l?e.jsx("p",{children:l}):null]}),e.jsx("button",{className:"icon-button",type:"button",onClick:a,"aria-label":"Cerrar",children:"×"})]}),e.jsx("div",{className:"modal__body",children:t}),n?e.jsx("footer",{className:"modal__footer",children:n}):null]})}):null}export{m as M};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{u as d,r,j as s,L as f}from"./index-kE4_6-nI.js";import{E as u}from"./DashboardParts-nkkWHQei.js";import{P as x}from"./PageHeader-BIrj68Xg.js";function b(){const{notifications:t,markNotification:o}=d(),[a,c]=r.useState("todas"),e=r.useMemo(()=>t.filter(i=>a==="sin_leer"?!i.read:a==="sla"?i.kind==="sla":a==="accion"?i.kind==="accion":!0),[a,t]);return s.jsxs("div",{className:"page-stack",children:[s.jsx(x,{eyebrow:"Centro de actividad",title:"Notificaciones",description:"Todo lo que exige una decisión, sin mezclarlo con ruido operativo."}),s.jsx("div",{className:"status-tabs status-tabs--compact",role:"tablist","aria-label":"Filtrar notificaciones",children:[["todas","Todas"],["sin_leer","Sin leer"],["sla","SLA"],["accion","Acción requerida"]].map(([i,l])=>s.jsxs("button",{type:"button",role:"tab","aria-selected":a===i,className:a===i?"is-active":"",onClick:()=>c(i),children:[l,s.jsx("span",{children:t.filter(n=>i==="todas"||(i==="sin_leer"?!n.read:n.kind===i)).length})]},i))}),e.length===0?s.jsx(u,{title:"No hay notificaciones",detail:"Este filtro está al día."}):s.jsx("section",{className:"notification-list",children:e.map(i=>s.jsxs("article",{className:`notification notification--${i.kind}${i.read?" is-read":""}`,children:[s.jsxs("button",{type:"button",className:"notification__body",onClick:()=>o(i.id),children:[s.jsx("span",{className:"notification__signal","aria-hidden":"true"}),s.jsxs("div",{children:[s.jsxs("p",{children:[i.kind==="sla"?"SLA":i.kind==="accion"?"ACCIÓN":"SISTEMA"," · ",i.at]}),s.jsx("h2",{children:i.title}),s.jsx("span",{children:i.detail})]}),i.read?null:s.jsx("i",{"aria-label":"Sin leer"})]}),i.requisitionId?s.jsx(f,{to:`/requisiciones/${i.requisitionId}`,children:"Abrir caso →"}):null]},i.id))})]})}export{b as default};
+1
View File
@@ -0,0 +1 @@
import{j as e}from"./index-kE4_6-nI.js";function c({eyebrow:a,title:r,description:d,actions:s}){return e.jsxs("header",{className:"page-header",children:[e.jsxs("div",{children:[e.jsx("p",{className:"eyebrow",children:a}),e.jsx("h1",{children:r}),e.jsx("p",{className:"page-header__description",children:d})]}),s?e.jsx("div",{className:"page-header__actions",children:s}):null]})}export{c as P};
+1
View File
@@ -0,0 +1 @@
import{u,r as c,j as e,s as l,e as x,L as j,g}from"./index-kE4_6-nI.js";import{P as y}from"./PageHeader-BIrj68Xg.js";function v(){const{requisitions:r,advanceStage:d}=u(),[i,o]=c.useState("Todos"),p=c.useMemo(()=>r.filter(s=>s.status==="reclutando"&&(i==="Todos"||s.country===i)),[r,i]),h=[...new Set(r.filter(s=>s.status==="reclutando").map(s=>s.country))];return e.jsxs("div",{className:"page-stack pipeline-page",children:[e.jsx(y,{eyebrow:"Coordinación · Casos asignados",title:"Embudo de reclutamiento",description:"Actualiza la etapa; la duración y la auditoría se registran automáticamente.",actions:e.jsxs("label",{className:"header-select",children:[e.jsx("span",{children:"Territorio"}),e.jsxs("select",{value:i,onChange:s=>o(s.target.value),children:[e.jsx("option",{children:"Todos"}),h.map(s=>e.jsx("option",{children:s},s))]})]})}),e.jsx("section",{className:"pipeline","aria-label":"Etapas de reclutamiento",children:l.map((s,n)=>{const t=p.filter(a=>a.stage===s);return e.jsxs("div",{className:"pipeline-lane",children:[e.jsxs("header",{children:[e.jsx("span",{children:String(n+1).padStart(2,"0")}),e.jsx("h2",{children:x[s]}),e.jsx("b",{children:t.length})]}),e.jsxs("div",{className:"pipeline-lane__body",children:[t.map(a=>e.jsxs("article",{className:`pipeline-card signal-${g(a)}`,children:[e.jsxs("p",{children:[a.id," · Tier ",a.tier]}),e.jsx("h3",{children:a.position}),e.jsxs("span",{children:[a.city,", ",a.country]}),e.jsxs("dl",{children:[e.jsxs("div",{children:[e.jsx("dt",{children:"Vacantes"}),e.jsx("dd",{children:a.quantity})]}),e.jsxs("div",{children:[e.jsx("dt",{children:"Responsable"}),e.jsx("dd",{children:a.recruiter??"Sin asignar"})]})]}),e.jsxs("div",{children:[e.jsx(j,{to:`/requisiciones/${a.id}`,children:"Ver expediente"}),e.jsx("button",{type:"button",disabled:n===l.length-1,onClick:()=>d(a.id),children:n===l.length-1?"Cerrada":"Avanzar →"})]})]},a.id)),t.length===0?e.jsx("p",{className:"pipeline-empty",children:"Sin casos en esta etapa."}):null]})]},s)})})]})}export{v as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{u as b,r as l,j as s,L as n,c as g,g as m,b as v}from"./index-kE4_6-nI.js";import{E as y}from"./DashboardParts-nkkWHQei.js";import{P as q}from"./PageHeader-BIrj68Xg.js";import{S as f}from"./StatusBadge-B-6mWO83.js";const x=[{label:"Todas",status:"all"},{label:"Borradores",status:"borrador"},{label:"Correcciones",status:"correccion"},{label:"Pendiente de RRHH",status:"pendiente_rrhh"},{label:"Pendiente de Administración",status:"pendiente_administracion"},{label:"Aprobadas",status:"aprobada"},{label:"Reclutamiento activo",status:"reclutando"},{label:"Cerradas",status:"cerrada"}];function E({ownOnly:r=!1}){const{requisitions:o,role:u}=b(),[i,h]=l.useState("all"),[t,j]=l.useState("Todos"),[c,p]=l.useState(""),d=l.useMemo(()=>o.filter(e=>r&&e.requesterEmail!=="laura.medina@gomezleemarketing.com"||i!=="all"&&e.status!==i||t!=="Todos"&&e.country!==t?!1:`${e.id} ${e.position} ${e.city} ${e.country} ${e.client}`.toLowerCase().includes(c.toLowerCase())),[t,r,c,o,i]);return s.jsxs("div",{className:"page-stack",children:[s.jsx(q,{eyebrow:r?"Solicitudes propias · Coordinación":"Registro maestro · Vista global",title:r?"Mis requisiciones":"Requisiciones",description:r?"Consulta borradores, aprobaciones y reclutamientos sin perder el hilo del proceso.":"Explora el registro global con estado, territorio, responsable y señal de SLA.",actions:u==="coordinacion"?s.jsxs(n,{className:"button button--primary",to:"/requisiciones/nueva",children:[s.jsxs("svg",{viewBox:"0 0 24 24",width:"15",height:"15",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",style:{marginRight:"6px",verticalAlign:"middle",flexShrink:0},children:[s.jsx("line",{x1:"12",y1:"5",x2:"12",y2:"19"}),s.jsx("line",{x1:"5",y1:"12",x2:"19",y2:"12"})]}),"Nueva requisición"]}):void 0}),r?s.jsx("div",{className:"status-tabs",role:"tablist","aria-label":"Filtrar mis requisiciones",children:x.map(e=>s.jsxs("button",{type:"button",role:"tab","aria-selected":i===e.status,className:i===e.status?"is-active":"",onClick:()=>h(e.status),children:[e.label,s.jsx("span",{children:o.filter(a=>a.requesterEmail==="laura.medina@gomezleemarketing.com"&&(e.status==="all"||a.status===e.status)).length})]},e.status))}):null,s.jsxs("section",{className:"list-toolbar","aria-label":"Filtros de requisiciones",children:[s.jsxs("label",{className:"search-field",children:[s.jsx("span",{className:"sr-only",children:"Buscar"}),s.jsx("input",{type:"search",value:c,onChange:e=>p(e.target.value),placeholder:"Buscar por ID, posición, ciudad o proyecto"})]}),r?null:s.jsxs("label",{children:[s.jsx("span",{children:"Estado"}),s.jsxs("select",{value:i,onChange:e=>h(e.target.value),children:[s.jsx("option",{value:"all",children:"Todos"}),x.slice(1).map(e=>s.jsx("option",{value:e.status,children:e.label},e.status))]})]}),s.jsxs("label",{children:[s.jsx("span",{children:"Territorio"}),s.jsxs("select",{value:t,onChange:e=>j(e.target.value),children:[s.jsx("option",{children:"Todos"}),g.map(e=>s.jsx("option",{children:e},e))]})]}),s.jsxs("p",{children:[d.length," resultados"]})]}),d.length===0?s.jsx(y,{title:"No hay requisiciones en esta vista",detail:"Cambia los filtros o crea una nueva requisición.",action:u==="coordinacion"?s.jsx(n,{className:"button button--primary",to:"/requisiciones/nueva",children:"Crear requisición"}):void 0}):s.jsx("section",{className:"requisition-table table-wrap",children:s.jsxs("table",{children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"Requisición"}),s.jsx("th",{children:"Territorio"}),s.jsx("th",{children:"Estado"}),s.jsx("th",{children:"Responsable"}),s.jsx("th",{children:"SLA"}),s.jsx("th",{children:"Creada"}),s.jsx("th",{children:s.jsx("span",{className:"sr-only",children:"Abrir"})})]})}),s.jsx("tbody",{children:d.map(e=>{const a=m(e);return s.jsxs("tr",{className:`table-signal table-signal--${a}`,children:[s.jsx("td",{"data-label":"Requisición",children:s.jsxs(n,{to:`/requisiciones/${e.id}`,children:[s.jsx("strong",{children:e.position}),s.jsxs("small",{children:[e.id," · ",e.quantity," ",e.quantity===1?"vacante":"vacantes"," · Tier ",e.tier]})]})}),s.jsxs("td",{"data-label":"Territorio",children:[s.jsx("span",{children:e.country}),s.jsx("small",{children:e.city})]}),s.jsx("td",{"data-label":"Estado",children:s.jsx(f,{status:e.status})}),s.jsx("td",{"data-label":"Responsable",children:e.recruiter??(e.status==="pendiente_administracion"?"Administración":"RRHH")}),s.jsx("td",{"data-label":"SLA",children:s.jsx("span",{className:`sla-label sla-label--${a}`,children:e.slaMode==="cumplimiento"?"Por cumplimiento":a==="late"?"Vencido":a==="risk"?"En riesgo":a==="neutral"?"Sin iniciar":"En tiempo"})}),s.jsx("td",{"data-label":"Creada",children:v(e.createdAt)}),s.jsx("td",{children:s.jsx(n,{className:"row-arrow",to:`/requisiciones/${e.id}`,"aria-label":`Abrir ${e.id}`,children:"→"})})]},e.id)})})]})})]})}export{E as default};
+1
View File
@@ -0,0 +1 @@
import{j as a,i as t,k as e}from"./index-kE4_6-nI.js";function r({status:s}){return a.jsx("span",{className:`status-badge status-badge--${e[s]}`,children:t[s]})}export{r as S};
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2
View File
@@ -0,0 +1,2 @@
[LocalizedFileNames]
GLM.png=@GLM.png,0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4F758B" />
<meta name="description" content="Portal de requisición y seguimiento de vacantes de GomezLee Marketing" />
<link rel="icon" type="image/png" href="./brand/GLM.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Portal de Vacantes | GomezLee Marketing</title>
<script type="module" crossorigin src="/requisicion-vacantes/assets/index-kE4_6-nI.js"></script>
<link rel="stylesheet" crossorigin href="/requisicion-vacantes/assets/index-Bdmi_GGp.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
)
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4F758B" />
<meta name="description" content="Portal de requisición y seguimiento de vacantes de GomezLee Marketing" />
<link rel="icon" type="image/png" href="./brand/GLM.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Portal de Vacantes | GomezLee Marketing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3783
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "glm-portal-requisicion-vacantes",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"check": "tsc -b --pretty false"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0",
"recharts": "^3.1.2"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2
View File
@@ -0,0 +1,2 @@
[LocalizedFileNames]
GLM.png=@GLM.png,0
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+51
View File
@@ -0,0 +1,51 @@
import { lazy, Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from './components/AppShell'
import LoginPage from './pages/LoginPage'
import { usePortal } from './state/PortalContext'
import { roleHomes } from './utils'
const DirectorDashboard = lazy(() => import('./pages/DirectorDashboard'))
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
const ApprovalsPage = lazy(() => import('./pages/ApprovalsPage'))
const RequisitionsPage = lazy(() => import('./pages/RequisitionsPage'))
const RequisitionDetailPage = lazy(() => import('./pages/RequisitionDetailPage'))
const CapacityPage = lazy(() => import('./pages/CapacityPage'))
const ReportsPage = lazy(() => import('./pages/ReportsPage'))
const NotificationsPage = lazy(() => import('./pages/NotificationsPage'))
const NewRequisitionPage = lazy(() => import('./pages/NewRequisitionPage'))
const PipelinePage = lazy(() => import('./pages/PipelinePage'))
function LoadingPage() {
return <div className="loading-page" role="status"><span /><p>Cargando vista</p></div>
}
export function App() {
const { authenticated, role } = usePortal()
if (!authenticated) {
return <Routes><Route path="*" element={<LoginPage />} /></Routes>
}
return (
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Navigate to={roleHomes[role]} replace />} />
<Route path="dashboard" element={<DirectorDashboard />} />
<Route path="administracion" element={<AdminDashboard />} />
<Route path="aprobaciones" element={<ApprovalsPage />} />
<Route path="requisiciones" element={<RequisitionsPage />} />
<Route path="mis-requisiciones" element={<RequisitionsPage ownOnly />} />
<Route path="requisiciones/nueva" element={<NewRequisitionPage />} />
<Route path="requisiciones/:id" element={<RequisitionDetailPage />} />
<Route path="capacidad" element={<CapacityPage />} />
<Route path="reportes" element={<ReportsPage />} />
<Route path="pipeline" element={<PipelinePage />} />
<Route path="notificaciones" element={<NotificationsPage />} />
<Route path="*" element={<Navigate to={roleHomes[role]} replace />} />
</Route>
</Routes>
</Suspense>
)
}
+261
View File
@@ -0,0 +1,261 @@
import React, { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { usePortal } from '../state/PortalContext'
import type { PortalRole } from '../types'
import { roleHomes, roleLabels } from '../utils'
/* SVG icons for mobile nav */
const HomeIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><polyline points="9 22 9 12 15 12 15 22" />
</svg>
)
const CheckIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
)
const ListIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
)
const BellIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
const PlusIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="16" /><line x1="8" y1="12" x2="16" y2="12" />
</svg>
)
const UsersIcon = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
const mobileIconMap: Record<string, () => React.JSX.Element> = {
'/dashboard': HomeIcon,
'/administracion': HomeIcon,
'/mis-requisiciones': ListIcon,
'/aprobaciones': CheckIcon,
'/requisiciones': ListIcon,
'/requisiciones/nueva': PlusIcon,
'/pipeline': UsersIcon,
'/notificaciones': BellIcon,
}
const navigation: Record<PortalRole, Array<{ to: string; label: string; mark: string }>> = {
director: [
{ to: '/dashboard', label: 'Resumen', mark: '01' },
{ to: '/aprobaciones', label: 'Aprobaciones', mark: '02' },
{ to: '/requisiciones', label: 'Requisiciones', mark: '03' },
{ to: '/capacidad', label: 'Capacidad', mark: '04' },
{ to: '/reportes', label: 'Reportes', mark: '05' },
{ to: '/notificaciones', label: 'Notificaciones', mark: '06' },
],
administracion: [
{ to: '/administracion', label: 'Resumen', mark: '01' },
{ to: '/aprobaciones', label: 'Aprobaciones', mark: '02' },
{ to: '/requisiciones', label: 'Requisiciones', mark: '03' },
{ to: '/notificaciones', label: 'Notificaciones', mark: '04' },
],
coordinacion: [
{ to: '/mis-requisiciones', label: 'Mis requisiciones', mark: '01' },
{ to: '/requisiciones/nueva', label: 'Nueva requisición', mark: '02' },
{ to: '/pipeline', label: 'Mis casos', mark: '03' },
{ to: '/notificaciones', label: 'Notificaciones', mark: '04' },
],
}
export function AppShell() {
const { role, setRole, logout, notifications, flash } = usePortal()
const [showUserMenu, setShowUserMenu] = useState(false)
const [showMobileDrawer, setShowMobileDrawer] = useState(false)
const userMenuRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const unread = notifications.filter((item) => !item.read).length
const items = navigation[role]
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false)
}
}
if (showUserMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showUserMenu])
// Close drawer on navigation
useEffect(() => {
setShowMobileDrawer(false)
}, [navigate])
// Lock body scroll when drawer open
useEffect(() => {
if (showMobileDrawer) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => { document.body.style.overflow = '' }
}, [showMobileDrawer])
const changeRole = (nextRole: PortalRole) => {
setRole(nextRole)
navigate(roleHomes[nextRole])
}
return (
<div className="app-frame">
<a className="skip-link" href="#contenido">Saltar al contenido</a>
{/* Mobile drawer backdrop */}
{showMobileDrawer && (
<div
className="mobile-drawer-backdrop"
aria-hidden="true"
onClick={() => setShowMobileDrawer(false)}
/>
)}
<aside className={`side-nav${showMobileDrawer ? ' is-open' : ''}`} aria-label="Navegación principal">
<div className="side-nav__brand">
<img src={`${import.meta.env.BASE_URL}brand/logo-glm.png`} alt="GomezLee Marketing" />
</div>
<p className="side-nav__context">{roleLabels[role]}</p>
<nav>
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-item${isActive ? ' nav-item--active' : ''}`}
onClick={() => setShowMobileDrawer(false)}
>
<span className="nav-item__mark" aria-hidden="true">{item.mark}</span>
<span>{item.label}</span>
{item.to === '/notificaciones' && unread > 0 ? <span className="nav-item__count">{unread}</span> : null}
</NavLink>
))}
</nav>
<div className="side-nav__footer" ref={userMenuRef}>
{showUserMenu && (
<div className="user-menu-popover" role="menu">
<div className="user-menu-popover__header">
<strong>Laura Medina</strong>
<small>{roleLabels[role]}</small>
</div>
<button
type="button"
className="user-menu-logout-btn"
onClick={() => {
setShowUserMenu(false)
logout()
}}
role="menuitem"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Cerrar sesión</span>
</button>
</div>
)}
<button
type="button"
className={`user-profile-trigger ${showUserMenu ? 'is-active' : ''}`}
onClick={() => setShowUserMenu((prev) => !prev)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<span className="avatar" aria-hidden="true">LM</span>
<span className="user-profile-trigger__name">Laura Medina</span>
<svg
className={`user-profile-trigger__chevron ${showUserMenu ? 'is-rotated' : ''}`}
viewBox="0 0 20 20"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
</svg>
</button>
</div>
</aside>
<div className="app-main">
<header className="top-bar">
{/* Hamburger - mobile only */}
<button
type="button"
className="hamburger-btn"
aria-label="Abrir menú"
aria-expanded={showMobileDrawer}
onClick={() => setShowMobileDrawer((prev) => !prev)}
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{showMobileDrawer
? <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></>
: <><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></>
}
</svg>
</button>
<div className="top-bar__title">
<span>Portal de vacantes</span>
<small>GomezLee Marketing</small>
</div>
<div className="role-switch" aria-label="Cambiar vista de prototipo">
{(Object.keys(roleLabels) as PortalRole[]).map((item) => (
<button key={item} type="button" className={item === role ? 'is-active' : ''} onClick={() => changeRole(item)}>
{roleLabels[item]}
</button>
))}
</div>
<button className="notification-button" type="button" onClick={() => navigate('/notificaciones')} aria-label={`${unread} notificaciones sin leer`}>
<svg className="notification-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{unread > 0 ? <b>{unread}</b> : null}
</button>
</header>
<main id="contenido" className="page-canvas">
<Outlet />
</main>
</div>
{/* Icon-based mobile bottom nav */}
<nav className="mobile-nav" aria-label="Navegación móvil">
{items.slice(0, 4).map((item) => {
const Icon = mobileIconMap[item.to] ?? ListIcon
return (
<NavLink key={item.to} to={item.to} className={({ isActive }) => (isActive ? 'is-active' : '')}>
<span className="mobile-nav__icon" aria-hidden="true">
<Icon />
{item.to === '/notificaciones' && unread > 0 ? <b>{unread}</b> : null}
</span>
<span>{item.label}</span>
</NavLink>
)
})}
</nav>
{flash ? <div className="toast" role="status"><span aria-hidden="true"></span>{flash}</div> : null}
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react'
export function Metric({ label, value, note, tone = 'blue' }: { label: string; value: string | number; note: string; tone?: 'blue' | 'green' | 'orange' | 'red' }) {
return (
<article className={`metric metric--${tone}`}>
<span className="metric__line" aria-hidden="true" />
<p>{label}</p>
<strong>{value}</strong>
<small>{note}</small>
</article>
)
}
export function ChartPanel({ title, description, filter, children, className = '' }: {
title: string
description: string
filter: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={`chart-panel ${className}`}>
<header className="chart-panel__header">
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div className="chart-panel__filter">{filter}</div>
</header>
<div className="chart-panel__body">{children}</div>
</section>
)
}
export function PanelFilter({ label, value, onChange, options }: {
label: string
value: string
onChange: (value: string) => void
options: string[]
}) {
return (
<label className="compact-select">
<span className="sr-only">{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)} aria-label={label}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</label>
)
}
export function EmptyState({ title, detail, action }: { title: string; detail: string; action?: ReactNode }) {
return (
<div className="empty-state">
<span aria-hidden="true">00</span>
<h2>{title}</h2>
<p>{detail}</p>
{action}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
interface GlmLogoProps extends React.SVGProps<SVGSVGElement> {
className?: string
}
export function GlmLogo({ className, ...props }: GlmLogoProps) {
return (
<svg
viewBox="0 0 450 160"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
{/* G */}
<path
fill="#5d7689"
d="M 48 10 C 27 10 10 27 10 48 L 10 112 C 10 133 27 150 48 150 L 132 150 C 137.5 150 142 145.5 142 140 L 142 134 C 142 128.5 137.5 124 132 124 L 50 124 C 42.3 124 36 117.7 36 110 L 36 103 C 36 97.5 40.5 93 46 93 L 132 93 C 137.5 93 142 88.5 142 83 L 142 77 C 142 71.5 137.5 67 132 67 L 46 67 C 40.5 67 36 62.5 36 57 L 36 50 C 36 42.3 42.3 36 50 36 L 132 36 C 137.5 36 142 31.5 142 26 L 142 20 C 142 14.5 137.5 10 132 10 Z"
/>
{/* L */}
<path
fill="#6cc24a"
d="M 154 20 C 154 14.5 158.5 10 164 10 L 170 10 C 175.5 10 180 14.5 180 20 L 180 112 C 180 118.6 185.4 124 192 124 L 244 124 C 249.5 124 254 128.5 254 134 L 254 140 C 254 145.5 249.5 150 244 150 L 190 150 C 170.1 150 154 133.9 154 114 Z"
/>
{/* M */}
<path
fill="#5d7689"
d="M 266 20 C 266 14.5 270.5 10 276 10 C 281.5 10 286 14.5 286 20 L 286 114 L 342 54 C 345.5 50.2 351.5 50.2 355 54 L 411 114 L 411 20 C 411 14.5 415.5 10 421 10 C 426.5 10 431 14.5 431 20 L 431 140 C 431 145.5 426.5 150 421 150 C 417 150 413.5 147.6 411.8 144 L 348.5 76 L 285.2 144 C 283.5 147.6 280 150 276 150 C 270.5 150 266 145.5 266 140 Z"
/>
</svg>
)
}
export default GlmLogo
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, type ReactNode } from 'react'
export function Modal({ open, title, description, onClose, children, footer }: {
open: boolean
title: string
description?: string
onClose: () => void
children: ReactNode
footer?: ReactNode
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" onMouseDown={(event) => event.stopPropagation()}>
<header className="modal__header">
<div>
<p className="eyebrow">Acción registrada</p>
<h2 id="modal-title">{title}</h2>
{description ? <p>{description}</p> : null}
</div>
<button className="icon-button" type="button" onClick={onClose} aria-label="Cerrar">×</button>
</header>
<div className="modal__body">{children}</div>
{footer ? <footer className="modal__footer">{footer}</footer> : null}
</section>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
export function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description: string; actions?: ReactNode }) {
return (
<header className="page-header">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
<p className="page-header__description">{description}</p>
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { statusLabels } from '../data/catalogs'
import type { RequisitionStatus } from '../types'
import { statusTone } from '../utils'
export function StatusBadge({ status }: { status: RequisitionStatus }) {
return <span className={`status-badge status-badge--${statusTone[status]}`}>{statusLabels[status]}</span>
}
+78
View File
@@ -0,0 +1,78 @@
import type { RecruitingStage, RequisitionStatus, VacancyType } from '../types'
export const countries = [
'Colombia',
'Costa Rica',
'El Salvador',
'Guatemala',
'Honduras',
'Jamaica',
'México',
'Nicaragua',
'Panamá',
'Puerto Rico',
'República Dominicana',
'Trinidad y Tobago',
]
export const citiesByCountry: Record<string, string[]> = {
Colombia: ['Bogotá', 'Medellín', 'Cali'],
'Costa Rica': ['San José', 'Heredia', 'Alajuela'],
'El Salvador': ['San Salvador', 'Santa Ana', 'San Miguel'],
Guatemala: ['Ciudad de Guatemala', 'Quetzaltenango', 'Escuintla'],
Honduras: ['Tegucigalpa', 'San Pedro Sula', 'La Ceiba'],
Jamaica: ['Kingston', 'Montego Bay'],
México: ['Ciudad de México', 'Monterrey', 'Guadalajara'],
Nicaragua: ['Managua', 'León', 'Matagalpa'],
Panamá: ['Ciudad de Panamá', 'Colón', 'Chiriquí'],
'Puerto Rico': ['San Juan', 'Ponce', 'Bayamón'],
'República Dominicana': ['Santo Domingo', 'Santiago de los Caballeros', 'Mao'],
'Trinidad y Tobago': ['Puerto España', 'San Fernando'],
}
export const positions = [
'Mercaderista',
'Promotor/a',
'Impulsador/a',
'Representante de ventas',
'Analista de datos',
'Analista de RRHH',
'Desarrollador/a full-stack',
'Supervisor/a',
'Coordinador/a',
'Gerente de proyecto',
'Country Manager',
]
export const clients = ['GomezLee Marketing', 'Proyecto Alba', 'Cuenta Caribe', 'Cuenta Regional', 'Operación Andina']
export const vacancyTypes: VacancyType[] = ['Nueva', 'Reemplazo', 'Temporera', 'Vacaciones', 'Incapacidad']
export const statusLabels: Record<RequisitionStatus, string> = {
borrador: 'Borrador',
pendiente_rrhh: 'Pendiente de RRHH',
pendiente_administracion: 'Pendiente de Administración',
correccion: 'Corrección requerida',
aprobada: 'Aprobada',
reclutando: 'Reclutamiento activo',
cerrada: 'Cerrada',
cancelada: 'Cancelada',
}
export const stageLabels: Record<RecruitingStage, string> = {
busqueda: 'Búsqueda',
primera_entrevista: '1.ª entrevista',
segunda_entrevista: '2.ª entrevista',
referencias: 'Referencias',
examenes_medicos: 'Exámenes médicos',
seleccionado: 'Seleccionado',
}
export const stageOrder: RecruitingStage[] = [
'busqueda',
'primera_entrevista',
'segunda_entrevista',
'referencias',
'examenes_medicos',
'seleccionado',
]
+330
View File
@@ -0,0 +1,330 @@
import type { NotificationItem, RecruiterCapacity, Requisition } from '../types'
const audit = (id: string, actor: string, action: string, detail: string, at: string) => ({
id,
actor,
action,
detail,
at,
})
export const initialRequisitions: Requisition[] = [
{
id: 'REQ-260817-0142',
groupId: 'SOL-2026-0084',
position: 'Desarrollador/a full-stack',
country: 'República Dominicana',
city: 'Santo Domingo',
client: 'GomezLee Marketing',
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: 'Nueva',
quantity: 2,
duration: 'Indefinida',
startDate: '2026-09-01',
justification: 'Ampliación del equipo digital para asumir dos productos regionales durante el siguiente trimestre.',
comments: 'Ambas posiciones comparten ciudad, perfil y paquete salarial.',
budget: 2400,
salaryPackage: 'USD 1,200 mensuales + beneficios de ley por posición',
currency: 'USD',
status: 'pendiente_rrhh',
stage: 'busqueda',
tier: 2,
createdAt: '2026-08-16T09:20:00-04:00',
submittedAt: '2026-08-16T09:32:00-04:00',
dueDate: '2026-08-28',
slaTargetDays: 10,
approvalDays: 1,
recruitingDays: 0,
slaMode: 'dias',
units: [
{ id: 'REQ-260817-0142-01', label: 'Vacante 1 de 2', stage: 'busqueda' },
{ id: 'REQ-260817-0142-02', label: 'Vacante 2 de 2', stage: 'busqueda' },
],
audit: [audit('a-142-1', 'Laura Medina', 'Envió la requisición', 'Inició el SLA y pasó a revisión de RRHH.', '2026-08-16T09:32:00-04:00')],
},
{
id: 'REQ-260815-0138',
groupId: 'SOL-2026-0081',
position: 'Supervisor/a',
country: 'Panamá',
city: 'Ciudad de Panamá',
client: 'Cuenta Caribe',
requester: 'Diego Santos',
requesterEmail: 'diego.santos@gomezleemarketing.com',
type: 'Reemplazo',
quantity: 1,
duration: 'Indefinida',
startDate: '2026-08-31',
replacementName: 'Colaborador saliente',
replacementExitDate: '2026-08-21',
justification: 'Continuidad de la supervisión de campo tras una salida confirmada del equipo.',
budget: 1800,
salaryPackage: 'PAB 1,800 + viáticos de movilización',
currency: 'PAB',
status: 'pendiente_administracion',
stage: 'busqueda',
tier: 3,
createdAt: '2026-08-14T11:00:00-05:00',
submittedAt: '2026-08-14T11:18:00-05:00',
dueDate: '2026-08-27',
slaTargetDays: 9,
approvalDays: 2,
recruitingDays: 0,
slaMode: 'dias',
units: [{ id: 'REQ-260815-0138-01', label: 'Vacante 1 de 1', stage: 'busqueda' }],
audit: [
audit('a-138-1', 'Diego Santos', 'Envió la requisición', 'Inició el SLA.', '2026-08-14T11:18:00-05:00'),
audit('a-138-2', 'Dirección de RRHH', 'Aprobó la revisión inicial', 'La solicitud pasó a Administración.', '2026-08-15T15:05:00-05:00'),
],
},
{
id: 'REQ-260813-0131',
groupId: 'SOL-2026-0076',
position: 'Analista de datos',
country: 'Colombia',
city: 'Bogotá',
client: 'Operación Andina',
requester: 'Camila Acosta',
requesterEmail: 'camila.acosta@gomezleemarketing.com',
type: 'Nueva',
quantity: 1,
duration: '12 meses',
startDate: '2026-09-15',
justification: 'Cobertura analítica para el despliegue del nuevo modelo de reportes operativos.',
budget: 6200000,
salaryPackage: 'COP 6,200,000 + beneficios',
currency: 'COP',
status: 'aprobada',
stage: 'busqueda',
tier: 2,
createdAt: '2026-08-12T08:10:00-05:00',
submittedAt: '2026-08-12T08:22:00-05:00',
dueDate: '2026-09-15',
slaTargetDays: 15,
approvalDays: 3,
recruitingDays: 0,
slaMode: 'cumplimiento',
units: [{ id: 'REQ-260813-0131-01', label: 'Vacante 1 de 1', stage: 'busqueda' }],
audit: [
audit('a-131-1', 'Camila Acosta', 'Envió la requisición', 'Inició el SLA.', '2026-08-12T08:22:00-05:00'),
audit('a-131-2', 'Dirección de RRHH', 'Aprobó la revisión inicial', 'La solicitud pasó a Administración.', '2026-08-13T10:04:00-05:00'),
audit('a-131-3', 'Administración', 'Aprobó el presupuesto', 'Pendiente de activación de reclutamiento.', '2026-08-15T16:40:00-05:00'),
],
},
{
id: 'REQ-260806-0119',
groupId: 'SOL-2026-0068',
position: 'Mercaderista',
country: 'Guatemala',
city: 'Ciudad de Guatemala',
client: 'Proyecto Alba',
requester: 'Natalia Fuentes',
requesterEmail: 'natalia.fuentes@gomezleemarketing.com',
type: 'Nueva',
quantity: 8,
duration: '6 meses',
startDate: '2026-08-24',
justification: 'Apertura simultánea de ocho rutas de ejecución para la temporada de mayor demanda.',
budget: 36000,
salaryPackage: 'GTQ 4,500 + variable por posición',
currency: 'GTQ',
status: 'reclutando',
stage: 'primera_entrevista',
tier: 1,
recruiter: 'Carla Méndez',
createdAt: '2026-08-05T09:00:00-06:00',
submittedAt: '2026-08-05T09:15:00-06:00',
dueDate: '2026-08-21',
slaTargetDays: 12,
approvalDays: 2,
recruitingDays: 6,
slaMode: 'dias',
units: Array.from({ length: 8 }, (_, index) => ({
id: `REQ-260806-0119-${String(index + 1).padStart(2, '0')}`,
label: `Vacante ${index + 1} de 8`,
stage: index < 3 ? 'segunda_entrevista' : 'primera_entrevista',
})),
audit: [
audit('a-119-1', 'Natalia Fuentes', 'Envió la requisición', 'Inició el SLA.', '2026-08-05T09:15:00-06:00'),
audit('a-119-2', 'Administración', 'Aprobó el presupuesto', 'La solicitud quedó lista para activación.', '2026-08-07T12:20:00-06:00'),
audit('a-119-3', 'Dirección de RRHH', 'Activó el reclutamiento', 'Asignada a Carla Méndez.', '2026-08-07T14:15:00-06:00'),
],
},
{
id: 'REQ-260729-0103',
groupId: 'SOL-2026-0059',
position: 'Representante de ventas',
country: 'México',
city: 'Monterrey',
client: 'Cuenta Regional',
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: 'Reemplazo',
quantity: 1,
duration: 'Indefinida',
startDate: '2026-08-20',
replacementName: 'Colaborador saliente',
replacementExitDate: '2026-08-14',
justification: 'Reemplazo prioritario para evitar una ruta comercial sin cobertura.',
budget: 28000,
salaryPackage: 'MXN 28,000 + esquema variable',
currency: 'MXN',
status: 'reclutando',
stage: 'referencias',
tier: 2,
recruiter: 'Elena Núñez',
createdAt: '2026-07-29T10:30:00-06:00',
submittedAt: '2026-07-29T10:44:00-06:00',
dueDate: '2026-08-14',
slaTargetDays: 12,
approvalDays: 4,
recruitingDays: 10,
slaMode: 'dias',
units: [{ id: 'REQ-260729-0103-01', label: 'Vacante 1 de 1', stage: 'referencias', candidate: 'Candidatura finalista' }],
audit: [audit('a-103-1', 'Laura Medina', 'Envió la requisición', 'Inició el SLA.', '2026-07-29T10:44:00-06:00')],
},
{
id: 'REQ-260810-0125',
groupId: 'SOL-2026-0072',
position: 'Coordinador/a',
country: 'Costa Rica',
city: 'San José',
client: 'GomezLee Marketing',
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: 'Nueva',
quantity: 1,
duration: 'Indefinida',
startDate: '2026-09-01',
justification: 'Fortalecer la coordinación del equipo de campo y el control de calidad de la operación.',
comments: 'Administración solicitó precisar el componente variable.',
budget: 2100,
salaryPackage: 'USD 2,100 + variable por definir',
currency: 'USD',
status: 'correccion',
stage: 'busqueda',
tier: 3,
createdAt: '2026-08-10T13:00:00-06:00',
submittedAt: '2026-08-10T13:24:00-06:00',
dueDate: '2026-08-31',
slaTargetDays: 15,
approvalDays: 5,
recruitingDays: 0,
slaMode: 'dias',
units: [{ id: 'REQ-260810-0125-01', label: 'Vacante 1 de 1', stage: 'busqueda' }],
audit: [
audit('a-125-1', 'Laura Medina', 'Envió la requisición', 'Inició el SLA.', '2026-08-10T13:24:00-06:00'),
audit('a-125-2', 'Administración', 'Solicitó corrección', 'Especificar el componente variable del paquete salarial.', '2026-08-14T10:12:00-06:00'),
],
},
{
id: 'REQ-260701-0078',
groupId: 'SOL-2026-0042',
position: 'Analista de RRHH',
country: 'República Dominicana',
city: 'Santiago de los Caballeros',
client: 'GomezLee Marketing',
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: 'Nueva',
quantity: 1,
duration: 'Indefinida',
startDate: '2026-07-25',
justification: 'Cobertura permanente para el equipo regional de selección.',
budget: 1100,
salaryPackage: 'USD 1,100 + beneficios',
currency: 'USD',
status: 'cerrada',
stage: 'seleccionado',
tier: 2,
recruiter: 'David López',
createdAt: '2026-07-01T08:45:00-04:00',
submittedAt: '2026-07-01T09:00:00-04:00',
dueDate: '2026-07-22',
slaTargetDays: 15,
approvalDays: 3,
recruitingDays: 11,
slaMode: 'dias',
units: [{ id: 'REQ-260701-0078-01', label: 'Vacante 1 de 1', stage: 'seleccionado', candidate: 'Candidatura seleccionada', closedAt: '2026-07-20' }],
audit: [audit('a-078-1', 'David López', 'Cerró la vacante', 'Candidatura seleccionada. SLA cumplido.', '2026-07-20T16:00:00-04:00')],
},
{
id: 'BOR-260817-0006',
groupId: 'BOR-2026-0006',
position: 'Promotor/a',
country: 'Puerto Rico',
city: 'San Juan',
client: 'Cuenta Caribe',
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: 'Temporera',
quantity: 4,
duration: '3 meses',
startDate: '2026-09-10',
justification: 'Refuerzo temporal para activaciones de temporada.',
budget: 0,
salaryPackage: 'Pendiente de completar',
currency: 'USD',
status: 'borrador',
stage: 'busqueda',
tier: 1,
createdAt: '2026-08-17T08:10:00-04:00',
slaTargetDays: 0,
approvalDays: 0,
recruitingDays: 0,
slaMode: 'cumplimiento',
units: [],
audit: [audit('a-bor-1', 'Laura Medina', 'Guardó un borrador', 'La requisición aún no inicia el SLA.', '2026-08-17T08:10:00-04:00')],
},
]
export const initialRecruiters: RecruiterCapacity[] = [
{
id: 'rec-1',
name: 'Elena Núñez',
countries: ['México', 'Puerto Rico'],
score: 18,
assignments: [
{ requisitionId: 'REQ-260729-0103', position: 'Representante de ventas', tier: 2, phase: 'Referencias', points: 3 },
{ requisitionId: 'REQ-260802-0110', position: 'Supervisor/a de campo', tier: 3, phase: '2.ª entrevista', points: 5 },
],
},
{
id: 'rec-2',
name: 'David López',
countries: ['República Dominicana', 'Panamá'],
score: 10,
assignments: [
{ requisitionId: 'REQ-260809-0123', position: 'Analista de datos', tier: 2, phase: '1.ª entrevista', points: 3 },
],
},
{
id: 'rec-3',
name: 'Carla Méndez',
countries: ['Guatemala', 'Honduras'],
score: 27,
assignments: [
{ requisitionId: 'REQ-260806-0119', position: 'Mercaderista', tier: 1, phase: '1.ª entrevista', points: 3 },
{ requisitionId: 'REQ-260804-0115', position: 'Country Manager', tier: 3, phase: 'Búsqueda', points: 5 },
{ requisitionId: 'REQ-260731-0106', position: 'Coordinador/a', tier: 3, phase: 'Referencias', points: 5 },
],
},
{
id: 'rec-4',
name: 'Tomás Reyes',
countries: ['Colombia', 'Costa Rica'],
score: 7,
assignments: [
{ requisitionId: 'REQ-260803-0112', position: 'Promotor/a', tier: 1, phase: '2.ª entrevista', points: 3 },
],
},
]
export const initialNotifications: NotificationItem[] = [
{ id: 'n-1', title: 'Presupuesto listo para revisión', detail: 'REQ-260815-0138 espera la aprobación de Administración.', at: 'Hace 24 min', kind: 'accion', read: false, requisitionId: 'REQ-260815-0138' },
{ id: 'n-2', title: 'SLA en riesgo', detail: 'REQ-260729-0103 superó su fecha objetivo por 1 día hábil.', at: 'Hace 1 h', kind: 'sla', read: false, requisitionId: 'REQ-260729-0103' },
{ id: 'n-3', title: 'Capacidad crítica', detail: 'Carla Méndez alcanzó 27 puntos de carga.', at: 'Hace 3 h', kind: 'sla', read: false },
{ id: 'n-4', title: 'Corrección solicitada', detail: 'Administración pidió precisar el componente variable de REQ-260810-0125.', at: 'Ayer', kind: 'accion', read: true, requisitionId: 'REQ-260810-0125' },
{ id: 'n-5', title: 'Vacante cerrada', detail: 'REQ-260701-0078 cerró dentro del SLA acordado.', at: '20 jul', kind: 'sistema', read: true, requisitionId: 'REQ-260701-0078' },
]
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { App } from './App'
import { PortalProvider } from './state/PortalContext'
import './styles.css'
const basename = import.meta.env.BASE_URL.replace(/\/$/, '')
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={basename}>
<PortalProvider>
<App />
</PortalProvider>
</BrowserRouter>
</StrictMode>,
)
+63
View File
@@ -0,0 +1,63 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { ChartPanel, Metric, PanelFilter } from '../components/DashboardParts'
import { PageHeader } from '../components/PageHeader'
import { StatusBadge } from '../components/StatusBadge'
import { usePortal } from '../state/PortalContext'
import { formatMoney } from '../utils'
const impact = [
{ territorio: 'RD', monto: 18400 },
{ territorio: 'PA', monto: 12600 },
{ territorio: 'CO', monto: 9800 },
{ territorio: 'GT', monto: 7200 },
{ territorio: 'MX', monto: 6100 },
]
export default function AdminDashboard() {
const { requisitions } = usePortal()
const [impactScope, setImpactScope] = useState('Pendientes')
const pending = useMemo(() => requisitions.filter((item) => item.status === 'pendiente_administracion'), [requisitions])
const total = pending.reduce((sum, item) => sum + (item.currency === 'USD' || item.currency === 'PAB' ? item.budget : item.budget / 4000), 0)
return (
<div className="page-stack">
<PageHeader eyebrow="Vista financiera · Administración" title="Decisiones de presupuesto" description="Revisa el impacto económico y resuelve las solicitudes que ya validó RRHH." actions={<Link className="button button--primary" to="/aprobaciones">Revisar aprobaciones</Link>} />
<section className="metrics-grid metrics-grid--three">
<Metric label="Pendientes de decisión" value={pending.length} note="Validadas por RRHH" tone="orange" />
<Metric label="Impacto estimado" value={formatMoney(total, 'USD')} note="Equivalente mensual" />
<Metric label="Tiempo de respuesta" value="1.8 d" note="Meta interna: ≤ 2 días" tone="green" />
</section>
<div className="dashboard-grid dashboard-grid--lead">
<section className="data-panel approval-preview">
<header><div><h2>Cola de aprobación</h2><p>Ordenada por riesgo de SLA</p></div><Link to="/aprobaciones">Abrir cola</Link></header>
<div className="table-wrap">
<table>
<thead><tr><th>Requisición</th><th>Presupuesto</th><th>Estado</th><th><span className="sr-only">Acción</span></th></tr></thead>
<tbody>
{pending.map((item) => <tr key={item.id}><td data-label="Requisición"><Link to={`/requisiciones/${item.id}`}><strong>{item.position}</strong><small>{item.id} · {item.country}</small></Link></td><td data-label="Presupuesto">{formatMoney(item.budget, item.currency)}</td><td data-label="Estado"><StatusBadge status={item.status} /></td><td><Link className="text-link" to={`/requisiciones/${item.id}`}>Revisar</Link></td></tr>)}
</tbody>
</table>
</div>
</section>
<ChartPanel title="Impacto por territorio" description="Equivalente mensual de paquetes abiertos" filter={<PanelFilter label="Filtrar impacto" value={impactScope} onChange={setImpactScope} options={['Pendientes', 'Aprobados', 'Todos']} />}>
<div className="chart-height chart-height--compact">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={impact} layout="vertical" margin={{ top: 8, right: 18, bottom: 0, left: -10 }}>
<CartesianGrid horizontal={false} stroke="#E4E8EA" />
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#6B7780', fontSize: 11 }} />
<YAxis type="category" dataKey="territorio" axisLine={false} tickLine={false} tick={{ fill: '#4A4A4A', fontSize: 11 }} width={35} />
<Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} />
<Bar dataKey="monto" fill="#4F758B" />
</BarChart>
</ResponsiveContainer>
</div>
</ChartPanel>
</div>
<section className="policy-strip">
<span>REGLA</span><strong>Administración solo recibe solicitudes aprobadas por RRHH.</strong><p>Una decisión actualiza la auditoría y notifica al solicitante.</p>
</section>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { EmptyState, Metric } from '../components/DashboardParts'
import { Modal } from '../components/Modal'
import { PageHeader } from '../components/PageHeader'
import { StatusBadge } from '../components/StatusBadge'
import { usePortal } from '../state/PortalContext'
import type { Requisition } from '../types'
import { formatDate, formatMoney, getSlaState } from '../utils'
type ModalMode = 'reject' | 'activate' | null
export default function ApprovalsPage() {
const { role, requisitions, recruiters, approveHr, approveAdmin, reject, activateRecruitment } = usePortal()
const [selected, setSelected] = useState<Requisition | null>(null)
const [mode, setMode] = useState<ModalMode>(null)
const [reason, setReason] = useState('')
const [recruiter, setRecruiter] = useState(recruiters[0]?.name ?? '')
const queue = useMemo(() => requisitions.filter((item) => {
if (role === 'administracion') return item.status === 'pendiente_administracion'
return item.status === 'pendiente_rrhh' || item.status === 'aprobada'
}), [requisitions, role])
const totalBudget = queue.reduce((sum, item) => sum + ((item.currency === 'USD' || item.currency === 'PAB') ? item.budget : item.budget / 4000), 0)
const openModal = (item: Requisition, nextMode: ModalMode) => {
setSelected(item)
setReason('')
setMode(nextMode)
}
const closeModal = () => {
setSelected(null)
setMode(null)
}
const confirmReject = () => {
if (!selected || reason.trim().length < 10) return
reject(selected.id, reason.trim())
closeModal()
}
const confirmActivation = () => {
if (!selected || !recruiter) return
activateRecruitment(selected.id, recruiter)
closeModal()
}
return (
<div className="page-stack">
<PageHeader
eyebrow={role === 'administracion' ? 'Puerta final · Administración' : 'Control secuencial · Dirección RRHH'}
title="Aprobaciones"
description={role === 'administracion' ? 'Resuelve presupuesto y paquete salarial después de la validación de RRHH.' : 'Valida solicitudes y activa el reclutamiento cuando ambas firmas estén completas.'}
/>
<section className="metrics-grid metrics-grid--three">
<Metric label="En tu cola" value={queue.length} note="Ordenadas por SLA" tone="orange" />
<Metric label="Impacto visible" value={formatMoney(totalBudget, 'USD')} note="Equivalente mensual" />
<Metric label="Más antigua" value="3 d" note="Desde el envío" tone={queue.length ? 'red' : 'green'} />
</section>
<section className="approval-queue">
{queue.length === 0 ? (
<EmptyState title="No hay aprobaciones pendientes" detail="Las nuevas solicitudes aparecerán aquí cuando completen el paso anterior." />
) : queue.map((item) => {
const state = getSlaState(item)
const canActivate = item.status === 'aprobada'
return (
<article className={`approval-card signal-${state}`} key={item.id}>
<div className="approval-card__index"><span>{item.id}</span><small>{formatDate(item.submittedAt)}</small></div>
<div className="approval-card__main">
<div className="approval-card__title"><div><p>{item.country} · {item.city}</p><h2>{item.position}</h2></div><StatusBadge status={item.status} /></div>
<dl className="approval-card__facts">
<div><dt>Solicitante</dt><dd>{item.requester}</dd></div>
<div><dt>Cliente / proyecto</dt><dd>{item.client}</dd></div>
<div><dt>Vacantes</dt><dd>{item.quantity} · Tier {item.tier}</dd></div>
<div><dt>Inicio requerido</dt><dd>{formatDate(item.startDate)}</dd></div>
</dl>
<div className="approval-card__budget">
<div><span>Presupuesto mensual</span><strong>{formatMoney(item.budget, item.currency)}</strong></div>
<div><span>Paquete salarial</span><p>{item.salaryPackage}</p></div>
</div>
</div>
<div className="approval-card__actions">
<Link className="button button--quiet" to={`/requisiciones/${item.id}`}>Ver expediente</Link>
{canActivate ? (
<button className="button button--primary" type="button" onClick={() => openModal(item, 'activate')}>Activar reclutamiento</button>
) : (
<>
<button className="button button--danger-quiet" type="button" onClick={() => openModal(item, 'reject')}>Solicitar corrección</button>
<button className="button button--primary" type="button" onClick={() => role === 'administracion' ? approveAdmin(item.id) : approveHr(item.id)}>Aprobar {role === 'administracion' ? 'presupuesto' : 'solicitud'}</button>
</>
)}
</div>
</article>
)
})}
</section>
<Modal
open={mode === 'reject'}
title="Solicitar una corrección"
description="El motivo será visible para el solicitante y quedará en la auditoría."
onClose={closeModal}
footer={<><button type="button" className="button button--quiet" onClick={closeModal}>Cancelar</button><button type="button" className="button button--danger" disabled={reason.trim().length < 10} onClick={confirmReject}>Enviar corrección</button></>}
>
<label className="field"><span>Motivo de la corrección <b>*</b></span><textarea rows={5} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Indica exactamente qué debe corregirse…" /><small>Mínimo 10 caracteres.</small></label>
</Modal>
<Modal
open={mode === 'activate'}
title="Activar el reclutamiento"
description="Asigna a la persona que dará seguimiento al caso."
onClose={closeModal}
footer={<><button type="button" className="button button--quiet" onClick={closeModal}>Cancelar</button><button type="button" className="button button--primary" onClick={confirmActivation}>Activar y asignar</button></>}
>
<label className="field"><span>Reclutador/a</span><select value={recruiter} onChange={(event) => setRecruiter(event.target.value)}>{recruiters.map((item) => <option key={item.id}>{item.name} · {item.score} pts</option>)}</select></label>
<div className="inline-note">El reloj del SLA ya está corriendo desde el envío. La activación inicia el tramo de reclutamiento.</div>
</Modal>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Metric } from '../components/DashboardParts'
import { Modal } from '../components/Modal'
import { PageHeader } from '../components/PageHeader'
import { usePortal } from '../state/PortalContext'
import { capacityState } from '../utils'
interface Selection {
requisitionId: string
from: string
position: string
}
export default function CapacityPage() {
const { recruiters, requisitions, reassign } = usePortal()
const [selection, setSelection] = useState<Selection | null>(null)
const [target, setTarget] = useState('')
const [reason, setReason] = useState('')
const critical = recruiters.filter((item) => item.score >= 26).length
const available = recruiters.filter((item) => item.score <= 15).length
const activeCases = recruiters.reduce((sum, item) => sum + item.assignments.length, 0)
const suggestedTarget = useMemo(() => [...recruiters].sort((a, b) => a.score - b.score).find((item) => item.name !== selection?.from)?.name ?? '', [recruiters, selection])
const openReassign = (nextSelection: Selection) => {
setSelection(nextSelection)
setTarget([...recruiters].sort((a, b) => a.score - b.score).find((item) => item.name !== nextSelection.from)?.name ?? '')
setReason('')
}
const close = () => setSelection(null)
const confirm = () => {
if (!selection || !target || reason.trim().length < 10) return
reassign(selection.requisitionId, target, reason.trim())
close()
}
return (
<div className="page-stack">
<PageHeader eyebrow="Dirección de RRHH · Planeación" title="Capacidad del equipo" description="Balancea la carga antes de que la presión se convierta en retraso." />
<section className="metrics-grid metrics-grid--three">
<Metric label="Reclutadores disponibles" value={available} note="015 puntos" tone="green" />
<Metric label="Capacidad crítica" value={critical} note="26+ puntos" tone="red" />
<Metric label="Casos asignados" value={activeCases} note="Requisiciones activas" />
</section>
<section className="capacity-formula" aria-label="Fórmula del puntaje de carga">
<div><span>COMPLEJIDAD</span><strong>Tier 1 = 1 · Tier 2 = 3 · Tier 3 = 5</strong></div>
<i>+</i>
<div><span>VOLUMEN</span><strong>12 = 0 · 310 = 2 · 11+ = 4</strong></div>
<i>=</i>
<div><span>PUNTAJE</span><strong>Disponible · Al límite · Crítico</strong></div>
</section>
<div className="capacity-thresholds">
<div className="threshold threshold--ok"><span>015</span><b>Disponible</b><p>Puede asumir nuevas posiciones.</p></div>
<div className="threshold threshold--risk"><span>1625</span><b>Al límite</b><p>Evitar asignaciones masivas.</p></div>
<div className="threshold threshold--late"><span>26+</span><b>Crítico</b><p>Reasignar o sumar apoyo temporal.</p></div>
</div>
<section className="recruiter-grid">
{recruiters.map((recruiter) => {
const state = capacityState(recruiter.score)
return (
<article className={`recruiter-card recruiter-card--${state.tone}`} key={recruiter.id}>
<header>
<span className="avatar">{recruiter.name.split(' ').map((part) => part[0]).join('')}</span>
<div><h2>{recruiter.name}</h2><p>{recruiter.countries.join(' · ')}</p></div>
<div className="score"><strong>{recruiter.score}</strong><span>puntos</span></div>
</header>
<div className="recruiter-card__status"><span>{state.label}</span><progress className={`capacity-progress capacity-progress--${state.tone}`} max="30" value={Math.min(30, recruiter.score)} /></div>
<div className="assignment-list">
<div className="assignment-list__head"><span>Vacante</span><span>Tier / fase</span></div>
{recruiter.assignments.map((assignment) => {
const availableToMove = requisitions.some((item) => item.id === assignment.requisitionId)
return (
<div className="assignment" key={assignment.requisitionId}>
<div><Link to={availableToMove ? `/requisiciones/${assignment.requisitionId}` : '#'}>{assignment.position}</Link><small>{assignment.requisitionId}</small></div>
<div><b>T{assignment.tier}</b><span>{assignment.phase}</span></div>
<button type="button" disabled={!availableToMove} onClick={() => openReassign({ requisitionId: assignment.requisitionId, from: recruiter.name, position: assignment.position })}>{availableToMove ? 'Reasignar' : 'Referencia'}</button>
</div>
)
})}
</div>
</article>
)
})}
</section>
<Modal
open={Boolean(selection)}
title={`Reasignar ${selection?.position ?? ''}`}
description={`Mover ${selection?.requisitionId ?? ''} desde ${selection?.from ?? ''}.`}
onClose={close}
footer={<><button className="button button--quiet" type="button" onClick={close}>Cancelar</button><button className="button button--primary" type="button" disabled={!target || reason.trim().length < 10} onClick={confirm}>Confirmar reasignación</button></>}
>
<div className="reassignment-preview"><div><span>Sale de</span><strong>{selection?.from}</strong></div><b aria-hidden="true"></b><div><span>Recomendado</span><strong>{suggestedTarget}</strong></div></div>
<label className="field"><span>Nuevo/a reclutador/a <b>*</b></span><select value={target} onChange={(event) => setTarget(event.target.value)}>{recruiters.filter((item) => item.name !== selection?.from).map((item) => <option key={item.id} value={item.name}>{item.name} · {item.score} puntos</option>)}</select></label>
<label className="field"><span>Nota de entrega <b>*</b></span><textarea rows={4} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Estado de candidatos y motivo de la transferencia…" /><small>Se notificará a ambas personas y quedará en la auditoría.</small></label>
</Modal>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { ChartPanel, Metric, PanelFilter } from '../components/DashboardParts'
import { PageHeader } from '../components/PageHeader'
import { StatusBadge } from '../components/StatusBadge'
import { countries } from '../data/catalogs'
import { usePortal } from '../state/PortalContext'
import { capacityState, getSlaState } from '../utils'
const ttfTrend = [
{ mes: 'Mar', dias: 18 },
{ mes: 'Abr', dias: 17 },
{ mes: 'May', dias: 19 },
{ mes: 'Jun', dias: 15 },
{ mes: 'Jul', dias: 14 },
{ mes: 'Ago', dias: 12 },
]
const countryActivity = [
{ pais: 'RD', activas: 8, cerradas: 5 },
{ pais: 'GT', activas: 6, cerradas: 4 },
{ pais: 'CO', activas: 5, cerradas: 6 },
{ pais: 'PA', activas: 4, cerradas: 3 },
{ pais: 'MX', activas: 3, cerradas: 4 },
{ pais: 'CR', activas: 2, cerradas: 3 },
]
const stageHeat = [
{ name: 'Búsqueda', value: 2.1, tone: 'ok' },
{ name: '1.ª entrevista', value: 4.8, tone: 'late' },
{ name: '2.ª entrevista', value: 3.4, tone: 'risk' },
{ name: 'Referencias', value: 1.6, tone: 'ok' },
{ name: 'Exámenes médicos', value: 2.7, tone: 'risk' },
]
export default function DirectorDashboard() {
const { requisitions, recruiters } = usePortal()
const [country, setCountry] = useState('Todos los países')
const [period, setPeriod] = useState('Últimos 6 meses')
const [slaScope, setSlaScope] = useState('Casos activos')
const [ttfScope, setTtfScope] = useState('Global')
const [stageScope, setStageScope] = useState('Promedio global')
const [countryScope, setCountryScope] = useState('Top 6')
const filtered = useMemo(
() => requisitions.filter((item) => country === 'Todos los países' || item.country === country),
[country, requisitions],
)
const active = filtered.filter((item) => !['borrador', 'cerrada', 'cancelada'].includes(item.status))
const pending = active.filter((item) => item.status.startsWith('pendiente')).length
const risks = active.filter((item) => ['risk', 'late'].includes(getSlaState(item))).length
const slaCounts = active.reduce(
(result, item) => ({ ...result, [getSlaState(item)]: result[getSlaState(item)] + 1 }),
{ ok: 0, risk: 0, late: 0, neutral: 0 },
)
const urgent = active.filter((item) => ['risk', 'late'].includes(getSlaState(item))).slice(0, 4)
return (
<div className="page-stack">
<PageHeader
eyebrow="Vista global · Dirección de RRHH"
title="El pulso del reclutamiento"
description="Aprobaciones, SLA y capacidad del equipo en una sola lectura operativa."
actions={
<div className="filter-pair">
<label><span>Territorio</span><select value={country} onChange={(event) => setCountry(event.target.value)}><option>Todos los países</option>{countries.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span>Período</span><select value={period} onChange={(event) => setPeriod(event.target.value)}><option>Últimos 6 meses</option><option>Este mes</option><option>Este trimestre</option></select></label>
</div>
}
/>
<section className="metrics-grid" aria-label="Indicadores principales">
<Metric label="Casos activos" value={active.length} note={`${active.filter((item) => item.status === 'reclutando').length} en reclutamiento`} />
<Metric label="Por aprobar" value={pending} note="RRHH + Administración" tone="orange" />
<Metric label="SLA en atención" value={risks} note="En riesgo o vencidos" tone="red" />
<Metric label="TTF promedio" value="12 d" note="2 días menos que julio" tone="green" />
</section>
<div className="dashboard-grid dashboard-grid--lead">
<ChartPanel
title="Semáforo de SLA"
description="Tiempo total desde el envío de la requisición"
filter={<PanelFilter label="Filtrar semáforo" value={slaScope} onChange={setSlaScope} options={['Casos activos', 'Pendientes', 'En reclutamiento']} />}
className="chart-panel--signal"
>
<div className="sla-traffic" role="img" aria-label={`${slaCounts.ok} dentro del SLA, ${slaCounts.risk} en riesgo y ${slaCounts.late} vencidos`}>
<div className="sla-traffic__total"><strong>{active.length}</strong><span>casos medidos</span></div>
<div className="sla-traffic__segments">
<div className="sla-segment sla-segment--ok"><b>{slaCounts.ok}</b><span>En tiempo</span><small>Margen mayor a 3 días</small></div>
<div className="sla-segment sla-segment--risk"><b>{slaCounts.risk}</b><span>En riesgo</span><small>3 días o menos</small></div>
<div className="sla-segment sla-segment--late"><b>{slaCounts.late}</b><span>Vencidos</span><small>Acción inmediata</small></div>
</div>
<div className="sla-split"><span>Aprobación <b>3.1 d</b></span><i /><span>Reclutamiento <b>8.9 d</b></span></div>
</div>
</ChartPanel>
<ChartPanel
title="TTF en descenso"
description="Días hábiles desde envío hasta cierre"
filter={<PanelFilter label="Filtrar TTF" value={ttfScope} onChange={setTtfScope} options={['Global', 'Por país', 'Por tier']} />}
>
<div className="chart-height chart-height--compact">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={ttfTrend} margin={{ top: 12, right: 18, bottom: 0, left: -18 }}>
<CartesianGrid vertical={false} stroke="#E4E8EA" />
<XAxis dataKey="mes" axisLine={false} tickLine={false} tick={{ fill: '#6B7780', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#6B7780', fontSize: 11 }} />
<Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} />
<Line type="monotone" dataKey="dias" stroke="#4F758B" strokeWidth={3} dot={{ fill: '#6CC24A', stroke: '#FFFFFF', strokeWidth: 2, r: 4 }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</ChartPanel>
</div>
<div className="dashboard-grid">
<ChartPanel
title="Velocidad por etapa"
description="El color revela dónde se concentra la espera"
filter={<PanelFilter label="Filtrar velocidad" value={stageScope} onChange={setStageScope} options={['Promedio global', 'Tier 1', 'Tier 2', 'Tier 3']} />}
>
<div className="stage-heatmap">
{stageHeat.map((item) => <div key={item.name} className={`heat-row heat-row--${item.tone}`}><span>{item.name}</span><b>{item.value} d</b><i style={{ width: `${item.value * 16}%` }} /></div>)}
</div>
<p className="chart-insight"><strong>Cuello de botella:</strong> la 1.ª entrevista concentra el 31% del tiempo total.</p>
</ChartPanel>
<ChartPanel
title="Actividad por territorio"
description="Casos activos y cierres del período"
filter={<PanelFilter label="Filtrar territorios" value={countryScope} onChange={setCountryScope} options={['Top 6', 'Todos', 'Solo Caribe']} />}
>
<div className="chart-height chart-height--compact">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={countryActivity} margin={{ top: 12, right: 4, bottom: 0, left: -24 }}>
<CartesianGrid vertical={false} stroke="#E4E8EA" />
<XAxis dataKey="pais" axisLine={false} tickLine={false} tick={{ fill: '#6B7780', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#6B7780', fontSize: 11 }} />
<Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} />
<Bar dataKey="activas" fill="#4F758B" />
<Bar dataKey="cerradas" fill="#6CC24A" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-legend"><span><i className="legend-blue" />Activas</span><span><i className="legend-green" />Cerradas</span></div>
</ChartPanel>
</div>
<div className="dashboard-grid dashboard-grid--bottom">
<section className="data-panel">
<header><div><h2>Atención hoy</h2><p>Casos que requieren una decisión</p></div><Link to="/requisiciones">Ver todos</Link></header>
<div className="attention-list">
{urgent.map((item) => (
<Link key={item.id} to={`/requisiciones/${item.id}`} className={`attention-row signal-${getSlaState(item)}`}>
<div><b>{item.position}</b><span>{item.id} · {item.country}</span></div>
<StatusBadge status={item.status} />
<strong>{getSlaState(item) === 'late' ? 'Vencido' : '≤ 3 días'}</strong>
</Link>
))}
</div>
</section>
<section className="data-panel capacity-preview">
<header><div><h2>Capacidad del equipo</h2><p>Puntaje de carga activo</p></div><Link to="/capacidad">Planear</Link></header>
<div>
{recruiters.map((recruiter) => {
const state = capacityState(recruiter.score)
return <div className="capacity-line" key={recruiter.id}><span className="avatar avatar--small">{recruiter.name.split(' ').map((part) => part[0]).join('')}</span><div><b>{recruiter.name}</b><small>{state.label}</small></div><progress value={Math.min(recruiter.score, 30)} max="30" className={`capacity-progress capacity-progress--${state.tone}`} /><strong>{recruiter.score}</strong></div>
})}
</div>
</section>
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { useNavigate } from 'react-router-dom'
import { usePortal } from '../state/PortalContext'
import { roleHomes } from '../utils'
export default function LoginPage() {
const { login, role } = usePortal()
const navigate = useNavigate()
const handleLogin = () => {
login()
navigate(roleHomes[role])
}
return (
<main className="login-page">
<div className="login-card-container">
<div className="login-card">
<div className="login-card__top-bar" />
<div className="login-card__logo-box">
<img
src={`${import.meta.env.BASE_URL}brand/GLM.png`}
alt="GLM"
className="login-card__logo-img"
/>
</div>
<h1 className="login-card__title">Portal de Requisición de Vacantes</h1>
<p className="login-card__subtitle">GOMEZLEE MARKETING</p>
<button className="login-google-btn" type="button" onClick={handleLogin}>
<svg className="login-google-btn__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
<span>Continuar con Google</span>
</button>
</div>
</div>
</main>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PageHeader } from '../components/PageHeader'
import { citiesByCountry, clients, countries, positions, vacancyTypes } from '../data/catalogs'
import { usePortal } from '../state/PortalContext'
import type { RequisitionFormValue } from '../types'
const steps = [
{ id: 1, label: 'Vacante', helper: 'Qué y dónde' },
{ id: 2, label: 'Contexto', helper: 'Por qué y cuándo' },
{ id: 3, label: 'Compensación', helper: 'Presupuesto' },
{ id: 4, label: 'Revisión', helper: 'Confirmar' },
]
const initialValue: RequisitionFormValue = {
country: '',
city: '',
position: '',
vacancyType: 'Nueva',
quantity: 1,
duration: '',
startDate: '',
client: '',
replacementName: '',
replacementExitDate: '',
justification: '',
comments: '',
budget: '',
salaryPackage: '',
currency: 'USD',
}
export default function NewRequisitionPage() {
const { createRequisition } = usePortal()
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [value, setValue] = useState(initialValue)
const [errors, setErrors] = useState<Record<string, string>>({})
const cities = value.country ? citiesByCountry[value.country] ?? [] : []
const requiresReplacement = value.vacancyType !== 'Nueva'
const tier = /gerente|manager|coordinador|supervisor/i.test(value.position) ? 3 : /analista|desarrollador|representante/i.test(value.position) ? 2 : 1
const baseDays = tier === 1 ? 5 : tier === 2 ? 10 : 15
const targetDays = baseDays + Math.max(0, value.quantity - 1) * 2
const futureMode = value.startDate ? new Date(value.startDate).getTime() - Date.now() > 25 * 86_400_000 : false
const update = <Key extends keyof RequisitionFormValue>(key: Key, nextValue: RequisitionFormValue[Key]) => {
setValue((current) => ({ ...current, [key]: nextValue, ...(key === 'country' ? { city: '' } : {}) }))
if (errors[key]) setErrors((current) => ({ ...current, [key]: '' }))
}
const validate = (currentStep: number) => {
const nextErrors: Record<string, string> = {}
if (currentStep === 1) {
if (!value.country) nextErrors.country = 'Selecciona el país.'
if (!value.city) nextErrors.city = 'Selecciona la ciudad.'
if (!value.position) nextErrors.position = 'Selecciona la posición.'
if (!value.duration.trim()) nextErrors.duration = 'Indica la duración.'
if (!value.startDate) nextErrors.startDate = 'Indica la fecha de inicio.'
if (!value.client) nextErrors.client = 'Selecciona el cliente o proyecto.'
if (value.quantity < 1) nextErrors.quantity = 'La cantidad mínima es 1.'
}
if (currentStep === 2) {
if (requiresReplacement && !value.replacementName.trim()) nextErrors.replacementName = 'Indica la persona o posición que se reemplaza.'
if (requiresReplacement && !value.replacementExitDate) nextErrors.replacementExitDate = 'Indica la fecha de salida.'
if (value.justification.trim().length < 20) nextErrors.justification = 'Explica el motivo con al menos 20 caracteres.'
}
if (currentStep === 3) {
if (!value.budget || Number(value.budget) <= 0) nextErrors.budget = 'Indica un presupuesto mayor que cero.'
if (value.salaryPackage.trim().length < 8) nextErrors.salaryPackage = 'Describe el paquete salarial.'
}
setErrors(nextErrors)
return Object.keys(nextErrors).length === 0
}
const next = () => {
if (validate(step)) setStep((current) => Math.min(4, current + 1))
}
const previous = () => setStep((current) => Math.max(1, current - 1))
const safeDraft = useMemo<RequisitionFormValue>(() => ({
...value,
country: value.country || 'Sin definir',
city: value.city || 'Sin definir',
position: value.position || 'Posición sin definir',
duration: value.duration || 'Sin definir',
startDate: value.startDate || new Date().toISOString().slice(0, 10),
client: value.client || 'Sin definir',
}), [value])
const saveDraft = () => {
createRequisition(safeDraft, true)
navigate('/mis-requisiciones')
}
const submit = () => {
const id = createRequisition(value, false)
navigate(`/requisiciones/${id}`)
}
return (
<div className="page-stack form-page">
<PageHeader eyebrow="Coordinación · Nueva solicitud" title="Requisición de personal" description="Completa lo esencial. La ruta de aprobación y el SLA se generan al enviar." />
<ol className="form-stepper" aria-label="Progreso del formulario">
{steps.map((item) => <li key={item.id} className={`${step === item.id ? 'is-current' : ''}${step > item.id ? ' is-complete' : ''}`}><button type="button" disabled={item.id > step} onClick={() => setStep(item.id)}><span>{step > item.id ? '✓' : String(item.id).padStart(2, '0')}</span><b>{item.label}</b><small>{item.helper}</small></button></li>)}
</ol>
<div className="form-layout">
<form className="requisition-form" onSubmit={(event) => event.preventDefault()} noValidate>
{step === 1 ? (
<section className="form-section" aria-labelledby="step-1-title">
<header><p>PASO 01</p><h2 id="step-1-title">Información de la vacante</h2><span>Tipo, cantidad y duración se definen primero para calcular correctamente IDs y SLA.</span></header>
<div className="field-grid">
<label className="field"><span>País <b>*</b></span><select value={value.country} onChange={(event) => update('country', event.target.value)} className={errors.country ? 'is-invalid' : ''}><option value="">Seleccionar país</option>{countries.map((item) => <option key={item}>{item}</option>)}</select>{errors.country ? <small className="field-error">{errors.country}</small> : null}</label>
<label className="field"><span>Ciudad / zona <b>*</b></span><select value={value.city} disabled={!value.country} onChange={(event) => update('city', event.target.value)} className={errors.city ? 'is-invalid' : ''}><option value="">{value.country ? 'Seleccionar ciudad' : 'Primero selecciona el país'}</option>{cities.map((item) => <option key={item}>{item}</option>)}</select>{errors.city ? <small className="field-error">{errors.city}</small> : null}</label>
<label className="field field--wide"><span>Posición <b>*</b></span><select value={value.position} onChange={(event) => update('position', event.target.value)} className={errors.position ? 'is-invalid' : ''}><option value="">Seleccionar posición</option>{positions.map((item) => <option key={item}>{item}</option>)}</select>{errors.position ? <small className="field-error">{errors.position}</small> : null}</label>
<fieldset className="field field--wide choice-field"><legend>Tipo de vacante <b>*</b></legend><div>{vacancyTypes.map((item) => <label key={item} className={value.vacancyType === item ? 'is-selected' : ''}><input type="radio" name="vacancyType" value={item} checked={value.vacancyType === item} onChange={() => update('vacancyType', item)} /><span>{item}</span></label>)}</div></fieldset>
<label className="field"><span>Cantidad de vacantes <b>*</b></span><div className="number-control"><button type="button" onClick={() => update('quantity', Math.max(1, value.quantity - 1))} aria-label="Restar una vacante"></button><input type="number" min="1" max="99" value={value.quantity} onChange={(event) => update('quantity', Math.max(1, Number(event.target.value)))} /><button type="button" onClick={() => update('quantity', Math.min(99, value.quantity + 1))} aria-label="Agregar una vacante">+</button></div>{errors.quantity ? <small className="field-error">{errors.quantity}</small> : <small>Se crearán {value.quantity} IDs individuales bajo una sola solicitud.</small>}</label>
<label className="field"><span>Duración de la posición <b>*</b></span><input value={value.duration} onChange={(event) => update('duration', event.target.value)} placeholder="Ej. Indefinida, 6 meses" className={errors.duration ? 'is-invalid' : ''} />{errors.duration ? <small className="field-error">{errors.duration}</small> : null}</label>
<label className="field"><span>Fecha de inicio requerida <b>*</b></span><input type="date" value={value.startDate} onChange={(event) => update('startDate', event.target.value)} className={errors.startDate ? 'is-invalid' : ''} />{errors.startDate ? <small className="field-error">{errors.startDate}</small> : null}</label>
<label className="field"><span>Cliente / proyecto <b>*</b></span><select value={value.client} onChange={(event) => update('client', event.target.value)} className={errors.client ? 'is-invalid' : ''}><option value="">Seleccionar cliente o proyecto</option>{clients.map((item) => <option key={item}>{item}</option>)}</select>{errors.client ? <small className="field-error">{errors.client}</small> : null}</label>
</div>
</section>
) : null}
{step === 2 ? (
<section className="form-section" aria-labelledby="step-2-title">
<header><p>PASO 02</p><h2 id="step-2-title">Contexto de la solicitud</h2><span>La información condicional aparece solo cuando realmente hace falta.</span></header>
<div className="field-grid">
{requiresReplacement ? <div className="conditional-fields field--wide"><p>Datos de {value.vacancyType.toLowerCase()}</p><div className="field-grid"><label className="field"><span>Persona o posición a reemplazar <b>*</b></span><input value={value.replacementName} onChange={(event) => update('replacementName', event.target.value)} className={errors.replacementName ? 'is-invalid' : ''} />{errors.replacementName ? <small className="field-error">{errors.replacementName}</small> : null}</label><label className="field"><span>Fecha de salida <b>*</b></span><input type="date" value={value.replacementExitDate} onChange={(event) => update('replacementExitDate', event.target.value)} className={errors.replacementExitDate ? 'is-invalid' : ''} />{errors.replacementExitDate ? <small className="field-error">{errors.replacementExitDate}</small> : null}</label></div></div> : null}
<label className="field field--wide"><span>Justificación de la requisición <b>*</b></span><textarea rows={6} value={value.justification} onChange={(event) => update('justification', event.target.value)} placeholder="Describe el motivo, el impacto operativo y por qué se necesita ahora…" className={errors.justification ? 'is-invalid' : ''} />{errors.justification ? <small className="field-error">{errors.justification}</small> : <small>{value.justification.length} caracteres · mínimo 20</small>}</label>
<label className="field field--wide"><span>Comentarios adicionales</span><textarea rows={4} value={value.comments} onChange={(event) => update('comments', event.target.value)} placeholder="Uniformes, horario, requisitos de movilidad u otra aclaración relevante…" /></label>
</div>
</section>
) : null}
{step === 3 ? (
<section className="form-section" aria-labelledby="step-3-title">
<header><p>PASO 03</p><h2 id="step-3-title">Compensación y presupuesto</h2><span>Estos datos solo serán visibles para los roles autorizados.</span></header>
<div className="privacy-note"><span aria-hidden="true">PRIVADO</span><p>RRHH revisa el paquete; Administración realiza la aprobación financiera final.</p></div>
<div className="field-grid">
<label className="field"><span>Moneda <b>*</b></span><select value={value.currency} onChange={(event) => update('currency', event.target.value as RequisitionFormValue['currency'])}><option>USD</option><option>DOP</option><option>GTQ</option><option>COP</option><option>PAB</option><option>MXN</option></select></label>
<label className="field"><span>Presupuesto mensual total <b>*</b></span><input type="number" min="0" value={value.budget} onChange={(event) => update('budget', event.target.value)} placeholder="0" className={errors.budget ? 'is-invalid' : ''} />{errors.budget ? <small className="field-error">{errors.budget}</small> : <small>Para {value.quantity} {value.quantity === 1 ? 'vacante' : 'vacantes'}.</small>}</label>
<label className="field field--wide"><span>Paquete salarial <b>*</b></span><textarea rows={5} value={value.salaryPackage} onChange={(event) => update('salaryPackage', event.target.value)} placeholder="Salario base, variable, viáticos, beneficios y cualquier condición relevante…" className={errors.salaryPackage ? 'is-invalid' : ''} />{errors.salaryPackage ? <small className="field-error">{errors.salaryPackage}</small> : null}</label>
</div>
</section>
) : null}
{step === 4 ? (
<section className="form-section review-step" aria-labelledby="step-4-title">
<header><p>PASO 04</p><h2 id="step-4-title">Revisa antes de enviar</h2><span>Al enviar, el reloj del SLA inicia y la solicitud pasa a RRHH.</span></header>
<div className="review-hero"><div><p>SOLICITUD</p><h3>{value.position}</h3><span>{value.city}, {value.country} · {value.client}</span></div><strong>{value.quantity}<small>{value.quantity === 1 ? 'vacante' : 'vacantes'}</small></strong></div>
<div className="review-grid">
<section><h4>Vacante</h4><dl><div><dt>Tipo</dt><dd>{value.vacancyType}</dd></div><div><dt>Duración</dt><dd>{value.duration}</dd></div><div><dt>Inicio</dt><dd>{value.startDate}</dd></div><div><dt>Tier estimado</dt><dd>Tier {tier}</dd></div></dl><button type="button" onClick={() => setStep(1)}>Editar</button></section>
<section><h4>Contexto</h4><p>{value.justification}</p>{value.comments ? <small>{value.comments}</small> : null}<button type="button" onClick={() => setStep(2)}>Editar</button></section>
<section><h4>Compensación</h4><dl><div><dt>Presupuesto</dt><dd>{value.currency} {Number(value.budget).toLocaleString('es-419')}</dd></div><div><dt>Paquete</dt><dd>{value.salaryPackage}</dd></div></dl><button type="button" onClick={() => setStep(3)}>Editar</button></section>
</div>
<div className="sla-preview"><span className={futureMode ? 'sla-preview__date' : 'sla-preview__days'}>{futureMode ? 'CUMPLE / NO CUMPLE' : `${targetDays} DÍAS HÁBILES`}</span><div><h4>SLA estimado</h4><p>{futureMode ? 'La fecha de inicio es futura; se medirá el cumplimiento de la incorporación, no días transcurridos.' : `${baseDays} días base + ${Math.max(0, value.quantity - 1) * 2} días por volumen.`}</p></div></div>
<div className="id-preview"><p>Esta solicitud generará:</p>{Array.from({ length: Math.min(value.quantity, 5) }, (_, index) => <span key={index}>REQ--{String(index + 1).padStart(2, '0')}</span>)}{value.quantity > 5 ? <span>+ {value.quantity - 5} IDs</span> : null}</div>
</section>
) : null}
<footer className="form-actions">
<button className="button button--quiet" type="button" onClick={saveDraft}>Guardar borrador</button>
<div>{step > 1 ? <button className="button button--quiet" type="button" onClick={previous}>Anterior</button> : null}{step < 4 ? <button className="button button--primary" type="button" onClick={next}>Continuar</button> : <button className="button button--primary" type="button" onClick={submit}>Enviar a RRHH</button>}</div>
</footer>
</form>
<aside className="form-aside">
<section><p className="eyebrow">Solicitante</p><div className="requester"><span className="avatar">LM</span><div><b>Laura Medina</b><small>laura.medina@gomezleemarketing.com</small></div></div><p>El país y los permisos se validarán con el perfil de Google al integrar el backend.</p></section>
<section className="form-aside__route"><p className="eyebrow">Ruta de aprobación</p><ol><li className="is-current"><span>01</span><b>RRHH</b><small>Validación inicial</small></li><li><span>02</span><b>Administración</b><small>Presupuesto final</small></li><li><span>03</span><b>Dirección RRHH</b><small>Activa reclutamiento</small></li></ol></section>
<section className="form-tip"><span>BUENA PRÁCTICA</span><p>Si dos personas tienen distinta ciudad o salario, crea requisiciones separadas. Si comparten ambos, usa la cantidad.</p></section>
</aside>
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { EmptyState } from '../components/DashboardParts'
import { PageHeader } from '../components/PageHeader'
import { usePortal } from '../state/PortalContext'
type NotificationFilter = 'todas' | 'sin_leer' | 'sla' | 'accion'
export default function NotificationsPage() {
const { notifications, markNotification } = usePortal()
const [filter, setFilter] = useState<NotificationFilter>('todas')
const visible = useMemo(() => notifications.filter((item) => {
if (filter === 'sin_leer') return !item.read
if (filter === 'sla') return item.kind === 'sla'
if (filter === 'accion') return item.kind === 'accion'
return true
}), [filter, notifications])
return (
<div className="page-stack">
<PageHeader eyebrow="Centro de actividad" title="Notificaciones" description="Todo lo que exige una decisión, sin mezclarlo con ruido operativo." />
<div className="status-tabs status-tabs--compact" role="tablist" aria-label="Filtrar notificaciones">
{([['todas', 'Todas'], ['sin_leer', 'Sin leer'], ['sla', 'SLA'], ['accion', 'Acción requerida']] as const).map(([value, label]) => <button type="button" role="tab" aria-selected={filter === value} className={filter === value ? 'is-active' : ''} key={value} onClick={() => setFilter(value)}>{label}<span>{notifications.filter((item) => value === 'todas' || (value === 'sin_leer' ? !item.read : item.kind === value)).length}</span></button>)}
</div>
{visible.length === 0 ? <EmptyState title="No hay notificaciones" detail="Este filtro está al día." /> : (
<section className="notification-list">
{visible.map((item) => (
<article key={item.id} className={`notification notification--${item.kind}${item.read ? ' is-read' : ''}`}>
<button type="button" className="notification__body" onClick={() => markNotification(item.id)}>
<span className="notification__signal" aria-hidden="true" />
<div><p>{item.kind === 'sla' ? 'SLA' : item.kind === 'accion' ? 'ACCIÓN' : 'SISTEMA'} · {item.at}</p><h2>{item.title}</h2><span>{item.detail}</span></div>
{!item.read ? <i aria-label="Sin leer" /> : null}
</button>
{item.requisitionId ? <Link to={`/requisiciones/${item.requisitionId}`}>Abrir caso </Link> : null}
</article>
))}
</section>
)}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { PageHeader } from '../components/PageHeader'
import { stageLabels, stageOrder } from '../data/catalogs'
import { usePortal } from '../state/PortalContext'
import { getSlaState } from '../utils'
export default function PipelinePage() {
const { requisitions, advanceStage } = usePortal()
const [territory, setTerritory] = useState('Todos')
const active = useMemo(() => requisitions.filter((item) => item.status === 'reclutando' && (territory === 'Todos' || item.country === territory)), [requisitions, territory])
const territories = [...new Set(requisitions.filter((item) => item.status === 'reclutando').map((item) => item.country))]
return (
<div className="page-stack pipeline-page">
<PageHeader eyebrow="Coordinación · Casos asignados" title="Embudo de reclutamiento" description="Actualiza la etapa; la duración y la auditoría se registran automáticamente." actions={<label className="header-select"><span>Territorio</span><select value={territory} onChange={(event) => setTerritory(event.target.value)}><option>Todos</option>{territories.map((item) => <option key={item}>{item}</option>)}</select></label>} />
<section className="pipeline" aria-label="Etapas de reclutamiento">
{stageOrder.map((stage, stageIndex) => {
const cases = active.filter((item) => item.stage === stage)
return (
<div className="pipeline-lane" key={stage}>
<header><span>{String(stageIndex + 1).padStart(2, '0')}</span><h2>{stageLabels[stage]}</h2><b>{cases.length}</b></header>
<div className="pipeline-lane__body">
{cases.map((item) => (
<article className={`pipeline-card signal-${getSlaState(item)}`} key={item.id}>
<p>{item.id} · Tier {item.tier}</p>
<h3>{item.position}</h3>
<span>{item.city}, {item.country}</span>
<dl><div><dt>Vacantes</dt><dd>{item.quantity}</dd></div><div><dt>Responsable</dt><dd>{item.recruiter ?? 'Sin asignar'}</dd></div></dl>
<div><Link to={`/requisiciones/${item.id}`}>Ver expediente</Link><button type="button" disabled={stageIndex === stageOrder.length - 1} onClick={() => advanceStage(item.id)}>{stageIndex === stageOrder.length - 1 ? 'Cerrada' : 'Avanzar →'}</button></div>
</article>
))}
{cases.length === 0 ? <p className="pipeline-empty">Sin casos en esta etapa.</p> : null}
</div>
</div>
)
})}
</section>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { ChartPanel, PanelFilter } from '../components/DashboardParts'
import { PageHeader } from '../components/PageHeader'
import { countries } from '../data/catalogs'
const statusData = [
{ name: 'Reclutamiento', value: 14, color: '#4F758B' },
{ name: 'Aprobación', value: 6, color: '#F0C75E' },
{ name: 'Corrección', value: 3, color: '#FF6A13' },
{ name: 'Cerradas', value: 11, color: '#6CC24A' },
]
const tierData = [{ tier: 'Tier 1', dias: 8 }, { tier: 'Tier 2', dias: 13 }, { tier: 'Tier 3', dias: 21 }]
const responsibilityData = [
{ mes: 'Mar', aprobacion: 4.1, reclutamiento: 13.9 },
{ mes: 'Abr', aprobacion: 3.8, reclutamiento: 13.2 },
{ mes: 'May', aprobacion: 5.2, reclutamiento: 13.8 },
{ mes: 'Jun', aprobacion: 3.4, reclutamiento: 11.6 },
{ mes: 'Jul', aprobacion: 3.1, reclutamiento: 10.9 },
{ mes: 'Ago', aprobacion: 2.7, reclutamiento: 9.3 },
]
const slaTrend = [{ mes: 'Mar', valor: 82 }, { mes: 'Abr', valor: 84 }, { mes: 'May', valor: 79 }, { mes: 'Jun', valor: 87 }, { mes: 'Jul', valor: 89 }, { mes: 'Ago', valor: 92 }]
export default function ReportsPage() {
const [country, setCountry] = useState('Todos los países')
const [period, setPeriod] = useState('Últimos 6 meses')
const [statusScope, setStatusScope] = useState('Todas')
const [tierScope, setTierScope] = useState('Global')
const [responsibilityScope, setResponsibilityScope] = useState('Promedio')
const [slaScope, setSlaScope] = useState('Todos los tiers')
return (
<div className="page-stack">
<PageHeader
eyebrow="Analítica · Dirección de RRHH"
title="Reportes"
description="Mide desempeño sin ocultar dónde se consume el tiempo."
actions={<div className="filter-pair"><label><span>Territorio</span><select value={country} onChange={(event) => setCountry(event.target.value)}><option>Todos los países</option>{countries.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>Período</span><select value={period} onChange={(event) => setPeriod(event.target.value)}><option>Últimos 6 meses</option><option>Este trimestre</option><option>Este año</option></select></label></div>}
/>
<div className="dashboard-grid">
<ChartPanel title="Estado del portafolio" description="Distribución de requisiciones" filter={<PanelFilter label="Filtrar estados" value={statusScope} onChange={setStatusScope} options={['Todas', 'Casos activos', 'Cerradas']} />}>
<div className="donut-layout">
<div className="chart-height chart-height--compact"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={statusData} dataKey="value" nameKey="name" innerRadius={62} outerRadius={92} paddingAngle={1}>{statusData.map((item) => <Cell key={item.name} fill={item.color} />)}</Pie><Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} /></PieChart></ResponsiveContainer></div>
<div className="donut-legend">{statusData.map((item) => <div key={item.name}><i style={{ background: item.color }} /><span>{item.name}</span><b>{item.value}</b></div>)}</div>
</div>
</ChartPanel>
<ChartPanel title="TTF por complejidad" description="Días hábiles promedio por tier" filter={<PanelFilter label="Filtrar TTF por tier" value={tierScope} onChange={setTierScope} options={['Global', 'Solo activos', 'Solo cerrados']} />}>
<div className="chart-height chart-height--compact"><ResponsiveContainer width="100%" height="100%"><BarChart data={tierData} margin={{ top: 16, right: 16, left: -22, bottom: 0 }}><CartesianGrid vertical={false} stroke="#E4E8EA" /><XAxis dataKey="tier" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} /><Bar dataKey="dias" fill="#4F758B" /></BarChart></ResponsiveContainer></div>
<p className="chart-insight"><strong>Lectura:</strong> los roles Tier 3 tardan 2.6× más que los perfiles de volumen.</p>
</ChartPanel>
<ChartPanel title="Responsabilidad del ciclo" description="Aprobación vs. reclutamiento" filter={<PanelFilter label="Filtrar responsabilidad" value={responsibilityScope} onChange={setResponsibilityScope} options={['Promedio', 'Mediana', 'Percentil 90']} />}>
<div className="chart-height chart-height--compact"><ResponsiveContainer width="100%" height="100%"><BarChart data={responsibilityData} margin={{ top: 16, right: 12, left: -22, bottom: 0 }}><CartesianGrid vertical={false} stroke="#E4E8EA" /><XAxis dataKey="mes" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} /><Bar dataKey="aprobacion" stackId="cycle" fill="#F0C75E" /><Bar dataKey="reclutamiento" stackId="cycle" fill="#4F758B" /></BarChart></ResponsiveContainer></div>
<div className="chart-legend"><span><i className="legend-gold" />Aprobación</span><span><i className="legend-blue" />Reclutamiento</span></div>
</ChartPanel>
<ChartPanel title="Cumplimiento de SLA" description="Porcentaje de cierres dentro del objetivo" filter={<PanelFilter label="Filtrar cumplimiento" value={slaScope} onChange={setSlaScope} options={['Todos los tiers', 'Tier 1', 'Tier 2', 'Tier 3']} />}>
<div className="chart-height chart-height--compact"><ResponsiveContainer width="100%" height="100%"><LineChart data={slaTrend} margin={{ top: 16, right: 16, left: -22, bottom: 0 }}><CartesianGrid vertical={false} stroke="#E4E8EA" /><XAxis dataKey="mes" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><YAxis domain={[70, 100]} axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#6B7780' }} /><Tooltip contentStyle={{ border: '1px solid #D0D0D0', borderRadius: 4, fontFamily: 'Arial' }} /><Line type="monotone" dataKey="valor" stroke="#6CC24A" strokeWidth={3} dot={{ fill: '#4F758B', r: 4 }} /></LineChart></ResponsiveContainer></div>
<p className="chart-insight"><strong>Meta:</strong> 90% · Agosto cerró en 92%.</p>
</ChartPanel>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { Link, Navigate, useParams } from 'react-router-dom'
import { Modal } from '../components/Modal'
import { PageHeader } from '../components/PageHeader'
import { StatusBadge } from '../components/StatusBadge'
import { stageLabels, stageOrder } from '../data/catalogs'
import { usePortal } from '../state/PortalContext'
import { formatDate, formatDateTime, formatMoney, getSlaState } from '../utils'
export default function RequisitionDetailPage() {
const { id } = useParams()
const { role, requisitions, recruiters, approveHr, approveAdmin, reject, activateRecruitment, advanceStage } = usePortal()
const item = requisitions.find((requisition) => requisition.id === id)
const [rejectOpen, setRejectOpen] = useState(false)
const [activateOpen, setActivateOpen] = useState(false)
const [reason, setReason] = useState('')
const [recruiter, setRecruiter] = useState(recruiters[0]?.name ?? '')
if (!item) return <Navigate to="/requisiciones" replace />
const sla = getSlaState(item)
const elapsed = item.approvalDays + item.recruitingDays
const progress = item.slaTargetDays ? Math.min(100, Math.round((elapsed / item.slaTargetDays) * 100)) : 0
const currentStageIndex = stageOrder.indexOf(item.stage)
const canApprove = (role === 'director' && item.status === 'pendiente_rrhh') || (role === 'administracion' && item.status === 'pendiente_administracion')
const showBudget = role !== 'coordinacion'
const confirmReject = () => {
if (reason.trim().length < 10) return
reject(item.id, reason.trim())
setRejectOpen(false)
}
const confirmActivation = () => {
activateRecruitment(item.id, recruiter)
setActivateOpen(false)
}
return (
<div className="page-stack detail-page">
<Link className="back-link" to={role === 'coordinacion' ? '/mis-requisiciones' : '/requisiciones'}> Volver a requisiciones</Link>
<PageHeader eyebrow={`${item.groupId} · ${item.country}`} title={item.position} description={`${item.city} · ${item.client} · ${item.quantity} ${item.quantity === 1 ? 'vacante' : 'vacantes'}`} actions={<StatusBadge status={item.status} />} />
<section className={`sla-banner sla-banner--${sla}`}>
<div className="sla-banner__lead"><p>{item.slaMode === 'cumplimiento' ? 'SLA POR CUMPLIMIENTO' : 'RELOJ DE SLA'}</p><strong>{item.slaMode === 'cumplimiento' ? formatDate(item.startDate) : `${elapsed} / ${item.slaTargetDays} días`}</strong><span>{item.slaMode === 'cumplimiento' ? 'Fecha objetivo de incorporación' : `Fecha límite: ${formatDate(item.dueDate)}`}</span></div>
<div className="sla-banner__progress"><div><i style={{ width: `${progress}%` }} /></div><p><span>Aprobación <b>{item.approvalDays} d</b></span><span>Reclutamiento <b>{item.recruitingDays} d</b></span></p></div>
<div className="sla-banner__state"><span>Estado</span><strong>{item.slaMode === 'cumplimiento' ? 'Se mide cumplimiento' : sla === 'late' ? 'Vencido' : sla === 'risk' ? 'En riesgo' : sla === 'neutral' ? 'Sin iniciar' : 'En tiempo'}</strong></div>
</section>
<section className="process-track" aria-label="Flujo de aprobación">
{[
['Enviada', Boolean(item.submittedAt)],
['Revisión de RRHH', !['borrador', 'pendiente_rrhh'].includes(item.status)],
['Aprobación de Administración', ['aprobada', 'reclutando', 'cerrada'].includes(item.status)],
['Reclutamiento activo', ['reclutando', 'cerrada'].includes(item.status)],
['Cierre', item.status === 'cerrada'],
].map(([label, complete], index) => <div key={String(label)} className={complete ? 'is-complete' : ''}><span>{complete ? '✓' : index + 1}</span><b>{String(label)}</b></div>)}
</section>
<div className="detail-grid">
<div className="detail-grid__main">
<section className="detail-section">
<header><span>01</span><h2>Solicitud</h2></header>
<dl className="detail-list">
<div><dt>Solicitante</dt><dd>{item.requester}<small>{item.requesterEmail}</small></dd></div>
<div><dt>Tipo de vacante</dt><dd>{item.type}</dd></div>
<div><dt>Duración</dt><dd>{item.duration}</dd></div>
<div><dt>Inicio requerido</dt><dd>{formatDate(item.startDate)}</dd></div>
{item.replacementName ? <div><dt>Persona a reemplazar</dt><dd>{item.replacementName}<small>Salida: {formatDate(item.replacementExitDate)}</small></dd></div> : null}
<div className="detail-list__wide"><dt>Justificación</dt><dd>{item.justification}</dd></div>
{item.comments ? <div className="detail-list__wide"><dt>Comentarios</dt><dd>{item.comments}</dd></div> : null}
</dl>
</section>
<section className="detail-section">
<header><span>02</span><h2>Vacantes individuales</h2><p>{item.units.length || item.quantity} IDs bajo {item.groupId}</p></header>
{item.units.length ? <div className="unit-list">{item.units.map((unit) => <article key={unit.id}><div><b>{unit.id}</b><span>{unit.label}</span></div><strong>{stageLabels[unit.stage]}</strong>{unit.candidate ? <small>{unit.candidate}</small> : <small>Sin candidatura seleccionada</small>}</article>)}</div> : <div className="inline-note">Los IDs individuales se generarán al enviar la requisición.</div>}
</section>
<section className="detail-section">
<header><span>03</span><h2>Registro de auditoría</h2><p>Historial inmutable del caso</p></header>
<ol className="audit-trail">{[...item.audit].reverse().map((entry) => <li key={entry.id}><time>{formatDateTime(entry.at)}</time><div><b>{entry.action}</b><span>{entry.actor}</span><p>{entry.detail}</p></div></li>)}</ol>
</section>
</div>
<aside className="detail-grid__aside">
<section className="side-panel">
<p className="eyebrow">Responsabilidad</p><h2>{item.recruiter ?? (item.status === 'pendiente_administracion' ? 'Administración' : item.status === 'pendiente_rrhh' ? 'Dirección de RRHH' : 'Sin asignar')}</h2><p>Responsable actual del siguiente movimiento.</p>
</section>
<section className="side-panel">
<p className="eyebrow">Paquete financiero</p>
{showBudget ? <><strong className="money-value">{formatMoney(item.budget, item.currency)}</strong><p>{item.salaryPackage}</p></> : <div className="protected-data"><span aria-hidden="true"></span><p>Visible para RRHH y Administración.</p></div>}
</section>
<section className="side-panel side-panel--actions">
<p className="eyebrow">Siguiente acción</p>
{canApprove ? <><button className="button button--primary button--full" type="button" onClick={() => role === 'administracion' ? approveAdmin(item.id) : approveHr(item.id)}>Aprobar {role === 'administracion' ? 'presupuesto' : 'solicitud'}</button><button className="button button--danger-quiet button--full" type="button" onClick={() => setRejectOpen(true)}>Solicitar corrección</button></> : null}
{role === 'director' && item.status === 'aprobada' ? <button className="button button--primary button--full" type="button" onClick={() => setActivateOpen(true)}>Activar reclutamiento</button> : null}
{role === 'coordinacion' && item.status === 'reclutando' ? <button className="button button--primary button--full" type="button" onClick={() => advanceStage(item.id)} disabled={currentStageIndex === stageOrder.length - 1}>Avanzar a {stageLabels[stageOrder[Math.min(currentStageIndex + 1, stageOrder.length - 1)]]}</button> : null}
{!canApprove && !(role === 'director' && item.status === 'aprobada') && !(role === 'coordinacion' && item.status === 'reclutando') ? <p>No hay acciones pendientes para tu rol.</p> : null}
</section>
</aside>
</div>
<Modal open={rejectOpen} title="Solicitar corrección" description="Explica con precisión qué debe cambiar el solicitante." onClose={() => setRejectOpen(false)} footer={<><button className="button button--quiet" type="button" onClick={() => setRejectOpen(false)}>Cancelar</button><button className="button button--danger" type="button" disabled={reason.trim().length < 10} onClick={confirmReject}>Enviar corrección</button></>}>
<label className="field"><span>Motivo <b>*</b></span><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} /><small>Mínimo 10 caracteres.</small></label>
</Modal>
<Modal open={activateOpen} title="Activar reclutamiento" description="Selecciona a la persona responsable." onClose={() => setActivateOpen(false)} footer={<><button className="button button--quiet" type="button" onClick={() => setActivateOpen(false)}>Cancelar</button><button className="button button--primary" type="button" onClick={confirmActivation}>Activar</button></>}>
<label className="field"><span>Reclutador/a</span><select value={recruiter} onChange={(event) => setRecruiter(event.target.value)}>{recruiters.map((person) => <option key={person.id}>{person.name}</option>)}</select></label>
</Modal>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { EmptyState } from '../components/DashboardParts'
import { PageHeader } from '../components/PageHeader'
import { StatusBadge } from '../components/StatusBadge'
import { countries } from '../data/catalogs'
import { usePortal } from '../state/PortalContext'
import type { RequisitionStatus } from '../types'
import { formatDate, getSlaState } from '../utils'
const ownTabs: Array<{ label: string; status: RequisitionStatus | 'all' }> = [
{ label: 'Todas', status: 'all' },
{ label: 'Borradores', status: 'borrador' },
{ label: 'Correcciones', status: 'correccion' },
{ label: 'Pendiente de RRHH', status: 'pendiente_rrhh' },
{ label: 'Pendiente de Administración', status: 'pendiente_administracion' },
{ label: 'Aprobadas', status: 'aprobada' },
{ label: 'Reclutamiento activo', status: 'reclutando' },
{ label: 'Cerradas', status: 'cerrada' },
]
export default function RequisitionsPage({ ownOnly = false }: { ownOnly?: boolean }) {
const { requisitions, role } = usePortal()
const [status, setStatus] = useState<RequisitionStatus | 'all'>('all')
const [country, setCountry] = useState('Todos')
const [query, setQuery] = useState('')
const visible = useMemo(() => requisitions.filter((item) => {
if (ownOnly && item.requesterEmail !== 'laura.medina@gomezleemarketing.com') return false
if (status !== 'all' && item.status !== status) return false
if (country !== 'Todos' && item.country !== country) return false
const search = `${item.id} ${item.position} ${item.city} ${item.country} ${item.client}`.toLowerCase()
return search.includes(query.toLowerCase())
}), [country, ownOnly, query, requisitions, status])
return (
<div className="page-stack">
<PageHeader
eyebrow={ownOnly ? 'Solicitudes propias · Coordinación' : 'Registro maestro · Vista global'}
title={ownOnly ? 'Mis requisiciones' : 'Requisiciones'}
description={ownOnly ? 'Consulta borradores, aprobaciones y reclutamientos sin perder el hilo del proceso.' : 'Explora el registro global con estado, territorio, responsable y señal de SLA.'}
actions={role === 'coordinacion' ? <Link className="button button--primary" to="/requisiciones/nueva"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{marginRight: '6px', verticalAlign: 'middle', flexShrink: 0}}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Nueva requisición</Link> : undefined}
/>
{ownOnly ? <div className="status-tabs" role="tablist" aria-label="Filtrar mis requisiciones">
{ownTabs.map((tab) => <button type="button" key={tab.status} role="tab" aria-selected={status === tab.status} className={status === tab.status ? 'is-active' : ''} onClick={() => setStatus(tab.status)}>{tab.label}<span>{requisitions.filter((item) => item.requesterEmail === 'laura.medina@gomezleemarketing.com' && (tab.status === 'all' || item.status === tab.status)).length}</span></button>)}
</div> : null}
<section className="list-toolbar" aria-label="Filtros de requisiciones">
<label className="search-field"><span className="sr-only">Buscar</span><input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por ID, posición, ciudad o proyecto" /></label>
{!ownOnly ? <label><span>Estado</span><select value={status} onChange={(event) => setStatus(event.target.value as RequisitionStatus | 'all')}><option value="all">Todos</option>{ownTabs.slice(1).map((item) => <option key={item.status} value={item.status}>{item.label}</option>)}</select></label> : null}
<label><span>Territorio</span><select value={country} onChange={(event) => setCountry(event.target.value)}><option>Todos</option>{countries.map((item) => <option key={item}>{item}</option>)}</select></label>
<p>{visible.length} resultados</p>
</section>
{visible.length === 0 ? (
<EmptyState title="No hay requisiciones en esta vista" detail="Cambia los filtros o crea una nueva requisición." action={role === 'coordinacion' ? <Link className="button button--primary" to="/requisiciones/nueva">Crear requisición</Link> : undefined} />
) : (
<section className="requisition-table table-wrap">
<table>
<thead><tr><th>Requisición</th><th>Territorio</th><th>Estado</th><th>Responsable</th><th>SLA</th><th>Creada</th><th><span className="sr-only">Abrir</span></th></tr></thead>
<tbody>
{visible.map((item) => {
const sla = getSlaState(item)
return (
<tr key={item.id} className={`table-signal table-signal--${sla}`}>
<td data-label="Requisición"><Link to={`/requisiciones/${item.id}`}><strong>{item.position}</strong><small>{item.id} · {item.quantity} {item.quantity === 1 ? 'vacante' : 'vacantes'} · Tier {item.tier}</small></Link></td>
<td data-label="Territorio"><span>{item.country}</span><small>{item.city}</small></td>
<td data-label="Estado"><StatusBadge status={item.status} /></td>
<td data-label="Responsable">{item.recruiter ?? (item.status === 'pendiente_administracion' ? 'Administración' : 'RRHH')}</td>
<td data-label="SLA"><span className={`sla-label sla-label--${sla}`}>{item.slaMode === 'cumplimiento' ? 'Por cumplimiento' : sla === 'late' ? 'Vencido' : sla === 'risk' ? 'En riesgo' : sla === 'neutral' ? 'Sin iniciar' : 'En tiempo'}</span></td>
<td data-label="Creada">{formatDate(item.createdAt)}</td>
<td><Link className="row-arrow" to={`/requisiciones/${item.id}`} aria-label={`Abrir ${item.id}`}></Link></td>
</tr>
)
})}
</tbody>
</table>
</section>
)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type {
NotificationItem,
RecruiterCapacity,
RecruitingStage,
Requisition,
RequisitionFormValue,
} from '../types'
/**
* Contrato de integración del frontend.
* La implementación actual vive en PortalContext y conserva el prototipo en
* sessionStorage. Al conectar Supabase/n8n, este contrato permite sustituir la
* persistencia sin cambiar las páginas ni los componentes visuales.
*/
export interface PortalGateway {
listRequisitions(): Promise<Requisition[]>
getRequisition(id: string): Promise<Requisition | null>
createRequisition(input: RequisitionFormValue, asDraft: boolean): Promise<Requisition>
approveByHr(id: string): Promise<void>
approveByAdministration(id: string): Promise<void>
rejectRequisition(id: string, reason: string): Promise<void>
activateRecruitment(id: string, recruiterName: string): Promise<void>
reassignRecruiter(id: string, recruiterName: string, reason: string): Promise<void>
advanceRecruitingStage(id: string, stage: RecruitingStage): Promise<void>
listCapacity(): Promise<RecruiterCapacity[]>
listNotifications(): Promise<NotificationItem[]>
markNotificationRead(id: string): Promise<void>
}
+350
View File
@@ -0,0 +1,350 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { initialNotifications, initialRecruiters, initialRequisitions } from '../data/demo'
import { stageOrder } from '../data/catalogs'
import type {
NotificationItem,
PortalRole,
RecruiterCapacity,
RecruitingStage,
Requisition,
RequisitionFormValue,
RequisitionStatus,
} from '../types'
import { getLoadPoints } from '../utils'
interface PortalSnapshot {
version: 1
authenticated: boolean
role: PortalRole
requisitions: Requisition[]
recruiters: RecruiterCapacity[]
notifications: NotificationItem[]
}
interface PortalContextValue extends PortalSnapshot {
flash: string | null
login: () => void
logout: () => void
setRole: (role: PortalRole) => void
approveHr: (id: string) => void
approveAdmin: (id: string) => void
reject: (id: string, reason: string) => void
activateRecruitment: (id: string, recruiter: string) => void
reassign: (id: string, recruiter: string, reason: string) => void
advanceStage: (id: string) => void
createRequisition: (value: RequisitionFormValue, asDraft: boolean) => string
markNotification: (id: string) => void
}
const STORAGE_KEY = 'glm-portal-requisiciones-v1'
const readSnapshot = (): PortalSnapshot => {
try {
const stored = sessionStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as PortalSnapshot
if (parsed.version === 1) return parsed
}
} catch {
// A clean prototype state is safer than failing when storage is unavailable.
}
return {
version: 1,
authenticated: false,
role: 'director',
requisitions: initialRequisitions,
recruiters: initialRecruiters,
notifications: initialNotifications,
}
}
const PortalContext = createContext<PortalContextValue | null>(null)
const nowIso = () => new Date().toISOString()
const addBusinessDays = (date: Date, businessDays: number) => {
const result = new Date(date)
let added = 0
while (added < businessDays) {
result.setDate(result.getDate() + 1)
const day = result.getDay()
if (day !== 0 && day !== 6) added += 1
}
return result.toISOString().slice(0, 10)
}
const getTier = (position: string): 1 | 2 | 3 => {
if (/gerente|manager|coordinador|supervisor/i.test(position)) return 3
if (/analista|desarrollador|representante/i.test(position)) return 2
return 1
}
export function PortalProvider({ children }: { children: ReactNode }) {
const [snapshot, setSnapshot] = useState<PortalSnapshot>(readSnapshot)
const [flash, setFlash] = useState<string | null>(null)
const flashTimer = useRef<number | null>(null)
useEffect(() => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
}, [snapshot])
const notify = useCallback((message: string) => {
setFlash(message)
if (flashTimer.current) window.clearTimeout(flashTimer.current)
flashTimer.current = window.setTimeout(() => setFlash(null), 3600)
}, [])
const login = useCallback(() => setSnapshot((current) => ({ ...current, authenticated: true })), [])
const logout = useCallback(() => setSnapshot((current) => ({ ...current, authenticated: false })), [])
const setRole = useCallback((role: PortalRole) => setSnapshot((current) => ({ ...current, role })), [])
const recordStatus = useCallback((id: string, status: RequisitionStatus, action: string, detail: string) => {
setSnapshot((current) => ({
...current,
requisitions: current.requisitions.map((requisition) =>
requisition.id === id
? {
...requisition,
status,
audit: [
...requisition.audit,
{ id: `audit-${Date.now()}`, at: nowIso(), actor: 'Usuario de prototipo', action, detail },
],
}
: requisition,
),
}))
notify(action)
}, [notify])
const approveHr = useCallback(
(id: string) => recordStatus(id, 'pendiente_administracion', 'Revisión de RRHH aprobada', 'La requisición pasó a Administración.'),
[recordStatus],
)
const approveAdmin = useCallback(
(id: string) => recordStatus(id, 'aprobada', 'Presupuesto aprobado', 'La requisición quedó lista para activar el reclutamiento.'),
[recordStatus],
)
const reject = useCallback(
(id: string, reason: string) => recordStatus(id, 'correccion', 'Corrección solicitada', reason),
[recordStatus],
)
const activateRecruitment = useCallback((id: string, recruiter: string) => {
setSnapshot((current) => ({
...current,
requisitions: current.requisitions.map((requisition) =>
requisition.id === id
? {
...requisition,
status: 'reclutando',
stage: 'busqueda',
recruiter,
audit: [
...requisition.audit,
{
id: `audit-${Date.now()}`,
at: nowIso(),
actor: 'Dirección de RRHH',
action: 'Reclutamiento activado',
detail: `Caso asignado a ${recruiter}.`,
},
],
}
: requisition,
),
}))
notify('Reclutamiento activado')
}, [notify])
const reassign = useCallback((id: string, recruiter: string, reason: string) => {
setSnapshot((current) => {
const requisition = current.requisitions.find((item) => item.id === id)
if (!requisition) return current
const previousRecruiter = requisition.recruiter
const points = getLoadPoints(requisition.tier, requisition.quantity)
return {
...current,
requisitions: current.requisitions.map((item) =>
item.id === id
? {
...item,
recruiter,
audit: [
...item.audit,
{
id: `audit-${Date.now()}`,
at: nowIso(),
actor: 'Dirección de RRHH',
action: 'Requisición reasignada',
detail: `${previousRecruiter ?? 'Sin asignar'}${recruiter}. ${reason}`,
},
],
}
: item,
),
recruiters: current.recruiters.map((item) => {
if (item.name === previousRecruiter) {
return {
...item,
score: Math.max(0, item.score - points),
assignments: item.assignments.filter((assignment) => assignment.requisitionId !== id),
}
}
if (item.name === recruiter) {
return {
...item,
score: item.score + points,
assignments: [
...item.assignments,
{ requisitionId: id, position: requisition.position, tier: requisition.tier, phase: 'Reasignada', points },
],
}
}
return item
}),
}
})
notify('Reasignación registrada en la auditoría')
}, [notify])
const advanceStage = useCallback((id: string) => {
setSnapshot((current) => ({
...current,
requisitions: current.requisitions.map((requisition) => {
if (requisition.id !== id) return requisition
const currentIndex = stageOrder.indexOf(requisition.stage)
const nextStage = stageOrder[Math.min(currentIndex + 1, stageOrder.length - 1)] as RecruitingStage
const closed = nextStage === 'seleccionado'
return {
...requisition,
stage: nextStage,
status: closed ? 'cerrada' : 'reclutando',
units: requisition.units.map((unit) => ({ ...unit, stage: nextStage, closedAt: closed ? nowIso() : unit.closedAt })),
audit: [
...requisition.audit,
{
id: `audit-${Date.now()}`,
at: nowIso(),
actor: requisition.recruiter ?? 'Coordinación',
action: closed ? 'Vacante cerrada' : 'Etapa actualizada',
detail: `La requisición avanzó a ${nextStage.replaceAll('_', ' ')}.`,
},
],
}
}),
}))
notify('Etapa actualizada')
}, [notify])
const createRequisition = useCallback((value: RequisitionFormValue, asDraft: boolean) => {
const stamp = Date.now().toString().slice(-6)
const id = asDraft ? `BOR-${stamp}` : `REQ-${stamp}`
const tier = getTier(value.position)
const baseDays = tier === 1 ? 5 : tier === 2 ? 10 : 15
const targetDays = baseDays + Math.max(0, value.quantity - 1) * 2
const createdAt = nowIso()
const longLead = new Date(value.startDate).getTime() - Date.now() > 25 * 86_400_000
const requisition: Requisition = {
id,
groupId: `SOL-${stamp}`,
position: value.position,
country: value.country,
city: value.city,
client: value.client,
requester: 'Laura Medina',
requesterEmail: 'laura.medina@gomezleemarketing.com',
type: value.vacancyType,
quantity: value.quantity,
duration: value.duration,
startDate: value.startDate,
replacementName: value.replacementName || undefined,
replacementExitDate: value.replacementExitDate || undefined,
justification: value.justification,
comments: value.comments || undefined,
budget: Number(value.budget || 0),
salaryPackage: value.salaryPackage || 'Pendiente de completar',
currency: value.currency,
status: asDraft ? 'borrador' : 'pendiente_rrhh',
stage: 'busqueda',
tier,
createdAt,
submittedAt: asDraft ? undefined : createdAt,
dueDate: asDraft ? undefined : addBusinessDays(new Date(), targetDays),
slaTargetDays: asDraft ? 0 : targetDays,
approvalDays: 0,
recruitingDays: 0,
slaMode: longLead ? 'cumplimiento' : 'dias',
units: asDraft
? []
: Array.from({ length: value.quantity }, (_, index) => ({
id: `${id}-${String(index + 1).padStart(2, '0')}`,
label: `Vacante ${index + 1} de ${value.quantity}`,
stage: 'busqueda' as const,
})),
audit: [
{
id: `audit-${stamp}`,
at: createdAt,
actor: 'Laura Medina',
action: asDraft ? 'Guardó un borrador' : 'Envió la requisición',
detail: asDraft ? 'El SLA aún no ha iniciado.' : 'Inició el SLA y pasó a revisión de RRHH.',
},
],
}
setSnapshot((current) => ({ ...current, requisitions: [requisition, ...current.requisitions] }))
notify(asDraft ? 'Borrador guardado' : 'Requisición enviada a RRHH')
return id
}, [notify])
const markNotification = useCallback((id: string) => {
setSnapshot((current) => ({
...current,
notifications: current.notifications.map((item) => (item.id === id ? { ...item, read: true } : item)),
}))
}, [])
const value = useMemo<PortalContextValue>(
() => ({
...snapshot,
flash,
login,
logout,
setRole,
approveHr,
approveAdmin,
reject,
activateRecruitment,
reassign,
advanceStage,
createRequisition,
markNotification,
}),
[
snapshot,
flash,
login,
logout,
setRole,
approveHr,
approveAdmin,
reject,
activateRecruitment,
reassign,
advanceStage,
createRequisition,
markNotification,
],
)
return <PortalContext.Provider value={value}>{children}</PortalContext.Provider>
}
export const usePortal = () => {
const context = useContext(PortalContext)
if (!context) throw new Error('usePortal debe usarse dentro de PortalProvider')
return context
}
+3158
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
export type PortalRole = 'director' | 'administracion' | 'coordinacion'
export type RequisitionStatus =
| 'borrador'
| 'pendiente_rrhh'
| 'pendiente_administracion'
| 'correccion'
| 'aprobada'
| 'reclutando'
| 'cerrada'
| 'cancelada'
export type RecruitingStage =
| 'busqueda'
| 'primera_entrevista'
| 'segunda_entrevista'
| 'referencias'
| 'examenes_medicos'
| 'seleccionado'
export type VacancyType = 'Nueva' | 'Reemplazo' | 'Temporera' | 'Vacaciones' | 'Incapacidad'
export interface VacancyUnit {
id: string
label: string
stage: RecruitingStage
candidate?: string
closedAt?: string
}
export interface AuditEntry {
id: string
at: string
actor: string
action: string
detail: string
}
export interface Requisition {
id: string
groupId: string
position: string
country: string
city: string
client: string
requester: string
requesterEmail: string
type: VacancyType
quantity: number
duration: string
startDate: string
replacementName?: string
replacementExitDate?: string
justification: string
comments?: string
budget: number
salaryPackage: string
currency: 'USD' | 'DOP' | 'GTQ' | 'COP' | 'PAB' | 'MXN'
status: RequisitionStatus
stage: RecruitingStage
tier: 1 | 2 | 3
recruiter?: string
createdAt: string
submittedAt?: string
dueDate?: string
slaTargetDays: number
approvalDays: number
recruitingDays: number
slaMode: 'dias' | 'cumplimiento'
units: VacancyUnit[]
audit: AuditEntry[]
}
export interface RecruiterAssignment {
requisitionId: string
position: string
tier: 1 | 2 | 3
phase: string
points: number
}
export interface RecruiterCapacity {
id: string
name: string
countries: string[]
score: number
assignments: RecruiterAssignment[]
}
export interface NotificationItem {
id: string
title: string
detail: string
at: string
kind: 'accion' | 'sla' | 'sistema'
read: boolean
requisitionId?: string
}
export interface RequisitionFormValue {
country: string
city: string
position: string
vacancyType: VacancyType
quantity: number
duration: string
startDate: string
client: string
replacementName: string
replacementExitDate: string
justification: string
comments: string
budget: string
salaryPackage: string
currency: Requisition['currency']
}
+65
View File
@@ -0,0 +1,65 @@
import type { PortalRole, Requisition, RequisitionStatus } from './types'
export const roleHomes: Record<PortalRole, string> = {
director: '/dashboard',
administracion: '/administracion',
coordinacion: '/mis-requisiciones',
}
export const roleLabels: Record<PortalRole, string> = {
director: 'Dirección RRHH',
administracion: 'Administración',
coordinacion: 'Coordinación',
}
export const formatDate = (value?: string) => {
if (!value) return '—'
return new Intl.DateTimeFormat('es-419', { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(value))
}
export const formatDateTime = (value: string) =>
new Intl.DateTimeFormat('es-419', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
export const formatMoney = (value: number, currency: Requisition['currency']) =>
new Intl.NumberFormat('es-419', {
style: 'currency',
currency,
maximumFractionDigits: 0,
}).format(value)
export const getSlaState = (requisition: Requisition): 'ok' | 'risk' | 'late' | 'neutral' => {
if (requisition.status === 'borrador' || !requisition.dueDate) return 'neutral'
if (requisition.status === 'cerrada') return requisition.approvalDays + requisition.recruitingDays <= requisition.slaTargetDays ? 'ok' : 'late'
const remaining = Math.ceil((new Date(requisition.dueDate).getTime() - Date.now()) / 86_400_000)
if (remaining < 0) return 'late'
if (remaining <= 3) return 'risk'
return 'ok'
}
export const statusTone: Record<RequisitionStatus, string> = {
borrador: 'neutral',
pendiente_rrhh: 'warning',
pendiente_administracion: 'info',
correccion: 'danger',
aprobada: 'success',
reclutando: 'active',
cerrada: 'success',
cancelada: 'neutral',
}
export const capacityState = (score: number) => {
if (score <= 15) return { label: 'Disponible', tone: 'ok' as const }
if (score <= 25) return { label: 'Al límite', tone: 'risk' as const }
return { label: 'Capacidad crítica', tone: 'late' as const }
}
export const getLoadPoints = (tier: 1 | 2 | 3, quantity: number) => {
const base = tier === 1 ? 1 : tier === 2 ? 3 : 5
const volume = quantity >= 11 ? 4 : quantity >= 3 ? 2 : 0
return base + volume
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"],
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/requisicion-vacantes/',
plugins: [react()],
server: {
port: 5173,
},
preview: {
port: 4173,
},
})