510 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|