Add project with dist
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_ANON_KEY=
|
||||
VITE_N8N_BASE_URL=
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
# dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -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
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+55
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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};
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||
Vendored
+1
@@ -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};
|
||||
Vendored
+1
@@ -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};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||
Vendored
+1
@@ -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};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+12
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
[LocalizedFileNames]
|
||||
GLM.png=@GLM.png,0
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Vendored
+19
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
Generated
+3783
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
[LocalizedFileNames]
|
||||
GLM.png=@GLM.png,0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+51
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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="0–15 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>1–2 = 0 · 3–10 = 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>0—15</span><b>Disponible</b><p>Puede asumir nuevas posiciones.</p></div>
|
||||
<div className="threshold threshold--risk"><span>16—25</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+116
@@ -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']
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user