Initial commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
tmp/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
241
README.md
Normal file
241
README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# MysteryApp-Cursor - Recruiter Workflow SAAS
|
||||
|
||||
A comprehensive SAAS application for managing recruiter workflows, built with modern technologies and following TDD principles.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **User Management**: Multi-role authentication system (Admin, Recruiter, Employer, Candidate)
|
||||
- **Employer Management**: Company profiles, job postings, candidate management
|
||||
- **Candidate Management**: Profile creation, resume uploads, application tracking
|
||||
- **Job Management**: Job posting creation, filtering, search capabilities
|
||||
- **Application Tracking**: End-to-end application workflow management
|
||||
- **Resume Management**: File upload, download, and management system
|
||||
|
||||
### User Roles
|
||||
- **Admin**: Full system access and user management
|
||||
- **Recruiter**: Candidate and employer management, job posting oversight
|
||||
- **Employer**: Job posting creation, candidate review, application management
|
||||
- **Candidate**: Job browsing, application submission, profile management
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Node.js** with Express.js
|
||||
- **PostgreSQL** database
|
||||
- **JWT** authentication
|
||||
- **Multer** for file uploads
|
||||
- **Jest** for testing
|
||||
- **Docker** containerization
|
||||
|
||||
### Frontend
|
||||
- **React 18** with modern hooks
|
||||
- **React Router** for navigation
|
||||
- **React Query** for data fetching
|
||||
- **Tailwind CSS** for styling
|
||||
- **Lucide React** for icons
|
||||
- **React Hot Toast** for notifications
|
||||
|
||||
### Infrastructure
|
||||
- **Docker Compose** for orchestration
|
||||
- **PostgreSQL** database
|
||||
- **File upload** handling with proper validation
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Git
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd MysteryApp-Cursor
|
||||
```
|
||||
|
||||
2. **Start the application**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
3. **Initialize the database**
|
||||
```bash
|
||||
# Run database migrations
|
||||
docker-compose exec MysteryApp-Cursor-backend npm run migrate
|
||||
|
||||
# Seed the database with sample data
|
||||
docker-compose exec MysteryApp-Cursor-backend npm run seed
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:3001
|
||||
- Database: localhost:5432
|
||||
|
||||
### Demo Accounts
|
||||
|
||||
The application comes with pre-seeded demo accounts:
|
||||
|
||||
- **Admin**: admin@mysteryapp.com / admin123
|
||||
- **Recruiter**: recruiter@mysteryapp.com / recruiter123
|
||||
- **Employer**: employer@techcorp.com / employer123
|
||||
- **Candidate**: candidate@example.com / candidate123
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Authentication Endpoints
|
||||
- `POST /api/auth/register` - User registration
|
||||
- `POST /api/auth/login` - User login
|
||||
- `GET /api/auth/me` - Get current user
|
||||
- `POST /api/auth/logout` - User logout
|
||||
|
||||
### User Management
|
||||
- `GET /api/users` - List all users (Admin only)
|
||||
- `GET /api/users/:id` - Get user by ID
|
||||
- `PUT /api/users/:id` - Update user profile
|
||||
- `PUT /api/users/:id/deactivate` - Deactivate user (Admin only)
|
||||
|
||||
### Job Management
|
||||
- `GET /api/jobs` - List jobs with filtering
|
||||
- `GET /api/jobs/:id` - Get job details
|
||||
- `POST /api/jobs` - Create job posting
|
||||
- `PUT /api/jobs/:id` - Update job posting
|
||||
- `DELETE /api/jobs/:id` - Delete job posting
|
||||
|
||||
### Candidate Management
|
||||
- `GET /api/candidates` - List candidates with filtering
|
||||
- `GET /api/candidates/:id` - Get candidate details
|
||||
- `POST /api/candidates` - Create candidate profile
|
||||
- `PUT /api/candidates/:id` - Update candidate profile
|
||||
|
||||
### Application Management
|
||||
- `GET /api/applications` - List applications
|
||||
- `GET /api/applications/:id` - Get application details
|
||||
- `POST /api/applications` - Submit application
|
||||
- `PUT /api/applications/:id/status` - Update application status
|
||||
|
||||
### Resume Management
|
||||
- `GET /api/resumes/candidate/:candidateId` - Get candidate resumes
|
||||
- `POST /api/resumes/upload` - Upload resume
|
||||
- `GET /api/resumes/:id/download` - Download resume
|
||||
- `PUT /api/resumes/:id/primary` - Set primary resume
|
||||
- `DELETE /api/resumes/:id` - Delete resume
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
```bash
|
||||
# Run all tests
|
||||
docker-compose exec MysteryApp-Cursor-backend npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
docker-compose exec MysteryApp-Cursor-backend npm run test:watch
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
```bash
|
||||
# Run frontend tests
|
||||
docker-compose exec MysteryApp-Cursor-frontend npm test
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
MysteryApp-Cursor/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── middleware/ # Authentication middleware
|
||||
│ │ ├── database/ # Database schema and migrations
|
||||
│ │ ├── tests/ # Backend tests
|
||||
│ │ └── server.js # Main server file
|
||||
│ ├── uploads/ # File upload directory
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable React components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── contexts/ # React contexts
|
||||
│ │ └── App.js # Main App component
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml # Docker orchestration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
The application uses a comprehensive PostgreSQL schema with the following main tables:
|
||||
- `users` - User authentication and basic info
|
||||
- `employers` - Company/employer profiles
|
||||
- `candidates` - Candidate profiles and skills
|
||||
- `jobs` - Job postings with requirements
|
||||
- `applications` - Job applications and status tracking
|
||||
- `resumes` - Resume file management
|
||||
- `interviews` - Interview scheduling and management
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Job Management
|
||||
- Advanced filtering by location, salary, experience level, skills
|
||||
- Search functionality across job titles and descriptions
|
||||
- Remote work support
|
||||
- Application deadline management
|
||||
- Status tracking (active, paused, closed, draft)
|
||||
|
||||
### Candidate Management
|
||||
- Skills-based filtering and search
|
||||
- Experience level categorization
|
||||
- Location-based filtering
|
||||
- Salary expectation tracking
|
||||
- Portfolio and social media links
|
||||
|
||||
### Application Workflow
|
||||
- Multi-stage application process
|
||||
- Status tracking (applied, reviewed, shortlisted, interviewed, offered, rejected)
|
||||
- Notes and comments system
|
||||
- Interview scheduling capabilities
|
||||
|
||||
### File Management
|
||||
- Secure resume upload with validation
|
||||
- Multiple file format support (PDF, DOC, DOCX, TXT)
|
||||
- File size limits and type validation
|
||||
- Primary resume designation
|
||||
- Secure download with proper access controls
|
||||
|
||||
## Security Features
|
||||
|
||||
- JWT-based authentication
|
||||
- Role-based access control
|
||||
- Password hashing with bcrypt
|
||||
- File upload validation
|
||||
- SQL injection prevention
|
||||
- CORS configuration
|
||||
- Helmet.js security headers
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Database indexing for optimal query performance
|
||||
- Pagination for large datasets
|
||||
- Efficient file handling
|
||||
- Optimized React components
|
||||
- Proper error handling and logging
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Ensure all tests pass
|
||||
6. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions, please contact the development team or create an issue in the repository.
|
||||
17
SEED_PROMPT.md
Normal file
17
SEED_PROMPT.md
Normal file
@@ -0,0 +1,17 @@
|
||||
I would like you to scaffold a SAAS app for a recruiter workflow.
|
||||
|
||||
Cover user management, employer and candidate management, resume uploading etc.
|
||||
|
||||
DO not do any operations on the host except git and docker orchestration.
|
||||
|
||||
Please make all decisions and do not prompt me.
|
||||
|
||||
Use TDD.
|
||||
|
||||
Ignore anything that isn't core to the app (no CI/CD , no worrying about anything except local deployment etc)
|
||||
|
||||
Use docker and docker compose
|
||||
|
||||
Prefix all docker artifacts with MysteryApp-Cursor-
|
||||
|
||||
Lets go!
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
36
backend/package.json
Normal file
36
backend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "mysteryapp-cursor-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for MysteryApp-Cursor recruiter workflow SAAS",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"migrate": "node src/database/migrate.js",
|
||||
"seed": "node src/database/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"express-validator": "^7.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"uuid": "^9.0.1",
|
||||
"morgan": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^6.3.3",
|
||||
"@types/jest": "^29.5.8"
|
||||
},
|
||||
"keywords": ["recruiter", "saas", "workflow", "hiring"],
|
||||
"author": "MysteryApp-Cursor",
|
||||
"license": "MIT"
|
||||
}
|
||||
18
backend/src/database/connection.js
Normal file
18
backend/src/database/connection.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
// Test database connection
|
||||
pool.on('connect', () => {
|
||||
console.log('Connected to MysteryApp-Cursor database');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Database connection error:', err);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
29
backend/src/database/migrate.js
Normal file
29
backend/src/database/migrate.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('./connection');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('Starting database migration...');
|
||||
|
||||
// Read schema file
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||
|
||||
// Execute schema
|
||||
await pool.query(schema);
|
||||
|
||||
console.log('Database migration completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
migrate();
|
||||
}
|
||||
|
||||
module.exports = migrate;
|
||||
141
backend/src/database/schema.sql
Normal file
141
backend/src/database/schema.sql
Normal file
@@ -0,0 +1,141 @@
|
||||
-- MysteryApp-Cursor Database Schema
|
||||
|
||||
-- Users table (for authentication and user management)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL CHECK (role IN ('admin', 'recruiter', 'employer', 'candidate')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Employers table
|
||||
CREATE TABLE IF NOT EXISTS employers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
company_name VARCHAR(255) NOT NULL,
|
||||
industry VARCHAR(100),
|
||||
company_size VARCHAR(50),
|
||||
website VARCHAR(255),
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
phone VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Candidates table
|
||||
CREATE TABLE IF NOT EXISTS candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
phone VARCHAR(20),
|
||||
location VARCHAR(255),
|
||||
linkedin_url VARCHAR(255),
|
||||
github_url VARCHAR(255),
|
||||
portfolio_url VARCHAR(255),
|
||||
bio TEXT,
|
||||
skills TEXT[],
|
||||
experience_level VARCHAR(50),
|
||||
availability VARCHAR(50),
|
||||
salary_expectation INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Job postings table
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
employer_id UUID REFERENCES employers(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
requirements TEXT[],
|
||||
responsibilities TEXT[],
|
||||
location VARCHAR(255),
|
||||
employment_type VARCHAR(50) CHECK (employment_type IN ('full-time', 'part-time', 'contract', 'internship')),
|
||||
salary_min INTEGER,
|
||||
salary_max INTEGER,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'closed', 'draft')),
|
||||
remote_allowed BOOLEAN DEFAULT false,
|
||||
experience_level VARCHAR(50),
|
||||
skills_required TEXT[],
|
||||
benefits TEXT[],
|
||||
application_deadline DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Applications table
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_id UUID REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) DEFAULT 'applied' CHECK (status IN ('applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn')),
|
||||
cover_letter TEXT,
|
||||
notes TEXT,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(job_id, candidate_id)
|
||||
);
|
||||
|
||||
-- Resumes table
|
||||
CREATE TABLE IF NOT EXISTS resumes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
is_primary BOOLEAN DEFAULT false,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Interviews table
|
||||
CREATE TABLE IF NOT EXISTS interviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id UUID REFERENCES applications(id) ON DELETE CASCADE,
|
||||
scheduled_at TIMESTAMP NOT NULL,
|
||||
duration_minutes INTEGER DEFAULT 60,
|
||||
interview_type VARCHAR(50) CHECK (interview_type IN ('phone', 'video', 'in-person', 'technical')),
|
||||
location VARCHAR(255),
|
||||
meeting_link VARCHAR(500),
|
||||
notes TEXT,
|
||||
status VARCHAR(50) DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'cancelled', 'rescheduled')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_employers_user_id ON employers(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_candidates_user_id ON candidates(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_employer_id ON jobs(employer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_candidate_id ON applications(candidate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resumes_candidate_id ON resumes(candidate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id);
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply updated_at triggers
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_employers_updated_at BEFORE UPDATE ON employers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_candidates_updated_at BEFORE UPDATE ON candidates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_applications_updated_at BEFORE UPDATE ON applications FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_interviews_updated_at BEFORE UPDATE ON interviews FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
128
backend/src/database/seed.js
Normal file
128
backend/src/database/seed.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pool = require('./connection');
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
console.log('Starting database seeding...');
|
||||
|
||||
// Hash passwords
|
||||
const adminPassword = await bcrypt.hash('admin123', 10);
|
||||
const recruiterPassword = await bcrypt.hash('recruiter123', 10);
|
||||
const employerPassword = await bcrypt.hash('employer123', 10);
|
||||
const candidatePassword = await bcrypt.hash('candidate123', 10);
|
||||
|
||||
// Insert users
|
||||
const users = [
|
||||
{
|
||||
email: 'admin@mysteryapp.com',
|
||||
password_hash: adminPassword,
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
role: 'admin'
|
||||
},
|
||||
{
|
||||
email: 'recruiter@mysteryapp.com',
|
||||
password_hash: recruiterPassword,
|
||||
first_name: 'John',
|
||||
last_name: 'Recruiter',
|
||||
role: 'recruiter'
|
||||
},
|
||||
{
|
||||
email: 'employer@techcorp.com',
|
||||
password_hash: employerPassword,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
role: 'employer'
|
||||
},
|
||||
{
|
||||
email: 'candidate@example.com',
|
||||
password_hash: candidatePassword,
|
||||
first_name: 'Mike',
|
||||
last_name: 'Johnson',
|
||||
role: 'candidate'
|
||||
}
|
||||
];
|
||||
|
||||
const userResults = [];
|
||||
for (const user of users) {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id',
|
||||
[user.email, user.password_hash, user.first_name, user.last_name, user.role]
|
||||
);
|
||||
userResults.push({ ...user, id: result.rows[0].id });
|
||||
}
|
||||
|
||||
// Insert employer
|
||||
const employerResult = await pool.query(
|
||||
`INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[
|
||||
userResults[2].id, // employer user
|
||||
'TechCorp Solutions',
|
||||
'Technology',
|
||||
'50-200',
|
||||
'https://techcorp.com',
|
||||
'Leading technology company specializing in innovative software solutions.',
|
||||
'123 Tech Street, San Francisco, CA 94105',
|
||||
'+1-555-0123'
|
||||
]
|
||||
);
|
||||
|
||||
// Insert candidate
|
||||
await pool.query(
|
||||
`INSERT INTO candidates (user_id, phone, location, linkedin_url, bio, skills, experience_level, availability, salary_expectation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
userResults[3].id, // candidate user
|
||||
'+1-555-0456',
|
||||
'San Francisco, CA',
|
||||
'https://linkedin.com/in/mikejohnson',
|
||||
'Experienced software developer with 5+ years in full-stack development.',
|
||||
['JavaScript', 'React', 'Node.js', 'Python', 'PostgreSQL'],
|
||||
'senior',
|
||||
'immediately',
|
||||
120000
|
||||
]
|
||||
);
|
||||
|
||||
// Insert job posting
|
||||
const jobResult = await pool.query(
|
||||
`INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, remote_allowed, experience_level, skills_required, benefits)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`,
|
||||
[
|
||||
employerResult.rows[0].id,
|
||||
'Senior Full Stack Developer',
|
||||
'We are looking for a talented Senior Full Stack Developer to join our growing team.',
|
||||
['5+ years of experience', 'Bachelor degree in Computer Science', 'Strong problem-solving skills'],
|
||||
['Develop web applications', 'Collaborate with team members', 'Code reviews', 'Mentor junior developers'],
|
||||
'San Francisco, CA',
|
||||
'full-time',
|
||||
100000,
|
||||
150000,
|
||||
true,
|
||||
'senior',
|
||||
['JavaScript', 'React', 'Node.js', 'PostgreSQL', 'AWS'],
|
||||
['Health insurance', '401k', 'Flexible work hours', 'Remote work']
|
||||
]
|
||||
);
|
||||
|
||||
console.log('Database seeding completed successfully!');
|
||||
console.log('Sample users created:');
|
||||
console.log('- Admin: admin@mysteryapp.com / admin123');
|
||||
console.log('- Recruiter: recruiter@mysteryapp.com / recruiter123');
|
||||
console.log('- Employer: employer@techcorp.com / employer123');
|
||||
console.log('- Candidate: candidate@example.com / candidate123');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Seeding failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
seed();
|
||||
}
|
||||
|
||||
module.exports = seed;
|
||||
54
backend/src/middleware/auth.js
Normal file
54
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../database/connection');
|
||||
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get user details from database
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, email, first_name, last_name, role, is_active FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
if (!user.is_active) {
|
||||
return res.status(401).json({ error: 'Account deactivated' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
};
|
||||
|
||||
const requireRole = (roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireRole
|
||||
};
|
||||
424
backend/src/routes/applications.js
Normal file
424
backend/src/routes/applications.js
Normal file
@@ -0,0 +1,424 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all applications (with filtering)
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
jobId,
|
||||
candidateId,
|
||||
status,
|
||||
employerId,
|
||||
page = 1,
|
||||
limit = 10
|
||||
} = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT a.*,
|
||||
j.title as job_title,
|
||||
j.employer_id,
|
||||
e.company_name,
|
||||
c.user_id as candidate_user_id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email as candidate_email
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
JOIN candidates c ON a.candidate_id = c.id
|
||||
JOIN users u ON c.user_id = u.id
|
||||
`;
|
||||
const queryParams = [];
|
||||
let paramCount = 0;
|
||||
const conditions = [];
|
||||
|
||||
if (jobId) {
|
||||
paramCount++;
|
||||
conditions.push(`a.job_id = $${paramCount}`);
|
||||
queryParams.push(jobId);
|
||||
}
|
||||
|
||||
if (candidateId) {
|
||||
paramCount++;
|
||||
conditions.push(`a.candidate_id = $${paramCount}`);
|
||||
queryParams.push(candidateId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
conditions.push(`a.status = $${paramCount}`);
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (employerId) {
|
||||
paramCount++;
|
||||
conditions.push(`j.employer_id = $${paramCount}`);
|
||||
queryParams.push(employerId);
|
||||
}
|
||||
|
||||
// Role-based filtering
|
||||
if (req.user.role === 'candidate') {
|
||||
// Candidates can only see their own applications
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length > 0) {
|
||||
paramCount++;
|
||||
conditions.push(`a.candidate_id = $${paramCount}`);
|
||||
queryParams.push(candidateResult.rows[0].id);
|
||||
}
|
||||
} else if (req.user.role === 'employer') {
|
||||
// Employers can only see applications for their jobs
|
||||
const employerResult = await pool.query(
|
||||
'SELECT id FROM employers WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (employerResult.rows.length > 0) {
|
||||
paramCount++;
|
||||
conditions.push(`j.employer_id = $${paramCount}`);
|
||||
queryParams.push(employerResult.rows[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY a.applied_at DESC`;
|
||||
|
||||
// Add pagination
|
||||
const offset = (page - 1) * limit;
|
||||
paramCount++;
|
||||
query += ` LIMIT $${paramCount}`;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
query += ` OFFSET $${paramCount}`;
|
||||
queryParams.push(offset);
|
||||
|
||||
const result = await pool.query(query, queryParams);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
JOIN candidates c ON a.candidate_id = c.id
|
||||
`;
|
||||
const countParams = [];
|
||||
let countParamCount = 0;
|
||||
const countConditions = [];
|
||||
|
||||
if (jobId) {
|
||||
countParamCount++;
|
||||
countConditions.push(`a.job_id = $${countParamCount}`);
|
||||
countParams.push(jobId);
|
||||
}
|
||||
|
||||
if (candidateId) {
|
||||
countParamCount++;
|
||||
countConditions.push(`a.candidate_id = $${countParamCount}`);
|
||||
countParams.push(candidateId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
countParamCount++;
|
||||
countConditions.push(`a.status = $${countParamCount}`);
|
||||
countParams.push(status);
|
||||
}
|
||||
|
||||
if (employerId) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.employer_id = $${countParamCount}`);
|
||||
countParams.push(employerId);
|
||||
}
|
||||
|
||||
// Role-based filtering for count
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length > 0) {
|
||||
countParamCount++;
|
||||
countConditions.push(`a.candidate_id = $${countParamCount}`);
|
||||
countParams.push(candidateResult.rows[0].id);
|
||||
}
|
||||
} else if (req.user.role === 'employer') {
|
||||
const employerResult = await pool.query(
|
||||
'SELECT id FROM employers WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (employerResult.rows.length > 0) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.employer_id = $${countParamCount}`);
|
||||
countParams.push(employerResult.rows[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (countConditions.length > 0) {
|
||||
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const countResult = await pool.query(countQuery, countParams);
|
||||
|
||||
res.json({
|
||||
applications: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(countResult.rows[0].count / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get applications error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch applications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT a.*,
|
||||
j.title as job_title,
|
||||
j.description as job_description,
|
||||
j.employer_id,
|
||||
e.company_name,
|
||||
c.user_id as candidate_user_id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email as candidate_email
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
JOIN candidates c ON a.candidate_id = c.id
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE a.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Application not found' });
|
||||
}
|
||||
|
||||
const application = result.rows[0];
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role === 'employer') {
|
||||
const employerResult = await pool.query(
|
||||
'SELECT id FROM employers WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (employerResult.rows.length === 0 || application.employer_id !== employerResult.rows[0].id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(application);
|
||||
} catch (error) {
|
||||
console.error('Get application error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create application
|
||||
router.post('/', authenticateToken, requireRole(['candidate']), [
|
||||
body('jobId').isUUID(),
|
||||
body('coverLetter').optional().trim(),
|
||||
body('notes').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { jobId, coverLetter, notes } = req.body;
|
||||
|
||||
// Get candidate ID for the current user
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (candidateResult.rows.length === 0) {
|
||||
return res.status(400).json({ error: 'Candidate profile not found' });
|
||||
}
|
||||
|
||||
const candidateId = candidateResult.rows[0].id;
|
||||
|
||||
// Check if job exists and is active
|
||||
const jobResult = await pool.query(
|
||||
'SELECT id, status FROM jobs WHERE id = $1',
|
||||
[jobId]
|
||||
);
|
||||
|
||||
if (jobResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
if (jobResult.rows[0].status !== 'active') {
|
||||
return res.status(400).json({ error: 'Job is not accepting applications' });
|
||||
}
|
||||
|
||||
// Check if application already exists
|
||||
const existingApplication = await pool.query(
|
||||
'SELECT id FROM applications WHERE job_id = $1 AND candidate_id = $2',
|
||||
[jobId, candidateId]
|
||||
);
|
||||
|
||||
if (existingApplication.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Application already exists' });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO applications (job_id, candidate_id, cover_letter, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`, [jobId, candidateId, coverLetter, notes]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Application submitted successfully',
|
||||
application: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create application error:', error);
|
||||
res.status(500).json({ error: 'Failed to submit application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update application status
|
||||
router.put('/:id/status', authenticateToken, [
|
||||
body('status').isIn(['applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn'])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
// Check if application exists and user has permission
|
||||
const applicationResult = await pool.query(`
|
||||
SELECT a.*, j.employer_id, e.user_id as employer_user_id
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE a.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (applicationResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Application not found' });
|
||||
}
|
||||
|
||||
const application = applicationResult.rows[0];
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
// Candidates can only withdraw their applications
|
||||
if (status !== 'withdrawn') {
|
||||
return res.status(403).json({ error: 'Candidates can only withdraw applications' });
|
||||
}
|
||||
} else if (req.user.role === 'employer') {
|
||||
if (application.employer_user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'UPDATE applications SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||||
[status, id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Application status updated successfully',
|
||||
application: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update application status error:', error);
|
||||
res.status(500).json({ error: 'Failed to update application status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update application notes
|
||||
router.put('/:id/notes', authenticateToken, [
|
||||
body('notes').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { notes } = req.body;
|
||||
|
||||
// Check if application exists and user has permission
|
||||
const applicationResult = await pool.query(`
|
||||
SELECT a.*, j.employer_id, e.user_id as employer_user_id
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE a.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (applicationResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Application not found' });
|
||||
}
|
||||
|
||||
const application = applicationResult.rows[0];
|
||||
|
||||
// Check permissions (employers, recruiters, and admins can add notes)
|
||||
if (req.user.role === 'candidate') {
|
||||
return res.status(403).json({ error: 'Candidates cannot add notes to applications' });
|
||||
} else if (req.user.role === 'employer') {
|
||||
if (application.employer_user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'UPDATE applications SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||||
[notes, id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Application notes updated successfully',
|
||||
application: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update application notes error:', error);
|
||||
res.status(500).json({ error: 'Failed to update application notes' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
153
backend/src/routes/auth.js
Normal file
153
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Register
|
||||
router.post('/register', [
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('role').isIn(['recruiter', 'employer', 'candidate'])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, role } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const userResult = await pool.query(
|
||||
'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id, email, first_name, last_name, role',
|
||||
[email, passwordHash, firstName, lastName, role]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User created successfully',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', [
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('password').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Get user
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, email, password_hash, first_name, last_name, role, is_active FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(401).json({ error: 'Account deactivated' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
user: {
|
||||
id: req.user.id,
|
||||
email: req.user.email,
|
||||
firstName: req.user.first_name,
|
||||
lastName: req.user.last_name,
|
||||
role: req.user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Failed to get user information' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (client-side token removal)
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
res.json({ message: 'Logout successful' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
374
backend/src/routes/candidates.js
Normal file
374
backend/src/routes/candidates.js
Normal file
@@ -0,0 +1,374 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all candidates
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { skills, experienceLevel, location, page = 1, limit = 10 } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT c.*, u.email, u.first_name, u.last_name
|
||||
FROM candidates c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
`;
|
||||
const queryParams = [];
|
||||
let paramCount = 0;
|
||||
const conditions = [];
|
||||
|
||||
if (skills) {
|
||||
const skillArray = skills.split(',').map(s => s.trim());
|
||||
paramCount++;
|
||||
conditions.push(`c.skills && $${paramCount}`);
|
||||
queryParams.push(skillArray);
|
||||
}
|
||||
|
||||
if (experienceLevel) {
|
||||
paramCount++;
|
||||
conditions.push(`c.experience_level = $${paramCount}`);
|
||||
queryParams.push(experienceLevel);
|
||||
}
|
||||
|
||||
if (location) {
|
||||
paramCount++;
|
||||
conditions.push(`c.location ILIKE $${paramCount}`);
|
||||
queryParams.push(`%${location}%`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY c.created_at DESC`;
|
||||
|
||||
// Add pagination
|
||||
const offset = (page - 1) * limit;
|
||||
paramCount++;
|
||||
query += ` LIMIT $${paramCount}`;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
query += ` OFFSET $${paramCount}`;
|
||||
queryParams.push(offset);
|
||||
|
||||
const result = await pool.query(query, queryParams);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM candidates c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
`;
|
||||
const countParams = [];
|
||||
let countParamCount = 0;
|
||||
const countConditions = [];
|
||||
|
||||
if (skills) {
|
||||
const skillArray = skills.split(',').map(s => s.trim());
|
||||
countParamCount++;
|
||||
countConditions.push(`c.skills && $${countParamCount}`);
|
||||
countParams.push(skillArray);
|
||||
}
|
||||
|
||||
if (experienceLevel) {
|
||||
countParamCount++;
|
||||
countConditions.push(`c.experience_level = $${countParamCount}`);
|
||||
countParams.push(experienceLevel);
|
||||
}
|
||||
|
||||
if (location) {
|
||||
countParamCount++;
|
||||
countConditions.push(`c.location ILIKE $${countParamCount}`);
|
||||
countParams.push(`%${location}%`);
|
||||
}
|
||||
|
||||
if (countConditions.length > 0) {
|
||||
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const countResult = await pool.query(countQuery, countParams);
|
||||
|
||||
res.json({
|
||||
candidates: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(countResult.rows[0].count / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get candidates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch candidates' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get candidate by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT c.*, u.email, u.first_name, u.last_name
|
||||
FROM candidates c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Candidate not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get candidate error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch candidate' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create candidate profile
|
||||
router.post('/', authenticateToken, requireRole(['candidate']), [
|
||||
body('phone').optional().trim(),
|
||||
body('location').optional().trim(),
|
||||
body('linkedinUrl').optional().isURL(),
|
||||
body('githubUrl').optional().isURL(),
|
||||
body('portfolioUrl').optional().isURL(),
|
||||
body('bio').optional().trim(),
|
||||
body('skills').optional().isArray(),
|
||||
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
|
||||
body('availability').optional().trim(),
|
||||
body('salaryExpectation').optional().isInt({ min: 0 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
phone,
|
||||
location,
|
||||
linkedinUrl,
|
||||
githubUrl,
|
||||
portfolioUrl,
|
||||
bio,
|
||||
skills,
|
||||
experienceLevel,
|
||||
availability,
|
||||
salaryExpectation
|
||||
} = req.body;
|
||||
|
||||
// Check if candidate profile already exists for this user
|
||||
const existingCandidate = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (existingCandidate.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Candidate profile already exists' });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO candidates (user_id, phone, location, linkedin_url, github_url, portfolio_url, bio, skills, experience_level, availability, salary_expectation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`, [req.user.id, phone, location, linkedinUrl, githubUrl, portfolioUrl, bio, skills, experienceLevel, availability, salaryExpectation]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Candidate profile created successfully',
|
||||
candidate: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create candidate error:', error);
|
||||
res.status(500).json({ error: 'Failed to create candidate profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update candidate profile
|
||||
router.put('/:id', authenticateToken, [
|
||||
body('phone').optional().trim(),
|
||||
body('location').optional().trim(),
|
||||
body('linkedinUrl').optional().isURL(),
|
||||
body('githubUrl').optional().isURL(),
|
||||
body('portfolioUrl').optional().isURL(),
|
||||
body('bio').optional().trim(),
|
||||
body('skills').optional().isArray(),
|
||||
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
|
||||
body('availability').optional().trim(),
|
||||
body('salaryExpectation').optional().isInt({ min: 0 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const {
|
||||
phone,
|
||||
location,
|
||||
linkedinUrl,
|
||||
githubUrl,
|
||||
portfolioUrl,
|
||||
bio,
|
||||
skills,
|
||||
experienceLevel,
|
||||
availability,
|
||||
salaryExpectation
|
||||
} = req.body;
|
||||
|
||||
// Check if candidate exists and user has permission
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT user_id FROM candidates WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (candidateResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Candidate not found' });
|
||||
}
|
||||
|
||||
// Users can only update their own candidate profile unless they're admin
|
||||
if (candidateResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (phone !== undefined) {
|
||||
updateFields.push(`phone = $${paramCount}`);
|
||||
updateValues.push(phone);
|
||||
paramCount++;
|
||||
}
|
||||
if (location !== undefined) {
|
||||
updateFields.push(`location = $${paramCount}`);
|
||||
updateValues.push(location);
|
||||
paramCount++;
|
||||
}
|
||||
if (linkedinUrl !== undefined) {
|
||||
updateFields.push(`linkedin_url = $${paramCount}`);
|
||||
updateValues.push(linkedinUrl);
|
||||
paramCount++;
|
||||
}
|
||||
if (githubUrl !== undefined) {
|
||||
updateFields.push(`github_url = $${paramCount}`);
|
||||
updateValues.push(githubUrl);
|
||||
paramCount++;
|
||||
}
|
||||
if (portfolioUrl !== undefined) {
|
||||
updateFields.push(`portfolio_url = $${paramCount}`);
|
||||
updateValues.push(portfolioUrl);
|
||||
paramCount++;
|
||||
}
|
||||
if (bio !== undefined) {
|
||||
updateFields.push(`bio = $${paramCount}`);
|
||||
updateValues.push(bio);
|
||||
paramCount++;
|
||||
}
|
||||
if (skills !== undefined) {
|
||||
updateFields.push(`skills = $${paramCount}`);
|
||||
updateValues.push(skills);
|
||||
paramCount++;
|
||||
}
|
||||
if (experienceLevel !== undefined) {
|
||||
updateFields.push(`experience_level = $${paramCount}`);
|
||||
updateValues.push(experienceLevel);
|
||||
paramCount++;
|
||||
}
|
||||
if (availability !== undefined) {
|
||||
updateFields.push(`availability = $${paramCount}`);
|
||||
updateValues.push(availability);
|
||||
paramCount++;
|
||||
}
|
||||
if (salaryExpectation !== undefined) {
|
||||
updateFields.push(`salary_expectation = $${paramCount}`);
|
||||
updateValues.push(salaryExpectation);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
updateValues.push(id);
|
||||
const query = `UPDATE candidates SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
|
||||
|
||||
const result = await pool.query(query, updateValues);
|
||||
|
||||
res.json({
|
||||
message: 'Candidate profile updated successfully',
|
||||
candidate: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update candidate error:', error);
|
||||
res.status(500).json({ error: 'Failed to update candidate profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get candidate's applications
|
||||
router.get('/:id/applications', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, page = 1, limit = 10 } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT a.*, j.title as job_title, j.employer_id, e.company_name
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE a.candidate_id = $1
|
||||
`;
|
||||
const queryParams = [id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
query += ` AND a.status = $${paramCount}`;
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
query += ` ORDER BY a.applied_at DESC`;
|
||||
|
||||
// Add pagination
|
||||
const offset = (page - 1) * limit;
|
||||
paramCount++;
|
||||
query += ` LIMIT $${paramCount}`;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
query += ` OFFSET $${paramCount}`;
|
||||
queryParams.push(offset);
|
||||
|
||||
const result = await pool.query(query, queryParams);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM applications a
|
||||
WHERE a.candidate_id = $1
|
||||
`;
|
||||
const countParams = [id];
|
||||
if (status) {
|
||||
countQuery += ' AND a.status = $2';
|
||||
countParams.push(status);
|
||||
}
|
||||
const countResult = await pool.query(countQuery, countParams);
|
||||
|
||||
res.json({
|
||||
applications: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(countResult.rows[0].count / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get candidate applications error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch candidate applications' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
256
backend/src/routes/employers.js
Normal file
256
backend/src/routes/employers.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all employers
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT e.*, u.email, u.first_name, u.last_name
|
||||
FROM employers e
|
||||
JOIN users u ON e.user_id = u.id
|
||||
ORDER BY e.created_at DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get employers error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch employers' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get employer by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT e.*, u.email, u.first_name, u.last_name
|
||||
FROM employers e
|
||||
JOIN users u ON e.user_id = u.id
|
||||
WHERE e.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Employer not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get employer error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch employer' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create employer profile
|
||||
router.post('/', authenticateToken, requireRole(['employer']), [
|
||||
body('companyName').notEmpty().trim(),
|
||||
body('industry').optional().trim(),
|
||||
body('companySize').optional().trim(),
|
||||
body('website').optional().isURL(),
|
||||
body('description').optional().trim(),
|
||||
body('address').optional().trim(),
|
||||
body('phone').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
companyName,
|
||||
industry,
|
||||
companySize,
|
||||
website,
|
||||
description,
|
||||
address,
|
||||
phone
|
||||
} = req.body;
|
||||
|
||||
// Check if employer profile already exists for this user
|
||||
const existingEmployer = await pool.query(
|
||||
'SELECT id FROM employers WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (existingEmployer.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Employer profile already exists' });
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`, [req.user.id, companyName, industry, companySize, website, description, address, phone]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Employer profile created successfully',
|
||||
employer: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create employer error:', error);
|
||||
res.status(500).json({ error: 'Failed to create employer profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update employer profile
|
||||
router.put('/:id', authenticateToken, [
|
||||
body('companyName').optional().notEmpty().trim(),
|
||||
body('industry').optional().trim(),
|
||||
body('companySize').optional().trim(),
|
||||
body('website').optional().isURL(),
|
||||
body('description').optional().trim(),
|
||||
body('address').optional().trim(),
|
||||
body('phone').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const {
|
||||
companyName,
|
||||
industry,
|
||||
companySize,
|
||||
website,
|
||||
description,
|
||||
address,
|
||||
phone
|
||||
} = req.body;
|
||||
|
||||
// Check if employer exists and user has permission
|
||||
const employerResult = await pool.query(
|
||||
'SELECT user_id FROM employers WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (employerResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Employer not found' });
|
||||
}
|
||||
|
||||
// Users can only update their own employer profile unless they're admin
|
||||
if (employerResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (companyName) {
|
||||
updateFields.push(`company_name = $${paramCount}`);
|
||||
updateValues.push(companyName);
|
||||
paramCount++;
|
||||
}
|
||||
if (industry !== undefined) {
|
||||
updateFields.push(`industry = $${paramCount}`);
|
||||
updateValues.push(industry);
|
||||
paramCount++;
|
||||
}
|
||||
if (companySize !== undefined) {
|
||||
updateFields.push(`company_size = $${paramCount}`);
|
||||
updateValues.push(companySize);
|
||||
paramCount++;
|
||||
}
|
||||
if (website !== undefined) {
|
||||
updateFields.push(`website = $${paramCount}`);
|
||||
updateValues.push(website);
|
||||
paramCount++;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateFields.push(`description = $${paramCount}`);
|
||||
updateValues.push(description);
|
||||
paramCount++;
|
||||
}
|
||||
if (address !== undefined) {
|
||||
updateFields.push(`address = $${paramCount}`);
|
||||
updateValues.push(address);
|
||||
paramCount++;
|
||||
}
|
||||
if (phone !== undefined) {
|
||||
updateFields.push(`phone = $${paramCount}`);
|
||||
updateValues.push(phone);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
updateValues.push(id);
|
||||
const query = `UPDATE employers SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
|
||||
|
||||
const result = await pool.query(query, updateValues);
|
||||
|
||||
res.json({
|
||||
message: 'Employer profile updated successfully',
|
||||
employer: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update employer error:', error);
|
||||
res.status(500).json({ error: 'Failed to update employer profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get employer's jobs
|
||||
router.get('/:id/jobs', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, page = 1, limit = 10 } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT * FROM jobs
|
||||
WHERE employer_id = $1
|
||||
`;
|
||||
const queryParams = [id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
query += ` AND status = $${paramCount}`;
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`;
|
||||
|
||||
// Add pagination
|
||||
const offset = (page - 1) * limit;
|
||||
paramCount++;
|
||||
query += ` LIMIT $${paramCount}`;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
query += ` OFFSET $${paramCount}`;
|
||||
queryParams.push(offset);
|
||||
|
||||
const result = await pool.query(query, queryParams);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = 'SELECT COUNT(*) FROM jobs WHERE employer_id = $1';
|
||||
const countParams = [id];
|
||||
if (status) {
|
||||
countQuery += ' AND status = $2';
|
||||
countParams.push(status);
|
||||
}
|
||||
const countResult = await pool.query(countQuery, countParams);
|
||||
|
||||
res.json({
|
||||
jobs: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(countResult.rows[0].count / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get employer jobs error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch employer jobs' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
478
backend/src/routes/jobs.js
Normal file
478
backend/src/routes/jobs.js
Normal file
@@ -0,0 +1,478 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all jobs with filtering and search
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
search,
|
||||
location,
|
||||
employmentType,
|
||||
experienceLevel,
|
||||
skills,
|
||||
salaryMin,
|
||||
salaryMax,
|
||||
remoteAllowed,
|
||||
status = 'active',
|
||||
page = 1,
|
||||
limit = 10
|
||||
} = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT j.*, e.company_name, e.industry, e.company_size
|
||||
FROM jobs j
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
`;
|
||||
const queryParams = [];
|
||||
let paramCount = 0;
|
||||
const conditions = [];
|
||||
|
||||
// Always filter by status
|
||||
paramCount++;
|
||||
conditions.push(`j.status = $${paramCount}`);
|
||||
queryParams.push(status);
|
||||
|
||||
if (search) {
|
||||
paramCount++;
|
||||
conditions.push(`(j.title ILIKE $${paramCount} OR j.description ILIKE $${paramCount})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
if (location) {
|
||||
paramCount++;
|
||||
conditions.push(`j.location ILIKE $${paramCount}`);
|
||||
queryParams.push(`%${location}%`);
|
||||
}
|
||||
|
||||
if (employmentType) {
|
||||
paramCount++;
|
||||
conditions.push(`j.employment_type = $${paramCount}`);
|
||||
queryParams.push(employmentType);
|
||||
}
|
||||
|
||||
if (experienceLevel) {
|
||||
paramCount++;
|
||||
conditions.push(`j.experience_level = $${paramCount}`);
|
||||
queryParams.push(experienceLevel);
|
||||
}
|
||||
|
||||
if (skills) {
|
||||
const skillArray = skills.split(',').map(s => s.trim());
|
||||
paramCount++;
|
||||
conditions.push(`j.skills_required && $${paramCount}`);
|
||||
queryParams.push(skillArray);
|
||||
}
|
||||
|
||||
if (salaryMin) {
|
||||
paramCount++;
|
||||
conditions.push(`j.salary_max >= $${paramCount}`);
|
||||
queryParams.push(parseInt(salaryMin));
|
||||
}
|
||||
|
||||
if (salaryMax) {
|
||||
paramCount++;
|
||||
conditions.push(`j.salary_min <= $${paramCount}`);
|
||||
queryParams.push(parseInt(salaryMax));
|
||||
}
|
||||
|
||||
if (remoteAllowed === 'true') {
|
||||
conditions.push(`j.remote_allowed = true`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY j.created_at DESC`;
|
||||
|
||||
// Add pagination
|
||||
const offset = (page - 1) * limit;
|
||||
paramCount++;
|
||||
query += ` LIMIT $${paramCount}`;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
query += ` OFFSET $${paramCount}`;
|
||||
queryParams.push(offset);
|
||||
|
||||
const result = await pool.query(query, queryParams);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM jobs j
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
`;
|
||||
const countParams = [];
|
||||
let countParamCount = 0;
|
||||
const countConditions = [];
|
||||
|
||||
countParamCount++;
|
||||
countConditions.push(`j.status = $${countParamCount}`);
|
||||
countParams.push(status);
|
||||
|
||||
if (search) {
|
||||
countParamCount++;
|
||||
countConditions.push(`(j.title ILIKE $${countParamCount} OR j.description ILIKE $${countParamCount})`);
|
||||
countParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
if (location) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.location ILIKE $${countParamCount}`);
|
||||
countParams.push(`%${location}%`);
|
||||
}
|
||||
|
||||
if (employmentType) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.employment_type = $${countParamCount}`);
|
||||
countParams.push(employmentType);
|
||||
}
|
||||
|
||||
if (experienceLevel) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.experience_level = $${countParamCount}`);
|
||||
countParams.push(experienceLevel);
|
||||
}
|
||||
|
||||
if (skills) {
|
||||
const skillArray = skills.split(',').map(s => s.trim());
|
||||
countParamCount++;
|
||||
countConditions.push(`j.skills_required && $${countParamCount}`);
|
||||
countParams.push(skillArray);
|
||||
}
|
||||
|
||||
if (salaryMin) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.salary_max >= $${countParamCount}`);
|
||||
countParams.push(parseInt(salaryMin));
|
||||
}
|
||||
|
||||
if (salaryMax) {
|
||||
countParamCount++;
|
||||
countConditions.push(`j.salary_min <= $${countParamCount}`);
|
||||
countParams.push(parseInt(salaryMax));
|
||||
}
|
||||
|
||||
if (remoteAllowed === 'true') {
|
||||
countConditions.push(`j.remote_allowed = true`);
|
||||
}
|
||||
|
||||
if (countConditions.length > 0) {
|
||||
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const countResult = await pool.query(countQuery, countParams);
|
||||
|
||||
res.json({
|
||||
jobs: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(countResult.rows[0].count / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get jobs error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch jobs' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get job by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
SELECT j.*, e.company_name, e.industry, e.company_size, e.website, e.description as company_description
|
||||
FROM jobs j
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE j.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get job error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch job' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create job posting
|
||||
router.post('/', authenticateToken, requireRole(['employer', 'recruiter']), [
|
||||
body('title').notEmpty().trim(),
|
||||
body('description').notEmpty().trim(),
|
||||
body('requirements').isArray(),
|
||||
body('responsibilities').isArray(),
|
||||
body('location').notEmpty().trim(),
|
||||
body('employmentType').isIn(['full-time', 'part-time', 'contract', 'internship']),
|
||||
body('salaryMin').optional().isInt({ min: 0 }),
|
||||
body('salaryMax').optional().isInt({ min: 0 }),
|
||||
body('currency').optional().isLength({ min: 3, max: 3 }),
|
||||
body('remoteAllowed').optional().isBoolean(),
|
||||
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
|
||||
body('skillsRequired').optional().isArray(),
|
||||
body('benefits').optional().isArray(),
|
||||
body('applicationDeadline').optional().isISO8601()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
requirements,
|
||||
responsibilities,
|
||||
location,
|
||||
employmentType,
|
||||
salaryMin,
|
||||
salaryMax,
|
||||
currency = 'USD',
|
||||
remoteAllowed = false,
|
||||
experienceLevel,
|
||||
skillsRequired,
|
||||
benefits,
|
||||
applicationDeadline
|
||||
} = req.body;
|
||||
|
||||
// Get employer_id for the current user
|
||||
let employerId;
|
||||
if (req.user.role === 'employer') {
|
||||
const employerResult = await pool.query(
|
||||
'SELECT id FROM employers WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (employerResult.rows.length === 0) {
|
||||
return res.status(400).json({ error: 'Employer profile not found' });
|
||||
}
|
||||
employerId = employerResult.rows[0].id;
|
||||
} else {
|
||||
// For recruiters, they need to specify which employer
|
||||
const { employerId: providedEmployerId } = req.body;
|
||||
if (!providedEmployerId) {
|
||||
return res.status(400).json({ error: 'Employer ID required for recruiters' });
|
||||
}
|
||||
employerId = providedEmployerId;
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, currency, remote_allowed, experience_level, skills_required, benefits, application_deadline)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`, [employerId, title, description, requirements, responsibilities, location, employmentType, salaryMin, salaryMax, currency, remoteAllowed, experienceLevel, skillsRequired, benefits, applicationDeadline]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Job posting created successfully',
|
||||
job: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create job error:', error);
|
||||
res.status(500).json({ error: 'Failed to create job posting' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update job posting
|
||||
router.put('/:id', authenticateToken, [
|
||||
body('title').optional().notEmpty().trim(),
|
||||
body('description').optional().notEmpty().trim(),
|
||||
body('requirements').optional().isArray(),
|
||||
body('responsibilities').optional().isArray(),
|
||||
body('location').optional().notEmpty().trim(),
|
||||
body('employmentType').optional().isIn(['full-time', 'part-time', 'contract', 'internship']),
|
||||
body('salaryMin').optional().isInt({ min: 0 }),
|
||||
body('salaryMax').optional().isInt({ min: 0 }),
|
||||
body('currency').optional().isLength({ min: 3, max: 3 }),
|
||||
body('status').optional().isIn(['active', 'paused', 'closed', 'draft']),
|
||||
body('remoteAllowed').optional().isBoolean(),
|
||||
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
|
||||
body('skillsRequired').optional().isArray(),
|
||||
body('benefits').optional().isArray(),
|
||||
body('applicationDeadline').optional().isISO8601()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
requirements,
|
||||
responsibilities,
|
||||
location,
|
||||
employmentType,
|
||||
salaryMin,
|
||||
salaryMax,
|
||||
currency,
|
||||
status,
|
||||
remoteAllowed,
|
||||
experienceLevel,
|
||||
skillsRequired,
|
||||
benefits,
|
||||
applicationDeadline
|
||||
} = req.body;
|
||||
|
||||
// Check if job exists and user has permission
|
||||
const jobResult = await pool.query(`
|
||||
SELECT j.*, e.user_id as employer_user_id
|
||||
FROM jobs j
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE j.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (jobResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
const job = jobResult.rows[0];
|
||||
|
||||
// Users can only update jobs from their own employer unless they're admin
|
||||
if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (title) {
|
||||
updateFields.push(`title = $${paramCount}`);
|
||||
updateValues.push(title);
|
||||
paramCount++;
|
||||
}
|
||||
if (description) {
|
||||
updateFields.push(`description = $${paramCount}`);
|
||||
updateValues.push(description);
|
||||
paramCount++;
|
||||
}
|
||||
if (requirements) {
|
||||
updateFields.push(`requirements = $${paramCount}`);
|
||||
updateValues.push(requirements);
|
||||
paramCount++;
|
||||
}
|
||||
if (responsibilities) {
|
||||
updateFields.push(`responsibilities = $${paramCount}`);
|
||||
updateValues.push(responsibilities);
|
||||
paramCount++;
|
||||
}
|
||||
if (location) {
|
||||
updateFields.push(`location = $${paramCount}`);
|
||||
updateValues.push(location);
|
||||
paramCount++;
|
||||
}
|
||||
if (employmentType) {
|
||||
updateFields.push(`employment_type = $${paramCount}`);
|
||||
updateValues.push(employmentType);
|
||||
paramCount++;
|
||||
}
|
||||
if (salaryMin !== undefined) {
|
||||
updateFields.push(`salary_min = $${paramCount}`);
|
||||
updateValues.push(salaryMin);
|
||||
paramCount++;
|
||||
}
|
||||
if (salaryMax !== undefined) {
|
||||
updateFields.push(`salary_max = $${paramCount}`);
|
||||
updateValues.push(salaryMax);
|
||||
paramCount++;
|
||||
}
|
||||
if (currency) {
|
||||
updateFields.push(`currency = $${paramCount}`);
|
||||
updateValues.push(currency);
|
||||
paramCount++;
|
||||
}
|
||||
if (status) {
|
||||
updateFields.push(`status = $${paramCount}`);
|
||||
updateValues.push(status);
|
||||
paramCount++;
|
||||
}
|
||||
if (remoteAllowed !== undefined) {
|
||||
updateFields.push(`remote_allowed = $${paramCount}`);
|
||||
updateValues.push(remoteAllowed);
|
||||
paramCount++;
|
||||
}
|
||||
if (experienceLevel) {
|
||||
updateFields.push(`experience_level = $${paramCount}`);
|
||||
updateValues.push(experienceLevel);
|
||||
paramCount++;
|
||||
}
|
||||
if (skillsRequired) {
|
||||
updateFields.push(`skills_required = $${paramCount}`);
|
||||
updateValues.push(skillsRequired);
|
||||
paramCount++;
|
||||
}
|
||||
if (benefits) {
|
||||
updateFields.push(`benefits = $${paramCount}`);
|
||||
updateValues.push(benefits);
|
||||
paramCount++;
|
||||
}
|
||||
if (applicationDeadline) {
|
||||
updateFields.push(`application_deadline = $${paramCount}`);
|
||||
updateValues.push(applicationDeadline);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
updateValues.push(id);
|
||||
const query = `UPDATE jobs SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
|
||||
|
||||
const result = await pool.query(query, updateValues);
|
||||
|
||||
res.json({
|
||||
message: 'Job posting updated successfully',
|
||||
job: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update job error:', error);
|
||||
res.status(500).json({ error: 'Failed to update job posting' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete job posting
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if job exists and user has permission
|
||||
const jobResult = await pool.query(`
|
||||
SELECT j.*, e.user_id as employer_user_id
|
||||
FROM jobs j
|
||||
JOIN employers e ON j.employer_id = e.id
|
||||
WHERE j.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (jobResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
const job = jobResult.rows[0];
|
||||
|
||||
// Users can only delete jobs from their own employer unless they're admin
|
||||
if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM jobs WHERE id = $1', [id]);
|
||||
|
||||
res.json({ message: 'Job posting deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete job error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete job posting' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
298
backend/src/routes/resumes.js
Normal file
298
backend/src/routes/resumes.js
Normal file
@@ -0,0 +1,298 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../uploads/resumes');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${uuidv4()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only PDF, DOC, DOCX, and TXT files are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get all resumes for a candidate
|
||||
router.get('/candidate/:candidateId', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { candidateId } = req.params;
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== candidateId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM resumes WHERE candidate_id = $1 ORDER BY uploaded_at DESC',
|
||||
[candidateId]
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get resumes error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch resumes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get resume by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM resumes WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Resume not found' });
|
||||
}
|
||||
|
||||
const resume = result.rows[0];
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
res.json(resume);
|
||||
} catch (error) {
|
||||
console.error('Get resume error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch resume' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload resume
|
||||
router.post('/upload', authenticateToken, requireRole(['candidate']), upload.single('resume'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Get candidate ID for the current user
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (candidateResult.rows.length === 0) {
|
||||
return res.status(400).json({ error: 'Candidate profile not found' });
|
||||
}
|
||||
|
||||
const candidateId = candidateResult.rows[0].id;
|
||||
|
||||
// If this is set as primary, unset other primary resumes
|
||||
if (req.body.isPrimary === 'true') {
|
||||
await pool.query(
|
||||
'UPDATE resumes SET is_primary = false WHERE candidate_id = $1',
|
||||
[candidateId]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO resumes (candidate_id, filename, original_name, file_path, file_size, mime_type, is_primary)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
candidateId,
|
||||
req.file.filename,
|
||||
req.file.originalname,
|
||||
req.file.path,
|
||||
req.file.size,
|
||||
req.file.mimetype,
|
||||
req.body.isPrimary === 'true'
|
||||
]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Resume uploaded successfully',
|
||||
resume: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload resume error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload resume' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download resume
|
||||
router.get('/:id/download', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM resumes WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Resume not found' });
|
||||
}
|
||||
|
||||
const resume = result.rows[0];
|
||||
|
||||
// Check permissions
|
||||
if (req.user.role === 'candidate') {
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(resume.file_path)) {
|
||||
return res.status(404).json({ error: 'Resume file not found' });
|
||||
}
|
||||
|
||||
res.download(resume.file_path, resume.original_name);
|
||||
} catch (error) {
|
||||
console.error('Download resume error:', error);
|
||||
res.status(500).json({ error: 'Failed to download resume' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set primary resume
|
||||
router.put('/:id/primary', authenticateToken, requireRole(['candidate']), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get candidate ID for the current user
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (candidateResult.rows.length === 0) {
|
||||
return res.status(400).json({ error: 'Candidate profile not found' });
|
||||
}
|
||||
|
||||
const candidateId = candidateResult.rows[0].id;
|
||||
|
||||
// Check if resume exists and belongs to the candidate
|
||||
const resumeResult = await pool.query(
|
||||
'SELECT id FROM resumes WHERE id = $1 AND candidate_id = $2',
|
||||
[id, candidateId]
|
||||
);
|
||||
|
||||
if (resumeResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Resume not found' });
|
||||
}
|
||||
|
||||
// Unset other primary resumes
|
||||
await pool.query(
|
||||
'UPDATE resumes SET is_primary = false WHERE candidate_id = $1',
|
||||
[candidateId]
|
||||
);
|
||||
|
||||
// Set this resume as primary
|
||||
const result = await pool.query(
|
||||
'UPDATE resumes SET is_primary = true WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Primary resume updated successfully',
|
||||
resume: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Set primary resume error:', error);
|
||||
res.status(500).json({ error: 'Failed to set primary resume' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete resume
|
||||
router.delete('/:id', authenticateToken, requireRole(['candidate']), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get candidate ID for the current user
|
||||
const candidateResult = await pool.query(
|
||||
'SELECT id FROM candidates WHERE user_id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (candidateResult.rows.length === 0) {
|
||||
return res.status(400).json({ error: 'Candidate profile not found' });
|
||||
}
|
||||
|
||||
const candidateId = candidateResult.rows[0].id;
|
||||
|
||||
// Check if resume exists and belongs to the candidate
|
||||
const resumeResult = await pool.query(
|
||||
'SELECT * FROM resumes WHERE id = $1 AND candidate_id = $2',
|
||||
[id, candidateId]
|
||||
);
|
||||
|
||||
if (resumeResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Resume not found' });
|
||||
}
|
||||
|
||||
const resume = resumeResult.rows[0];
|
||||
|
||||
// Delete file from filesystem
|
||||
if (fs.existsSync(resume.file_path)) {
|
||||
fs.unlinkSync(resume.file_path);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await pool.query('DELETE FROM resumes WHERE id = $1', [id]);
|
||||
|
||||
res.json({ message: 'Resume deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete resume error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete resume' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
165
backend/src/routes/users.js
Normal file
165
backend/src/routes/users.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const pool = require('../database/connection');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Users can only view their own profile unless they're admin
|
||||
if (req.user.id !== id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update user profile
|
||||
router.put('/:id', authenticateToken, [
|
||||
body('firstName').optional().notEmpty().trim(),
|
||||
body('lastName').optional().notEmpty().trim(),
|
||||
body('email').optional().isEmail().normalizeEmail()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { firstName, lastName, email } = req.body;
|
||||
|
||||
// Users can only update their own profile unless they're admin
|
||||
if (req.user.id !== id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
if (email) {
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1 AND id != $2',
|
||||
[email, id]
|
||||
);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Email already in use' });
|
||||
}
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (firstName) {
|
||||
updateFields.push(`first_name = $${paramCount}`);
|
||||
updateValues.push(firstName);
|
||||
paramCount++;
|
||||
}
|
||||
if (lastName) {
|
||||
updateFields.push(`last_name = $${paramCount}`);
|
||||
updateValues.push(lastName);
|
||||
paramCount++;
|
||||
}
|
||||
if (email) {
|
||||
updateFields.push(`email = $${paramCount}`);
|
||||
updateValues.push(email);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
updateValues.push(id);
|
||||
const query = `UPDATE users SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING id, email, first_name, last_name, role, is_active, updated_at`;
|
||||
|
||||
const result = await pool.query(query, updateValues);
|
||||
|
||||
res.json({
|
||||
message: 'User updated successfully',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update user error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Deactivate user (admin only)
|
||||
router.put('/:id/deactivate', authenticateToken, requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'User deactivated successfully',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Deactivate user error:', error);
|
||||
res.status(500).json({ error: 'Failed to deactivate user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Activate user (admin only)
|
||||
router.put('/:id/activate', authenticateToken, requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET is_active = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'User activated successfully',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Activate user error:', error);
|
||||
res.status(500).json({ error: 'Failed to activate user' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
57
backend/src/server.js
Normal file
57
backend/src/server.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
require('dotenv').config();
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const userRoutes = require('./routes/users');
|
||||
const employerRoutes = require('./routes/employers');
|
||||
const candidateRoutes = require('./routes/candidates');
|
||||
const jobRoutes = require('./routes/jobs');
|
||||
const applicationRoutes = require('./routes/applications');
|
||||
const resumeRoutes = require('./routes/resumes');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(morgan('combined'));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/employers', employerRoutes);
|
||||
app.use('/api/candidates', candidateRoutes);
|
||||
app.use('/api/jobs', jobRoutes);
|
||||
app.use('/api/applications', applicationRoutes);
|
||||
app.use('/api/resumes', resumeRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Something went wrong!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`MysteryApp-Cursor backend server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
185
backend/src/tests/auth.test.js
Normal file
185
backend/src/tests/auth.test.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../server');
|
||||
const pool = require('../database/connection');
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up database before each test
|
||||
await pool.query('DELETE FROM users WHERE email LIKE $1', ['test%']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('should register a new user successfully', async () => {
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
role: 'candidate'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'User created successfully');
|
||||
expect(response.body).toHaveProperty('token');
|
||||
expect(response.body).toHaveProperty('user');
|
||||
expect(response.body.user.email).toBe(userData.email);
|
||||
});
|
||||
|
||||
it('should return error for duplicate email', async () => {
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
role: 'candidate'
|
||||
};
|
||||
|
||||
// Register first user
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData);
|
||||
|
||||
// Try to register with same email
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'User already exists');
|
||||
});
|
||||
|
||||
it('should return error for invalid role', async () => {
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
role: 'invalid_role'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('errors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
role: 'candidate'
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData);
|
||||
});
|
||||
|
||||
it('should login with valid credentials', async () => {
|
||||
const loginData = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send(loginData)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'Login successful');
|
||||
expect(response.body).toHaveProperty('token');
|
||||
expect(response.body).toHaveProperty('user');
|
||||
});
|
||||
|
||||
it('should return error for invalid credentials', async () => {
|
||||
const loginData = {
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send(loginData)
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Invalid credentials');
|
||||
});
|
||||
|
||||
it('should return error for non-existent user', async () => {
|
||||
const loginData = {
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send(loginData)
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let token;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user and get token
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
role: 'candidate'
|
||||
};
|
||||
|
||||
const registerResponse = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData);
|
||||
|
||||
token = registerResponse.body.token;
|
||||
});
|
||||
|
||||
it('should return user data with valid token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('user');
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should return error without token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Access token required');
|
||||
});
|
||||
|
||||
it('should return error with invalid token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Invalid or expired token');
|
||||
});
|
||||
});
|
||||
});
|
||||
252
backend/src/tests/jobs.test.js
Normal file
252
backend/src/tests/jobs.test.js
Normal file
@@ -0,0 +1,252 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../server');
|
||||
const pool = require('../database/connection');
|
||||
|
||||
describe('Jobs API', () => {
|
||||
let authToken;
|
||||
let employerId;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test user and get auth token
|
||||
const userData = {
|
||||
firstName: 'Test',
|
||||
lastName: 'Employer',
|
||||
email: 'employer@test.com',
|
||||
password: 'password123',
|
||||
role: 'employer'
|
||||
};
|
||||
|
||||
const registerResponse = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(userData);
|
||||
|
||||
authToken = registerResponse.body.token;
|
||||
|
||||
// Create employer profile
|
||||
const employerData = {
|
||||
companyName: 'Test Company',
|
||||
industry: 'Technology',
|
||||
companySize: '50-200',
|
||||
website: 'https://testcompany.com',
|
||||
description: 'A test company',
|
||||
address: '123 Test St',
|
||||
phone: '+1-555-0123'
|
||||
};
|
||||
|
||||
const employerResponse = await request(app)
|
||||
.post('/api/employers')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(employerData);
|
||||
|
||||
employerId = employerResponse.body.employer.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('POST /api/jobs', () => {
|
||||
it('should create a new job posting', async () => {
|
||||
const jobData = {
|
||||
title: 'Senior Developer',
|
||||
description: 'We are looking for a senior developer',
|
||||
requirements: ['5+ years experience', 'JavaScript knowledge'],
|
||||
responsibilities: ['Develop applications', 'Code reviews'],
|
||||
location: 'San Francisco, CA',
|
||||
employmentType: 'full-time',
|
||||
salaryMin: 100000,
|
||||
salaryMax: 150000,
|
||||
remoteAllowed: true,
|
||||
experienceLevel: 'senior',
|
||||
skillsRequired: ['JavaScript', 'React', 'Node.js'],
|
||||
benefits: ['Health insurance', '401k']
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'Job posting created successfully');
|
||||
expect(response.body).toHaveProperty('job');
|
||||
expect(response.body.job.title).toBe(jobData.title);
|
||||
});
|
||||
|
||||
it('should return error for missing required fields', async () => {
|
||||
const jobData = {
|
||||
title: 'Senior Developer'
|
||||
// Missing required fields
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('errors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/jobs', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test job
|
||||
const jobData = {
|
||||
title: 'Test Job',
|
||||
description: 'A test job description',
|
||||
requirements: ['Test requirement'],
|
||||
responsibilities: ['Test responsibility'],
|
||||
location: 'Test Location',
|
||||
employmentType: 'full-time'
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData);
|
||||
});
|
||||
|
||||
it('should return list of jobs', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('jobs');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
expect(Array.isArray(response.body.jobs)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter jobs by search term', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/jobs?search=Test')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.jobs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should filter jobs by location', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/jobs?location=Test')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.jobs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/jobs/:id', () => {
|
||||
let jobId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test job
|
||||
const jobData = {
|
||||
title: 'Test Job for Details',
|
||||
description: 'A test job description',
|
||||
requirements: ['Test requirement'],
|
||||
responsibilities: ['Test responsibility'],
|
||||
location: 'Test Location',
|
||||
employmentType: 'full-time'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData);
|
||||
|
||||
jobId = response.body.job.id;
|
||||
});
|
||||
|
||||
it('should return job details', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/jobs/${jobId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('id', jobId);
|
||||
expect(response.body).toHaveProperty('title', 'Test Job for Details');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent job', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/jobs/00000000-0000-0000-0000-000000000000')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Job not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/jobs/:id', () => {
|
||||
let jobId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test job
|
||||
const jobData = {
|
||||
title: 'Test Job for Update',
|
||||
description: 'A test job description',
|
||||
requirements: ['Test requirement'],
|
||||
responsibilities: ['Test responsibility'],
|
||||
location: 'Test Location',
|
||||
employmentType: 'full-time'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData);
|
||||
|
||||
jobId = response.body.job.id;
|
||||
});
|
||||
|
||||
it('should update job successfully', async () => {
|
||||
const updateData = {
|
||||
title: 'Updated Test Job',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/jobs/${jobId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(updateData)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'Job posting updated successfully');
|
||||
expect(response.body.job.title).toBe('Updated Test Job');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/jobs/:id', () => {
|
||||
let jobId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test job
|
||||
const jobData = {
|
||||
title: 'Test Job for Delete',
|
||||
description: 'A test job description',
|
||||
requirements: ['Test requirement'],
|
||||
responsibilities: ['Test responsibility'],
|
||||
location: 'Test Location',
|
||||
employmentType: 'full-time'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/jobs')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(jobData);
|
||||
|
||||
jobId = response.body.job.id;
|
||||
});
|
||||
|
||||
it('should delete job successfully', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/jobs/${jobId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'Job posting deleted successfully');
|
||||
});
|
||||
});
|
||||
});
|
||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
mysteryapp-cursor-database:
|
||||
image: postgres:15-alpine
|
||||
container_name: mysteryapp-cursor-database
|
||||
environment:
|
||||
POSTGRES_DB: mysteryapp_cursor
|
||||
POSTGRES_USER: mysteryapp_user
|
||||
POSTGRES_PASSWORD: mysteryapp_password
|
||||
ports:
|
||||
- "0.0.0.0:5432:5432"
|
||||
volumes:
|
||||
- mysteryapp-cursor-postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- mysteryapp-cursor-network
|
||||
|
||||
mysteryapp-cursor-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mysteryapp-cursor-backend
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://mysteryapp_user:mysteryapp_password@mysteryapp-cursor-database:5432/mysteryapp_cursor
|
||||
JWT_SECRET: mysteryapp_jwt_secret_key_2024
|
||||
PORT: 3001
|
||||
ports:
|
||||
- "0.0.0.0:3001:3001"
|
||||
depends_on:
|
||||
- mysteryapp-cursor-database
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- mysteryapp-cursor-network
|
||||
|
||||
mysteryapp-cursor-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mysteryapp-cursor-frontend
|
||||
environment:
|
||||
REACT_APP_API_URL: http://localhost:3001
|
||||
ports:
|
||||
- "0.0.0.0:12000:3000"
|
||||
depends_on:
|
||||
- mysteryapp-cursor-backend
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- mysteryapp-cursor-network
|
||||
|
||||
volumes:
|
||||
mysteryapp-cursor-postgres-data:
|
||||
|
||||
networks:
|
||||
mysteryapp-cursor-network:
|
||||
driver: bridge
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
53
frontend/package.json
Normal file
53
frontend/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "mysteryapp-cursor-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for MysteryApp-Cursor recruiter workflow SAAS",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-query": "^3.39.3",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17"
|
||||
},
|
||||
"proxy": "http://MysteryApp-Cursor-backend:3001"
|
||||
}
|
||||
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="MysteryApp-Cursor - Professional Recruiter Workflow SAAS"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>MysteryApp-Cursor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
136
frontend/src/App.js
Normal file
136
frontend/src/App.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Jobs from './pages/Jobs';
|
||||
import JobDetails from './pages/JobDetails';
|
||||
import CreateJob from './pages/CreateJob';
|
||||
import Candidates from './pages/Candidates';
|
||||
import CandidateDetails from './pages/CandidateDetails';
|
||||
import Applications from './pages/Applications';
|
||||
import Profile from './pages/Profile';
|
||||
import Employers from './pages/Employers';
|
||||
import EmployerDetails from './pages/EmployerDetails';
|
||||
import Resumes from './pages/Resumes';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function ProtectedRoute({ children, allowedRoles = [] }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" replace />} />
|
||||
<Route path="/register" element={!user ? <Register /> : <Navigate to="/dashboard" replace />} />
|
||||
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="jobs" element={
|
||||
<ProtectedRoute>
|
||||
<Jobs />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="jobs/create" element={
|
||||
<ProtectedRoute allowedRoles={['employer', 'recruiter']}>
|
||||
<CreateJob />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="jobs/:id" element={
|
||||
<ProtectedRoute>
|
||||
<JobDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="candidates" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
|
||||
<Candidates />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="candidates/:id" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
|
||||
<CandidateDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="applications" element={
|
||||
<ProtectedRoute>
|
||||
<Applications />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="employers" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
|
||||
<Employers />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="employers/:id" element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
|
||||
<EmployerDetails />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="resumes" element={
|
||||
<ProtectedRoute allowedRoles={['candidate']}>
|
||||
<Resumes />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="profile" element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<AppRoutes />
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
43
frontend/src/App.test.js
Normal file
43
frontend/src/App.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
// Mock the AuthContext
|
||||
jest.mock('./contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
loading: false,
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
fetchUser: jest.fn()
|
||||
}),
|
||||
AuthProvider: ({ children }) => children
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
jest.mock('react-router-dom', () => ({
|
||||
BrowserRouter: ({ children }) => <div>{children}</div>,
|
||||
Routes: ({ children }) => <div>{children}</div>,
|
||||
Route: ({ children }) => <div>{children}</div>,
|
||||
Navigate: ({ to }) => <div data-testid="navigate">{to}</div>,
|
||||
Outlet: () => <div data-testid="outlet">Outlet</div>
|
||||
}));
|
||||
|
||||
// Mock react-query
|
||||
jest.mock('react-query', () => ({
|
||||
QueryClient: jest.fn(() => ({})),
|
||||
QueryClientProvider: ({ children }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
jest.mock('react-hot-toast', () => ({
|
||||
Toaster: () => <div data-testid="toaster">Toaster</div>
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByTestId('toaster')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
186
frontend/src/components/Layout.js
Normal file
186
frontend/src/components/Layout.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
Home,
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Bell
|
||||
} from 'lucide-react';
|
||||
|
||||
const Layout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Jobs', href: '/jobs', icon: Briefcase, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Candidates', href: '/candidates', icon: Users, roles: ['admin', 'recruiter', 'employer'] },
|
||||
{ name: 'Applications', href: '/applications', icon: FileText, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
|
||||
{ name: 'Employers', href: '/employers', icon: Building, roles: ['admin', 'recruiter'] },
|
||||
{ name: 'Resumes', href: '/resumes', icon: FileText, roles: ['candidate'] },
|
||||
];
|
||||
|
||||
const filteredNavigation = navigation.filter(item =>
|
||||
item.roles.includes(user?.role)
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-white">
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
|
||||
</div>
|
||||
<nav className="mt-5 px-2 space-y-1">
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-base font-medium rounded-md`}
|
||||
>
|
||||
<item.icon className="mr-4 h-6 w-6" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-white">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
|
||||
</div>
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{filteredNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
|
||||
>
|
||||
<item.icon className="mr-3 h-6 w-6" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
|
||||
<div className="flex-shrink-0 w-full group block">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-gray-500 group-hover:text-gray-700">
|
||||
{user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64 flex flex-col flex-1">
|
||||
<div className="sticky top-0 z-10 lg:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Top bar for desktop */}
|
||||
<div className="hidden lg:block lg:pl-64">
|
||||
<div className="sticky top-0 z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200">
|
||||
<div className="flex-1 px-4 flex justify-between">
|
||||
<div className="flex-1 flex">
|
||||
<div className="w-full flex md:ml-0">
|
||||
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
<div className="ml-3 relative">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<User className="h-5 w-5 mr-2" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<LogOut className="h-5 w-5 mr-2" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
70
frontend/src/components/Layout.test.js
Normal file
70
frontend/src/components/Layout.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
|
||||
// Mock the AuthContext
|
||||
const mockUseAuth = {
|
||||
user: {
|
||||
id: '1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'candidate'
|
||||
},
|
||||
logout: jest.fn()
|
||||
};
|
||||
|
||||
jest.mock('../contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({ pathname: '/dashboard' }),
|
||||
Link: ({ children, to }) => <a href={to}>{children}</a>,
|
||||
Outlet: () => <div data-testid="outlet">Outlet</div>
|
||||
}));
|
||||
|
||||
const renderWithRouter = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{component}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders the layout with user information', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('MysteryApp-Cursor')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('candidate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation items for candidate role', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jobs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Applications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resumes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders logout button', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls logout when logout button is clicked', () => {
|
||||
renderWithRouter(<Layout />);
|
||||
|
||||
const logoutButton = screen.getByText('Logout');
|
||||
logoutButton.click();
|
||||
|
||||
expect(mockUseAuth.logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
frontend/src/contexts/AuthContext.js
Normal file
105
frontend/src/contexts/AuthContext.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
fetchUser();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/auth/me');
|
||||
setUser(response.data.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error);
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', { email, password });
|
||||
const { token, user } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
setUser(user);
|
||||
|
||||
toast.success('Login successful!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Login failed';
|
||||
toast.error(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register', userData);
|
||||
const { token, user } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
setUser(user);
|
||||
|
||||
toast.success('Registration successful!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Registration failed';
|
||||
toast.error(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
setUser(null);
|
||||
toast.success('Logged out successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchUser
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
47
frontend/src/index.css
Normal file
47
frontend/src/index.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md border border-gray-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply px-6 py-4 border-t border-gray-200;
|
||||
}
|
||||
}
|
||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
111
frontend/src/pages/Applications.js
Normal file
111
frontend/src/pages/Applications.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { FileText, Briefcase, Building, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
const Applications = () => {
|
||||
const { data, isLoading } = useQuery('applications', async () => {
|
||||
const response = await axios.get('/api/applications');
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'applied': return <Clock className="h-4 w-4" />;
|
||||
case 'reviewed': return <AlertCircle className="h-4 w-4" />;
|
||||
case 'shortlisted': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'interviewed': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'offered': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'rejected': return <XCircle className="h-4 w-4" />;
|
||||
default: return <Clock className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'applied': return 'bg-blue-100 text-blue-800';
|
||||
case 'reviewed': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'shortlisted': return 'bg-green-100 text-green-800';
|
||||
case 'interviewed': return 'bg-green-100 text-green-800';
|
||||
case 'offered': return 'bg-green-100 text-green-800';
|
||||
case 'rejected': return 'bg-red-100 text-red-800';
|
||||
case 'withdrawn': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Applications</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Track your job applications and their status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{data?.applications?.length > 0 ? (
|
||||
data.applications.map((application) => (
|
||||
<div key={application.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{application.job_title}
|
||||
</h3>
|
||||
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(application.status)}`}>
|
||||
{getStatusIcon(application.status)}
|
||||
<span className="ml-1 capitalize">{application.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-1" />
|
||||
{application.company_name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
Applied on {new Date(application.applied_at).toLocaleDateString()}
|
||||
</div>
|
||||
{application.cover_letter && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{application.cover_letter}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{application.notes && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Notes:</strong> {application.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No applications found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
You haven't applied to any jobs yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Applications;
|
||||
149
frontend/src/pages/CandidateDetails.js
Normal file
149
frontend/src/pages/CandidateDetails.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { MapPin, Mail, Phone, Linkedin, Github, Globe, User } from 'lucide-react';
|
||||
|
||||
const CandidateDetails = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data: candidate, isLoading } = useQuery(['candidate', id], async () => {
|
||||
const response = await axios.get(`/api/candidates/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Candidate not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The candidate you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{candidate.first_name} {candidate.last_name}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">{candidate.email}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{candidate.location && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
{candidate.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
{candidate.phone}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.linkedin_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Linkedin className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.linkedin_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
LinkedIn Profile
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.github_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.github_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
GitHub Profile
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.portfolio_url && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<a href={candidate.portfolio_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Portfolio
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidate.bio && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">About</h2>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{candidate.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.skills && candidate.skills.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{candidate.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Experience Level</h2>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
{candidate.experience_level || 'Not specified'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidate.salary_expectation && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Salary Expectation</h2>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
${candidate.salary_expectation.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CandidateDetails;
|
||||
277
frontend/src/pages/Candidates.js
Normal file
277
frontend/src/pages/Candidates.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Search, MapPin, User, Star } from 'lucide-react';
|
||||
|
||||
const Candidates = () => {
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
location: '',
|
||||
experienceLevel: '',
|
||||
skills: ''
|
||||
});
|
||||
|
||||
const { data, isLoading, refetch } = useQuery(['candidates', filters], async () => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const response = await axios.get(`/api/candidates?${params.toString()}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getExperienceColor = (level) => {
|
||||
switch (level) {
|
||||
case 'entry': return 'bg-green-100 text-green-800';
|
||||
case 'mid': return 'bg-blue-100 text-blue-800';
|
||||
case 'senior': return 'bg-purple-100 text-purple-800';
|
||||
case 'lead': return 'bg-orange-100 text-orange-800';
|
||||
case 'executive': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Candidates</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Browse and discover talented candidates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
|
||||
Search
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Name or keywords"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
||||
Location
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MapPin className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
id="location"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="City, state, or country"
|
||||
value={filters.location}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
|
||||
Experience Level
|
||||
</label>
|
||||
<select
|
||||
id="experienceLevel"
|
||||
name="experienceLevel"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
value={filters.experienceLevel}
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="entry">Entry Level</option>
|
||||
<option value="mid">Mid Level</option>
|
||||
<option value="senior">Senior Level</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="executive">Executive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="skills" className="block text-sm font-medium text-gray-700">
|
||||
Skills
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="skills"
|
||||
id="skills"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="JavaScript, React, etc."
|
||||
value={filters.skills}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Search Candidates
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Candidates List */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.candidates?.length > 0 ? (
|
||||
data.candidates.map((candidate) => (
|
||||
<div key={candidate.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<Link to={`/candidates/${candidate.id}`} className="hover:text-primary-600">
|
||||
{candidate.first_name} {candidate.last_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{candidate.email}</p>
|
||||
|
||||
{candidate.location && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{candidate.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.experience_level && (
|
||||
<div className="mt-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExperienceColor(candidate.experience_level)}`}>
|
||||
{candidate.experience_level} level
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.bio && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-3">
|
||||
{candidate.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.skills && candidate.skills.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{candidate.skills.slice(0, 4).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{candidate.skills.length > 4 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{candidate.skills.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidate.salary_expectation && (
|
||||
<div className="mt-3 flex items-center text-sm text-gray-500">
|
||||
<Star className="h-4 w-4 mr-1" />
|
||||
Expected: ${candidate.salary_expectation?.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<User className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No candidates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Try adjusting your search criteria
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.pagination && data.pagination.pages > 1 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Previous
|
||||
</button>
|
||||
<button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
{((data.pagination.page - 1) * data.pagination.limit) + 1}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">{data.pagination.total}</span>{' '}
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Candidates;
|
||||
464
frontend/src/pages/CreateJob.js
Normal file
464
frontend/src/pages/CreateJob.js
Normal file
@@ -0,0 +1,464 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useMutation } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ArrowLeft, Save, Plus, X } from 'lucide-react';
|
||||
|
||||
const CreateJob = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
requirements: [''],
|
||||
responsibilities: [''],
|
||||
location: '',
|
||||
employmentType: 'full-time',
|
||||
salaryMin: '',
|
||||
salaryMax: '',
|
||||
currency: 'USD',
|
||||
remoteAllowed: false,
|
||||
experienceLevel: '',
|
||||
skillsRequired: [''],
|
||||
benefits: [''],
|
||||
applicationDeadline: ''
|
||||
});
|
||||
|
||||
const createJobMutation = useMutation(async (jobData) => {
|
||||
const response = await axios.post('/api/jobs', jobData);
|
||||
return response.data;
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Job posted successfully!');
|
||||
navigate('/jobs');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create job');
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleArrayChange = (field, index, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].map((item, i) => i === index ? value : item)
|
||||
}));
|
||||
};
|
||||
|
||||
const addArrayItem = (field) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], '']
|
||||
}));
|
||||
};
|
||||
|
||||
const removeArrayItem = (field, index) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Filter out empty array items
|
||||
const cleanedData = {
|
||||
...formData,
|
||||
requirements: formData.requirements.filter(req => req.trim()),
|
||||
responsibilities: formData.responsibilities.filter(resp => resp.trim()),
|
||||
skillsRequired: formData.skillsRequired.filter(skill => skill.trim()),
|
||||
benefits: formData.benefits.filter(benefit => benefit.trim()),
|
||||
salaryMin: formData.salaryMin ? parseInt(formData.salaryMin) : undefined,
|
||||
salaryMax: formData.salaryMax ? parseInt(formData.salaryMax) : undefined,
|
||||
applicationDeadline: formData.applicationDeadline || undefined
|
||||
};
|
||||
|
||||
createJobMutation.mutate(cleanedData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate('/jobs')}
|
||||
className="mr-4 p-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Post a New Job</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Create a job posting to attract qualified candidates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||
Job Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Senior Software Engineer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
Job Description *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="description"
|
||||
rows={4}
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Describe the role, company culture, and what makes this opportunity special..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
||||
Location *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
id="location"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., San Francisco, CA"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
|
||||
Employment Type *
|
||||
</label>
|
||||
<select
|
||||
name="employmentType"
|
||||
id="employmentType"
|
||||
required
|
||||
className="mt-1 input"
|
||||
value={formData.employmentType}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="full-time">Full-time</option>
|
||||
<option value="part-time">Part-time</option>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="internship">Internship</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements and Responsibilities */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Requirements & Responsibilities</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Requirements
|
||||
</label>
|
||||
{formData.requirements.map((req, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={req}
|
||||
onChange={(e) => handleArrayChange('requirements', index, e.target.value)}
|
||||
placeholder="e.g., 5+ years of experience in React"
|
||||
/>
|
||||
{formData.requirements.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('requirements', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('requirements')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Requirement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Responsibilities
|
||||
</label>
|
||||
{formData.responsibilities.map((resp, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={resp}
|
||||
onChange={(e) => handleArrayChange('responsibilities', index, e.target.value)}
|
||||
placeholder="e.g., Develop and maintain web applications"
|
||||
/>
|
||||
{formData.responsibilities.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('responsibilities', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('responsibilities')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Responsibility
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compensation */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Compensation</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="salaryMin" className="block text-sm font-medium text-gray-700">
|
||||
Minimum Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="salaryMin"
|
||||
id="salaryMin"
|
||||
className="mt-1 input"
|
||||
value={formData.salaryMin}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., 80000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="salaryMax" className="block text-sm font-medium text-gray-700">
|
||||
Maximum Salary
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="salaryMax"
|
||||
id="salaryMax"
|
||||
className="mt-1 input"
|
||||
value={formData.salaryMax}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., 120000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="currency" className="block text-sm font-medium text-gray-700">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
name="currency"
|
||||
id="currency"
|
||||
className="mt-1 input"
|
||||
value={formData.currency}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Details */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Additional Details</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
|
||||
Experience Level
|
||||
</label>
|
||||
<select
|
||||
name="experienceLevel"
|
||||
id="experienceLevel"
|
||||
className="mt-1 input"
|
||||
value={formData.experienceLevel}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Select level</option>
|
||||
<option value="entry">Entry Level</option>
|
||||
<option value="mid">Mid Level</option>
|
||||
<option value="senior">Senior Level</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="executive">Executive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="applicationDeadline" className="block text-sm font-medium text-gray-700">
|
||||
Application Deadline
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="applicationDeadline"
|
||||
id="applicationDeadline"
|
||||
className="mt-1 input"
|
||||
value={formData.applicationDeadline}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remoteAllowed"
|
||||
id="remoteAllowed"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={formData.remoteAllowed}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label htmlFor="remoteAllowed" className="ml-2 block text-sm text-gray-900">
|
||||
Remote work allowed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Required Skills
|
||||
</label>
|
||||
{formData.skillsRequired.map((skill, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={skill}
|
||||
onChange={(e) => handleArrayChange('skillsRequired', index, e.target.value)}
|
||||
placeholder="e.g., JavaScript, React, Node.js"
|
||||
/>
|
||||
{formData.skillsRequired.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('skillsRequired', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('skillsRequired')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Benefits
|
||||
</label>
|
||||
{formData.benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={benefit}
|
||||
onChange={(e) => handleArrayChange('benefits', index, e.target.value)}
|
||||
placeholder="e.g., Health insurance, 401k, Flexible hours"
|
||||
/>
|
||||
{formData.benefits.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('benefits', index)}
|
||||
className="ml-2 p-2 text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('benefits')}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Benefit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/jobs')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createJobMutation.isLoading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{createJobMutation.isLoading ? 'Creating...' : 'Post Job'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateJob;
|
||||
284
frontend/src/pages/Dashboard.js
Normal file
284
frontend/src/pages/Dashboard.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: stats, isLoading } = useQuery('dashboard-stats', async () => {
|
||||
const [jobsRes, applicationsRes, candidatesRes, employersRes] = await Promise.all([
|
||||
axios.get('/api/jobs?limit=1'),
|
||||
axios.get('/api/applications?limit=1'),
|
||||
user?.role === 'candidate' ? Promise.resolve({ data: { applications: { pagination: { total: 0 } } } }) : axios.get('/api/applications?limit=1'),
|
||||
user?.role === 'employer' || user?.role === 'admin' || user?.role === 'recruiter' ? axios.get('/api/employers?limit=1') : Promise.resolve({ data: [] })
|
||||
]);
|
||||
|
||||
return {
|
||||
totalJobs: jobsRes.data.pagination?.total || 0,
|
||||
totalApplications: applicationsRes.data.pagination?.total || 0,
|
||||
totalCandidates: candidatesRes.data.pagination?.total || 0,
|
||||
totalEmployers: employersRes.data.length || 0
|
||||
};
|
||||
});
|
||||
|
||||
const { data: recentJobs } = useQuery('recent-jobs', async () => {
|
||||
const response = await axios.get('/api/jobs?limit=5');
|
||||
return response.data.jobs;
|
||||
});
|
||||
|
||||
const { data: recentApplications } = useQuery('recent-applications', async () => {
|
||||
if (user?.role === 'candidate') {
|
||||
const response = await axios.get('/api/applications?limit=5');
|
||||
return response.data.applications;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const statsCards = [
|
||||
{
|
||||
name: 'Total Jobs',
|
||||
value: stats?.totalJobs || 0,
|
||||
icon: Briefcase,
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'Applications',
|
||||
value: stats?.totalApplications || 0,
|
||||
icon: FileText,
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'Candidates',
|
||||
value: stats?.totalCandidates || 0,
|
||||
icon: Users,
|
||||
color: 'bg-purple-500'
|
||||
},
|
||||
{
|
||||
name: 'Employers',
|
||||
value: stats?.totalEmployers || 0,
|
||||
icon: Building,
|
||||
color: 'bg-orange-500'
|
||||
}
|
||||
];
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{getGreeting()}, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Welcome to your MysteryApp-Cursor dashboard
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Debug: User role = {user?.role || 'undefined'}, User ID = {user?.id || 'undefined'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statsCards.map((card) => (
|
||||
<div key={card.name} className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`p-3 rounded-md ${card.color}`}>
|
||||
<card.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
{card.name}
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{card.value}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Recent Jobs */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Recent Job Postings
|
||||
</h3>
|
||||
<div className="mt-5">
|
||||
{recentJobs?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentJobs.map((job) => (
|
||||
<div key={job.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{job.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{job.company_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
{new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No recent jobs found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Recent Applications
|
||||
</h3>
|
||||
<div className="mt-5">
|
||||
{recentApplications?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentApplications.map((application) => (
|
||||
<div key={application.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{application.job_title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{application.company_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
application.status === 'applied' ? 'bg-blue-100 text-blue-800' :
|
||||
application.status === 'reviewed' ? 'bg-yellow-100 text-yellow-800' :
|
||||
application.status === 'shortlisted' ? 'bg-green-100 text-green-800' :
|
||||
application.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No recent applications found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{user?.role === 'employer' && (
|
||||
<Link
|
||||
to="/jobs/create"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<Briefcase className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
Post a Job
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Create a new job posting to attract candidates
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user?.role === 'candidate' && (
|
||||
<a
|
||||
href="/jobs"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<TrendingUp className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
Browse Jobs
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Find your next career opportunity
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/applications"
|
||||
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
|
||||
<FileText className="h-6 w-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<span className="absolute inset-0" />
|
||||
View Applications
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Track your application status
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
111
frontend/src/pages/EmployerDetails.js
Normal file
111
frontend/src/pages/EmployerDetails.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Building, MapPin, Users, Globe, Mail, Phone } from 'lucide-react';
|
||||
|
||||
const EmployerDetails = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
const { data: employer, isLoading } = useQuery(['employer', id], async () => {
|
||||
const response = await axios.get(`/api/employers/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!employer) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Employer not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The employer you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Building className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{employer.company_name}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">{employer.first_name} {employer.last_name}</p>
|
||||
<p className="text-sm text-gray-500">{employer.email}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{employer.industry && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-2" />
|
||||
{employer.industry}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.company_size && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
{employer.company_size} employees
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.website && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Website
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
{employer.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{employer.description && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">About {employer.company_name}</h2>
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{employer.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.address && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Address</h2>
|
||||
<div className="flex items-start">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-3 mt-0.5" />
|
||||
<p className="text-gray-600">{employer.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployerDetails;
|
||||
98
frontend/src/pages/Employers.js
Normal file
98
frontend/src/pages/Employers.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Building, MapPin, Users, Globe } from 'lucide-react';
|
||||
|
||||
const Employers = () => {
|
||||
const { data, isLoading } = useQuery('employers', async () => {
|
||||
const response = await axios.get('/api/employers');
|
||||
return response.data;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Employers</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Browse companies and employers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.length > 0 ? (
|
||||
data.map((employer) => (
|
||||
<div key={employer.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Building className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<Link to={`/employers/${employer.id}`} className="hover:text-primary-600">
|
||||
{employer.company_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{employer.first_name} {employer.last_name}</p>
|
||||
|
||||
{employer.industry && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<Building className="h-4 w-4 mr-1" />
|
||||
{employer.industry}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.company_size && (
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
{employer.company_size} employees
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.website && (
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Globe className="h-4 w-4 mr-1" />
|
||||
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
|
||||
Website
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employer.description && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-3">
|
||||
{employer.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<Building className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No employers found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No employers have registered yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Employers;
|
||||
293
frontend/src/pages/JobDetails.js
Normal file
293
frontend/src/pages/JobDetails.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { MapPin, Clock, DollarSign, Briefcase, Users, Calendar } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const JobDetails = () => {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [coverLetter, setCoverLetter] = useState('');
|
||||
|
||||
const { data: job, isLoading } = useQuery(['job', id], async () => {
|
||||
const response = await axios.get(`/api/jobs/${id}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!coverLetter.trim()) {
|
||||
toast.error('Please provide a cover letter');
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
await axios.post('/api/applications', {
|
||||
jobId: id,
|
||||
coverLetter: coverLetter.trim()
|
||||
});
|
||||
toast.success('Application submitted successfully!');
|
||||
setCoverLetter('');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to submit application');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900">Job not found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
The job you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link to="/jobs" className="btn btn-primary">
|
||||
Browse Jobs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatSalary = (min, max, currency = 'USD') => {
|
||||
if (!min && !max) return 'Salary not specified';
|
||||
if (!min) return `Up to ${currency} ${max?.toLocaleString()}`;
|
||||
if (!max) return `From ${currency} ${min?.toLocaleString()}`;
|
||||
return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link to="/jobs" className="text-sm text-primary-600 hover:text-primary-500">
|
||||
← Back to Jobs
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-gray-900">{job.title}</h1>
|
||||
<div className="mt-2 flex items-center text-lg text-gray-600">
|
||||
<Briefcase className="h-5 w-5 mr-2" />
|
||||
{job.company_name}
|
||||
</div>
|
||||
</div>
|
||||
{user?.role === 'candidate' && job.status === 'active' && (
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply Now'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Job Description */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Job Description</h2>
|
||||
<div className="prose max-w-none">
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{job.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
{job.requirements && job.requirements.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Requirements</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.requirements.map((requirement, index) => (
|
||||
<li key={index} className="text-gray-600">{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Responsibilities */}
|
||||
{job.responsibilities && job.responsibilities.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Responsibilities</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.responsibilities.map((responsibility, index) => (
|
||||
<li key={index} className="text-gray-600">{responsibility}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Required */}
|
||||
{job.skills_required && job.skills_required.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Required Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{job.skills_required.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Benefits */}
|
||||
{job.benefits && job.benefits.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Benefits</h2>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{job.benefits.map((benefit, index) => (
|
||||
<li key={index} className="text-gray-600">{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Job Details */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Job Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{job.location}</p>
|
||||
{job.remote_allowed && (
|
||||
<p className="text-sm text-gray-500">Remote work allowed</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Briefcase className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 capitalize">
|
||||
{job.employment_type?.replace('-', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatSalary(job.salary_min, job.salary_max, job.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.experience_level && (
|
||||
<div className="flex items-center">
|
||||
<Users className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 capitalize">
|
||||
{job.experience_level} level
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Posted {new Date(job.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.application_deadline && (
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Apply by {new Date(job.application_deadline).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Company</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900">{job.company_name}</p>
|
||||
{job.industry && (
|
||||
<p className="text-sm text-gray-500">{job.industry}</p>
|
||||
)}
|
||||
{job.company_size && (
|
||||
<p className="text-sm text-gray-500">{job.company_size} employees</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply Section for Candidates */}
|
||||
{user?.role === 'candidate' && job.status === 'active' && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Apply for this job</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="coverLetter" className="block text-sm font-medium text-gray-700">
|
||||
Cover Letter
|
||||
</label>
|
||||
<textarea
|
||||
id="coverLetter"
|
||||
rows={4}
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Tell us why you're interested in this position..."
|
||||
value={coverLetter}
|
||||
onChange={(e) => setCoverLetter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying || !coverLetter.trim()}
|
||||
className="w-full btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Submit Application'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobDetails;
|
||||
295
frontend/src/pages/Jobs.js
Normal file
295
frontend/src/pages/Jobs.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Search, MapPin, Clock, DollarSign, Briefcase, Plus } from 'lucide-react';
|
||||
|
||||
const Jobs = () => {
|
||||
const { user } = useAuth();
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
location: '',
|
||||
employmentType: '',
|
||||
experienceLevel: ''
|
||||
});
|
||||
|
||||
const { data, isLoading, refetch } = useQuery(['jobs', filters], async () => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const response = await axios.get(`/api/jobs?${params.toString()}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const formatSalary = (min, max, currency = 'USD') => {
|
||||
if (!min && !max) return 'Salary not specified';
|
||||
if (!min) return `Up to ${currency} ${max?.toLocaleString()}`;
|
||||
if (!max) return `From ${currency} ${min?.toLocaleString()}`;
|
||||
return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-green-100 text-green-800';
|
||||
case 'paused': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'closed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Job Postings</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Find your next career opportunity
|
||||
</p>
|
||||
</div>
|
||||
{/* Debug info */}
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Debug: User role = {user?.role || 'undefined'}
|
||||
</div>
|
||||
{(user?.role === 'employer' || user?.role === 'recruiter') && (
|
||||
<Link
|
||||
to="/jobs/create"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Post a Job
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
|
||||
Search
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Job title or keywords"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
|
||||
Location
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MapPin className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
id="location"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="City, state, or remote"
|
||||
value={filters.location}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
|
||||
Employment Type
|
||||
</label>
|
||||
<select
|
||||
id="employmentType"
|
||||
name="employmentType"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
value={filters.employmentType}
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="full-time">Full-time</option>
|
||||
<option value="part-time">Part-time</option>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="internship">Internship</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
|
||||
Experience Level
|
||||
</label>
|
||||
<select
|
||||
id="experienceLevel"
|
||||
name="experienceLevel"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
value={filters.experienceLevel}
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="entry">Entry Level</option>
|
||||
<option value="mid">Mid Level</option>
|
||||
<option value="senior">Senior Level</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="executive">Executive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Search Jobs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jobs List */}
|
||||
<div className="space-y-4">
|
||||
{data?.jobs?.length > 0 ? (
|
||||
data.jobs.map((job) => (
|
||||
<div key={job.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<Link to={`/jobs/${job.id}`} className="hover:text-primary-600">
|
||||
{job.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center text-sm text-gray-500">
|
||||
<Briefcase className="h-4 w-4 mr-1" />
|
||||
{job.company_name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500 space-x-4">
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{job.location}
|
||||
{job.remote_allowed && <span className="ml-1">(Remote OK)</span>}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-4 w-4 mr-1" />
|
||||
{formatSalary(job.salary_min, job.salary_max, job.currency)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
{new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{job.description}
|
||||
</p>
|
||||
</div>
|
||||
{job.skills_required && job.skills_required.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{job.skills_required.slice(0, 5).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{job.skills_required.length > 5 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{job.skills_required.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Briefcase className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Try adjusting your search criteria
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.pagination && data.pagination.pages > 1 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Previous
|
||||
</button>
|
||||
<button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
{((data.pagination.page - 1) * data.pagination.limit) + 1}
|
||||
</span>{' '}
|
||||
to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)}
|
||||
</span>{' '}
|
||||
of{' '}
|
||||
<span className="font-medium">{data.pagination.total}</span>{' '}
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Jobs;
|
||||
136
frontend/src/pages/Login.js
Normal file
136
frontend/src/pages/Login.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
|
||||
const Login = () => {
|
||||
const { login } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(formData.email, formData.password);
|
||||
|
||||
if (!result.success) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Demo accounts:
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-500 space-y-1">
|
||||
<p>Admin: admin@mysteryapp.com / admin123</p>
|
||||
<p>Recruiter: recruiter@mysteryapp.com / recruiter123</p>
|
||||
<p>Employer: employer@techcorp.com / employer123</p>
|
||||
<p>Candidate: candidate@example.com / candidate123</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
142
frontend/src/pages/Profile.js
Normal file
142
frontend/src/pages/Profile.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { User, Mail, Save } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Profile = () => {
|
||||
const { user, fetchUser } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
email: user?.email || ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await fetch(`/api/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
await fetchUser();
|
||||
toast.success('Profile updated successfully!');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage your account information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
id="firstName"
|
||||
required
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
id="lastName"
|
||||
required
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Role:</strong> {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
242
frontend/src/pages/Register.js
Normal file
242
frontend/src/pages/Register.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react';
|
||||
|
||||
const Register = () => {
|
||||
const { register } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'candidate'
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
alert('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await register({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
role: formData.role
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
sign in to your existing account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="First name"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Last name"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
|
||||
Account Type
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={formData.role}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="candidate">Candidate</option>
|
||||
<option value="employer">Employer</option>
|
||||
<option value="recruiter">Recruiter</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
placeholder="Confirm password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
245
frontend/src/pages/Resumes.js
Normal file
245
frontend/src/pages/Resumes.js
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import axios from 'axios';
|
||||
import { Upload, Download, Trash2, Star, FileText } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Resumes = () => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
|
||||
const { data: resumes, isLoading, refetch } = useQuery('resumes', async () => {
|
||||
// This would need to be implemented based on the candidate's ID
|
||||
// For now, return empty array
|
||||
return [];
|
||||
});
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||
toast.error('File size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Only PDF, DOC, DOCX, and TXT files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('resume', selectedFile);
|
||||
formData.append('isPrimary', resumes?.length === 0 ? 'true' : 'false');
|
||||
|
||||
try {
|
||||
await axios.post('/api/resumes/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
toast.success('Resume uploaded successfully!');
|
||||
setSelectedFile(null);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to upload resume');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (resumeId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resumes/${resumeId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', response.headers['content-disposition']?.split('filename=')[1] || 'resume.pdf');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error('Failed to download resume');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (resumeId) => {
|
||||
try {
|
||||
await axios.put(`/api/resumes/${resumeId}/primary`);
|
||||
toast.success('Primary resume updated!');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error('Failed to set primary resume');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (resumeId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this resume?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/resumes/${resumeId}`);
|
||||
toast.success('Resume deleted successfully!');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete resume');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resumes</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage your resume files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Upload Resume</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="resume" className="block text-sm font-medium text-gray-700">
|
||||
Select Resume File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="resume"
|
||||
accept=".pdf,.doc,.docx,.txt"
|
||||
onChange={handleFileSelect}
|
||||
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PDF, DOC, DOCX, or TXT files only. Maximum size: 10MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-5 w-5 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-900">{selectedFile.name}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumes List */}
|
||||
<div className="space-y-4">
|
||||
{resumes?.length > 0 ? (
|
||||
resumes.map((resume) => (
|
||||
<div key={resume.id} className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-8 w-8 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{resume.original_name}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>{(resume.file_size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Uploaded {new Date(resume.uploaded_at).toLocaleDateString()}</span>
|
||||
{resume.is_primary && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Primary
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDownload(resume.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</button>
|
||||
{!resume.is_primary && (
|
||||
<button
|
||||
onClick={() => handleSetPrimary(resume.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Set Primary
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(resume.id)}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No resumes uploaded</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Upload your first resume to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Resumes;
|
||||
25
frontend/tailwind.config.js
Normal file
25
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user