commit b5a16d8daf582c0509a9fd86426256536e6524a2 Author: Isaac_Aracena Date: Fri Jun 12 16:08:49 2026 -0400 Initial commit: Proyecto portal-requisicion-vacantes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97eb8c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# v0 sandbox internal files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts +.snowflake/ +.v0-trash/ +.vercel/ + +# Environment variables +.env*.local + +# Common ignores +node_modules +.next/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..325bea5 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Portal Requisición Vacantes + +Portal web para la requisición y gestión de vacantes. Desarrollado con Next.js y React. + +## Características + +- Interfaz moderna y responsive +- Gestión de ciclo de reclutamiento (Recruitment Pipeline) +- Integración con base de datos y backend +- Construido con buenas prácticas y componentes reutilizables + +## Requisitos previos + +- Node.js (v18 o superior) +- pnpm + +## Instalación y ejecución + +1. Instalar dependencias: + ```bash + pnpm install + ``` + +2. Ejecutar el servidor de desarrollo: + ```bash + pnpm run dev + ``` + +El servidor estará disponible en `http://localhost:3000`. diff --git a/app/(portal)/admin/page.tsx b/app/(portal)/admin/page.tsx new file mode 100644 index 0000000..fe7f873 --- /dev/null +++ b/app/(portal)/admin/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { SettingsPage } from "@/components/pages/settings-page" + +export default function AdminRoute() { + return +} diff --git a/app/(portal)/aprobaciones/page.tsx b/app/(portal)/aprobaciones/page.tsx new file mode 100644 index 0000000..7ab1d1b --- /dev/null +++ b/app/(portal)/aprobaciones/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ApprovalsPage } from "@/components/pages/approvals-page" + +export default function ApprovalsRoute() { + return +} diff --git a/app/(portal)/calendario/page.tsx b/app/(portal)/calendario/page.tsx new file mode 100644 index 0000000..3e54a36 --- /dev/null +++ b/app/(portal)/calendario/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { CalendarPage } from "@/components/pages/calendar-page" + +export default function CalendarRoute() { + return +} diff --git a/app/(portal)/dashboard/page.tsx b/app/(portal)/dashboard/page.tsx new file mode 100644 index 0000000..647ec63 --- /dev/null +++ b/app/(portal)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardPage } from '@/components/pages/dashboard-page' + +export default function Dashboard() { + return +} diff --git a/app/(portal)/layout.tsx b/app/(portal)/layout.tsx new file mode 100644 index 0000000..84204d5 --- /dev/null +++ b/app/(portal)/layout.tsx @@ -0,0 +1,9 @@ +import { MainLayout } from '@/components/layout/main-layout' + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/(portal)/notificaciones/page.tsx b/app/(portal)/notificaciones/page.tsx new file mode 100644 index 0000000..4945c3f --- /dev/null +++ b/app/(portal)/notificaciones/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { NotificationsPage } from "@/components/pages/notifications-page" + +export default function NotificationsRoute() { + return +} diff --git a/app/(portal)/pipeline/page.tsx b/app/(portal)/pipeline/page.tsx new file mode 100644 index 0000000..34b6bdb --- /dev/null +++ b/app/(portal)/pipeline/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { RecruitmentPipelinePage } from "@/components/pages/recruitment-pipeline-page" + +export default function PipelineRoute() { + return +} diff --git a/app/(portal)/reclutadores/page.tsx b/app/(portal)/reclutadores/page.tsx new file mode 100644 index 0000000..beac8ee --- /dev/null +++ b/app/(portal)/reclutadores/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { UsersManagementPage } from "@/components/pages/users-management-page" + +export default function RecruitersRoute() { + return +} diff --git a/app/(portal)/reportes/page.tsx b/app/(portal)/reportes/page.tsx new file mode 100644 index 0000000..3d2e337 --- /dev/null +++ b/app/(portal)/reportes/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ReportsPage } from "@/components/pages/reports-page" + +export default function ReportsRoute() { + return +} diff --git a/app/(portal)/requisiciones/[id]/page.tsx b/app/(portal)/requisiciones/[id]/page.tsx new file mode 100644 index 0000000..c2ac96e --- /dev/null +++ b/app/(portal)/requisiciones/[id]/page.tsx @@ -0,0 +1,10 @@ +import { RequisitionDetailPage } from '@/components/pages/requisition-detail-page' + +interface PageProps { + params: Promise<{ id: string }> +} + +export default async function RequisitionPage({ params }: PageProps) { + const { id } = await params + return +} diff --git a/app/(portal)/requisiciones/nueva/page.tsx b/app/(portal)/requisiciones/nueva/page.tsx new file mode 100644 index 0000000..5194e57 --- /dev/null +++ b/app/(portal)/requisiciones/nueva/page.tsx @@ -0,0 +1,5 @@ +import { NewRequisitionPage } from '@/components/pages/new-requisition-page' + +export default function NuevaRequisicionPage() { + return +} diff --git a/app/(portal)/requisiciones/page.tsx b/app/(portal)/requisiciones/page.tsx new file mode 100644 index 0000000..8c965ec --- /dev/null +++ b/app/(portal)/requisiciones/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from 'react' +import { RequisitionsListPage } from '@/components/pages/requisitions-list-page' +import { Spinner } from '@/components/ui/spinner' + +export default function RequisitionsPage() { + return ( + }> + + + ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..3d97554 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,187 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +/* GomezLee Marketing Color System */ +:root { + /* Brand Colors from GLM */ + --glm-primary: #6CC24A; /* Verde GLM */ + --glm-secondary: #A4D65E; /* Verde Claro */ + --glm-accent: #C4D600; /* Lima */ + --glm-blue: #4F758B; /* Azul GLM */ + --glm-blue-medium: #5B7F95; /* Azul Medio */ + --glm-text: #4A4A4A; /* Gris Oscuro */ + --glm-muted: #D0D0D0; /* Gris Medio */ + --glm-black: #000; + --glm-white: #fff; + + /* Light Mode Theme */ + --background: #FFFFFF; + --foreground: #4A4A4A; + --card: #FFFFFF; + --card-foreground: #4A4A4A; + --popover: #FFFFFF; + --popover-foreground: #4A4A4A; + --primary: #6CC24A; + --primary-foreground: #FFFFFF; + --secondary: #F5F5F5; + --secondary-foreground: #4F758B; + --muted: #F5F5F5; + --muted-foreground: #6B8FA3; + --accent: #EEF6E8; + --accent-foreground: #4F758B; + --destructive: #FF6A13; + --destructive-foreground: #FFFFFF; + --border: #D0D0D0; + --input: #D0D0D0; + --ring: #6CC24A; + + /* Chart colors using GLM palette */ + --chart-1: #6CC24A; + --chart-2: #4F758B; + --chart-3: #F0C75E; + --chart-4: #FF6A13; + --chart-5: #6B8FA3; + + --radius: 0.5rem; + + /* Sidebar */ + --sidebar: #4F758B; + --sidebar-foreground: #FFFFFF; + --sidebar-primary: #6CC24A; + --sidebar-primary-foreground: #FFFFFF; + --sidebar-accent: #5B7F95; + --sidebar-accent-foreground: #FFFFFF; + --sidebar-border: #5B7F95; + --sidebar-ring: #6CC24A; + + /* Status Colors */ + --status-draft: #6B8FA3; + --status-pending-hr: #4F758B; + --status-pending-admin: #5B7F95; + --status-recruiting: #6CC24A; + --status-closed: #2C6E6F; + --status-cancelled: #FF6A13; + --status-correction: #F0C75E; +} + +.dark { + --background: #4A4A4A; + --foreground: #FFFFFF; + --card: #54595f; + --card-foreground: #FFFFFF; + --popover: #54595f; + --popover-foreground: #FFFFFF; + --primary: #6CC24A; + --primary-foreground: #FFFFFF; + --secondary: #6B8FA3; + --secondary-foreground: #FFFFFF; + --muted: #5B7F95; + --muted-foreground: #D0D0D0; + --accent: #4F758B; + --accent-foreground: #FFFFFF; + --destructive: #FF6A13; + --destructive-foreground: #FFFFFF; + --border: #5B7F95; + --input: #5B7F95; + --ring: #6CC24A; + + --chart-1: #6CC24A; + --chart-2: #4F758B; + --chart-3: #F0C75E; + --chart-4: #FF6A13; + --chart-5: #6B8FA3; + + --sidebar: #1f2937; + --sidebar-foreground: #FFFFFF; + --sidebar-primary: #6CC24A; + --sidebar-primary-foreground: #FFFFFF; + --sidebar-accent: #374151; + --sidebar-accent-foreground: #FFFFFF; + --sidebar-border: #374151; + --sidebar-ring: #6CC24A; +} + +@theme inline { + --font-sans: 'Arial', sans-serif; + --font-mono: monospace; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* Custom status colors */ + --color-status-draft: var(--status-draft); + --color-status-pending-hr: var(--status-pending-hr); + --color-status-pending-admin: var(--status-pending-admin); + --color-status-recruiting: var(--status-recruiting); + --color-status-closed: var(--status-closed); + --color-status-cancelled: var(--status-cancelled); + --color-status-correction: var(--status-correction); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: oklch(0.75 0.02 250 / 0.3); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: oklch(0.75 0.02 250 / 0.5); +} + +/* Smooth transitions */ +.transition-smooth { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7299090 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from 'next' +import { Toaster } from '@/components/ui/sonner' +import './globals.css' + +export const metadata: Metadata = { + title: 'Portal GLM | Requisiciones y Reclutamiento', + description: 'Portal de Gestión de Requisiciones y Reclutamiento - GomezLee Marketing', + icons: { + icon: '/images/logo-glm.png', + apple: '/images/logo-glm.png', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..28c5ca1 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function Home() { + redirect('/dashboard') +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/layout/main-layout.tsx b/components/layout/main-layout.tsx new file mode 100644 index 0000000..8ab5d1d --- /dev/null +++ b/components/layout/main-layout.tsx @@ -0,0 +1,555 @@ +'use client' + +import * as React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import Image from 'next/image' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Sheet, + SheetContent, + SheetTrigger, +} from '@/components/ui/sheet' +import { + LayoutDashboard, + FileText, + ClipboardCheck, + Users, + BarChart3, + Settings, + Bell, + ChevronLeft, + ChevronRight, + LogOut, + User, + Building2, + PlusCircle, + Briefcase, + Clock, + AlertTriangle, + Menu, +} from 'lucide-react' +import { currentUser, notifications } from '@/lib/mock-data' + +interface NavItem { + title: string + href: string + icon: React.ComponentType<{ className?: string }> + badge?: number + roles?: string[] +} + +const mainNavItems: NavItem[] = [ + { + title: 'Dashboard', + href: '/dashboard', + icon: LayoutDashboard, + }, + { + title: 'Nueva Requisición', + href: '/requisiciones/nueva', + icon: PlusCircle, + }, + { + title: 'Mis Requisiciones', + href: '/requisiciones', + icon: FileText, + }, + { + title: 'Aprobaciones', + href: '/aprobaciones', + icon: ClipboardCheck, + badge: 2, + roles: ['rrhh_local', 'rrhh_regional', 'administracion', 'director_rrhh'], + }, + { + title: 'Pipeline Reclutamiento', + href: '/pipeline', + icon: Briefcase, + roles: ['reclutador', 'director_rrhh', 'rrhh_regional'], + }, +] + +const secondaryNavItems: NavItem[] = [ + { + title: 'Reclutadores', + href: '/reclutadores', + icon: Users, + roles: ['director_rrhh', 'rrhh_regional'], + }, + { + title: 'Reportes', + href: '/reportes', + icon: BarChart3, + roles: ['director_rrhh', 'rrhh_regional', 'country_lead'], + }, + { + title: 'Administración', + href: '/admin', + icon: Settings, + roles: ['admin_sistema', 'director_rrhh'], + }, +] + +function getInitials(name: string): string { + return name + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} + +export function MainLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const [collapsed, setCollapsed] = React.useState(false) + const unreadNotifications = notifications.filter(n => !n.read).length + + const userRole = currentUser.role + + const filterByRole = (items: NavItem[]) => { + return items.filter(item => { + if (!item.roles) return true + return item.roles.includes(userRole) + }) + } + + const filteredMainNav = filterByRole(mainNavItems) + const filteredSecondaryNav = filterByRole(secondaryNavItems) + + return ( + +
+ {/* Desktop Sidebar */} + + + {/* Main Content */} +
+ {/* Top Header */} +
+
+ + + + + + {/* Logo */} +
+
+ GomezLee Marketing +
+
+ Portal GLM + Requisiciones +
+
+ + {/* Navigation */} + + + + + {/* User Section */} +
+ + + + + + Mi Cuenta + + + + Perfil + + + + Configuración + + + + + Cerrar Sesión + + + +
+
+
+ +
+ + {new Date().toLocaleDateString('es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +
+
+ +
+ {/* Quick Stats */} +
+
+
+ +
+
+ 2 + Pendientes +
+
+
+
+ +
+
+ 3 + En riesgo +
+
+
+ + {/* Notifications */} + + + + + + + Notificaciones + {unreadNotifications > 0 && ( + + {unreadNotifications} nuevas + + )} + + + + {notifications.slice(0, 5).map((notif) => ( + +
+ + {notif.title} + + {!notif.read && ( + + )} +
+ + {notif.message} + + + {new Date(notif.createdAt).toLocaleDateString('es-ES')} + +
+ ))} +
+ + + Ver todas las notificaciones + +
+
+
+
+ + {/* Page Content */} +
+ {children} +
+
+
+
+ ) +} diff --git a/components/pages/approvals-page.tsx b/components/pages/approvals-page.tsx new file mode 100644 index 0000000..1b5ca14 --- /dev/null +++ b/components/pages/approvals-page.tsx @@ -0,0 +1,709 @@ +'use client' + +import * as React from 'react' +import Link from 'next/link' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Separator } from '@/components/ui/separator' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Search, + Clock, + CheckCircle2, + XCircle, + AlertTriangle, + Eye, + MapPin, + Building2, + Users, + Calendar, + DollarSign, + FileText, + ThumbsUp, + ThumbsDown, + ArrowRight, + Filter, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { requisitions, countries, currentUser } from '@/lib/mock-data' +import { STATUS_CONFIG, URGENCY_CONFIG, type Requisition } from '@/lib/types' +import { toast } from 'sonner' + +function getInitials(name: string): string { + return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) +} + +function getDaysUntilDeadline(deadline: string): number { + const now = new Date() + const deadlineDate = new Date(deadline) + return Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +function getDaysSinceCreated(dateString: string): number { + const now = new Date() + const created = new Date(dateString) + return Math.ceil((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)) +} + +interface ApprovalCardProps { + requisition: Requisition + approvalType: 'rrhh' | 'admin' + onApprove: (req: Requisition) => void + onReject: (req: Requisition) => void + onView: (req: Requisition) => void +} + +function ApprovalCard({ requisition, approvalType, onApprove, onReject, onView }: ApprovalCardProps) { + const urgency = URGENCY_CONFIG[requisition.urgency] + const daysLeft = getDaysUntilDeadline(requisition.hardDeadline) + const daysPending = getDaysSinceCreated(requisition.createdAt) + const isOverdue = daysLeft < 0 + const isAtRisk = daysLeft >= 0 && daysLeft <= 7 + const isPendingTooLong = daysPending > 2 + + return ( + + +
+
+ {/* Header */} +
+ + {requisition.id} + + + {urgency.label} + + {isPendingTooLong && ( + + + {daysPending}d pendiente + + )} +
+ + {/* Position & Location */} +

{requisition.positionName}

+
+ + + {requisition.cityName}, {requisition.countryName} + + + + {requisition.departmentName} + + + + {requisition.quantity} vacante{requisition.quantity > 1 ? 's' : ''} + +
+ + {/* Justification Preview */} +

+ {requisition.justification} +

+ + {/* Requester & SLA */} +
+
+ + + {getInitials(requisition.requesterName)} + + + + {requisition.requesterName} + + + - {formatDate(requisition.createdAt)} + +
+
+ + + SLA: {isOverdue ? `${Math.abs(daysLeft)}d vencido` : `${daysLeft}d`} + +
+
+ + {/* HR Approval Info (for Admin view) */} + {approvalType === 'admin' && requisition.approvedByHrAt && ( +
+
+ + Aprobado por RR.HH. el {formatDate(requisition.approvedByHrAt)} +
+
+ )} + + {/* Compensation (visible to authorized roles) */} + {(requisition.salaryPackage || requisition.budget) && ( +
+ + {requisition.salaryPackage && ( + {requisition.salaryPackage} + )} + {requisition.budget && ( + + Presupuesto: {requisition.currency} {requisition.budget.toLocaleString()} + + )} +
+ )} +
+ + {/* Action Buttons */} +
+ + + +
+
+
+
+ ) +} + +export function ApprovalsPage() { + const [searchTerm, setSearchTerm] = React.useState('') + const [countryFilter, setCountryFilter] = React.useState('all') + const [activeTab, setActiveTab] = React.useState<'rrhh' | 'admin'>('rrhh') + + // Dialog states + const [selectedRequisition, setSelectedRequisition] = React.useState(null) + const [approveDialogOpen, setApproveDialogOpen] = React.useState(false) + const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) + const [detailDialogOpen, setDetailDialogOpen] = React.useState(false) + const [rejectComment, setRejectComment] = React.useState('') + const [approveComment, setApproveComment] = React.useState('') + + // Filter requisitions based on approval type + const pendingHR = requisitions.filter(r => r.status === 'pendiente_rrhh') + const pendingAdmin = requisitions.filter(r => r.status === 'pendiente_admin') + + const currentQueue = activeTab === 'rrhh' ? pendingHR : pendingAdmin + + const filteredQueue = React.useMemo(() => { + let filtered = [...currentQueue] + + if (searchTerm) { + const term = searchTerm.toLowerCase() + filtered = filtered.filter(r => + r.id.toLowerCase().includes(term) || + r.positionName.toLowerCase().includes(term) || + r.requesterName.toLowerCase().includes(term) + ) + } + + if (countryFilter !== 'all') { + filtered = filtered.filter(r => r.countryId === countryFilter) + } + + // Sort by urgency and then by date + filtered.sort((a, b) => { + const urgencyOrder = { critica: 0, alta: 1, normal: 2 } + const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency] + if (urgencyDiff !== 0) return urgencyDiff + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + }) + + return filtered + }, [currentQueue, searchTerm, countryFilter]) + + const handleApprove = (requisition: Requisition) => { + setSelectedRequisition(requisition) + setApproveDialogOpen(true) + } + + const handleReject = (requisition: Requisition) => { + setSelectedRequisition(requisition) + setRejectDialogOpen(true) + } + + const handleView = (requisition: Requisition) => { + setSelectedRequisition(requisition) + setDetailDialogOpen(true) + } + + const confirmApprove = () => { + if (!selectedRequisition) return + + toast.success('Requisición aprobada', { + description: `${selectedRequisition.id} ha sido aprobada y pasará al siguiente paso.`, + }) + setApproveDialogOpen(false) + setApproveComment('') + setSelectedRequisition(null) + } + + const confirmReject = () => { + if (!selectedRequisition || !rejectComment.trim()) return + + toast.success('Requisición devuelta', { + description: `${selectedRequisition.id} ha sido devuelta al solicitante para corrección.`, + }) + setRejectDialogOpen(false) + setRejectComment('') + setSelectedRequisition(null) + } + + return ( +
+ {/* Header */} +
+
+

Aprobaciones

+

+ Gestiona las requisiciones pendientes de aprobación +

+
+
+ + {/* Stats Cards */} +
+ + +
+
+

Pendientes RR.HH.

+

{pendingHR.length}

+
+
+ +
+
+
+
+ + +
+
+

Pendientes Admin

+

{pendingAdmin.length}

+
+
+ +
+
+
+
+ + +
+
+

Urgentes

+

+ {[...pendingHR, ...pendingAdmin].filter(r => r.urgency === 'critica').length} +

+
+
+ +
+
+
+
+ + +
+
+

{'>'}48h Pendientes

+

+ {[...pendingHR, ...pendingAdmin].filter(r => getDaysSinceCreated(r.createdAt) > 2).length} +

+
+
+ +
+
+
+
+
+ + {/* Tabs */} + setActiveTab(v as 'rrhh' | 'admin')}> +
+ + + + RR.HH. ({pendingHR.length}) + + + + Administración ({pendingAdmin.length}) + + + + {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ +
+
+ + + {filteredQueue.length > 0 ? ( +
+ {filteredQueue.map((requisition) => ( + + ))} +
+ ) : ( + + + +

Sin pendientes

+

+ No hay requisiciones pendientes de aprobación de RR.HH. +

+
+
+ )} +
+ + + {filteredQueue.length > 0 ? ( +
+ {filteredQueue.map((requisition) => ( + + ))} +
+ ) : ( + + + +

Sin pendientes

+

+ No hay requisiciones pendientes de aprobación de Administración +

+
+
+ )} +
+
+ + {/* Approve Dialog */} + + + + Aprobar Requisición + + {selectedRequisition && ( + <> + Estás por aprobar {selectedRequisition.id} - {selectedRequisition.positionName} + + )} + + +
+
+ +