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