707 lines
28 KiB
TypeScript
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>
|
|
)
|
|
}
|