Files
2026-06-12 16:08:49 -04:00

707 lines
28 KiB
TypeScript

'use client'
import * as React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ArrowLeft,
Edit,
Clock,
Users,
MapPin,
Building2,
Calendar,
AlertTriangle,
CheckCircle2,
XCircle,
Send,
RefreshCw,
UserPlus,
FileText,
History,
MessageSquare,
Briefcase,
DollarSign,
Target,
ChevronRight,
Ban,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { requisitions, auditLog, users, recruiterCapacity } from '@/lib/mock-data'
import {
STATUS_CONFIG,
URGENCY_CONFIG,
STAGE_CONFIG,
type Requisition,
type CandidateStage
} 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: 'long',
year: 'numeric',
})
}
function formatDateTime(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Status flow visualization component
function StatusFlow({ currentStatus }: { currentStatus: Requisition['status'] }) {
const steps = [
{ key: 'borrador', label: 'Borrador', icon: FileText },
{ key: 'pendiente_rrhh', label: 'RR.HH.', icon: Users },
{ key: 'pendiente_admin', label: 'Admin', icon: Building2 },
{ key: 'reclutando', label: 'Reclutando', icon: Briefcase },
{ key: 'cerrado', label: 'Cerrado', icon: CheckCircle2 },
]
const currentIndex = steps.findIndex(s => s.key === currentStatus)
const isCancelled = currentStatus === 'cancelado'
const isCorrection = currentStatus === 'correccion_requerida'
return (
<div className="relative overflow-x-auto pb-4">
<div className="flex items-center justify-between min-w-[500px]">
{steps.map((step, index) => {
const Icon = step.icon
const isCompleted = index < currentIndex && !isCancelled
const isCurrent = step.key === currentStatus
const isPending = index > currentIndex
return (
<React.Fragment key={step.key}>
<div className="flex flex-col items-center gap-2">
<div className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2 transition-colors',
isCompleted && 'border-emerald-500 bg-emerald-500 text-white',
isCurrent && !isCancelled && 'border-primary bg-primary text-primary-foreground',
isPending && 'border-muted-foreground/30 bg-muted text-muted-foreground',
isCancelled && 'border-red-500 bg-red-500 text-white',
isCorrection && isCurrent && 'border-amber-500 bg-amber-500 text-white'
)}>
{isCompleted ? (
<CheckCircle2 className="h-5 w-5" />
) : isCancelled ? (
<XCircle className="h-5 w-5" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<span className={cn(
'text-xs font-medium',
(isCompleted || isCurrent) && 'text-foreground',
isPending && 'text-muted-foreground'
)}>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div className={cn(
'flex-1 h-0.5 mx-2 -mt-6',
index < currentIndex ? 'bg-emerald-500' : 'bg-muted-foreground/30'
)} />
)}
</React.Fragment>
)
})}
</div>
{isCorrection && (
<div className="mt-4 flex items-center justify-center gap-2 text-amber-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm font-medium">Corrección requerida - Regresado a solicitante</span>
</div>
)}
</div>
)
}
// Recruitment stages component
function RecruitmentStages({ currentStage }: { currentStage?: CandidateStage }) {
if (!currentStage) return null
const stages: CandidateStage[] = ['reclutando', 'primera_entrevista', 'segunda_entrevista', 'referencias', 'examenes_medicos', 'seleccionado']
const currentIndex = stages.indexOf(currentStage)
return (
<div className="space-y-3">
{stages.map((stage, index) => {
const config = STAGE_CONFIG[stage]
const isCompleted = index < currentIndex
const isCurrent = stage === currentStage
return (
<div key={stage} className="flex items-center gap-3">
<div className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium',
isCompleted && 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
isCurrent && 'bg-primary text-primary-foreground',
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
)}>
{isCompleted ? <CheckCircle2 className="h-4 w-4" /> : index + 1}
</div>
<span className={cn(
'text-sm',
isCurrent && 'font-medium text-foreground',
isCompleted && 'text-muted-foreground line-through',
!isCompleted && !isCurrent && 'text-muted-foreground'
)}>
{config.label}
</span>
</div>
)
})}
</div>
)
}
interface RequisitionDetailPageProps {
requisitionId: string
}
export function RequisitionDetailPage({ requisitionId }: RequisitionDetailPageProps) {
const router = useRouter()
const [cancelDialogOpen, setCancelDialogOpen] = React.useState(false)
const [assignDialogOpen, setAssignDialogOpen] = React.useState(false)
const [cancelReason, setCancelReason] = React.useState('')
const [selectedRecruiter, setSelectedRecruiter] = React.useState('')
const requisition = requisitions.find(r => r.id === requisitionId)
if (!requisition) {
return (
<div className="flex flex-col items-center justify-center h-[60vh]">
<AlertTriangle className="h-12 w-12 text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold">Requisición no encontrada</h2>
<p className="text-muted-foreground mb-4">La requisición {requisitionId} no existe.</p>
<Button asChild>
<Link href="/requisiciones">Volver a requisiciones</Link>
</Button>
</div>
)
}
const status = STATUS_CONFIG[requisition.status]
const urgency = URGENCY_CONFIG[requisition.urgency]
const daysLeft = getDaysUntilDeadline(requisition.hardDeadline)
const isOverdue = daysLeft < 0
const isAtRisk = daysLeft >= 0 && daysLeft <= 7
const progress = (requisition.vacanciesFilled / requisition.quantity) * 100
const relatedAuditLog = auditLog.filter(log => log.entityId === requisitionId)
const recruiters = users.filter(u => u.role === 'reclutador' && u.active)
const handleCancel = () => {
toast.success('Requisición cancelada', {
description: `${requisition.id} ha sido cancelada.`,
})
setCancelDialogOpen(false)
router.push('/requisiciones')
}
const handleAssignRecruiter = () => {
const recruiter = recruiters.find(r => r.id === selectedRecruiter)
toast.success('Reclutador asignado', {
description: `${recruiter?.name} ha sido asignado a ${requisition.id}.`,
})
setAssignDialogOpen(false)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/requisiciones">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold tracking-tight">{requisition.id}</h1>
<Badge variant="outline" className={cn(status.color, status.bgColor, status.borderColor)}>
{status.label}
</Badge>
<Badge variant="outline" className={cn('text-xs', urgency.color, urgency.bgColor)}>
{urgency.label}
</Badge>
</div>
<p className="text-muted-foreground">
{requisition.positionName} - {requisition.quantity} vacante{requisition.quantity > 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{requisition.status === 'borrador' && (
<>
<Button variant="outline" asChild>
<Link href={`/requisiciones/${requisition.id}/editar`}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Link>
</Button>
<Button>
<Send className="mr-2 h-4 w-4" />
Enviar a Aprobación
</Button>
</>
)}
{requisition.status === 'correccion_requerida' && (
<Button asChild>
<Link href={`/requisiciones/${requisition.id}/editar`}>
<RefreshCw className="mr-2 h-4 w-4" />
Corregir y Reenviar
</Link>
</Button>
)}
{requisition.status === 'reclutando' && !requisition.recruiterId && (
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Asignar Reclutador
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Asignar Reclutador</DialogTitle>
<DialogDescription>
Selecciona el reclutador que gestionará esta requisición.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Reclutador</Label>
<Select value={selectedRecruiter} onValueChange={setSelectedRecruiter}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar reclutador" />
</SelectTrigger>
<SelectContent>
{recruiters.map((recruiter) => {
const capacity = recruiterCapacity.find(c => c.recruiterId === recruiter.id)
return (
<SelectItem key={recruiter.id} value={recruiter.id}>
<div className="flex items-center gap-2">
<span>{recruiter.name}</span>
{capacity && (
<span className="text-xs text-muted-foreground">
(Score: {capacity.currentScore}/{capacity.maxThreshold})
</span>
)}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleAssignRecruiter} disabled={!selectedRecruiter}>
Asignar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{!['cerrado', 'cancelado'].includes(requisition.status) && (
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:text-destructive">
<Ban className="mr-2 h-4 w-4" />
Cancelar
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancelar Requisición</DialogTitle>
<DialogDescription>
Esta acción no se puede deshacer. La requisición quedará marcada como cancelada.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="cancelReason">Motivo de cancelación *</Label>
<Textarea
id="cancelReason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Describe el motivo de la cancelación..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelDialogOpen(false)}>
Volver
</Button>
<Button variant="destructive" onClick={handleCancel} disabled={!cancelReason.trim()}>
Confirmar Cancelación
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</div>
{/* Status Flow */}
<Card>
<CardHeader>
<CardTitle className="text-base">Estado del Proceso</CardTitle>
</CardHeader>
<CardContent>
<StatusFlow currentStatus={requisition.status} />
</CardContent>
</Card>
{/* Main Content */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
{/* Rejection Comment */}
{requisition.rejectionComment && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">Corrección Requerida</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
{requisition.rejectionComment}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Info Tabs */}
<Tabs defaultValue="detalles">
<TabsList>
<TabsTrigger value="detalles">Detalles</TabsTrigger>
<TabsTrigger value="historial">Historial</TabsTrigger>
{requisition.status === 'reclutando' && (
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
)}
</TabsList>
<TabsContent value="detalles" className="mt-4">
<Card>
<CardContent className="p-6 space-y-6">
{/* Basic Info */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Información de la Vacante
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Posición</p>
<p className="font-medium">{requisition.positionName}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Departamento</p>
<p className="font-medium">{requisition.departmentName}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Ubicación</p>
<p className="font-medium">{requisition.cityName}, {requisition.countryName}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Modalidad</p>
<p className="font-medium capitalize">{requisition.modalidad}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Tipo de Contratación</p>
<p className="font-medium capitalize">{requisition.tipoContratacion}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Cantidad de Vacantes</p>
<p className="font-medium">{requisition.quantity}</p>
</div>
</div>
</div>
<Separator />
{/* Justification */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Justificación
</h3>
<p className="text-sm leading-relaxed">{requisition.justification}</p>
</div>
{/* Compensation (if visible) */}
{(requisition.salaryPackage || requisition.budget) && (
<>
<Separator />
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Compensación
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{requisition.salaryPackage && (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Paquete Salarial</p>
<p className="font-medium">{requisition.salaryPackage}</p>
</div>
)}
{requisition.budget && (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Presupuesto</p>
<p className="font-medium">
{requisition.currency} {requisition.budget.toLocaleString()}
</p>
</div>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="historial" className="mt-4">
<Card>
<CardContent className="p-6">
<ScrollArea className="h-[400px] pr-4">
<div className="relative pl-6 border-l-2 border-muted space-y-6">
{relatedAuditLog.map((log, index) => (
<div key={log.id} className="relative">
<div className="absolute -left-[25px] h-3 w-3 rounded-full bg-primary" />
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">{log.action}</p>
<span className="text-xs text-muted-foreground">
{formatDateTime(log.createdAt)}
</span>
</div>
<p className="text-sm text-muted-foreground">Por {log.userName}</p>
{log.oldValue && log.newValue && (
<div className="flex items-center gap-2 text-xs mt-1">
<Badge variant="outline" className="text-muted-foreground">
{log.oldValue}
</Badge>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="outline">
{log.newValue}
</Badge>
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
{requisition.status === 'reclutando' && (
<TabsContent value="pipeline" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Etapas de Reclutamiento</CardTitle>
<CardDescription>Progreso del proceso de selección</CardDescription>
</CardHeader>
<CardContent>
<RecruitmentStages currentStage={requisition.currentStage} />
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
{/* Right Column - Summary */}
<div className="space-y-6">
{/* SLA Card */}
<Card className={cn(
isOverdue && 'border-red-200 dark:border-red-800',
isAtRisk && !isOverdue && 'border-amber-200 dark:border-amber-800'
)}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="h-4 w-4" />
SLA
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Fecha límite</span>
<span className="font-medium">{formatDate(requisition.hardDeadline)}</span>
</div>
<div className={cn(
'flex items-center justify-between',
isOverdue && 'text-red-600 dark:text-red-400',
isAtRisk && !isOverdue && 'text-amber-600 dark:text-amber-400'
)}>
<span className="text-sm">Días restantes</span>
<span className="font-semibold text-lg">
{isOverdue ? `${Math.abs(daysLeft)} días vencido` : `${daysLeft} días`}
</span>
</div>
{(isOverdue || isAtRisk) && (
<div className={cn(
'flex items-center gap-2 rounded-lg p-3 text-sm',
isOverdue && 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
isAtRisk && !isOverdue && 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
)}>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
{isOverdue ? 'SLA vencido - Acción inmediata requerida' : 'SLA en riesgo - Priorizar'}
</span>
</div>
)}
</CardContent>
</Card>
{/* Progress Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Progreso de Vacantes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end justify-between">
<div>
<span className="text-3xl font-bold">{requisition.vacanciesFilled}</span>
<span className="text-muted-foreground"> / {requisition.quantity}</span>
</div>
<span className="text-sm text-muted-foreground">
{Math.round(progress)}% completado
</span>
</div>
<Progress value={progress} className="h-2" />
<p className="text-xs text-muted-foreground">
{requisition.quantity - requisition.vacanciesFilled} vacante{(requisition.quantity - requisition.vacanciesFilled) !== 1 ? 's' : ''} pendiente{(requisition.quantity - requisition.vacanciesFilled) !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
{/* Requester Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Solicitante</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{getInitials(requisition.requesterName)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{requisition.requesterName}</p>
<p className="text-sm text-muted-foreground">{requisition.departmentName}</p>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Fecha de creación</span>
<span>{formatDate(requisition.createdAt)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Recruiter Card */}
{requisition.recruiterId && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Reclutador Asignado</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{getInitials(requisition.recruiterName || '')}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{requisition.recruiterName}</p>
<p className="text-sm text-muted-foreground">Reclutador</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Approvals Card */}
{(requisition.approvedByHrAt || requisition.approvedByAdminAt) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Aprobaciones</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{requisition.approvedByHrAt && (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span>RR.HH. - {formatDate(requisition.approvedByHrAt)}</span>
</div>
)}
{requisition.approvedByAdminAt && (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span>Administración - {formatDate(requisition.approvedByAdminAt)}</span>
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}