Files
MOHPortal/frontend/src/pages/Jobs.js
ReachableCEO 252775faf3
Some checks failed
CI / Backend Tests (push) Failing after 2m41s
CI / Frontend Tests (push) Successful in 2m14s
CI / Build Docker Images (push) Has been skipped
chore: sync infra docs and coverage
2025-10-16 22:41:22 -05:00

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;