Initial commit: Proyecto portal-requisicion-vacantes
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user