Initial commit
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
53
frontend/package.json
Normal file
53
frontend/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "mysteryapp-cursor-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for MysteryApp-Cursor recruiter workflow SAAS",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-query": "^3.39.3",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17"
|
||||
},
|
||||
"proxy": "http://MysteryApp-Cursor-backend:3001"
|
||||
}
|
||||
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="MysteryApp-Cursor - Professional Recruiter Workflow SAAS"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>MysteryApp-Cursor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
136
frontend/src/App.js
Normal file
136
frontend/src/App.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Jobs from './pages/Jobs';
|
||||
import JobDetails from './pages/JobDetails';
|
||||
import CreateJob from './pages/CreateJob';
|
||||
import Candidates from './pages/Candidates';
|
||||
import CandidateDetails from './pages/CandidateDetails';
|
||||
import Applications from './pages/Applications';
|
||||
import Profile from './pages/Profile';
|
||||
import Employers from './pages/Employers';
|
||||
import EmployerDetails from './pages/EmployerDetails';
|
||||
import Resumes from './pages/Resumes';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function ProtectedRoute({ children, allowedRoles = [] }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" replace />} />
|
||||
<Route path="/register" element={!user ? <Register /> : <Navigate to="/dashboard" replace />} />
|
||||
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="jobs" element={
|
||||
<ProtectedRoute>
|
||||
<Jobs />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="jobs/create" element={
|
||||
<ProtectedRoute allowedRoles={['employer', 'recruiter']}>
|
||||
<CreateJob />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="jobs/:id" element={
|
||||
<ProtectedRoute>
|
||||
<JobDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="candidates" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
|
||||
<Candidates />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="candidates/:id" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
|
||||
<CandidateDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="applications" element={
|
||||
<ProtectedRoute>
|
||||
<Applications />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="employers" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
|
||||
<Employers />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="employers/:id" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
|
||||
<EmployerDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="resumes" element={
|
||||
<ProtectedRoute allowedRoles={['candidate']}>
|
||||
<Resumes />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="profile" element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<AppRoutes />
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
43
frontend/src/App.test.js
Normal file
43
frontend/src/App.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
// Mock the AuthContext
|
||||
jest.mock('./contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
loading: false,
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
fetchUser: jest.fn()
|
||||
}),
|
||||
AuthProvider: ({ children }) => children
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
jest.mock('react-router-dom', () => ({
|
||||
BrowserRouter: ({ children }) => <div>{children}</div>,
|
||||
Routes: ({ children }) => <div>{children}</div>,
|
||||
Route: ({ children }) => <div>{children}</div>,
|
||||
Navigate: ({ to }) => <div data-testid="navigate">{to}</div>,
|
||||
Outlet: () => <div data-testid="outlet">Outlet</div>
|
||||
}));
|
||||
|
||||
// Mock react-query
|
||||
jest.mock('react-query', () => ({
|
||||
QueryClient: jest.fn(() => ({})),
|
||||
QueryClientProvider: ({ children }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
jest.mock('react-hot-toast', () => ({
|
||||
Toaster: () => <div data-testid="toaster">Toaster</div>
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByTestId('toaster')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
186
frontend/src/components/Layout.js
Normal file
186
frontend/src/components/Layout.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
Home,
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
const Layout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Jobs', href: '/jobs', icon: Briefcase, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Candidates', href: '/candidates', icon: Users, roles: ['admin', 'recruiter', 'employer'] },
|
||||
{ name: 'Applications', href: '/applications', icon: FileText, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Employers', href: '/employers', icon: Building, roles: ['admin', 'recruiter'] },
|
||||
{ name: 'Resumes', href: '/resumes', icon: FileText, roles: ['candidate'] },
|
||||
];
|
||||
|
||||
const filteredNavigation = navigation.filter(item =>
|
||||
item.roles.includes(user?.role)
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-white">
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
|
||||
</div>
|
||||
<nav className="mt-5 px-2 space-y-1">
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-base font-medium rounded-md`}
|
||||
>
|
||||
<item.icon className="mr-4 h-6 w-6" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-white">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
|
||||
</div>
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
|
||||
>
|
||||
<item.icon className="mr-3 h-6 w-6" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
|
||||
<div className="flex-shrink-0 w-full group block">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-gray-500 group-hover:text-gray-700">
|
||||
{user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64 flex flex-col flex-1">
|
||||
<div className="sticky top-0 z-10 lg:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Top bar for desktop */}
|
||||
<div className="hidden lg:block lg:pl-64">
|
||||
<div className="sticky top-0 z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200">
|
||||
<div className="flex-1 px-4 flex justify-between">
|
||||
<div className="flex-1 flex">
|
||||
<div className="w-full flex md:ml-0">
|
||||
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
<div className="ml-3 relative">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<User className="h-5 w-5 mr-2" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<LogOut className="h-5 w-5 mr-2" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
70
frontend/src/components/Layout.test.js
Normal file
70
frontend/src/components/Layout.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
|
||||
// Mock the AuthContext
|
||||
const mockUseAuth = {
|
||||
user: {
|
||||
id: '1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'candidate'
|
||||
},
|
||||
logout: jest.fn()
|
||||
};
|
||||
|
||||
jest.mock('../contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({ pathname: '/dashboard' }),
|
||||
Link: ({ children, to }) => <a href={to}>{children}</a>,
|
||||
Outlet: () => <div data-testid="outlet">Outlet</div>
|
||||
}));
|
||||
|
||||
const renderWithRouter = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{component}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders the layout with user information', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('MysteryApp-Cursor')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('candidate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation items for candidate role', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jobs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Applications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resumes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders logout button', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls logout when logout button is clicked', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
const logoutButton = screen.getByText('Logout');
|
||||
logoutButton.click();
|
||||
|
||||
expect(mockUseAuth.logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
frontend/src/contexts/AuthContext.js
Normal file
105
frontend/src/contexts/AuthContext.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
fetchUser();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/me');
|
||||
setUser(response.data.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error);
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', { email, password });
|
||||
const { token, user } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
setUser(user);
|
||||
|
||||
toast.success('Login successful!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Login failed';
|
||||
toast.error(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register', userData);
|
||||
const { token, user } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
setUser(user);
|
||||
|
||||
toast.success('Registration successful!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Registration failed';
|
||||
toast.error(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
setUser(null);
|
||||
toast.success('Logged out successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchUser
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
47
frontend/src/index.css
Normal file
47
frontend/src/index.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md border border-gray-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply px-6 py-4 border-t border-gray-200;
|
||||
}
|
||||
}
|
||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
111
frontend/src/pages/Applications.js
Normal file
111
frontend/src/pages/Applications.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { FileText, Briefcase, Building, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
const Applications = () => {
|
||||
const { data, isLoading } = useQuery('applications', async () => {
|
||||
const response = await axios.get('/api/applications');
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'applied': return <Clock className="h-4 w-4" />;
|
||||
case 'reviewed': return <AlertCircle className="h-4 w-4" />;
|
||||
case 'shortlisted': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'interviewed': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'offered': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'rejected': return <XCircle className="h-4 w-4" />;
|
||||
default: return <Clock className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'applied': return 'bg-blue-100 text-blue-800';
|
||||
case 'reviewed': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'shortlisted': return 'bg-green-100 text-green-800';
|
||||
case 'interviewed': return 'bg-green-100 text-green-800';
|
||||
case 'offered': return 'bg-green-100 text-green-800';
|
||||
case 'rejected': return 'bg-red-100 text-red-800';
|
||||
case 'withdrawn': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Applications</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Track your job applications and their status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{data?.applications?.length > 0 ? (
|
||||
data.applications.map((application) => (
|
||||
<div key={application.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">
|
||||
{application.job_title}
|
||||
</h3>
|
||||
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(application.status)}`}>
|
||||
{getStatusIcon(application.status)}
|
||||
<span className="ml-1 capitalize">{application.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-1" />
|
||||
{application.company_name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
Applied on {new Date(application.applied_at).toLocaleDateString()}
|
||||
</div>
|
||||
{application.cover_letter && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{application.cover_letter}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{application.notes && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Notes:</strong> {application.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No applications found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
You haven't applied to any jobs yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Applications;
|
||||
149
frontend/src/pages/CandidateDetails.js
Normal file
149
frontend/src/pages/CandidateDetails.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { MapPin, Mail, Phone, Linkedin, Github, Globe, User } from 'lucide-react';
|
||||
|
||||
const CandidateDetails = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data: candidate, isLoading } = useQuery(['candidate', id], async () => {
|
||||
const response = await axios.get(`/api/candidates/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Candidate not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The candidate you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{candidate.first_name} {candidate.last_name}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">{candidate.email}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{candidate.location && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
{candidate.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
{candidate.phone}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.linkedin_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Linkedin className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.linkedin_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
LinkedIn Profile
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.github_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.github_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
GitHub Profile
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.portfolio_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.portfolio_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Portfolio
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidate.bio && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">About</h2>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{candidate.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.skills && candidate.skills.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{candidate.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Experience Level</h2>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
{candidate.experience_level || 'Not specified'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidate.salary_expectation && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Salary Expectation</h2>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
${candidate.salary_expectation.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CandidateDetails;
|
||||
277
frontend/src/pages/Candidates.js
Normal file
277
frontend/src/pages/Candidates.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Search, MapPin, User, Star } from 'lucide-react';
|
||||
|
||||
const Candidates = () => {
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
location: '',
|
||||
experienceLevel: '',
|
||||
skills: ''
|
||||
});
|
||||
|
||||
const { data, isLoading, refetch } = useQuery(['candidates', filters], async () => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const response = await axios.get(`/api/candidates?${params.toString()}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getExperienceColor = (level) => {
|
||||
switch (level) {
|
||||
case 'entry': return 'bg-green-100 text-green-800';
|
||||
case 'mid': return 'bg-blue-100 text-blue-800';
|
||||
case 'senior': return 'bg-purple-100 text-purple-800';
|
||||
case 'lead': return 'bg-orange-100 text-orange-800';
|
||||
case 'executive': 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 className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Candidates</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Browse and discover talented candidates
|
||||
</p>
|
||||
</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="Name 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 country"
|
||||
value={filters.location}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<label htmlFor="skills" className="block text-sm font-medium text-gray-700">
|
||||
Skills
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="skills"
|
||||
id="skills"
|
||||
className="mt-1 block w-full px-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="JavaScript, React, etc."
|
||||
value={filters.skills}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Search Candidates
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Candidates List */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.candidates?.length > 0 ? (
|
||||
data.candidates.map((candidate) => (
|
||||
<div key={candidate.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<Link to={`/candidates/${candidate.id}`} className="hover:text-primary-600">
|
||||
{candidate.first_name} {candidate.last_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{candidate.email}</p>
|
||||
|
||||
{candidate.location && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{candidate.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.experience_level && (
|
||||
<div className="mt-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExperienceColor(candidate.experience_level)}`}>
|
||||
{candidate.experience_level} level
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.bio && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-3">
|
||||
{candidate.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.skills && candidate.skills.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{candidate.skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{candidate.skills.length > 4 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{candidate.skills.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.salary_expectation && (
|
||||
<div className="mt-3 flex items-center text-sm text-gray-500">
|
||||
<Star className="h-4 w-4 mr-1" />
|
||||
Expected: ${candidate.salary_expectation?.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<User className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No candidates 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 Candidates;
|
||||
464
frontend/src/pages/CreateJob.js
Normal file
464
frontend/src/pages/CreateJob.js
Normal file
@@ -0,0 +1,464 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useMutation } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ArrowLeft, Save, Plus, X } from 'lucide-react';
|
||||
|
||||
const CreateJob = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
requirements: [''],
|
||||
responsibilities: [''],
|
||||
location: '',
|
||||
employmentType: 'full-time',
|
||||
salaryMin: '',
|
||||
salaryMax: '',
|
||||
currency: 'USD',
|
||||
remoteAllowed: false,
|
||||
experienceLevel: '',
|
||||
skillsRequired: [''],
|
||||
benefits: [''],
|
||||
applicationDeadline: ''
|
||||
});
|
||||
|
||||
const createJobMutation = useMutation(async (jobData) => {
|
||||
const response = await axios.post('/api/jobs', jobData);
|
||||
return response.data;
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Job posted successfully!');
|
||||
navigate('/jobs');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create job');
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleArrayChange = (field, index, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].map((item, i) => i === index ? value : item)
|
||||
}));
|
||||
};
|
||||
|
||||
const addArrayItem = (field) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], '']
|
||||
}));
|
||||
};
|
||||
|
||||
const removeArrayItem = (field, index) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Filter out empty array items
|
||||
const cleanedData = {
|
||||
...formData,
|
||||
requirements: formData.requirements.filter(req => req.trim()),
|
||||
responsibilities: formData.responsibilities.filter(resp => resp.trim()),
|
||||
skillsRequired: formData.skillsRequired.filter(skill => skill.trim()),
|
||||
benefits: formData.benefits.filter(benefit => benefit.trim()),
|
||||
salaryMin: formData.salaryMin ? parseInt(formData.salaryMin) : undefined,
|
||||
salaryMax: formData.salaryMax ? parseInt(formData.salaryMax) : undefined,
|
||||
applicationDeadline: formData.applicationDeadline || undefined
|
||||
};
|
||||
|
||||
createJobMutation.mutate(cleanedData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate('/jobs')}
|
||||
className="mr-4 p-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Post a New Job</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Create a job posting to attract qualified candidates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Job Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Senior Software Engineer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Job Description *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="description"
|
||||
rows={4}
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Describe the role, company culture, and what makes this opportunity special..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
||||
Location *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
id="location"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., San Francisco, CA"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
|
||||
Employment Type *
|
||||
</label>
|
||||
<select
|
||||
name="employmentType"
|
||||
id="employmentType"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.employmentType}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements and Responsibilities */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Requirements & Responsibilities</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Requirements
|
||||
</label>
|
||||
{formData.requirements.map((req, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={req}
|
||||
onChange={(e) => handleArrayChange('requirements', index, e.target.value)}
|
||||
placeholder="e.g., 5+ years of experience in React"
|
||||
/>
|
||||
{formData.requirements.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('requirements', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('requirements')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Requirement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Responsibilities
|
||||
</label>
|
||||
{formData.responsibilities.map((resp, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={resp}
|
||||
onChange={(e) => handleArrayChange('responsibilities', index, e.target.value)}
|
||||
placeholder="e.g., Develop and maintain web applications"
|
||||
/>
|
||||
{formData.responsibilities.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('responsibilities', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('responsibilities')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Responsibility
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compensation */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Compensation</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="salaryMin" className="block text-sm font-medium text-gray-700">
|
||||
Minimum Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="salaryMin"
|
||||
id="salaryMin"
|
||||
className="mt-1 input"
|
||||
value={formData.salaryMin}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., 80000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="salaryMax" className="block text-sm font-medium text-gray-700">
|
||||
Maximum Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="salaryMax"
|
||||
id="salaryMax"
|
||||
className="mt-1 input"
|
||||
value={formData.salaryMax}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., 120000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="currency" className="block text-sm font-medium text-gray-700">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
name="currency"
|
||||
id="currency"
|
||||
className="mt-1 input"
|
||||
value={formData.currency}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Details */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Additional Details</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
|
||||
Experience Level
|
||||
</label>
|
||||
<select
|
||||
name="experienceLevel"
|
||||
id="experienceLevel"
|
||||
className="mt-1 input"
|
||||
value={formData.experienceLevel}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Select level</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>
|
||||
<label htmlFor="applicationDeadline" className="block text-sm font-medium text-gray-700">
|
||||
Application Deadline
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="applicationDeadline"
|
||||
id="applicationDeadline"
|
||||
className="mt-1 input"
|
||||
value={formData.applicationDeadline}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remoteAllowed"
|
||||
id="remoteAllowed"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={formData.remoteAllowed}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label htmlFor="remoteAllowed" className="ml-2 block text-sm text-gray-900">
|
||||
Remote work allowed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Required Skills
|
||||
</label>
|
||||
{formData.skillsRequired.map((skill, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={skill}
|
||||
onChange={(e) => handleArrayChange('skillsRequired', index, e.target.value)}
|
||||
placeholder="e.g., JavaScript, React, Node.js"
|
||||
/>
|
||||
{formData.skillsRequired.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('skillsRequired', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('skillsRequired')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Benefits
|
||||
</label>
|
||||
{formData.benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={benefit}
|
||||
onChange={(e) => handleArrayChange('benefits', index, e.target.value)}
|
||||
placeholder="e.g., Health insurance, 401k, Flexible hours"
|
||||
/>
|
||||
{formData.benefits.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('benefits', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('benefits')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Benefit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/jobs')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createJobMutation.isLoading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{createJobMutation.isLoading ? 'Creating...' : 'Post Job'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateJob;
|
||||
284
frontend/src/pages/Dashboard.js
Normal file
284
frontend/src/pages/Dashboard.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: stats, isLoading } = useQuery('dashboard-stats', async () => {
|
||||
const [jobsRes, applicationsRes, candidatesRes, employersRes] = await Promise.all([
|
||||
axios.get('/api/jobs?limit=1'),
|
||||
axios.get('/api/applications?limit=1'),
|
||||
user?.role === 'candidate' ? Promise.resolve({ data: { applications: { pagination: { total: 0 } } } }) : axios.get('/api/applications?limit=1'),
|
||||
user?.role === 'employer' || user?.role === 'admin' || user?.role === 'recruiter' ? axios.get('/api/employers?limit=1') : Promise.resolve({ data: [] })
|
||||
]);
|
||||
|
||||
return {
|
||||
totalJobs: jobsRes.data.pagination?.total || 0,
|
||||
totalApplications: applicationsRes.data.pagination?.total || 0,
|
||||
totalCandidates: candidatesRes.data.pagination?.total || 0,
|
||||
totalEmployers: employersRes.data.length || 0
|
||||
};
|
||||
});
|
||||
|
||||
const { data: recentJobs } = useQuery('recent-jobs', async () => {
|
||||
const response = await axios.get('/api/jobs?limit=5');
|
||||
return response.data.jobs;
|
||||
});
|
||||
|
||||
const { data: recentApplications } = useQuery('recent-applications', async () => {
|
||||
if (user?.role === 'candidate') {
|
||||
const response = await axios.get('/api/applications?limit=5');
|
||||
return response.data.applications;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const statsCards = [
|
||||
{
|
||||
name: 'Total Jobs',
|
||||
value: stats?.totalJobs || 0,
|
||||
icon: Briefcase,
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'Applications',
|
||||
value: stats?.totalApplications || 0,
|
||||
icon: FileText,
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'Candidates',
|
||||
value: stats?.totalCandidates || 0,
|
||||
icon: Users,
|
||||
color: 'bg-purple-500'
|
||||
},
|
||||
{
|
||||
name: 'Employers',
|
||||
value: stats?.totalEmployers || 0,
|
||||
icon: Building,
|
||||
color: 'bg-orange-500'
|
||||
}
|
||||
];
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{getGreeting()}, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Welcome to your MysteryApp-Cursor dashboard
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Debug: User role = {user?.role || 'undefined'}, User ID = {user?.id || 'undefined'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statsCards.map((card) => (
|
||||
<div key={card.name} className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`p-3 rounded-md ${card.color}`}>
|
||||
<card.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
{card.name}
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{card.value}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Recent Jobs */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Recent Job Postings
|
||||
</h3>
|
||||
<div className="mt-5">
|
||||
{recentJobs?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentJobs.map((job) => (
|
||||
<div key={job.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{job.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{job.company_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
{new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No recent jobs found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Recent Applications
|
||||
</h3>
|
||||
<div className="mt-5">
|
||||
{recentApplications?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentApplications.map((application) => (
|
||||
<div key={application.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{application.job_title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{application.company_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
application.status === 'applied' ? 'bg-blue-100 text-blue-800' :
|
||||
application.status === 'reviewed' ? 'bg-yellow-100 text-yellow-800' :
|
||||
application.status === 'shortlisted' ? 'bg-green-100 text-green-800' :
|
||||
application.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No recent applications found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{user?.role === 'employer' && (
|
||||
<Link
|
||||
to="/jobs/create"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<Briefcase className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
Post a Job
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Create a new job posting to attract candidates
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user?.role === 'candidate' && (
|
||||
<a
|
||||
href="/jobs"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<TrendingUp className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
Browse Jobs
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Find your next career opportunity
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/applications"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<FileText className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
View Applications
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Track your application status
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
111
frontend/src/pages/EmployerDetails.js
Normal file
111
frontend/src/pages/EmployerDetails.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Building, MapPin, Users, Globe, Mail, Phone } from 'lucide-react';
|
||||
|
||||
const EmployerDetails = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data: employer, isLoading } = useQuery(['employer', id], async () => {
|
||||
const response = await axios.get(`/api/employers/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!employer) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Employer not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The employer you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Building className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{employer.company_name}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">{employer.first_name} {employer.last_name}</p>
|
||||
<p className="text-sm text-gray-500">{employer.email}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{employer.industry && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-2" />
|
||||
{employer.industry}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.company_size && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
{employer.company_size} employees
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.website && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Website
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
{employer.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{employer.description && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">About {employer.company_name}</h2>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{employer.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.address && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Address</h2>
|
||||
<div className="flex items-start">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-3 mt-0.5" />
|
||||
<p className="text-gray-600">{employer.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployerDetails;
|
||||
98
frontend/src/pages/Employers.js
Normal file
98
frontend/src/pages/Employers.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Building, MapPin, Users, Globe } from 'lucide-react';
|
||||
|
||||
const Employers = () => {
|
||||
const { data, isLoading } = useQuery('employers', async () => {
|
||||
const response = await axios.get('/api/employers');
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Employers</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Browse companies and employers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.length > 0 ? (
|
||||
data.map((employer) => (
|
||||
<div key={employer.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Building className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<Link to={`/employers/${employer.id}`} className="hover:text-primary-600">
|
||||
{employer.company_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{employer.first_name} {employer.last_name}</p>
|
||||
|
||||
{employer.industry && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-1" />
|
||||
{employer.industry}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.company_size && (
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
{employer.company_size} employees
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.website && (
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-1" />
|
||||
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Website
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.description && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-3">
|
||||
{employer.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<Building className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No employers found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No employers have registered yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Employers;
|
||||
293
frontend/src/pages/JobDetails.js
Normal file
293
frontend/src/pages/JobDetails.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { MapPin, Clock, DollarSign, Briefcase, Users, Calendar } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const JobDetails = () => {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [coverLetter, setCoverLetter] = useState('');
|
||||
|
||||
const { data: job, isLoading } = useQuery(['job', id], async () => {
|
||||
const response = await axios.get(`/api/jobs/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!coverLetter.trim()) {
|
||||
toast.error('Please provide a cover letter');
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
await axios.post('/api/applications', {
|
||||
jobId: id,
|
||||
coverLetter: coverLetter.trim()
|
||||
});
|
||||
toast.success('Application submitted successfully!');
|
||||
setCoverLetter('');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to submit application');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Job not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The job you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link to="/jobs" className="btn btn-primary">
|
||||
Browse Jobs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link to="/jobs" className="text-sm text-primary-600 hover:text-primary-500">
|
||||
← Back to Jobs
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-gray-900">{job.title}</h1>
|
||||
<div className="mt-2 flex items-center text-lg text-gray-600">
|
||||
<Briefcase className="h-5 w-5 mr-2" />
|
||||
{job.company_name}
|
||||
</div>
|
||||
</div>
|
||||
{user?.role === 'candidate' && job.status === 'active' && (
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply Now'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Job Description */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Job Description</h2>
|
||||
<div className="prose max-w-none">
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{job.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
{job.requirements && job.requirements.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Requirements</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.requirements.map((requirement, index) => (
|
||||
<li key={index} className="text-gray-600">{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Responsibilities */}
|
||||
{job.responsibilities && job.responsibilities.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Responsibilities</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.responsibilities.map((responsibility, index) => (
|
||||
<li key={index} className="text-gray-600">{responsibility}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Required */}
|
||||
{job.skills_required && job.skills_required.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Required Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{job.skills_required.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Benefits */}
|
||||
{job.benefits && job.benefits.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Benefits</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.benefits.map((benefit, index) => (
|
||||
<li key={index} className="text-gray-600">{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Job Details */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Job Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{job.location}</p>
|
||||
{job.remote_allowed && (
|
||||
<p className="text-sm text-gray-500">Remote work allowed</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Briefcase className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 capitalize">
|
||||
{job.employment_type?.replace('-', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatSalary(job.salary_min, job.salary_max, job.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.experience_level && (
|
||||
<div className="flex items-center">
|
||||
<Users className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 capitalize">
|
||||
{job.experience_level} level
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Posted {new Date(job.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.application_deadline && (
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Apply by {new Date(job.application_deadline).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Company</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900">{job.company_name}</p>
|
||||
{job.industry && (
|
||||
<p className="text-sm text-gray-500">{job.industry}</p>
|
||||
)}
|
||||
{job.company_size && (
|
||||
<p className="text-sm text-gray-500">{job.company_size} employees</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply Section for Candidates */}
|
||||
{user?.role === 'candidate' && job.status === 'active' && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Apply for this job</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="coverLetter" className="block text-sm font-medium text-gray-700">
|
||||
Cover Letter
|
||||
</label>
|
||||
<textarea
|
||||
id="coverLetter"
|
||||
rows={4}
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Tell us why you're interested in this position..."
|
||||
value={coverLetter}
|
||||
onChange={(e) => setCoverLetter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying || !coverLetter.trim()}
|
||||
className="w-full btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Submit Application'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobDetails;
|
||||
295
frontend/src/pages/Jobs.js
Normal file
295
frontend/src/pages/Jobs.js
Normal file
@@ -0,0 +1,295 @@
|
||||
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, 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 className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</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>
|
||||
{/* Debug info */}
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Debug: User role = {user?.role || 'undefined'}
|
||||
</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;
|
||||
136
frontend/src/pages/Login.js
Normal file
136
frontend/src/pages/Login.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
|
||||
const Login = () => {
|
||||
const { login } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(formData.email, formData.password);
|
||||
|
||||
if (!result.success) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Demo accounts:
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-500 space-y-1">
|
||||
<p>Admin: admin@mysteryapp.com / admin123</p>
|
||||
<p>Recruiter: recruiter@mysteryapp.com / recruiter123</p>
|
||||
<p>Employer: employer@techcorp.com / employer123</p>
|
||||
<p>Candidate: candidate@example.com / candidate123</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
142
frontend/src/pages/Profile.js
Normal file
142
frontend/src/pages/Profile.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { User, Mail, Save } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Profile = () => {
|
||||
const { user, fetchUser } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
email: user?.email || ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await fetch(`/api/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
await fetchUser();
|
||||
toast.success('Profile updated successfully!');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage your account information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
id="firstName"
|
||||
required
|
||||
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"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
id="lastName"
|
||||
required
|
||||
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"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
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"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Role:</strong> {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
242
frontend/src/pages/Register.js
Normal file
242
frontend/src/pages/Register.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react';
|
||||
|
||||
const Register = () => {
|
||||
const { register } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'candidate'
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
alert('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await register({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
role: formData.role
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
sign in to your existing account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="First name"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Last name"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
|
||||
Account Type
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={formData.role}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="candidate">Candidate</option>
|
||||
<option value="employer">Employer</option>
|
||||
<option value="recruiter">Recruiter</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Confirm password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
245
frontend/src/pages/Resumes.js
Normal file
245
frontend/src/pages/Resumes.js
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Upload, Download, Trash2, Star, FileText } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Resumes = () => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
|
||||
const { data: resumes, isLoading, refetch } = useQuery('resumes', async () => {
|
||||
// This would need to be implemented based on the candidate's ID
|
||||
// For now, return empty array
|
||||
return [];
|
||||
});
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||
toast.error('File size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Only PDF, DOC, DOCX, and TXT files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('resume', selectedFile);
|
||||
formData.append('isPrimary', resumes?.length === 0 ? 'true' : 'false');
|
||||
|
||||
try {
|
||||
await axios.post('/api/resumes/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
toast.success('Resume uploaded successfully!');
|
||||
setSelectedFile(null);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to upload resume');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (resumeId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resumes/${resumeId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', response.headers['content-disposition']?.split('filename=')[1] || 'resume.pdf');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error('Failed to download resume');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (resumeId) => {
|
||||
try {
|
||||
await axios.put(`/api/resumes/${resumeId}/primary`);
|
||||
toast.success('Primary resume updated!');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error('Failed to set primary resume');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (resumeId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this resume?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/resumes/${resumeId}`);
|
||||
toast.success('Resume deleted successfully!');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete resume');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resumes</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage your resume files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Upload Resume</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="resume" className="block text-sm font-medium text-gray-700">
|
||||
Select Resume File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="resume"
|
||||
accept=".pdf,.doc,.docx,.txt"
|
||||
onChange={handleFileSelect}
|
||||
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PDF, DOC, DOCX, or TXT files only. Maximum size: 10MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-5 w-5 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-900">{selectedFile.name}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumes List */}
|
||||
<div className="space-y-4">
|
||||
{resumes?.length > 0 ? (
|
||||
resumes.map((resume) => (
|
||||
<div key={resume.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-8 w-8 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{resume.original_name}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>{(resume.file_size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Uploaded {new Date(resume.uploaded_at).toLocaleDateString()}</span>
|
||||
{resume.is_primary && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Primary
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDownload(resume.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</button>
|
||||
{!resume.is_primary && (
|
||||
<button
|
||||
onClick={() => handleSetPrimary(resume.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Set Primary
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(resume.id)}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No resumes uploaded</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Upload your first resume to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Resumes;
|
||||
25
frontend/tailwind.config.js
Normal file
25
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user