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