385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter, useSearchParams } 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 { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import {
|
|
PlusCircle,
|
|
Search,
|
|
Filter,
|
|
MoreHorizontal,
|
|
Eye,
|
|
Edit,
|
|
Clock,
|
|
Users,
|
|
MapPin,
|
|
Building2,
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
XCircle,
|
|
ArrowUpDown,
|
|
Download,
|
|
RefreshCw,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { requisitions, countries } from '@/lib/mock-data'
|
|
import { STATUS_CONFIG, URGENCY_CONFIG, type Requisition, type RequisitionStatus } from '@/lib/types'
|
|
|
|
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: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
interface RequisitionRowProps {
|
|
requisition: Requisition
|
|
}
|
|
|
|
function RequisitionTableRow({ requisition }: RequisitionRowProps) {
|
|
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
|
|
|
|
return (
|
|
<TableRow className="group">
|
|
<TableCell className="font-medium">
|
|
<Link href={`/requisiciones/${requisition.id}`} className="hover:text-primary hover:underline">
|
|
{requisition.id}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<p className="font-medium text-sm">{requisition.positionName}</p>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<MapPin className="h-3 w-3" />
|
|
{requisition.cityName}, {requisition.countryName}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">{requisition.vacanciesFilled}</span>
|
|
<span className="text-muted-foreground">/ {requisition.quantity}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className={cn('text-xs', status.color, status.bgColor, status.borderColor)}>
|
|
{status.label}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className={cn('text-xs', urgency.color, urgency.bgColor)}>
|
|
{urgency.label}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
|
{requisition.departmentName}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className={cn(
|
|
'flex items-center gap-1.5 text-sm',
|
|
isOverdue && 'text-red-600 dark:text-red-400',
|
|
isAtRisk && !isOverdue && 'text-amber-600 dark:text-amber-400'
|
|
)}>
|
|
{isOverdue ? (
|
|
<AlertTriangle className="h-4 w-4" />
|
|
) : isAtRisk ? (
|
|
<Clock className="h-4 w-4" />
|
|
) : (
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span>
|
|
{isOverdue
|
|
? `${Math.abs(daysLeft)}d vencido`
|
|
: `${daysLeft}d`
|
|
}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-muted-foreground">{formatDate(requisition.createdAt)}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/requisiciones/${requisition.id}`}>
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
Ver detalles
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{requisition.status === 'borrador' && (
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/requisiciones/${requisition.id}/editar`}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Editar
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{requisition.status === 'correccion_requerida' && (
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/requisiciones/${requisition.id}/editar`}>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
Corregir y reenviar
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}
|
|
|
|
export function RequisitionsListPage() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [searchTerm, setSearchTerm] = React.useState('')
|
|
const [statusFilter, setStatusFilter] = React.useState<string>('all')
|
|
const [countryFilter, setCountryFilter] = React.useState<string>('all')
|
|
const [activeTab, setActiveTab] = React.useState('todas')
|
|
|
|
// Check for filter param
|
|
React.useEffect(() => {
|
|
const filter = searchParams.get('filter')
|
|
if (filter === 'urgent') {
|
|
setActiveTab('urgentes')
|
|
}
|
|
}, [searchParams])
|
|
|
|
const filteredRequisitions = React.useMemo(() => {
|
|
let filtered = [...requisitions]
|
|
|
|
// Search filter
|
|
if (searchTerm) {
|
|
const term = searchTerm.toLowerCase()
|
|
filtered = filtered.filter(r =>
|
|
r.id.toLowerCase().includes(term) ||
|
|
r.positionName.toLowerCase().includes(term) ||
|
|
r.cityName.toLowerCase().includes(term) ||
|
|
r.requesterName.toLowerCase().includes(term)
|
|
)
|
|
}
|
|
|
|
// Status filter
|
|
if (statusFilter !== 'all') {
|
|
filtered = filtered.filter(r => r.status === statusFilter)
|
|
}
|
|
|
|
// Country filter
|
|
if (countryFilter !== 'all') {
|
|
filtered = filtered.filter(r => r.countryId === countryFilter)
|
|
}
|
|
|
|
// Tab filter
|
|
if (activeTab === 'abiertas') {
|
|
filtered = filtered.filter(r => !['cerrado', 'cancelado'].includes(r.status))
|
|
} else if (activeTab === 'urgentes') {
|
|
filtered = filtered.filter(r => {
|
|
const daysLeft = getDaysUntilDeadline(r.hardDeadline)
|
|
return !['cerrado', 'cancelado'].includes(r.status) && daysLeft <= 7
|
|
})
|
|
} else if (activeTab === 'cerradas') {
|
|
filtered = filtered.filter(r => ['cerrado', 'cancelado'].includes(r.status))
|
|
}
|
|
|
|
// Sort by date (newest first)
|
|
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
|
|
return filtered
|
|
}, [searchTerm, statusFilter, countryFilter, activeTab])
|
|
|
|
const stats = React.useMemo(() => {
|
|
const open = requisitions.filter(r => !['cerrado', 'cancelado'].includes(r.status))
|
|
const urgent = open.filter(r => getDaysUntilDeadline(r.hardDeadline) <= 7)
|
|
const closed = requisitions.filter(r => ['cerrado', 'cancelado'].includes(r.status))
|
|
|
|
return {
|
|
all: requisitions.length,
|
|
open: open.length,
|
|
urgent: urgent.length,
|
|
closed: closed.length,
|
|
}
|
|
}, [])
|
|
|
|
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">Requisiciones</h1>
|
|
<p className="text-muted-foreground">
|
|
Gestiona y da seguimiento a todas las requisiciones de vacantes
|
|
</p>
|
|
</div>
|
|
<Button asChild>
|
|
<Link href="/requisiciones/nueva">
|
|
<PlusCircle className="mr-2 h-4 w-4" />
|
|
Nueva Requisición
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-4 lg:w-[400px]">
|
|
<TabsTrigger value="todas" className="text-xs sm:text-sm">
|
|
Todas ({stats.all})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="abiertas" className="text-xs sm:text-sm">
|
|
Abiertas ({stats.open})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="urgentes" className="text-xs sm:text-sm">
|
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
Urgentes ({stats.urgent})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="cerradas" className="text-xs sm:text-sm">
|
|
Cerradas ({stats.closed})
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<Card className="mt-4">
|
|
<CardHeader className="pb-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Buscar por ID, posición, ciudad..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[180px] sm:w-[220px]">
|
|
<Filter className="mr-2 h-4 w-4" />
|
|
<SelectValue placeholder="Estado" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todos los estados</SelectItem>
|
|
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{config.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={countryFilter} onValueChange={setCountryFilter}>
|
|
<SelectTrigger className="w-[160px] sm:w-[200px]">
|
|
<SelectValue placeholder="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>
|
|
<Button variant="outline" size="icon">
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<TabsContent value={activeTab} className="mt-0">
|
|
{filteredRequisitions.length > 0 ? (
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[120px]">ID</TableHead>
|
|
<TableHead>Posición</TableHead>
|
|
<TableHead className="w-[100px]">Vacantes</TableHead>
|
|
<TableHead className="w-[160px]">Estado</TableHead>
|
|
<TableHead className="w-[100px]">Urgencia</TableHead>
|
|
<TableHead>Departamento</TableHead>
|
|
<TableHead className="w-[110px]">
|
|
<div className="flex items-center gap-1">
|
|
SLA
|
|
<ArrowUpDown className="h-3 w-3" />
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="w-[110px]">Creada</TableHead>
|
|
<TableHead className="w-[50px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredRequisitions.map((requisition) => (
|
|
<RequisitionTableRow key={requisition.id} requisition={requisition} />
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="rounded-full bg-muted p-3 mb-4">
|
|
<Search className="h-6 w-6 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-medium">No se encontraron requisiciones</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Intenta ajustar los filtros de búsqueda
|
|
</p>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</CardContent>
|
|
</Card>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|