Initial commit

This commit is contained in:
2025-10-16 17:04:52 -05:00
commit 039d51c4e5
45 changed files with 6939 additions and 0 deletions

12
frontend/Dockerfile Normal file
View 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
View 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"
}

View 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
View 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
View 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();
});
});

View 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;

View 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();
});
});

View 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
View 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
View 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>
);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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: [],
}