318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useQuery } from 'react-query';
|
|
import axios from 'axios';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { Search, MapPin, Clock, DollarSign, Briefcase, Plus } from 'lucide-react';
|
|
|
|
const Jobs = () => {
|
|
const { user } = useAuth();
|
|
const [filters, setFilters] = useState({
|
|
search: '',
|
|
location: '',
|
|
employmentType: '',
|
|
experienceLevel: ''
|
|
});
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
refetch
|
|
} = useQuery(['jobs', filters], async () => {
|
|
const params = new URLSearchParams();
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value) params.append(key, value);
|
|
});
|
|
|
|
const response = await axios.get(`/api/jobs?${params.toString()}`);
|
|
return response.data;
|
|
});
|
|
|
|
const handleFilterChange = (e) => {
|
|
setFilters({
|
|
...filters,
|
|
[e.target.name]: e.target.value
|
|
});
|
|
};
|
|
|
|
const handleSearch = (e) => {
|
|
e.preventDefault();
|
|
refetch();
|
|
};
|
|
|
|
const formatSalary = (min, max, currency = 'USD') => {
|
|
if (!min && !max) return 'Salary not specified';
|
|
if (!min) return `Up to ${currency} ${max?.toLocaleString()}`;
|
|
if (!max) return `From ${currency} ${min?.toLocaleString()}`;
|
|
return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`;
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'active': return 'bg-green-100 text-green-800';
|
|
case 'paused': return 'bg-yellow-100 text-yellow-800';
|
|
case 'closed': return 'bg-red-100 text-red-800';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div
|
|
role="status"
|
|
aria-label="Loading jobs"
|
|
className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"
|
|
></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-64 text-center space-y-3">
|
|
<h2 className="text-xl font-semibold text-gray-800">Unable to load jobs</h2>
|
|
<p className="text-sm text-gray-500">{error?.message || 'Please try again later.'}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => refetch()}
|
|
className="btn btn-primary"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Job Postings</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Find your next career opportunity
|
|
</p>
|
|
</div>
|
|
{(user?.role === 'employer' || user?.role === 'recruiter') && (
|
|
<Link
|
|
to="/jobs/create"
|
|
className="btn btn-primary"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Post a Job
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white shadow rounded-lg">
|
|
<div className="px-4 py-5 sm:p-6">
|
|
<form onSubmit={handleSearch} className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<div>
|
|
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
|
|
Search
|
|
</label>
|
|
<div className="mt-1 relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Search className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
name="search"
|
|
id="search"
|
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
|
placeholder="Job title or keywords"
|
|
value={filters.search}
|
|
onChange={handleFilterChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
|
Location
|
|
</label>
|
|
<div className="mt-1 relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<MapPin className="h-5 w-5 text-gray-400" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
name="location"
|
|
id="location"
|
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
|
placeholder="City, state, or remote"
|
|
value={filters.location}
|
|
onChange={handleFilterChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
|
|
Employment Type
|
|
</label>
|
|
<select
|
|
id="employmentType"
|
|
name="employmentType"
|
|
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
|
value={filters.employmentType}
|
|
onChange={handleFilterChange}
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="full-time">Full-time</option>
|
|
<option value="part-time">Part-time</option>
|
|
<option value="contract">Contract</option>
|
|
<option value="internship">Internship</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
|
|
Experience Level
|
|
</label>
|
|
<select
|
|
id="experienceLevel"
|
|
name="experienceLevel"
|
|
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
|
value={filters.experienceLevel}
|
|
onChange={handleFilterChange}
|
|
>
|
|
<option value="">All Levels</option>
|
|
<option value="entry">Entry Level</option>
|
|
<option value="mid">Mid Level</option>
|
|
<option value="senior">Senior Level</option>
|
|
<option value="lead">Lead</option>
|
|
<option value="executive">Executive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
>
|
|
Search Jobs
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Jobs List */}
|
|
<div className="space-y-4">
|
|
{data?.jobs?.length > 0 ? (
|
|
data.jobs.map((job) => (
|
|
<div key={job.id} className="bg-white shadow rounded-lg">
|
|
<div className="px-4 py-5 sm:p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center">
|
|
<h3 className="text-lg font-medium text-gray-900">
|
|
<Link to={`/jobs/${job.id}`} className="hover:text-primary-600">
|
|
{job.title}
|
|
</Link>
|
|
</h3>
|
|
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}>
|
|
{job.status}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center text-sm text-gray-500">
|
|
<Briefcase className="h-4 w-4 mr-1" />
|
|
{job.company_name}
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm text-gray-500 space-x-4">
|
|
<div className="flex items-center">
|
|
<MapPin className="h-4 w-4 mr-1" />
|
|
{job.location}
|
|
{job.remote_allowed && <span className="ml-1">(Remote OK)</span>}
|
|
</div>
|
|
<div className="flex items-center">
|
|
<DollarSign className="h-4 w-4 mr-1" />
|
|
{formatSalary(job.salary_min, job.salary_max, job.currency)}
|
|
</div>
|
|
<div className="flex items-center">
|
|
<Clock className="h-4 w-4 mr-1" />
|
|
{new Date(job.created_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<p className="text-sm text-gray-600 line-clamp-2">
|
|
{job.description}
|
|
</p>
|
|
</div>
|
|
{job.skills_required && job.skills_required.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
{job.skills_required.slice(0, 5).map((skill, index) => (
|
|
<span
|
|
key={index}
|
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800"
|
|
>
|
|
{skill}
|
|
</span>
|
|
))}
|
|
{job.skills_required.length > 5 && (
|
|
<span className="text-xs text-gray-500">
|
|
+{job.skills_required.length - 5} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<Briefcase className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Try adjusting your search criteria
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{data?.pagination && data.pagination.pages > 1 && (
|
|
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
<div className="flex-1 flex justify-between sm:hidden">
|
|
<button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
|
Previous
|
|
</button>
|
|
<button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
|
Next
|
|
</button>
|
|
</div>
|
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-700">
|
|
Showing{' '}
|
|
<span className="font-medium">
|
|
{((data.pagination.page - 1) * data.pagination.limit) + 1}
|
|
</span>{' '}
|
|
to{' '}
|
|
<span className="font-medium">
|
|
{Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)}
|
|
</span>{' '}
|
|
of{' '}
|
|
<span className="font-medium">{data.pagination.total}</span>{' '}
|
|
results
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Jobs;
|