Files
portal-requisicion-vacantes/components/pages/dashboard-page.tsx
T
2026-06-12 16:08:49 -04:00

510 lines
20 KiB
TypeScript

'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 { Progress } from '@/components/ui/progress'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
Legend,
AreaChart,
Area,
} from 'recharts'
import {
FileText,
Clock,
CheckCircle2,
AlertTriangle,
TrendingUp,
TrendingDown,
Users,
ArrowRight,
Calendar,
Target,
Activity,
Timer,
AlertCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import {
requisitions,
countries,
recruiterCapacity,
getDashboardStats
} from '@/lib/mock-data'
import { STATUS_CONFIG, type Requisition } from '@/lib/types'
// Chart data
const statusDistribution = [
{ name: 'Reclutando', value: 4, color: '#6ec1e4' },
{ name: 'Pendiente RR.HH.', value: 1, color: '#f59e0b' },
{ name: 'Pendiente Admin', value: 1, color: '#8b5cf6' },
{ name: 'Corrección', value: 1, color: '#f97316' },
{ name: 'Cerrado', value: 1, color: '#22c55e' },
{ name: 'Cancelado', value: 1, color: '#ef4444' },
]
const requisitionsByCountry = [
{ country: 'México', abiertas: 3, cerradas: 0, canceladas: 0 },
{ country: 'Colombia', abiertas: 2, cerradas: 0, canceladas: 0 },
{ country: 'Perú', abiertas: 1, cerradas: 0, canceladas: 0 },
{ country: 'Chile', abiertas: 0, cerradas: 1, canceladas: 0 },
{ country: 'Rep. Dom.', abiertas: 1, cerradas: 0, canceladas: 0 },
{ country: 'Argentina', abiertas: 1, cerradas: 0, canceladas: 0 },
{ country: 'Guatemala', abiertas: 0, cerradas: 0, canceladas: 1 },
]
const ttfTrend = [
{ mes: 'Sep', dias: 28 },
{ mes: 'Oct', dias: 25 },
{ mes: 'Nov', dias: 22 },
{ mes: 'Dic', dias: 20 },
{ mes: 'Ene', dias: 18 },
]
const stageVelocity = [
{ stage: 'Búsqueda', promedio: 5 },
{ stage: '1era Entrev.', promedio: 3 },
{ stage: '2da Entrev.', promedio: 4 },
{ stage: 'Referencias', promedio: 3 },
{ stage: 'Exámenes', promedio: 2 },
{ stage: 'Selección', promedio: 1 },
]
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 getCapacityStatus(score: number, max: number): { label: string; color: string; bgColor: string } {
const percentage = (score / max) * 100
if (percentage < 60) return { label: 'Disponible', color: 'text-emerald-700 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/30' }
if (percentage < 90) return { label: 'A capacidad', color: 'text-amber-700 dark:text-amber-400', bgColor: 'bg-amber-100 dark:bg-amber-900/30' }
return { label: 'Sobrecargado', color: 'text-red-700 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30' }
}
interface StatCardProps {
title: string
value: string | number
subtitle?: string
icon: React.ElementType
trend?: { value: number; positive: boolean }
iconColor?: string
iconBgColor?: string
}
function StatCard({ title, value, subtitle, icon: Icon, trend, iconColor = 'text-primary', iconBgColor = 'bg-primary/10' }: StatCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold tracking-tight">{value}</p>
{trend && (
<span className={cn(
'flex items-center text-xs font-medium',
trend.positive ? 'text-emerald-600' : 'text-red-600'
)}>
{trend.positive ? <TrendingUp className="mr-0.5 h-3 w-3" /> : <TrendingDown className="mr-0.5 h-3 w-3" />}
{trend.value}%
</span>
)}
</div>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
<div className={cn('flex h-12 w-12 items-center justify-center rounded-xl', iconBgColor)}>
<Icon className={cn('h-6 w-6', iconColor)} />
</div>
</div>
</CardContent>
</Card>
)
}
function RequisitionRow({ requisition }: { requisition: Requisition }) {
const status = STATUS_CONFIG[requisition.status]
const daysLeft = getDaysUntilDeadline(requisition.hardDeadline)
const isOverdue = daysLeft < 0
const isAtRisk = daysLeft >= 0 && daysLeft <= 7
return (
<Link
href={`/requisiciones/${requisition.id}`}
className="flex items-center gap-4 rounded-lg p-3 transition-colors hover:bg-muted/50"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{requisition.id}</span>
<Badge variant="outline" className={cn('text-xs', status.color, status.bgColor, status.borderColor)}>
{status.label}
</Badge>
</div>
<p className="text-sm text-muted-foreground truncate">
{requisition.positionName} - {requisition.cityName}, {requisition.countryName}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{requisition.vacanciesFilled}/{requisition.quantity}
</span>
<span className={cn(
'flex items-center gap-1',
isOverdue && 'text-red-600 dark:text-red-400',
isAtRisk && !isOverdue && 'text-amber-600 dark:text-amber-400'
)}>
<Clock className="h-3 w-3" />
{isOverdue ? `${Math.abs(daysLeft)}d vencido` : `${daysLeft}d restantes`}
</span>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
)
}
export function DashboardPage() {
const [selectedCountry, setSelectedCountry] = React.useState('all')
const stats = getDashboardStats()
const filteredRequisitions = selectedCountry === 'all'
? requisitions
: requisitions.filter(r => r.countryId === selectedCountry)
const openRequisitions = filteredRequisitions.filter(r => !['cerrado', 'cancelado'].includes(r.status))
const urgentRequisitions = openRequisitions.filter(r => {
const daysLeft = getDaysUntilDeadline(r.hardDeadline)
return daysLeft <= 7
}).sort((a, b) => new Date(a.hardDeadline).getTime() - new Date(b.hardDeadline).getTime())
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Resumen ejecutivo de requisiciones y reclutamiento
</p>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<Select value={selectedCountry} onValueChange={setSelectedCountry}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Filtrar por país" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los países</SelectItem>
{countries.map((country) => (
<SelectItem key={country.id} value={country.id}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Stats Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Requisiciones Abiertas"
value={stats.totalOpen}
subtitle={`${stats.recruiting} en reclutamiento activo`}
icon={FileText}
iconColor="text-sky-600"
iconBgColor="bg-sky-100 dark:bg-sky-900/30"
/>
<StatCard
title="Pendientes de Aprobación"
value={stats.pendingApproval}
subtitle="Requieren acción inmediata"
icon={Clock}
iconColor="text-amber-600"
iconBgColor="bg-amber-100 dark:bg-amber-900/30"
/>
<StatCard
title="SLA Vencido / En Riesgo"
value={`${stats.overdueCount} / ${stats.atRiskCount}`}
subtitle="Casos que requieren atención"
icon={AlertTriangle}
iconColor="text-red-600"
iconBgColor="bg-red-100 dark:bg-red-900/30"
/>
<StatCard
title="TTF Promedio"
value={`${stats.avgTTF} días`}
subtitle={`vs ${stats.avgTTF + 5} días mes anterior`}
icon={Target}
trend={{ value: 15, positive: true }}
iconColor="text-emerald-600"
iconBgColor="bg-emerald-100 dark:bg-emerald-900/30"
/>
</div>
{/* Charts Row */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Status Distribution */}
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Distribución por Estado</CardTitle>
<CardDescription>Estado actual de todas las requisiciones</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusDistribution}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{statusDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => [value, 'Requisiciones']}
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-xs text-foreground">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Requisitions by Country */}
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Requisiciones por País</CardTitle>
<CardDescription>Distribución geográfica de vacantes</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={requisitionsByCountry} layout="vertical" margin={{ left: 20, right: 20, top: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 12 }} stroke="hsl(var(--muted-foreground))" />
<YAxis dataKey="country" type="category" width={80} tick={{ fontSize: 12 }} stroke="hsl(var(--muted-foreground))" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Bar dataKey="abiertas" name="Abiertas" fill="var(--color-chart-1)" radius={[0, 4, 4, 0]} />
<Bar dataKey="cerradas" name="Cerradas" fill="var(--color-chart-2)" radius={[0, 4, 4, 0]} />
<Bar dataKey="canceladas" name="Canceladas" fill="var(--color-chart-4)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* TTF Trend & Stage Velocity */}
<div className="grid gap-6 lg:grid-cols-2">
{/* TTF Trend */}
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Tendencia TTF</CardTitle>
<CardDescription>Tiempo promedio para cubrir vacantes (días)</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[240px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={ttfTrend}>
<defs>
<linearGradient id="ttfGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6ec1e4" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#6ec1e4" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="mes" tick={{ fontSize: 12 }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 12 }} stroke="hsl(var(--muted-foreground))" />
<Tooltip
formatter={(value: number) => [`${value} días`, 'TTF Promedio']}
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Area type="monotone" dataKey="dias" stroke="var(--color-chart-1)" strokeWidth={2} fill="url(#ttfGradient)" />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Stage Velocity */}
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Velocidad por Etapa</CardTitle>
<CardDescription>Días promedio en cada fase del proceso</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[240px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stageVelocity}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="stage" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 12 }} stroke="hsl(var(--muted-foreground))" />
<Tooltip
formatter={(value: number) => [`${value} días`, 'Promedio']}
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Bar dataKey="promedio" fill="var(--color-chart-1)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Bottom Row */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Urgent Cases */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base font-semibold">Casos Urgentes</CardTitle>
<CardDescription>Requisiciones con SLA en riesgo o vencido</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href="/requisiciones?filter=urgent">Ver todos</Link>
</Button>
</CardHeader>
<CardContent>
<ScrollArea className="h-[320px] pr-4">
{urgentRequisitions.length > 0 ? (
<div className="space-y-1">
{urgentRequisitions.map((req) => (
<RequisitionRow key={req.id} requisition={req} />
))}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-500 mb-3" />
<p className="text-sm font-medium">Sin casos urgentes</p>
<p className="text-xs text-muted-foreground">Todas las requisiciones están dentro del SLA</p>
</div>
)}
</ScrollArea>
</CardContent>
</Card>
{/* Recruiter Capacity */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base font-semibold">Carga de Reclutadores</CardTitle>
<CardDescription>Capacidad actual del equipo</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href="/reclutadores">Ver todos</Link>
</Button>
</CardHeader>
<CardContent>
<ScrollArea className="h-[320px] pr-4">
<div className="space-y-4">
{recruiterCapacity.map((recruiter) => {
const capacityStatus = getCapacityStatus(recruiter.currentScore, recruiter.maxThreshold)
const percentage = Math.min((recruiter.currentScore / recruiter.maxThreshold) * 100, 100)
return (
<div key={recruiter.recruiterId} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs bg-muted">
{getInitials(recruiter.recruiterName)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{recruiter.recruiterName}</p>
<p className="text-xs text-muted-foreground">{recruiter.countryFocus.join(', ')}</p>
</div>
</div>
<Badge variant="outline" className={cn('text-xs', capacityStatus.color, capacityStatus.bgColor)}>
{capacityStatus.label}
</Badge>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Score: {recruiter.currentScore}/{recruiter.maxThreshold}</span>
<span className="text-muted-foreground">{recruiter.openCases} casos</span>
</div>
<Progress
value={percentage}
className={cn(
'h-2',
percentage >= 90 && '[&>div]:bg-red-500',
percentage >= 60 && percentage < 90 && '[&>div]:bg-amber-500',
percentage < 60 && '[&>div]:bg-emerald-500'
)}
/>
</div>
</div>
)
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}